Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,18 @@ jobs:
# package when it has no published baseline yet (pre-release) — logged, never falsely green.
- name: ApiCompat against published baseline
run: ./tools/check-api-compat.sh

api-coverage:
name: API coverage (samples exercise the surface)
runs-on: ubuntu-latest
needs: build-test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
# Runs every sample under coverlet (scoped to Core + DI) and asserts every gateable public type
# is exercised by at least one sample, with documented exemptions in
# tools/api-coverage/api-coverage-exclusions.txt.
- name: Assert samples cover the public surface
run: ./tools/run-api-coverage.sh
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ All notable changes to `credentials-dotnet` are documented here. The format is b

## [Unreleased]

### Added — Milestone M8b (Samples matrix & API-coverage gate)

The second of three M8 PRs. A first-class, offline samples matrix demonstrating every role × securing
form plus status/schema/trust/1.1, and an api-coverage gate proving the samples exercise the public
surface. Test count **338 → 352** (+14 sample smoke tests); build stays 0-warning.

- **`Credentials.Samples.Shared`** — the keystone wiring (`SampleKeys` in-memory `did:key` minting,
`SampleNarrator` FR-banner output, and `AllowlistIssuerTrustPolicy` — the one shipped trust policy,
which per FR-082 lives in samples, not the library).
- **14 `samples/*` console projects**, each exposing `Program.RunAsync(TextWriter, IServiceProvider?)`,
offline, narrating the FRs it demonstrates and throwing on any unexpected outcome:
DataIntegrity (eddsa-jcs-2022, + a capabilities query over `ISecuringCapabilities`/`SecuringSelector`),
DataIntegrityRdfc (eddsa-rdfc-2022), JoseEnvelope (vc+jwt), CoseEnvelope (vc+cose),
SdJwtVc (selective disclosure), SdJwtPresentation (KB-JWT holder binding), Bbs2023 (bbs-2023 derive,
`IsAvailable`-gated), PresentationDataIntegrity + PresentationJose (holder binding + presentation
verification), StatusList (Bitstring Status List revoke/verify), Schema (JSON Schema 2020-12),
IssuerTrust (allowlist trusted/untrusted), Vcdm11 (verify a foreign-issued 1.1 credential + the
`AcceptVcdm11=false` opt-out gate), and FullPipeline (status + schema + trust composed).
- **`Credentials.SampleSmokeTests`** — runs every sample's `RunAsync` in-process (14 facts); doubles as
the api-coverage driver under coverlet (scoped to Core + DI).
- **`tools/api-coverage`** (+ `tools/run-api-coverage.sh`, `tools/coverage.runsettings`) — a console
tool that diffs the public surface (via `MetadataLoadContext`) against the samples' coverage and fails
if any gateable public type is exercised by no sample. Type-level: **53 covered, 0 uncovered, 4
documented exemptions** (`api-coverage-exclusions.txt` — error-path exceptions + the tuning options
object; the tool also fails on a stale exclusion that has since become covered). Interfaces/enums and
internal types are auto-skipped.
- **CI** — `ci.yml` gains an `api-coverage` job (`needs: build-test`).

### Added — Milestone M8a (Quality & release gates)

The first of three M8 PRs (M8 = conformance + interop + samples + gates). M8a lands the pure-.NET
Expand Down
262 changes: 262 additions & 0 deletions Credentials.sln

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Credentials.Samples.Bbs2023</RootNamespace>
<AssemblyName>Credentials.Sample.Bbs2023</AssemblyName>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Credentials.Samples.Shared\Credentials.Samples.Shared.csproj" />
</ItemGroup>

</Project>
127 changes: 127 additions & 0 deletions samples/Credentials.Sample.Bbs2023/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Credentials;
using Credentials.Rdfc;
using Credentials.Roles;
using Credentials.Samples.Shared;
using Credentials.Verification;
using DataProofsDotnet;
using DataProofsDotnet.DataIntegrity;
using DataProofsDotnet.Rdfc.DataIntegrity;
using Microsoft.Extensions.DependencyInjection;
using NetCrypto;

namespace Credentials.Samples.Bbs2023;

/// <summary>
/// Selective disclosure via the <c>bbs-2023</c> cryptosuite (FR-014 gated / FR-031 / FR-042): a holder
/// derives a minimal proof from a bbs-2023 base (mandatory group + a selected claim, the rest withheld),
/// and the verifier accepts the derived proof. The whole crypto path is gated on the native BBS library's
/// availability — on an unsupported host the sample narrates a clear skip and exits 0.
/// </summary>
/// <remarks>
/// The engine intentionally gates bbs-2023 issuance (no key-store BBS create API exists; raw-key export
/// would violate FR-015), so — exactly like the M5 test suite — the base credential here is crafted
/// directly through the substrate's raw-key API. This is sample/test-only; the engine never does raw-key
/// issuance.
/// </remarks>
public static class Program
{
private static readonly DefaultKeyGenerator KeyGen = new();

/// <summary>Console entry point.</summary>
public static Task Main() => RunAsync(Console.Out);

/// <summary>Runs the sample, writing narration to <paramref name="output"/>.</summary>
/// <param name="output">Where to write the FR-tagged narration.</param>
/// <param name="services">An optional pre-configured provider; when null the sample builds its own.</param>
public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
{
var narrator = new SampleNarrator(output);
narrator.Banner("BBS selective disclosure (bbs-2023) — derive + verify", "FR-014 (gated)", "FR-031", "FR-042");

// Gate the whole crypto path on the native BBS library, exactly as the M5 test does.
if (!new Bbs2023Cryptosuite().IsAvailable)
{
narrator.Result("skipped: the native BBS library is unavailable on this host (no bbs-2023 path to exercise)");
return;
}

var provider = services ?? new ServiceCollection().AddCredentials(b => b.UseNetDid().UseBbs2023()).BuildServiceProvider();
var ownsProvider = services is null;
try
{
var deriver = provider.GetRequiredService<IBbsDeriver>();
var verifier = provider.GetRequiredService<IVerifier>();

// Craft a bbs-2023 base whose mandatory group is the issuer + the subject id.
var baseCredential = await CraftBaseAsync(["/issuer", "/credentialSubject/id"]);
narrator.Step("crafted a bbs-2023 base credential (substrate raw-key API; engine gates issuance)");

// Derive: reveal the mandatory group + the selected gpa, withholding alumniOf + favoriteColor.
var derived = await deriver.DeriveAsync(
baseCredential, new BbsDisclosureRequest { RevealPointers = ["/credentialSubject/gpa"] });
var subject = derived.AsElement().GetProperty("credentialSubject");
narrator.Step(
$"derived a minimal proof: securing={derived.Securing}, " +
$"id={subject.TryGetProperty("id", out _)} (mandatory), gpa={subject.TryGetProperty("gpa", out _)} (selected), " +
$"alumniOf={subject.TryGetProperty("alumniOf", out _)} / favoriteColor={subject.TryGetProperty("favoriteColor", out _)} (withheld)");

var result = await verifier.VerifyCredentialAsync(derived);
narrator.Result($"decision={result.Decision} (proof={result.Check(CheckKinds.Proof)!.Status}, structure={result.Check(CheckKinds.Structure)!.Status})");

if (result.Decision != VerificationDecision.Accepted)
throw new InvalidOperationException($"sample invariant failed: expected Accepted, got {result.Decision}");
}
finally
{
if (ownsProvider && provider is IDisposable disposable) disposable.Dispose();
}
}

/// <summary>
/// Crafts a bbs-2023 base credential via the substrate's raw-key API (the engine gates issuance). The
/// issuer is the BLS signing key's <c>did:key</c>. Mirrors the M5 test's CraftBaseAsync/EmbedBaseProofAsync.
/// </summary>
private static async Task<Credential> CraftBaseAsync(string[] mandatoryPointers)
{
var bls = KeyGen.Generate(KeyType.Bls12381G2);
var signerDid = $"did:key:{bls.MultibasePublicKey}";
var vm = $"{signerDid}#{bls.MultibasePublicKey}";

var credential = Credential.Build()
.AddContext("https://www.w3.org/ns/credentials/examples/v2")
.AddType("AlumniCredential")
.WithIssuer(signerDid)
.AddSubject(new JsonObject
{
["id"] = "did:example:abcdefgh",
["alumniOf"] = "The School of Examples",
["gpa"] = "4.0",
["favoriteColor"] = "purple",
})
.Seal();

var baseOptions = new DataIntegrityProof
{
Cryptosuite = Bbs2023Cryptosuite.CryptosuiteName,
VerificationMethod = vm,
ProofPurpose = "assertionMethod",
Created = "2026-01-02T00:00:00Z",
};

var hmacKey = new byte[32];
for (var i = 0; i < hmacKey.Length; i++)
{
hmacKey[i] = (byte)(i + 1);
}

var suite = new Bbs2023Cryptosuite();
var baseProof = await suite.CreateBaseProofAsync(
credential.AsElement(), baseOptions, bls.PrivateKey, hmacKey, mandatoryPointers);

var node = JsonNode.Parse(credential.ToBytes())!.AsObject();
node["proof"] = JsonSerializer.SerializeToNode(baseProof, DataProofsJsonOptions.Default);
return Credential.Parse(JsonSerializer.SerializeToUtf8Bytes(node));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Credentials.Samples.CoseEnvelope</RootNamespace>
<AssemblyName>Credentials.Sample.CoseEnvelope</AssemblyName>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Credentials.Samples.Shared\Credentials.Samples.Shared.csproj" />
</ItemGroup>

</Project>
69 changes: 69 additions & 0 deletions samples/Credentials.Sample.CoseEnvelope/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Text.Json.Nodes;
using Credentials;
using Credentials.Roles;
using Credentials.Samples.Shared;
using Credentials.Verification;
using Microsoft.Extensions.DependencyInjection;

namespace Credentials.Samples.CoseEnvelope;

/// <summary>Issues a VC-COSE enveloped credential (application/vc+cose) and verifies it both as the issued
/// credential object and from the raw COSE_Sign1 wire bytes.</summary>
public static class Program
{
/// <summary>Console entry point.</summary>
public static Task Main() => RunAsync(Console.Out);

/// <summary>Runs the sample, writing narration to <paramref name="output"/>.</summary>
/// <param name="output">Where to write the FR-tagged narration.</param>
/// <param name="services">An optional pre-configured provider; when null the sample builds its own.</param>
public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
{
var narrator = new SampleNarrator(output);
narrator.Banner("VC-COSE enveloping (application/vc+cose) — issue + verify (object & wire)", "FR-012");

var provider = services ?? new ServiceCollection().AddCredentials(b => b.UseNetDid()).BuildServiceProvider();
var ownsProvider = services is null;
try
{
var issuer = provider.GetRequiredService<IIssuer>();
var verifier = provider.GetRequiredService<IVerifier>();
var issuerKey = SampleKeys.New();
narrator.Step($"minted an issuer identity: {issuerKey.Did[..28]}…");

var unsecured = Credential.Build()
.WithId("urn:uuid:44444444-4444-4444-4444-444444444444")
.WithIssuer(issuerKey.Did)
.AddType("UniversityDegreeCredential")
.AddSubject(new JsonObject { ["id"] = "did:example:subject" })
.Seal();
narrator.Step($"built + sealed an unsecured VCDM {unsecured.Version} credential");

var issued = await issuer.IssueAsync(unsecured, new CoseEnvelopeIssuanceRequest
{
Signer = issuerKey.Signer,
VerificationMethod = issuerKey.VerificationMethod,
});

var coseLength = issued.CoseBytes!.Value.Length;
narrator.Step($"enveloped it: {issued.Form}, mediaType={issued.MediaType}, COSE_Sign1 bytes={coseLength}");
if (coseLength <= 0)
throw new InvalidOperationException($"sample invariant failed: expected a non-empty COSE_Sign1 envelope, got {coseLength} bytes");

var direct = await verifier.VerifyCredentialAsync(issued.Credential);
narrator.Step($"verified the issued credential: decision={direct.Decision}, mechanism={direct.Mechanism}");

var wire = await verifier.VerifyCredentialAsync(issued.CoseBytes!.Value);
narrator.Result($"verified from raw COSE_Sign1 bytes: decision={wire.Decision}, mechanism={wire.Mechanism}");

if (direct.Decision != VerificationDecision.Accepted)
throw new InvalidOperationException($"sample invariant failed: expected Accepted from the credential object, got {direct.Decision}");
if (wire.Decision != VerificationDecision.Accepted)
throw new InvalidOperationException($"sample invariant failed: expected Accepted from the wire bytes, got {wire.Decision}");
}
finally
{
if (ownsProvider && provider is IDisposable disposable) disposable.Dispose();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Credentials.Samples.DataIntegrity</RootNamespace>
<AssemblyName>Credentials.Sample.DataIntegrity</AssemblyName>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Credentials.Samples.Shared\Credentials.Samples.Shared.csproj" />
</ItemGroup>

</Project>
70 changes: 70 additions & 0 deletions samples/Credentials.Sample.DataIntegrity/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Text.Json.Nodes;
using Credentials;
using Credentials.Roles;
using Credentials.Samples.Shared;
using Credentials.Verification;
using Microsoft.Extensions.DependencyInjection;

namespace Credentials.Samples.DataIntegrity;

/// <summary>Issues a credential with an embedded W3C Data Integrity proof (eddsa-jcs-2022) and verifies it.</summary>
public static class Program
{
/// <summary>Console entry point.</summary>
public static Task Main() => RunAsync(Console.Out);

/// <summary>Runs the sample, writing narration to <paramref name="output"/>.</summary>
/// <param name="output">Where to write the FR-tagged narration.</param>
/// <param name="services">An optional pre-configured provider; when null the sample builds its own.</param>
public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
{
var narrator = new SampleNarrator(output);
narrator.Banner("Embedded Data Integrity (eddsa-jcs-2022) — issue + verify", "FR-010", "FR-011", "FR-040", "FR-043");

var provider = services ?? new ServiceCollection().AddCredentials(b => b.UseNetDid()).BuildServiceProvider();
var ownsProvider = services is null;
try
{
var issuer = provider.GetRequiredService<IIssuer>();
var verifier = provider.GetRequiredService<IVerifier>();

// Discover which securing forms/suites are registered (runtime-discovered strings, FR-053).
var capabilities = provider.GetRequiredService<ISecuringCapabilities>();
narrator.Step($"securing forms available: {string.Join(", ", capabilities.AvailableForms)} "
+ $"(eddsa-jcs-2022 supported: {capabilities.IsSupported(SecuringSelector.DataIntegrity("eddsa-jcs-2022"))})");

var issuerKey = SampleKeys.New();
narrator.Step($"minted an issuer identity: {issuerKey.Did[..28]}…");

var unsecured = Credential.Build()
.WithId("urn:uuid:11111111-1111-1111-1111-111111111111")
.WithIssuer(issuerKey.Did)
.AddType("UniversityDegreeCredential")
.AddSubject(new JsonObject
{
["id"] = "did:example:subject",
["degree"] = new JsonObject { ["type"] = "BachelorDegree", ["name"] = "B.Sc. Computer Science" },
})
.Seal();
narrator.Step($"built + sealed an unsecured VCDM {unsecured.Version} credential");

var issued = await issuer.IssueAsync(unsecured, new DataIntegrityIssuanceRequest
{
Cryptosuite = "eddsa-jcs-2022",
Signer = issuerKey.Signer,
VerificationMethod = issuerKey.VerificationMethod,
});
narrator.Step($"secured it: {issued.Credential.Securing}, mediaType={issued.MediaType}, embedded proof={issued.Credential.HasEmbeddedProof}");

var result = await verifier.VerifyCredentialAsync(issued.Credential);
narrator.Result($"decision={result.Decision} (proof={result.Check(CheckKinds.Proof)!.Status}, structure={result.Check(CheckKinds.Structure)!.Status})");

if (result.Decision != VerificationDecision.Accepted)
throw new InvalidOperationException($"sample invariant failed: expected Accepted, got {result.Decision}");
}
finally
{
if (ownsProvider && provider is IDisposable disposable) disposable.Dispose();
}
}
}
Loading
Loading