diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3a0f5b2..834e7e2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c70698..08bd4ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/Credentials.sln b/Credentials.sln
index 810fecc..d479e5d 100644
--- a/Credentials.sln
+++ b/Credentials.sln
@@ -25,6 +25,46 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.ArchitectureTes
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.RoundTripTests", "tests\Credentials.RoundTripTests\Credentials.RoundTripTests.csproj", "{95D74A3F-243F-4CB0-945F-B47EABC672CF}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Samples.Shared", "samples\Credentials.Samples.Shared\Credentials.Samples.Shared.csproj", "{757952EE-C92D-4072-8F40-1BA61093A87C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.DataIntegrity", "samples\Credentials.Sample.DataIntegrity\Credentials.Sample.DataIntegrity.csproj", "{41519048-787A-4EF9-BDD5-8CDAD0371302}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.Bbs2023", "samples\Credentials.Sample.Bbs2023\Credentials.Sample.Bbs2023.csproj", "{6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.CoseEnvelope", "samples\Credentials.Sample.CoseEnvelope\Credentials.Sample.CoseEnvelope.csproj", "{BCE757F3-EB66-4D43-8727-14C8A218DF56}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.DataIntegrityRdfc", "samples\Credentials.Sample.DataIntegrityRdfc\Credentials.Sample.DataIntegrityRdfc.csproj", "{24B516B7-D63D-46A7-A321-B3B7C1BACA4D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.FullPipeline", "samples\Credentials.Sample.FullPipeline\Credentials.Sample.FullPipeline.csproj", "{7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.IssuerTrust", "samples\Credentials.Sample.IssuerTrust\Credentials.Sample.IssuerTrust.csproj", "{70833E64-57E1-420C-ADF6-0EB4DE9D43DE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.JoseEnvelope", "samples\Credentials.Sample.JoseEnvelope\Credentials.Sample.JoseEnvelope.csproj", "{7AD98807-347C-4742-9769-06F464D4172F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.PresentationDataIntegrity", "samples\Credentials.Sample.PresentationDataIntegrity\Credentials.Sample.PresentationDataIntegrity.csproj", "{0A425634-BEA8-48DF-821B-B0B3355026BA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.PresentationJose", "samples\Credentials.Sample.PresentationJose\Credentials.Sample.PresentationJose.csproj", "{D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.Schema", "samples\Credentials.Sample.Schema\Credentials.Sample.Schema.csproj", "{0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.SdJwtPresentation", "samples\Credentials.Sample.SdJwtPresentation\Credentials.Sample.SdJwtPresentation.csproj", "{4531468B-0587-400C-B9CC-40A8D4FAF836}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.SdJwtVc", "samples\Credentials.Sample.SdJwtVc\Credentials.Sample.SdJwtVc.csproj", "{150E5C31-6234-4004-A59D-FE65531535AC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.StatusList", "samples\Credentials.Sample.StatusList\Credentials.Sample.StatusList.csproj", "{F6B47443-D6B9-4193-9D65-FAEED5DB73B3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Sample.Vcdm11", "samples\Credentials.Sample.Vcdm11\Credentials.Sample.Vcdm11.csproj", "{A125BD71-85FA-4356-AD5A-9098A5E17CFB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.SampleSmokeTests", "tests\Credentials.SampleSmokeTests\Credentials.SampleSmokeTests.csproj", "{0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "api-coverage", "api-coverage", "{C7BAFAB4-7248-6C1D-4F1A-5C71C8CBBB62}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Credentials.Tools.ApiCoverage", "tools\api-coverage\Credentials.Tools.ApiCoverage.csproj", "{5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -143,6 +183,210 @@ Global
{95D74A3F-243F-4CB0-945F-B47EABC672CF}.Release|x64.Build.0 = Release|Any CPU
{95D74A3F-243F-4CB0-945F-B47EABC672CF}.Release|x86.ActiveCfg = Release|Any CPU
{95D74A3F-243F-4CB0-945F-B47EABC672CF}.Release|x86.Build.0 = Release|Any CPU
+ {757952EE-C92D-4072-8F40-1BA61093A87C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {757952EE-C92D-4072-8F40-1BA61093A87C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {757952EE-C92D-4072-8F40-1BA61093A87C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {757952EE-C92D-4072-8F40-1BA61093A87C}.Debug|x64.Build.0 = Debug|Any CPU
+ {757952EE-C92D-4072-8F40-1BA61093A87C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {757952EE-C92D-4072-8F40-1BA61093A87C}.Debug|x86.Build.0 = Debug|Any CPU
+ {757952EE-C92D-4072-8F40-1BA61093A87C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {757952EE-C92D-4072-8F40-1BA61093A87C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {757952EE-C92D-4072-8F40-1BA61093A87C}.Release|x64.ActiveCfg = Release|Any CPU
+ {757952EE-C92D-4072-8F40-1BA61093A87C}.Release|x64.Build.0 = Release|Any CPU
+ {757952EE-C92D-4072-8F40-1BA61093A87C}.Release|x86.ActiveCfg = Release|Any CPU
+ {757952EE-C92D-4072-8F40-1BA61093A87C}.Release|x86.Build.0 = Release|Any CPU
+ {41519048-787A-4EF9-BDD5-8CDAD0371302}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {41519048-787A-4EF9-BDD5-8CDAD0371302}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {41519048-787A-4EF9-BDD5-8CDAD0371302}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {41519048-787A-4EF9-BDD5-8CDAD0371302}.Debug|x64.Build.0 = Debug|Any CPU
+ {41519048-787A-4EF9-BDD5-8CDAD0371302}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {41519048-787A-4EF9-BDD5-8CDAD0371302}.Debug|x86.Build.0 = Debug|Any CPU
+ {41519048-787A-4EF9-BDD5-8CDAD0371302}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {41519048-787A-4EF9-BDD5-8CDAD0371302}.Release|Any CPU.Build.0 = Release|Any CPU
+ {41519048-787A-4EF9-BDD5-8CDAD0371302}.Release|x64.ActiveCfg = Release|Any CPU
+ {41519048-787A-4EF9-BDD5-8CDAD0371302}.Release|x64.Build.0 = Release|Any CPU
+ {41519048-787A-4EF9-BDD5-8CDAD0371302}.Release|x86.ActiveCfg = Release|Any CPU
+ {41519048-787A-4EF9-BDD5-8CDAD0371302}.Release|x86.Build.0 = Release|Any CPU
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}.Debug|x64.Build.0 = Debug|Any CPU
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}.Debug|x86.Build.0 = Debug|Any CPU
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}.Release|x64.ActiveCfg = Release|Any CPU
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}.Release|x64.Build.0 = Release|Any CPU
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}.Release|x86.ActiveCfg = Release|Any CPU
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA}.Release|x86.Build.0 = Release|Any CPU
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56}.Debug|x64.Build.0 = Debug|Any CPU
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56}.Debug|x86.Build.0 = Debug|Any CPU
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56}.Release|x64.ActiveCfg = Release|Any CPU
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56}.Release|x64.Build.0 = Release|Any CPU
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56}.Release|x86.ActiveCfg = Release|Any CPU
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56}.Release|x86.Build.0 = Release|Any CPU
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D}.Debug|x64.Build.0 = Debug|Any CPU
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D}.Debug|x86.Build.0 = Debug|Any CPU
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D}.Release|x64.ActiveCfg = Release|Any CPU
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D}.Release|x64.Build.0 = Release|Any CPU
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D}.Release|x86.ActiveCfg = Release|Any CPU
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D}.Release|x86.Build.0 = Release|Any CPU
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}.Debug|x64.Build.0 = Debug|Any CPU
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}.Debug|x86.Build.0 = Debug|Any CPU
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}.Release|x64.ActiveCfg = Release|Any CPU
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}.Release|x64.Build.0 = Release|Any CPU
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}.Release|x86.ActiveCfg = Release|Any CPU
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93}.Release|x86.Build.0 = Release|Any CPU
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE}.Debug|x64.Build.0 = Debug|Any CPU
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE}.Debug|x86.Build.0 = Debug|Any CPU
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE}.Release|x64.ActiveCfg = Release|Any CPU
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE}.Release|x64.Build.0 = Release|Any CPU
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE}.Release|x86.ActiveCfg = Release|Any CPU
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE}.Release|x86.Build.0 = Release|Any CPU
+ {7AD98807-347C-4742-9769-06F464D4172F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7AD98807-347C-4742-9769-06F464D4172F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7AD98807-347C-4742-9769-06F464D4172F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7AD98807-347C-4742-9769-06F464D4172F}.Debug|x64.Build.0 = Debug|Any CPU
+ {7AD98807-347C-4742-9769-06F464D4172F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7AD98807-347C-4742-9769-06F464D4172F}.Debug|x86.Build.0 = Debug|Any CPU
+ {7AD98807-347C-4742-9769-06F464D4172F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7AD98807-347C-4742-9769-06F464D4172F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7AD98807-347C-4742-9769-06F464D4172F}.Release|x64.ActiveCfg = Release|Any CPU
+ {7AD98807-347C-4742-9769-06F464D4172F}.Release|x64.Build.0 = Release|Any CPU
+ {7AD98807-347C-4742-9769-06F464D4172F}.Release|x86.ActiveCfg = Release|Any CPU
+ {7AD98807-347C-4742-9769-06F464D4172F}.Release|x86.Build.0 = Release|Any CPU
+ {0A425634-BEA8-48DF-821B-B0B3355026BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0A425634-BEA8-48DF-821B-B0B3355026BA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0A425634-BEA8-48DF-821B-B0B3355026BA}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0A425634-BEA8-48DF-821B-B0B3355026BA}.Debug|x64.Build.0 = Debug|Any CPU
+ {0A425634-BEA8-48DF-821B-B0B3355026BA}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0A425634-BEA8-48DF-821B-B0B3355026BA}.Debug|x86.Build.0 = Debug|Any CPU
+ {0A425634-BEA8-48DF-821B-B0B3355026BA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0A425634-BEA8-48DF-821B-B0B3355026BA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0A425634-BEA8-48DF-821B-B0B3355026BA}.Release|x64.ActiveCfg = Release|Any CPU
+ {0A425634-BEA8-48DF-821B-B0B3355026BA}.Release|x64.Build.0 = Release|Any CPU
+ {0A425634-BEA8-48DF-821B-B0B3355026BA}.Release|x86.ActiveCfg = Release|Any CPU
+ {0A425634-BEA8-48DF-821B-B0B3355026BA}.Release|x86.Build.0 = Release|Any CPU
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}.Debug|x64.Build.0 = Debug|Any CPU
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}.Debug|x86.Build.0 = Debug|Any CPU
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}.Release|x64.ActiveCfg = Release|Any CPU
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}.Release|x64.Build.0 = Release|Any CPU
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}.Release|x86.ActiveCfg = Release|Any CPU
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98}.Release|x86.Build.0 = Release|Any CPU
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}.Debug|x64.Build.0 = Debug|Any CPU
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}.Debug|x86.Build.0 = Debug|Any CPU
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}.Release|x64.ActiveCfg = Release|Any CPU
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}.Release|x64.Build.0 = Release|Any CPU
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}.Release|x86.ActiveCfg = Release|Any CPU
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9}.Release|x86.Build.0 = Release|Any CPU
+ {4531468B-0587-400C-B9CC-40A8D4FAF836}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4531468B-0587-400C-B9CC-40A8D4FAF836}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4531468B-0587-400C-B9CC-40A8D4FAF836}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4531468B-0587-400C-B9CC-40A8D4FAF836}.Debug|x64.Build.0 = Debug|Any CPU
+ {4531468B-0587-400C-B9CC-40A8D4FAF836}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4531468B-0587-400C-B9CC-40A8D4FAF836}.Debug|x86.Build.0 = Debug|Any CPU
+ {4531468B-0587-400C-B9CC-40A8D4FAF836}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4531468B-0587-400C-B9CC-40A8D4FAF836}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4531468B-0587-400C-B9CC-40A8D4FAF836}.Release|x64.ActiveCfg = Release|Any CPU
+ {4531468B-0587-400C-B9CC-40A8D4FAF836}.Release|x64.Build.0 = Release|Any CPU
+ {4531468B-0587-400C-B9CC-40A8D4FAF836}.Release|x86.ActiveCfg = Release|Any CPU
+ {4531468B-0587-400C-B9CC-40A8D4FAF836}.Release|x86.Build.0 = Release|Any CPU
+ {150E5C31-6234-4004-A59D-FE65531535AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {150E5C31-6234-4004-A59D-FE65531535AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {150E5C31-6234-4004-A59D-FE65531535AC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {150E5C31-6234-4004-A59D-FE65531535AC}.Debug|x64.Build.0 = Debug|Any CPU
+ {150E5C31-6234-4004-A59D-FE65531535AC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {150E5C31-6234-4004-A59D-FE65531535AC}.Debug|x86.Build.0 = Debug|Any CPU
+ {150E5C31-6234-4004-A59D-FE65531535AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {150E5C31-6234-4004-A59D-FE65531535AC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {150E5C31-6234-4004-A59D-FE65531535AC}.Release|x64.ActiveCfg = Release|Any CPU
+ {150E5C31-6234-4004-A59D-FE65531535AC}.Release|x64.Build.0 = Release|Any CPU
+ {150E5C31-6234-4004-A59D-FE65531535AC}.Release|x86.ActiveCfg = Release|Any CPU
+ {150E5C31-6234-4004-A59D-FE65531535AC}.Release|x86.Build.0 = Release|Any CPU
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3}.Debug|x64.Build.0 = Debug|Any CPU
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3}.Debug|x86.Build.0 = Debug|Any CPU
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3}.Release|x64.ActiveCfg = Release|Any CPU
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3}.Release|x64.Build.0 = Release|Any CPU
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3}.Release|x86.ActiveCfg = Release|Any CPU
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3}.Release|x86.Build.0 = Release|Any CPU
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB}.Debug|x64.Build.0 = Debug|Any CPU
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB}.Debug|x86.Build.0 = Debug|Any CPU
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB}.Release|x64.ActiveCfg = Release|Any CPU
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB}.Release|x64.Build.0 = Release|Any CPU
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB}.Release|x86.ActiveCfg = Release|Any CPU
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB}.Release|x86.Build.0 = Release|Any CPU
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}.Debug|x64.Build.0 = Debug|Any CPU
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}.Debug|x86.Build.0 = Debug|Any CPU
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}.Release|x64.ActiveCfg = Release|Any CPU
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}.Release|x64.Build.0 = Release|Any CPU
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}.Release|x86.ActiveCfg = Release|Any CPU
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203}.Release|x86.Build.0 = Release|Any CPU
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}.Debug|x64.Build.0 = Debug|Any CPU
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}.Debug|x86.Build.0 = Debug|Any CPU
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}.Release|x64.ActiveCfg = Release|Any CPU
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}.Release|x64.Build.0 = Release|Any CPU
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}.Release|x86.ActiveCfg = Release|Any CPU
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -157,5 +401,23 @@ Global
{840E889E-8408-4A2D-A9AD-80F9433069F2} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{7495F11C-6ACB-445A-BE8F-A73969EB8651} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{95D74A3F-243F-4CB0-945F-B47EABC672CF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {757952EE-C92D-4072-8F40-1BA61093A87C} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {41519048-787A-4EF9-BDD5-8CDAD0371302} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {6E1DAA5B-76DC-4481-88C5-A97E088FF5CA} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {BCE757F3-EB66-4D43-8727-14C8A218DF56} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {24B516B7-D63D-46A7-A321-B3B7C1BACA4D} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {7A0F1CDA-CD9C-41E9-85CD-D60BB941DD93} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {70833E64-57E1-420C-ADF6-0EB4DE9D43DE} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {7AD98807-347C-4742-9769-06F464D4172F} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {0A425634-BEA8-48DF-821B-B0B3355026BA} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {D3D909B6-1C6B-45A2-9260-1ABEBDC66D98} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {0A631C0F-7ED8-4EB0-8F18-0745A9E734B9} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {4531468B-0587-400C-B9CC-40A8D4FAF836} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {150E5C31-6234-4004-A59D-FE65531535AC} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {F6B47443-D6B9-4193-9D65-FAEED5DB73B3} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {A125BD71-85FA-4356-AD5A-9098A5E17CFB} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {0B29BCE9-C3C8-4F76-BAAB-7877A34AE203} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {C7BAFAB4-7248-6C1D-4F1A-5C71C8CBBB62} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
+ {5AD619CA-B4BA-4BDC-A9A4-FA83E096BF80} = {C7BAFAB4-7248-6C1D-4F1A-5C71C8CBBB62}
EndGlobalSection
EndGlobal
diff --git a/samples/Credentials.Sample.Bbs2023/Credentials.Sample.Bbs2023.csproj b/samples/Credentials.Sample.Bbs2023/Credentials.Sample.Bbs2023.csproj
new file mode 100644
index 0000000..0861097
--- /dev/null
+++ b/samples/Credentials.Sample.Bbs2023/Credentials.Sample.Bbs2023.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.Bbs2023
+ Credentials.Sample.Bbs2023
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.Bbs2023/Program.cs b/samples/Credentials.Sample.Bbs2023/Program.cs
new file mode 100644
index 0000000..53b19f7
--- /dev/null
+++ b/samples/Credentials.Sample.Bbs2023/Program.cs
@@ -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;
+
+///
+/// Selective disclosure via the bbs-2023 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.
+///
+///
+/// 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.
+///
+public static class Program
+{
+ private static readonly DefaultKeyGenerator KeyGen = new();
+
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; when null the sample builds its own.
+ 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();
+ var verifier = provider.GetRequiredService();
+
+ // 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();
+ }
+ }
+
+ ///
+ /// 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 did:key. Mirrors the M5 test's CraftBaseAsync/EmbedBaseProofAsync.
+ ///
+ private static async Task 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));
+ }
+}
diff --git a/samples/Credentials.Sample.CoseEnvelope/Credentials.Sample.CoseEnvelope.csproj b/samples/Credentials.Sample.CoseEnvelope/Credentials.Sample.CoseEnvelope.csproj
new file mode 100644
index 0000000..0153bda
--- /dev/null
+++ b/samples/Credentials.Sample.CoseEnvelope/Credentials.Sample.CoseEnvelope.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.CoseEnvelope
+ Credentials.Sample.CoseEnvelope
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.CoseEnvelope/Program.cs b/samples/Credentials.Sample.CoseEnvelope/Program.cs
new file mode 100644
index 0000000..0e6e5f5
--- /dev/null
+++ b/samples/Credentials.Sample.CoseEnvelope/Program.cs
@@ -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;
+
+/// 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.
+public static class Program
+{
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; when null the sample builds its own.
+ 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();
+ var verifier = provider.GetRequiredService();
+ 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();
+ }
+ }
+}
diff --git a/samples/Credentials.Sample.DataIntegrity/Credentials.Sample.DataIntegrity.csproj b/samples/Credentials.Sample.DataIntegrity/Credentials.Sample.DataIntegrity.csproj
new file mode 100644
index 0000000..6165ac5
--- /dev/null
+++ b/samples/Credentials.Sample.DataIntegrity/Credentials.Sample.DataIntegrity.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.DataIntegrity
+ Credentials.Sample.DataIntegrity
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.DataIntegrity/Program.cs b/samples/Credentials.Sample.DataIntegrity/Program.cs
new file mode 100644
index 0000000..3196820
--- /dev/null
+++ b/samples/Credentials.Sample.DataIntegrity/Program.cs
@@ -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;
+
+/// Issues a credential with an embedded W3C Data Integrity proof (eddsa-jcs-2022) and verifies it.
+public static class Program
+{
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; when null the sample builds its own.
+ 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();
+ var verifier = provider.GetRequiredService();
+
+ // Discover which securing forms/suites are registered (runtime-discovered strings, FR-053).
+ var capabilities = provider.GetRequiredService();
+ 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();
+ }
+ }
+}
diff --git a/samples/Credentials.Sample.DataIntegrityRdfc/Credentials.Sample.DataIntegrityRdfc.csproj b/samples/Credentials.Sample.DataIntegrityRdfc/Credentials.Sample.DataIntegrityRdfc.csproj
new file mode 100644
index 0000000..2c3fd0a
--- /dev/null
+++ b/samples/Credentials.Sample.DataIntegrityRdfc/Credentials.Sample.DataIntegrityRdfc.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.DataIntegrityRdfc
+ Credentials.Sample.DataIntegrityRdfc
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.DataIntegrityRdfc/Program.cs b/samples/Credentials.Sample.DataIntegrityRdfc/Program.cs
new file mode 100644
index 0000000..885d13a
--- /dev/null
+++ b/samples/Credentials.Sample.DataIntegrityRdfc/Program.cs
@@ -0,0 +1,67 @@
+using System.Text.Json.Nodes;
+using Credentials;
+using Credentials.Roles;
+using Credentials.Samples.Shared;
+using Credentials.Verification;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Credentials.Samples.DataIntegrityRdfc;
+
+/// Issues a credential with an embedded W3C Data Integrity proof using the opt-in RDFC suite
+/// (eddsa-rdfc-2022, registered via UseRdfcSuites()) and verifies it.
+public static class Program
+{
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; when null the sample builds its own.
+ public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
+ {
+ var narrator = new SampleNarrator(output);
+ narrator.Banner("Embedded Data Integrity (eddsa-rdfc-2022) — issue + verify", "FR-011", "FR-053");
+
+ var provider = services ?? new ServiceCollection()
+ .AddCredentials(b => b.UseNetDid().UseRdfcSuites())
+ .BuildServiceProvider();
+ var ownsProvider = services is null;
+ try
+ {
+ var issuer = provider.GetRequiredService();
+ var verifier = provider.GetRequiredService();
+ var issuerKey = SampleKeys.New();
+ narrator.Step($"minted an issuer identity: {issuerKey.Did[..28]}…");
+
+ var unsecured = Credential.Build()
+ .WithId("urn:uuid:22222222-2222-2222-2222-222222222222")
+ .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-rdfc-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();
+ }
+ }
+}
diff --git a/samples/Credentials.Sample.FullPipeline/Credentials.Sample.FullPipeline.csproj b/samples/Credentials.Sample.FullPipeline/Credentials.Sample.FullPipeline.csproj
new file mode 100644
index 0000000..7009f05
--- /dev/null
+++ b/samples/Credentials.Sample.FullPipeline/Credentials.Sample.FullPipeline.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.FullPipeline
+ Credentials.Sample.FullPipeline
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.FullPipeline/Program.cs b/samples/Credentials.Sample.FullPipeline/Program.cs
new file mode 100644
index 0000000..d828c84
--- /dev/null
+++ b/samples/Credentials.Sample.FullPipeline/Program.cs
@@ -0,0 +1,175 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using Credentials;
+using Credentials.Roles;
+using Credentials.Samples.Shared;
+using Credentials.Schema;
+using Credentials.Status;
+using Credentials.Verification;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Credentials.Samples.FullPipeline;
+
+///
+/// The showcase: ONE credential carrying BOTH a credentialSchema and a credentialStatus,
+/// verified through a provider wired with every M2 hook — an in-sample status-list fetcher (bit clear),
+/// an in-sample schema resolver (matching schema), and an allowlist issuer-trust policy. Every gating
+/// check (proof / structure / validity / status / schema / issuerTrust) must land on Passed and the
+/// overall decision must be Accepted.
+///
+public static class Program
+{
+ private const string SchemaUrl = "https://schema.example/person";
+ private const string StatusListUrl = "https://issuer.example/status/1";
+ private const long StatusIndex = 94_567;
+
+ // A schema the issued credential satisfies (its subject carries a "name").
+ private const string PersonSchema =
+ """
+ {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "credentialSubject": {
+ "type": "object",
+ "properties": { "name": { "type": "string" } },
+ "required": ["name"]
+ }
+ },
+ "required": ["credentialSubject"]
+ }
+ """;
+
+ public static Task Main() => RunAsync(Console.Out);
+
+ public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
+ {
+ var narrator = new SampleNarrator(output);
+ narrator.Banner(
+ "Full pipeline — proof + structure + validity + status + schema + trust in one verification",
+ "FR-040", "FR-070", "FR-022", "FR-081", "FR-082");
+
+ var issuerKey = SampleKeys.New();
+ narrator.Step($"minted an issuer identity: {issuerKey.Did[..28]}…");
+
+ // The status list is itself a signed VC. Sign an empty (all-clear) revocation list with the
+ // SAME issuer so it is trusted (no issuer mismatch), then hand its secured bytes to the fetcher.
+ var statusListBytes = await IssueClearStatusListAsync(issuerKey);
+ narrator.Step($"issuer signed an all-clear status list (bit {StatusIndex} not set)");
+
+ var schemaBytes = Encoding.UTF8.GetBytes(PersonSchema);
+
+ var provider = services ?? new ServiceCollection()
+ .AddCredentials(b => b
+ .UseNetDid()
+ .UseStatusListFetcher(new InMemoryStatusListFetcher(statusListBytes))
+ .UseSchemaResolver(new InMemorySchemaResolver(schemaBytes))
+ .UseIssuerTrustPolicy(new AllowlistIssuerTrustPolicy(issuerKey.Did)))
+ .BuildServiceProvider();
+ var ownsProvider = services is null;
+ try
+ {
+ var issuer = provider.GetRequiredService();
+ var verifier = provider.GetRequiredService();
+
+ // ONE credential that references BOTH the schema and the status list, and declares a
+ // digestSRI over the schema bytes (the engine enforces it itself).
+ var schemaDigest = "sha256-" + Convert.ToBase64String(SHA256.HashData(schemaBytes));
+ var unsecured = Credential.Build()
+ .WithId("urn:uuid:33333333-3333-3333-3333-333333333333")
+ .WithIssuer(issuerKey.Did)
+ .AddType("PersonCredential")
+ .AddSchema(new JsonObject
+ {
+ ["id"] = SchemaUrl,
+ ["type"] = "JsonSchema",
+ ["digestSRI"] = schemaDigest,
+ })
+ .AddStatus(BitstringStatusListEntry.Create(StatusPurpose.Revocation, StatusIndex, StatusListUrl))
+ .AddSubject(new JsonObject { ["id"] = "did:example:subject", ["name"] = "Ada Lovelace" })
+ .Seal();
+ narrator.Step("issued one credential carrying BOTH credentialSchema and credentialStatus");
+
+ var issued = await issuer.IssueAsync(unsecured, new DataIntegrityIssuanceRequest
+ {
+ Cryptosuite = "eddsa-jcs-2022",
+ Signer = issuerKey.Signer,
+ VerificationMethod = issuerKey.VerificationMethod,
+ });
+
+ var result = await verifier.VerifyCredentialAsync(issued.Credential);
+
+ // Narrate every gating check, then prove they all Passed.
+ string[] gating =
+ [
+ CheckKinds.Proof, CheckKinds.Structure, CheckKinds.Validity,
+ CheckKinds.Status, CheckKinds.Schema, CheckKinds.IssuerTrust,
+ ];
+ foreach (var kind in gating)
+ {
+ var status = result.Check(kind)?.Status;
+ narrator.Step($"check {kind,-11} = {status}");
+ if (status != CheckStatus.Passed)
+ {
+ throw new InvalidOperationException(
+ $"expected check '{kind}' to be Passed, got {status?.ToString() ?? ""}");
+ }
+ }
+
+ narrator.Result($"decision={result.Decision} (all six gating checks Passed)");
+ if (result.Decision != VerificationDecision.Accepted)
+ {
+ throw new InvalidOperationException($"expected Accepted, got {result.Decision}");
+ }
+ }
+ finally
+ {
+ if (ownsProvider && provider is IDisposable d)
+ {
+ d.Dispose();
+ }
+ }
+ }
+
+ // Sign an empty (all-clear) revocation status list with the given issuer, returning its secured bytes.
+ private static async Task IssueClearStatusListAsync(SampleKey issuerKey)
+ {
+ using var seed = new ServiceCollection()
+ .AddCredentials(b => b.UseNetDid())
+ .BuildServiceProvider();
+
+ var list = new StatusListManager().CreateList(new StatusListCreateOptions
+ {
+ Id = StatusListUrl,
+ Issuer = issuerKey.Did,
+ StatusPurpose = StatusPurpose.Revocation,
+ ValidFrom = DateTimeOffset.UtcNow.AddDays(-1),
+ });
+
+ var issued = await seed.GetRequiredService().IssueAsync(list, new DataIntegrityIssuanceRequest
+ {
+ Cryptosuite = "eddsa-jcs-2022",
+ Signer = issuerKey.Signer,
+ VerificationMethod = issuerKey.VerificationMethod,
+ });
+ return issued.Credential.ToBytes();
+ }
+
+ /// An in-sample fetcher that always returns the one all-clear status list (offline).
+ private sealed class InMemoryStatusListFetcher(byte[] securedListBytes) : IStatusListFetcher
+ {
+ public Task FetchAsync(
+ StatusListReference reference, CancellationToken cancellationToken = default) =>
+ Task.FromResult(StatusListFetchResult.Found(securedListBytes));
+ }
+
+ /// An in-sample resolver that always returns the one matching JSON Schema (offline).
+ private sealed class InMemorySchemaResolver(byte[] schemaBytes) : ICredentialSchemaResolver
+ {
+ public Task ResolveAsync(
+ SchemaReference reference, CancellationToken cancellationToken = default) =>
+ Task.FromResult(SchemaResolutionResult.Found(
+ new ResolvedSchema(SchemaUrl, SchemaDialect.JsonSchema2020_12, schemaBytes)));
+ }
+}
diff --git a/samples/Credentials.Sample.IssuerTrust/Credentials.Sample.IssuerTrust.csproj b/samples/Credentials.Sample.IssuerTrust/Credentials.Sample.IssuerTrust.csproj
new file mode 100644
index 0000000..595ba16
--- /dev/null
+++ b/samples/Credentials.Sample.IssuerTrust/Credentials.Sample.IssuerTrust.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.IssuerTrust
+ Credentials.Sample.IssuerTrust
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.IssuerTrust/Program.cs b/samples/Credentials.Sample.IssuerTrust/Program.cs
new file mode 100644
index 0000000..266f25f
--- /dev/null
+++ b/samples/Credentials.Sample.IssuerTrust/Program.cs
@@ -0,0 +1,72 @@
+using System.Text.Json.Nodes;
+using Credentials;
+using Credentials.Roles;
+using Credentials.Samples.Shared;
+using Credentials.Verification;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Credentials.Samples.IssuerTrust;
+
+///
+/// Issuer-trust evaluation (FR-081/FR-082): the same proof-valid credential is Accepted when its issuer
+/// is on the verifier's allowlist and Rejected when it is not. The library ships no trust lists — the
+/// is a sample-side policy supplied by the verifier (FR-082).
+///
+public static class Program
+{
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; unused here because the sample wires
+ /// two different trust policies, so it always builds its own providers.
+ public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
+ {
+ _ = services;
+ var narrator = new SampleNarrator(output);
+ narrator.Banner("Issuer trust (allowlist policy) — trusted Accepted vs untrusted Rejected", "FR-081", "FR-082");
+ narrator.Step("FR-082: the library ships NO trust lists; this allowlist is a sample-side verifier policy");
+
+ var issuerKey = SampleKeys.New();
+ narrator.Step($"minted an issuer identity: {issuerKey.Did[..28]}…");
+
+ // Issue one proof-valid credential up front; both verifiers see the very same bytes.
+ await using var issuerProvider = new ServiceCollection().AddCredentials(b => b.UseNetDid()).BuildServiceProvider();
+ var issuer = issuerProvider.GetRequiredService();
+ var unsecured = Credential.Build()
+ .WithId("urn:uuid:22222222-2222-2222-2222-222222222222")
+ .WithIssuer(issuerKey.Did)
+ .AddType("UniversityDegreeCredential")
+ .AddSubject(new JsonObject { ["id"] = "did:example:subject" })
+ .Seal();
+ var issued = await issuer.IssueAsync(unsecured, new DataIntegrityIssuanceRequest
+ {
+ Cryptosuite = "eddsa-jcs-2022",
+ Signer = issuerKey.Signer,
+ VerificationMethod = issuerKey.VerificationMethod,
+ });
+ narrator.Step($"issued a Data Integrity credential (issuer={issuerKey.Did[..16]}…)");
+
+ // 1) Verifier that trusts this issuer → IssuerTrust Passed + Accepted.
+ await using var trustedProvider = new ServiceCollection()
+ .AddCredentials(b => b.UseNetDid().UseIssuerTrustPolicy(new AllowlistIssuerTrustPolicy(issuerKey.Did)))
+ .BuildServiceProvider();
+ var trusted = await trustedProvider.GetRequiredService().VerifyCredentialAsync(issued.Credential);
+ var trustedCheck = trusted.Check(CheckKinds.IssuerTrust)!;
+ narrator.Result($"trusted verifier: issuerTrust={trustedCheck.Status}, decision={trusted.Decision}");
+
+ // 2) Verifier whose allowlist excludes this issuer → IssuerTrust Failed + Rejected.
+ await using var untrustedProvider = new ServiceCollection()
+ .AddCredentials(b => b.UseNetDid().UseIssuerTrustPolicy(new AllowlistIssuerTrustPolicy("did:example:someone-else")))
+ .BuildServiceProvider();
+ var untrusted = await untrustedProvider.GetRequiredService().VerifyCredentialAsync(issued.Credential);
+ var untrustedCheck = untrusted.Check(CheckKinds.IssuerTrust)!;
+ narrator.Result($"untrusted verifier: issuerTrust={untrustedCheck.Status}, decision={untrusted.Decision}");
+
+ if (trustedCheck.Status != CheckStatus.Passed || trusted.Decision != VerificationDecision.Accepted)
+ throw new InvalidOperationException($"sample invariant failed: expected the allowlisted issuer to be Accepted, got {trusted.Decision} (issuerTrust={trustedCheck.Status})");
+ if (untrustedCheck.Status != CheckStatus.Failed || untrusted.Decision != VerificationDecision.Rejected)
+ throw new InvalidOperationException($"sample invariant failed: expected the non-allowlisted issuer to be Rejected, got {untrusted.Decision} (issuerTrust={untrustedCheck.Status})");
+ }
+}
diff --git a/samples/Credentials.Sample.JoseEnvelope/Credentials.Sample.JoseEnvelope.csproj b/samples/Credentials.Sample.JoseEnvelope/Credentials.Sample.JoseEnvelope.csproj
new file mode 100644
index 0000000..95e961d
--- /dev/null
+++ b/samples/Credentials.Sample.JoseEnvelope/Credentials.Sample.JoseEnvelope.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.JoseEnvelope
+ Credentials.Sample.JoseEnvelope
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.JoseEnvelope/Program.cs b/samples/Credentials.Sample.JoseEnvelope/Program.cs
new file mode 100644
index 0000000..3c6a3fb
--- /dev/null
+++ b/samples/Credentials.Sample.JoseEnvelope/Program.cs
@@ -0,0 +1,70 @@
+using System.Text;
+using System.Text.Json.Nodes;
+using Credentials;
+using Credentials.Roles;
+using Credentials.Samples.Shared;
+using Credentials.Verification;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Credentials.Samples.JoseEnvelope;
+
+/// Issues a VC-JOSE enveloped credential (application/vc+jwt) and verifies it both as the issued
+/// credential object and from the verbatim compact-JWS wire bytes.
+public static class Program
+{
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; when null the sample builds its own.
+ public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
+ {
+ var narrator = new SampleNarrator(output);
+ narrator.Banner("VC-JOSE enveloping (application/vc+jwt) — 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();
+ var verifier = provider.GetRequiredService();
+ var issuerKey = SampleKeys.New();
+ narrator.Step($"minted an issuer identity: {issuerKey.Did[..28]}…");
+
+ var unsecured = Credential.Build()
+ .WithId("urn:uuid:33333333-3333-3333-3333-333333333333")
+ .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 JoseEnvelopeIssuanceRequest
+ {
+ Signer = issuerKey.Signer,
+ VerificationMethod = issuerKey.VerificationMethod,
+ });
+
+ var segments = issued.CompactJws!.Split('.').Length;
+ narrator.Step($"enveloped it: {issued.Form}, mediaType={issued.MediaType}, compact-JWS segments={segments}");
+ if (segments != 3)
+ throw new InvalidOperationException($"sample invariant failed: expected a 3-part compact JWS, got {segments} segments");
+
+ var direct = await verifier.VerifyCredentialAsync(issued.Credential);
+ narrator.Step($"verified the issued credential: decision={direct.Decision}, mechanism={direct.Mechanism}");
+
+ var wire = await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(issued.CompactJws));
+ narrator.Result($"verified from verbatim compact-JWS 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();
+ }
+ }
+}
diff --git a/samples/Credentials.Sample.PresentationDataIntegrity/Credentials.Sample.PresentationDataIntegrity.csproj b/samples/Credentials.Sample.PresentationDataIntegrity/Credentials.Sample.PresentationDataIntegrity.csproj
new file mode 100644
index 0000000..103c4c4
--- /dev/null
+++ b/samples/Credentials.Sample.PresentationDataIntegrity/Credentials.Sample.PresentationDataIntegrity.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.PresentationDataIntegrity
+ Credentials.Sample.PresentationDataIntegrity
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.PresentationDataIntegrity/Program.cs b/samples/Credentials.Sample.PresentationDataIntegrity/Program.cs
new file mode 100644
index 0000000..a0b7037
--- /dev/null
+++ b/samples/Credentials.Sample.PresentationDataIntegrity/Program.cs
@@ -0,0 +1,90 @@
+using System.Text.Json.Nodes;
+using Credentials;
+using Credentials.Roles;
+using Credentials.Samples.Shared;
+using Credentials.Verification;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Credentials.Samples.PresentationDataIntegrity;
+
+///
+/// Holder flow: a holder ingests an issued credential, assembles a Verifiable Presentation over it, binds
+/// the VP to a holder key with an embedded Data Integrity authentication proof (challenge + domain), and a
+/// verifier verifies the holder binding plus the contained credential.
+///
+public static class Program
+{
+ private const string Challenge = "challenge-xyz-789";
+ private const string Domain = "https://verifier.example";
+
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; when null the sample builds its own.
+ public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
+ {
+ var narrator = new SampleNarrator(output);
+ narrator.Banner("Verifiable Presentation — Data Integrity holder binding (eddsa-jcs-2022)", "FR-030", "FR-033", "FR-034", "FR-041");
+
+ var provider = services ?? new ServiceCollection().AddCredentials(b => b.UseNetDid()).BuildServiceProvider();
+ var ownsProvider = services is null;
+ try
+ {
+ var issuer = provider.GetRequiredService();
+ var holder = provider.GetRequiredService();
+ var verifier = provider.GetRequiredService();
+
+ var issuerKey = SampleKeys.New();
+ var holderKey = SampleKeys.New();
+ narrator.Step($"minted an issuer {issuerKey.Did[..28]}… and a holder {holderKey.Did[..28]}…");
+
+ var unsecured = Credential.Build()
+ .WithIssuer(issuerKey.Did)
+ .AddSubject(new JsonObject { ["id"] = holderKey.Did, ["alumniOf"] = "Example University" })
+ .Seal();
+ var issued = await issuer.IssueAsync(unsecured, new DataIntegrityIssuanceRequest
+ {
+ Cryptosuite = "eddsa-jcs-2022",
+ Signer = issuerKey.Signer,
+ VerificationMethod = issuerKey.VerificationMethod,
+ });
+ narrator.Step($"issuer secured the credential: {issued.Credential.Securing}");
+
+ var held = holder.Ingest(issued.Credential.ToBytes());
+ narrator.Step($"holder ingested it: {held.Securing}");
+
+ var vp = holder.BuildPresentation(new VpAssemblyRequest
+ {
+ Holder = holderKey.Did,
+ Credentials = [ContainedCredential.Embedded(held.Credential)],
+ });
+ narrator.Step("holder assembled a VP over the embedded credential");
+
+ var bound = await holder.BindWithDataIntegrityAsync(vp, new VpBindingRequest
+ {
+ HolderSigner = holderKey.Signer,
+ VerificationMethod = holderKey.VerificationMethod,
+ Challenge = Challenge,
+ Domain = Domain,
+ });
+ narrator.Step($"holder bound the VP: {bound.Securing} (challenge + domain)");
+
+ var result = await verifier.VerifyPresentationAsync(bound, new PresentationVerificationOptions
+ {
+ RequireHolderBinding = true,
+ ExpectedChallenge = Challenge,
+ ExpectedDomain = Domain,
+ });
+ narrator.Result($"decision={result.Decision} (holderBinding={result.Check(CheckKinds.HolderBinding)!.Status}, contained={result.Credentials[0].Decision})");
+
+ 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();
+ }
+ }
+}
diff --git a/samples/Credentials.Sample.PresentationJose/Credentials.Sample.PresentationJose.csproj b/samples/Credentials.Sample.PresentationJose/Credentials.Sample.PresentationJose.csproj
new file mode 100644
index 0000000..a38a29d
--- /dev/null
+++ b/samples/Credentials.Sample.PresentationJose/Credentials.Sample.PresentationJose.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.PresentationJose
+ Credentials.Sample.PresentationJose
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.PresentationJose/Program.cs b/samples/Credentials.Sample.PresentationJose/Program.cs
new file mode 100644
index 0000000..8f9bb64
--- /dev/null
+++ b/samples/Credentials.Sample.PresentationJose/Program.cs
@@ -0,0 +1,93 @@
+using System.Text;
+using System.Text.Json.Nodes;
+using Credentials;
+using Credentials.Roles;
+using Credentials.Samples.Shared;
+using Credentials.Verification;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Credentials.Samples.PresentationJose;
+
+///
+/// Holder flow: a holder ingests an issued credential, assembles a Verifiable Presentation over it, binds
+/// the VP to a holder key as a JOSE-enveloped vp+jwt (challenge + domain), and a verifier verifies the
+/// holder binding plus the contained credential from the bound vp+jwt bytes.
+///
+public static class Program
+{
+ private const string Challenge = "challenge-xyz-789";
+ private const string Domain = "https://verifier.example";
+
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; when null the sample builds its own.
+ public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
+ {
+ var narrator = new SampleNarrator(output);
+ narrator.Banner("Verifiable Presentation — JOSE-enveloped holder binding (vp+jwt)", "FR-034", "FR-041");
+
+ var provider = services ?? new ServiceCollection().AddCredentials(b => b.UseNetDid()).BuildServiceProvider();
+ var ownsProvider = services is null;
+ try
+ {
+ var issuer = provider.GetRequiredService();
+ var holder = provider.GetRequiredService();
+ var verifier = provider.GetRequiredService();
+
+ var issuerKey = SampleKeys.New();
+ var holderKey = SampleKeys.New();
+ narrator.Step($"minted an issuer {issuerKey.Did[..28]}… and a holder {holderKey.Did[..28]}…");
+
+ var unsecured = Credential.Build()
+ .WithIssuer(issuerKey.Did)
+ .AddSubject(new JsonObject { ["id"] = holderKey.Did, ["alumniOf"] = "Example University" })
+ .Seal();
+ var issued = await issuer.IssueAsync(unsecured, new DataIntegrityIssuanceRequest
+ {
+ Cryptosuite = "eddsa-jcs-2022",
+ Signer = issuerKey.Signer,
+ VerificationMethod = issuerKey.VerificationMethod,
+ });
+ narrator.Step($"issuer secured the credential: {issued.Credential.Securing}");
+
+ var held = holder.Ingest(issued.Credential.ToBytes());
+ narrator.Step($"holder ingested it: {held.Securing}");
+
+ var vp = holder.BuildPresentation(new VpAssemblyRequest
+ {
+ Holder = holderKey.Did,
+ Credentials = [ContainedCredential.Embedded(held.Credential)],
+ });
+ narrator.Step("holder assembled a VP over the embedded credential");
+
+ var vpJwt = await holder.BindWithJoseEnvelopeAsync(vp, new VpBindingRequest
+ {
+ HolderSigner = holderKey.Signer,
+ VerificationMethod = holderKey.VerificationMethod,
+ Challenge = Challenge,
+ Domain = Domain,
+ });
+ narrator.Step($"holder bound the VP as a vp+jwt ({vpJwt.Split('.').Length}-part compact JWS)");
+
+ var result = await verifier.VerifyPresentationAsync(
+ Encoding.UTF8.GetBytes(vpJwt),
+ new PresentationVerificationOptions
+ {
+ RequireHolderBinding = true,
+ ExpectedChallenge = Challenge,
+ ExpectedDomain = Domain,
+ });
+ narrator.Result($"decision={result.Decision} (mechanism={result.Mechanism}, holderBinding={result.Check(CheckKinds.HolderBinding)!.Status}, contained={result.Credentials[0].Decision})");
+
+ 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();
+ }
+ }
+}
diff --git a/samples/Credentials.Sample.Schema/Credentials.Sample.Schema.csproj b/samples/Credentials.Sample.Schema/Credentials.Sample.Schema.csproj
new file mode 100644
index 0000000..9fc5d67
--- /dev/null
+++ b/samples/Credentials.Sample.Schema/Credentials.Sample.Schema.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.Schema
+ Credentials.Sample.Schema
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.Schema/Program.cs b/samples/Credentials.Sample.Schema/Program.cs
new file mode 100644
index 0000000..aeb7a66
--- /dev/null
+++ b/samples/Credentials.Sample.Schema/Program.cs
@@ -0,0 +1,97 @@
+using System.Text;
+using System.Text.Json.Nodes;
+using Credentials;
+using Credentials.Roles;
+using Credentials.Samples.Shared;
+using Credentials.Schema;
+using Credentials.Verification;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Credentials.Samples.Schema;
+
+///
+/// Credential schema validation (FR-070): the credential declares a credentialSchema the verifier
+/// fetches through a caller-supplied , then validates the credential
+/// against that JSON Schema 2020-12. A conforming credential ⇒ schema Passed + Accepted.
+///
+public static class Program
+{
+ private const string SchemaUrl = "https://schema.example/person";
+
+ private const string PersonSchema =
+ """
+ {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "credentialSubject": {
+ "type": "object",
+ "properties": { "name": { "type": "string" } },
+ "required": ["name"]
+ }
+ },
+ "required": ["credentialSubject"]
+ }
+ """;
+
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; when null the sample builds its own.
+ public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
+ {
+ var narrator = new SampleNarrator(output);
+ narrator.Banner("Credential Schema — JSON Schema 2020-12 validation", "FR-070");
+
+ var resolver = new InMemorySchemaResolver(Encoding.UTF8.GetBytes(PersonSchema));
+
+ var provider = services ?? new ServiceCollection()
+ .AddCredentials(b => b.UseNetDid().UseSchemaResolver(resolver))
+ .BuildServiceProvider();
+ var ownsProvider = services is null;
+ try
+ {
+ var issuer = provider.GetRequiredService();
+ var verifier = provider.GetRequiredService();
+ var issuerKey = SampleKeys.New();
+ narrator.Step($"minted an issuer identity: {issuerKey.Did[..28]}…");
+ narrator.Step($"serving a JSON Schema 2020-12 (requires credentialSubject.name) at {SchemaUrl}");
+
+ // The credential declares the schema and carries the required 'name' member, so it conforms.
+ var unsecured = Credential.Build()
+ .WithId("urn:uuid:33333333-3333-3333-3333-333333333333")
+ .WithIssuer(issuerKey.Did)
+ .AddSchema(new JsonObject { ["id"] = SchemaUrl, ["type"] = "JsonSchema" })
+ .AddSubject(new JsonObject { ["id"] = "did:example:subject", ["name"] = "Ada" })
+ .Seal();
+
+ var issued = await issuer.IssueAsync(unsecured, new DataIntegrityIssuanceRequest
+ {
+ Cryptosuite = "eddsa-jcs-2022",
+ Signer = issuerKey.Signer,
+ VerificationMethod = issuerKey.VerificationMethod,
+ });
+ narrator.Step("issued a credential with a credentialSchema and a conforming credentialSubject");
+
+ var result = await verifier.VerifyCredentialAsync(issued.Credential);
+ narrator.Result($"schema={result.Check(CheckKinds.Schema)!.Status}, decision={result.Decision}");
+
+ if (result.Check(CheckKinds.Schema)!.Status != CheckStatus.Passed || result.Decision != VerificationDecision.Accepted)
+ throw new InvalidOperationException(
+ $"sample invariant failed: expected schema Passed + Accepted, got {result.Check(CheckKinds.Schema)!.Status}/{result.Decision}");
+ }
+ finally
+ {
+ if (ownsProvider && provider is IDisposable disposable) disposable.Dispose();
+ }
+ }
+
+ /// An offline schema resolver that returns a fixed JSON Schema 2020-12 document.
+ private sealed class InMemorySchemaResolver(byte[] schemaBytes) : ICredentialSchemaResolver
+ {
+ public Task ResolveAsync(SchemaReference reference, CancellationToken cancellationToken = default) =>
+ Task.FromResult(SchemaResolutionResult.Found(new ResolvedSchema(SchemaUrl, SchemaDialect.JsonSchema2020_12, schemaBytes)));
+ }
+}
diff --git a/samples/Credentials.Sample.SdJwtPresentation/Credentials.Sample.SdJwtPresentation.csproj b/samples/Credentials.Sample.SdJwtPresentation/Credentials.Sample.SdJwtPresentation.csproj
new file mode 100644
index 0000000..ad1ba49
--- /dev/null
+++ b/samples/Credentials.Sample.SdJwtPresentation/Credentials.Sample.SdJwtPresentation.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.SdJwtPresentation
+ Credentials.Sample.SdJwtPresentation
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.SdJwtPresentation/Program.cs b/samples/Credentials.Sample.SdJwtPresentation/Program.cs
new file mode 100644
index 0000000..222cb2d
--- /dev/null
+++ b/samples/Credentials.Sample.SdJwtPresentation/Program.cs
@@ -0,0 +1,90 @@
+using System.Text;
+using System.Text.Json.Nodes;
+using Credentials;
+using Credentials.Roles;
+using Credentials.Samples.Shared;
+using Credentials.Verification;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Credentials.Samples.SdJwtPresentation;
+
+///
+/// Holder-side SD-JWT VC presentation (FR-030/FR-032): an issuer mints a cnf-bound SD-JWT VC with a
+/// disclosable claim, the holder ingests + inspects it, presents a chosen disclosure subset under a
+/// Key Binding JWT bound to the verifier's audience + nonce, and the verifier accepts it.
+///
+public static class Program
+{
+ private const string Vct = "https://credentials.example/identity_credential";
+ private const string Audience = "https://verifier.example";
+ private const string Nonce = "nonce-abc-123";
+
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; when null the sample builds its own.
+ public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
+ {
+ var narrator = new SampleNarrator(output);
+ narrator.Banner("SD-JWT VC holder presentation — ingest, inspect, present (KB-JWT) + verify", "FR-030", "FR-032");
+
+ var provider = services ?? new ServiceCollection().AddCredentials(b => b.UseNetDid()).BuildServiceProvider();
+ var ownsProvider = services is null;
+ try
+ {
+ var issuer = provider.GetRequiredService();
+ var holder = provider.GetRequiredService();
+ var verifier = provider.GetRequiredService();
+
+ var issuerKey = SampleKeys.New();
+ var holderKey = SampleKeys.New();
+ narrator.Step($"minted an issuer ({issuerKey.Did[..28]}…) and a holder confirmation key ({holderKey.Did[..28]}…)");
+
+ var unsecured = Credential.Build()
+ .WithId("urn:uuid:22222222-2222-2222-2222-222222222222")
+ .WithIssuer(issuerKey.Did)
+ .AddSubject(new JsonObject { ["id"] = "did:example:subject", ["given_name"] = "Alice", ["age"] = 42 })
+ .Seal();
+
+ var issued = await issuer.IssueAsync(unsecured, new SdJwtVcIssuanceRequest
+ {
+ Vct = Vct,
+ Signer = issuerKey.Signer,
+ VerificationMethod = issuerKey.VerificationMethod,
+ Disclosable = [DisclosureSelector.ObjectProperties("credentialSubject", "given_name")],
+ HolderBinding = HolderBindingKey.FromMultikey(holderKey.Signer.MultibasePublicKey),
+ });
+ narrator.Step($"issued a cnf-bound SD-JWT VC: form={issued.Form}, mediaType={issued.MediaType}");
+
+ var held = holder.Ingest(Encoding.UTF8.GetBytes(issued.CompactSdJwt!));
+ narrator.Step($"holder ingested it: securing={held.Securing}");
+
+ var inspection = holder.InspectSdJwt(held);
+ narrator.Step($"inspected: vct={inspection.Vct}, holderBinding={inspection.SupportsHolderBinding}, disclosable=[{string.Join(", ", inspection.DisclosableClaims)}]");
+
+ var presentation = await holder.PresentSdJwtAsync(held, new SdJwtPresentationRequest
+ {
+ DiscloseClaims = ["given_name"],
+ HolderSigner = holderKey.Signer,
+ VerificationMethod = holderKey.VerificationMethod,
+ Audience = Audience,
+ Nonce = Nonce,
+ });
+ narrator.Step($"presented with a KB-JWT bound to aud={Audience}, nonce={Nonce}");
+
+ var result = await verifier.VerifyCredentialAsync(
+ Encoding.UTF8.GetBytes(presentation),
+ new CredentialVerificationOptions { RequireHolderBinding = true, ExpectedAudience = Audience, ExpectedNonce = Nonce });
+ narrator.Result($"decision={result.Decision} (proof={result.Check(CheckKinds.Proof)!.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();
+ }
+ }
+}
diff --git a/samples/Credentials.Sample.SdJwtVc/Credentials.Sample.SdJwtVc.csproj b/samples/Credentials.Sample.SdJwtVc/Credentials.Sample.SdJwtVc.csproj
new file mode 100644
index 0000000..38c3ad4
--- /dev/null
+++ b/samples/Credentials.Sample.SdJwtVc/Credentials.Sample.SdJwtVc.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.SdJwtVc
+ Credentials.Sample.SdJwtVc
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.SdJwtVc/Program.cs b/samples/Credentials.Sample.SdJwtVc/Program.cs
new file mode 100644
index 0000000..f1d9185
--- /dev/null
+++ b/samples/Credentials.Sample.SdJwtVc/Program.cs
@@ -0,0 +1,75 @@
+using System.Text;
+using System.Text.Json.Nodes;
+using Credentials;
+using Credentials.Roles;
+using Credentials.Samples.Shared;
+using Credentials.Verification;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Credentials.Samples.SdJwtVc;
+
+///
+/// Issues an SD-JWT VC (FR-013) with two selectively-disclosable subject properties, shows the compact
+/// wire form + its application/dc+sd-jwt media type, then verifies straight from the compact bytes.
+///
+public static class Program
+{
+ private const string Vct = "https://credentials.example/identity";
+
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; when null the sample builds its own.
+ public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
+ {
+ var narrator = new SampleNarrator(output);
+ narrator.Banner("SD-JWT VC (application/dc+sd-jwt) — issue with selective disclosure + verify", "FR-013");
+
+ var provider = services ?? new ServiceCollection().AddCredentials(b => b.UseNetDid()).BuildServiceProvider();
+ var ownsProvider = services is null;
+ try
+ {
+ var issuer = provider.GetRequiredService();
+ var verifier = provider.GetRequiredService();
+ 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)
+ .AddSubject(new JsonObject
+ {
+ ["id"] = "did:example:subject",
+ ["given_name"] = "Alice",
+ ["age"] = 42,
+ })
+ .Seal();
+ narrator.Step($"built + sealed an unsecured VCDM {unsecured.Version} credential");
+
+ var issued = await issuer.IssueAsync(unsecured, new SdJwtVcIssuanceRequest
+ {
+ Vct = Vct,
+ Signer = issuerKey.Signer,
+ VerificationMethod = issuerKey.VerificationMethod,
+ Disclosable = [DisclosureSelector.ObjectProperties("credentialSubject", "given_name", "age")],
+ });
+ narrator.Step($"issued SD-JWT VC: mediaType={issued.MediaType}, disclosures={issued.CompactSdJwt!.Split('~').Length - 2}");
+ narrator.Step($"compact wire form: {issued.CompactSdJwt![..Math.Min(48, issued.CompactSdJwt!.Length)]}… ({issued.CompactSdJwt!.Length} chars, ends in '~')");
+
+ // Verify from the verbatim compact SD-JWT wire bytes (envelope detection picks SD-JWT VC).
+ var result = await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(issued.CompactSdJwt!));
+ narrator.Result($"decision={result.Decision} (mechanism={result.Mechanism}, proof={result.Check(CheckKinds.Proof)!.Status})");
+
+ if (issued.MediaType != "application/dc+sd-jwt")
+ throw new InvalidOperationException($"sample invariant failed: expected mediaType application/dc+sd-jwt, got {issued.MediaType}");
+ 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();
+ }
+ }
+}
diff --git a/samples/Credentials.Sample.StatusList/Credentials.Sample.StatusList.csproj b/samples/Credentials.Sample.StatusList/Credentials.Sample.StatusList.csproj
new file mode 100644
index 0000000..cb81f84
--- /dev/null
+++ b/samples/Credentials.Sample.StatusList/Credentials.Sample.StatusList.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.StatusList
+ Credentials.Sample.StatusList
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.StatusList/Program.cs b/samples/Credentials.Sample.StatusList/Program.cs
new file mode 100644
index 0000000..f7e3f16
--- /dev/null
+++ b/samples/Credentials.Sample.StatusList/Program.cs
@@ -0,0 +1,128 @@
+using System.Text.Json.Nodes;
+using Credentials;
+using Credentials.Roles;
+using Credentials.Samples.Shared;
+using Credentials.Status;
+using Credentials.Verification;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Credentials.Samples.StatusList;
+
+///
+/// Bitstring Status List (FR-016/020/022): a subject credential carries a BitstringStatusListEntry
+/// pointing at an issuer-signed status-list credential. The verifier dereferences that list through a
+/// caller-supplied , verifies the list's own proof, then reads the bit:
+/// CLEAR ⇒ Accepted, SET (revoked) ⇒ Rejected.
+///
+public static class Program
+{
+ private const string ListUrl = "https://issuer.example/status/1";
+ private const long Index = 94_567;
+
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; when null the sample builds its own.
+ public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
+ {
+ var narrator = new SampleNarrator(output);
+ narrator.Banner("Bitstring Status List — revocation through a status-list credential", "FR-016", "FR-020", "FR-022");
+
+ var manager = new StatusListManager();
+ var issuerKey = SampleKeys.New();
+ narrator.Step($"minted an issuer identity: {issuerKey.Did[..28]}…");
+
+ // The fetcher the verifier will call to dereference the status-list credential. The issuer flips
+ // the served list between the two verification passes (clear ⇒ revoked).
+ var fetcher = new InMemoryStatusListFetcher();
+
+ var provider = services ?? new ServiceCollection()
+ .AddCredentials(b => b.UseNetDid().UseStatusListFetcher(fetcher))
+ .BuildServiceProvider();
+ var ownsProvider = services is null;
+ try
+ {
+ var issuer = provider.GetRequiredService();
+ var verifier = provider.GetRequiredService();
+
+ // The subject credential references index 94567 of the revocation list (FR-016).
+ var subject = await IssueSubjectAsync(issuer, issuerKey);
+ narrator.Step($"issued a subject credential referencing index {Index} of the status list");
+
+ // Pass 1 — the list bit is CLEAR: the credential is not revoked ⇒ Accepted.
+ fetcher.Serve(await IssueStatusListAsync(issuer, issuerKey, manager, revoked: false));
+ var clear = await verifier.VerifyCredentialAsync(subject);
+ narrator.Result($"bit CLEAR: status={clear.Check(CheckKinds.Status)!.Status}, decision={clear.Decision}");
+ if (clear.Check(CheckKinds.Status)!.Status != CheckStatus.Passed || clear.Decision != VerificationDecision.Accepted)
+ throw new InvalidOperationException($"sample invariant failed: clear list expected Passed/Accepted, got {clear.Check(CheckKinds.Status)!.Status}/{clear.Decision}");
+
+ // Pass 2 — the issuer revokes index 94567 (sets the bit) and re-serves the signed list ⇒ Rejected.
+ fetcher.Serve(await IssueStatusListAsync(issuer, issuerKey, manager, revoked: true));
+ var revoked = await verifier.VerifyCredentialAsync(subject);
+ narrator.Result($"bit SET (revoked): status={revoked.Check(CheckKinds.Status)!.Status}, decision={revoked.Decision}");
+ if (revoked.Check(CheckKinds.Status)!.Status != CheckStatus.Failed || revoked.Decision != VerificationDecision.Rejected)
+ throw new InvalidOperationException($"sample invariant failed: revoked list expected Failed/Rejected, got {revoked.Check(CheckKinds.Status)!.Status}/{revoked.Decision}");
+ }
+ finally
+ {
+ if (ownsProvider && provider is IDisposable disposable) disposable.Dispose();
+ }
+ }
+
+ private static async Task IssueSubjectAsync(IIssuer issuer, SampleKey key)
+ {
+ var unsecured = Credential.Build()
+ .WithId("urn:uuid:22222222-2222-2222-2222-222222222222")
+ .WithIssuer(key.Did)
+ .AddSubject(new JsonObject { ["id"] = "did:example:subject" })
+ .AddStatus(BitstringStatusListEntry.Create(StatusPurpose.Revocation, Index, ListUrl))
+ .Seal();
+
+ var issued = await issuer.IssueAsync(unsecured, new DataIntegrityIssuanceRequest
+ {
+ Cryptosuite = "eddsa-jcs-2022",
+ Signer = key.Signer,
+ VerificationMethod = key.VerificationMethod,
+ });
+ return issued.Credential;
+ }
+
+ private static async Task IssueStatusListAsync(IIssuer issuer, SampleKey key, StatusListManager manager, bool revoked)
+ {
+ var list = manager.CreateList(new StatusListCreateOptions
+ {
+ Id = ListUrl,
+ Issuer = key.Did,
+ StatusPurpose = StatusPurpose.Revocation,
+ ValidFrom = DateTimeOffset.UtcNow.AddDays(-1),
+ });
+
+ if (revoked)
+ {
+ list = manager.Revoke(list, Index);
+ }
+
+ var issued = await issuer.IssueAsync(list, new DataIntegrityIssuanceRequest
+ {
+ Cryptosuite = "eddsa-jcs-2022",
+ Signer = key.Signer,
+ VerificationMethod = key.VerificationMethod,
+ });
+ return issued.Credential.ToBytes();
+ }
+
+ /// An offline status-list fetcher that returns the bytes the issuer last handed it.
+ private sealed class InMemoryStatusListFetcher : IStatusListFetcher
+ {
+ private byte[]? _listBytes;
+
+ public void Serve(byte[] listBytes) => _listBytes = listBytes;
+
+ public Task FetchAsync(StatusListReference reference, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_listBytes is null
+ ? StatusListFetchResult.NotFound("not_served")
+ : StatusListFetchResult.Found(_listBytes));
+ }
+}
diff --git a/samples/Credentials.Sample.Vcdm11/Credentials.Sample.Vcdm11.csproj b/samples/Credentials.Sample.Vcdm11/Credentials.Sample.Vcdm11.csproj
new file mode 100644
index 0000000..aa653bb
--- /dev/null
+++ b/samples/Credentials.Sample.Vcdm11/Credentials.Sample.Vcdm11.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ Credentials.Samples.Vcdm11
+ Credentials.Sample.Vcdm11
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Sample.Vcdm11/Program.cs b/samples/Credentials.Sample.Vcdm11/Program.cs
new file mode 100644
index 0000000..d273fe4
--- /dev/null
+++ b/samples/Credentials.Sample.Vcdm11/Program.cs
@@ -0,0 +1,80 @@
+using Credentials;
+using Credentials.Roles;
+using Credentials.Samples.Shared;
+using Credentials.Verification;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Credentials.Samples.Vcdm11;
+
+///
+/// Verifies a VCDM 1.1 credential (FR-044 / D8). The engine ISSUES 2.0 only, so this credential is a
+/// fixture the way a foreign 1.1 issuer would emit it — a Data-Integrity-secured 1.1 document whose
+/// did:key issuer is self-contained, so verification is fully offline. The sample shows that 1.1
+/// verifies by default (AcceptVcdm11 = true), is never upgraded to 2.0, and is rejected when the
+/// verifier opts out of 1.1.
+///
+public static class Program
+{
+ // A genuine eddsa-jcs-2022-secured VCDM 1.1 credential (issuer is a did:key, so the public key is in
+ // the DID — no network needed to verify). Generated once via the engine's internal mechanism (the
+ // public issuer is 2.0-only by contract and rejects 1.1).
+ private const string Vcdm11Credential = """
+ {
+ "@context": [ "https://www.w3.org/2018/credentials/v1" ],
+ "type": [ "VerifiableCredential" ],
+ "id": "urn:uuid:1111aaaa-1111-1111-1111-111111111111",
+ "issuer": "did:key:z6MkgzE8Ku9GCWPkR7snn3mQNpUvfYZAFofcCoJ6X7mbqaV8",
+ "issuanceDate": "2020-01-01T00:00:00Z",
+ "credentialSubject": { "id": "did:example:subject", "name": "Alice" },
+ "proof": {
+ "type": "DataIntegrityProof",
+ "cryptosuite": "eddsa-jcs-2022",
+ "verificationMethod": "did:key:z6MkgzE8Ku9GCWPkR7snn3mQNpUvfYZAFofcCoJ6X7mbqaV8#z6MkgzE8Ku9GCWPkR7snn3mQNpUvfYZAFofcCoJ6X7mbqaV8",
+ "proofPurpose": "assertionMethod",
+ "@context": [ "https://www.w3.org/2018/credentials/v1" ],
+ "proofValue": "z5Y6ZWrWtX7ALfFaQGN3uZW2shDC7aUFE1BwRM4tVF5oUfetiVKng4fR866EqhGFq9hkkeKEJv83WwjYYDBZgY6M8"
+ }
+ }
+ """;
+
+ /// Console entry point.
+ public static Task Main() => RunAsync(Console.Out);
+
+ /// Runs the sample, writing narration to .
+ /// Where to write the FR-tagged narration.
+ /// An optional pre-configured provider; when null the sample builds its own.
+ public static async Task RunAsync(TextWriter output, IServiceProvider? services = null)
+ {
+ var narrator = new SampleNarrator(output);
+ narrator.Banner("VCDM 1.1 verify — accept 1.1, never upgrade, opt-out gate", "FR-044");
+
+ var provider = services ?? new ServiceCollection().AddCredentials(b => b.UseNetDid()).BuildServiceProvider();
+ var ownsProvider = services is null;
+ try
+ {
+ var verifier = provider.GetRequiredService();
+
+ var credential = Credential.Parse(Vcdm11Credential);
+ narrator.Step($"parsed a {credential.Version} credential (issuance is 2.0-only; this is a foreign-issued 1.1 VC)");
+
+ // Default options accept 1.1 (AcceptVcdm11 = true).
+ var accepted = await verifier.VerifyCredentialAsync(credential);
+ narrator.Result($"default (AcceptVcdm11=true): {accepted.Decision} (proof={accepted.Check(CheckKinds.Proof)!.Status}, validity={accepted.Check(CheckKinds.Validity)!.Status})");
+
+ // Opt out of 1.1 — the same credential is now rejected before its proof is even trusted.
+ var rejected = await verifier.VerifyCredentialAsync(credential, new CredentialVerificationOptions { AcceptVcdm11 = false });
+ narrator.Result($"opt-out (AcceptVcdm11=false): {rejected.Decision}");
+
+ if (accepted.Decision != VerificationDecision.Accepted)
+ throw new InvalidOperationException($"expected the 1.1 credential to be Accepted by default, got {accepted.Decision}");
+ if (rejected.Decision != VerificationDecision.Rejected)
+ throw new InvalidOperationException($"expected the 1.1 credential to be Rejected when AcceptVcdm11=false, got {rejected.Decision}");
+ if (credential.Version != VcdmVersion.V1_1)
+ throw new InvalidOperationException("the 1.1 credential must not be upgraded to 2.0");
+ }
+ finally
+ {
+ if (ownsProvider && provider is IDisposable disposable) disposable.Dispose();
+ }
+ }
+}
diff --git a/samples/Credentials.Samples.Shared/AllowlistIssuerTrustPolicy.cs b/samples/Credentials.Samples.Shared/AllowlistIssuerTrustPolicy.cs
new file mode 100644
index 0000000..084900d
--- /dev/null
+++ b/samples/Credentials.Samples.Shared/AllowlistIssuerTrustPolicy.cs
@@ -0,0 +1,23 @@
+using Credentials.Trust;
+
+namespace Credentials.Samples.Shared;
+
+///
+/// A minimal allowlist issuer-trust policy (FR-082). The library ships no built-in trust lists;
+/// trust is the verifier's policy to supply. This sample shows the shape: a structured
+/// (decision + reason code), never a bare boolean, evaluated over the
+/// proof-verified issuer DID.
+///
+/// The issuer DIDs this verifier trusts.
+public sealed class AllowlistIssuerTrustPolicy(params string[] trustedIssuerDids) : IIssuerTrustPolicy
+{
+ private readonly HashSet _trusted = new(trustedIssuerDids, StringComparer.Ordinal);
+
+ /// Trusts the issuer iff its DID is on the allowlist; otherwise returns a reasoned Untrusted.
+ /// The proof-verified issuer context.
+ /// Unused (the decision is synchronous).
+ public Task EvaluateAsync(IssuerTrustContext context, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_trusted.Contains(context.IssuerId)
+ ? IssuerTrustResult.Trusted()
+ : IssuerTrustResult.Untrusted("issuer_not_allowlisted", "The issuer is not on the verifier's allowlist."));
+}
diff --git a/samples/Credentials.Samples.Shared/Credentials.Samples.Shared.csproj b/samples/Credentials.Samples.Shared/Credentials.Samples.Shared.csproj
new file mode 100644
index 0000000..c0e9194
--- /dev/null
+++ b/samples/Credentials.Samples.Shared/Credentials.Samples.Shared.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+ Credentials.Samples.Shared
+ Credentials.Samples.Shared
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Credentials.Samples.Shared/SampleSupport.cs b/samples/Credentials.Samples.Shared/SampleSupport.cs
new file mode 100644
index 0000000..ab8a869
--- /dev/null
+++ b/samples/Credentials.Samples.Shared/SampleSupport.cs
@@ -0,0 +1,51 @@
+using NetCrypto;
+
+namespace Credentials.Samples.Shared;
+
+/// Mints real in-memory keys + the matching did:key identifiers for the samples (offline).
+public static class SampleKeys
+{
+ private static readonly DefaultCryptoProvider Crypto = new();
+ private static readonly DefaultKeyGenerator KeyGen = new();
+
+ /// Generates a fresh key of the given type and its did:key DID + verification method.
+ /// The key type (default Ed25519).
+ public static SampleKey New(KeyType keyType = KeyType.Ed25519)
+ {
+ var keyPair = KeyGen.Generate(keyType);
+ var did = $"did:key:{keyPair.MultibasePublicKey}";
+ return new SampleKey(new KeyPairSigner(keyPair, Crypto), did, $"{did}#{keyPair.MultibasePublicKey}");
+ }
+}
+
+/// An in-memory signing identity: a NetCrypto.ISigner + its did:key DID and verification method.
+/// The signer (the engine never sees the raw private key).
+/// The did:key DID.
+/// The DID URL of the verification method.
+public sealed record SampleKey(ISigner Signer, string Did, string VerificationMethod);
+
+///
+/// Writes FR-tagged narration for a sample run to a caller-supplied so the
+/// same sample is runnable both as a console program (Console.Out) and from the smoke tests (a string
+/// buffer). Output is human-readable, not machine-parsed.
+///
+/// Where to write narration.
+public sealed class SampleNarrator(TextWriter output)
+{
+ /// Writes the sample's title banner and the requirements it demonstrates.
+ /// The sample title.
+ /// The FR/NFR ids the sample exercises.
+ public void Banner(string title, params string[] requirements)
+ {
+ output.WriteLine($"== {title} ==");
+ if (requirements.Length > 0) output.WriteLine($" demonstrates: {string.Join(", ", requirements)}");
+ }
+
+ /// Writes a numbered/bulleted step line.
+ /// The step description.
+ public void Step(string message) => output.WriteLine($" - {message}");
+
+ /// Writes the sample's final outcome line.
+ /// The outcome description.
+ public void Result(string message) => output.WriteLine($" => {message}");
+}
diff --git a/tasks/todo-2026-06-23-m8-conformance-interop-samples-gates.md b/tasks/todo-2026-06-23-m8-conformance-interop-samples-gates.md
index b9240d6..51856ff 100644
--- a/tasks/todo-2026-06-23-m8-conformance-interop-samples-gates.md
+++ b/tasks/todo-2026-06-23-m8-conformance-interop-samples-gates.md
@@ -161,3 +161,26 @@ ConsumerProbe as the authority; FrCoverage hardened to ignore commented-out tags
defeated.
**Next:** PR-B (samples + api-coverage gate), then PR-C (conformance + interop).
+
+### PR-B — Samples matrix + API-coverage gate (complete, 2026-06-24)
+
+**Status: complete + verified.** Branch `feature/m8b-samples-coverage`, rebased onto `main` after M8a
+merged (PR #8). Build 0-warning; **352 tests green** (338 + 14 sample smoke tests); all 14 samples run
+to their expected outcome; the api-coverage gate passes (53 covered / 0 uncovered / 4 exempted).
+
+**Delivered:** `Credentials.Samples.Shared` (keys/narrator/allowlist policy); 14 offline `samples/*`
+console projects (role × form + status + schema + trust + 1.1); `Credentials.SampleSmokeTests` (the
+in-process runner + coverage driver); `tools/api-coverage` + `run-api-coverage.sh` + `coverage.runsettings`
++ `api-coverage-exclusions.txt`; the `api-coverage` CI job.
+
+**Decisions:** (1) The api-coverage gate is **type-level** (every gateable public type is exercised by a
+sample), calibrated to the real coverage (only 5 public types were initially uncovered). This is the
+achievable, honest bar; member-level 100% over ~700 members is not realistic and would be exclusion-heavy.
+(2) Exemptions are a **documented text file** (not a `[ExcludeFromApiCoverage]` attribute) to avoid
+coupling production code to the test gate — 4 entries (3 error-path types + the options object), each
+with a reason; the tool also fails on a *stale* exclusion. (3) `SecuringSelector` was covered by adding a
+real capabilities query to the DataIntegrity sample rather than exempting it. (4) The Vcdm11 sample
+embeds a genuine `did:key`-issued secured-1.1 fixture (generated via the internal mechanism, since public
+issuance is 2.0-only) so it verifies fully offline.
+
+**Next:** PR-C (conformance shim + interop vectors).
diff --git a/tests/Credentials.SampleSmokeTests/Credentials.SampleSmokeTests.csproj b/tests/Credentials.SampleSmokeTests/Credentials.SampleSmokeTests.csproj
new file mode 100644
index 0000000..95987a8
--- /dev/null
+++ b/tests/Credentials.SampleSmokeTests/Credentials.SampleSmokeTests.csproj
@@ -0,0 +1,41 @@
+
+
+
+
+ Credentials.SampleSmokeTests
+ Credentials.SampleSmokeTests
+ false
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Credentials.SampleSmokeTests/SampleSmokeTests.cs b/tests/Credentials.SampleSmokeTests/SampleSmokeTests.cs
new file mode 100644
index 0000000..5e23bb3
--- /dev/null
+++ b/tests/Credentials.SampleSmokeTests/SampleSmokeTests.cs
@@ -0,0 +1,34 @@
+using FluentAssertions;
+using Xunit;
+
+namespace Credentials.SampleSmokeTests;
+
+///
+/// Runs each sample's Program.RunAsync in-process. Every sample throws on an unexpected outcome
+/// (e.g. a credential that should verify but doesn't), so "completes without throwing + produced
+/// narration" is the assertion. Under coverlet this run also drives the api-coverage gate.
+///
+public sealed class SampleSmokeTests
+{
+ private static async Task Run(Func sample)
+ {
+ var buffer = new StringWriter();
+ await sample(buffer, null);
+ buffer.ToString().Should().NotBeNullOrWhiteSpace("a sample must narrate what it did");
+ }
+
+ [Fact] public Task DataIntegrity() => Run(Samples.DataIntegrity.Program.RunAsync);
+ [Fact] public Task DataIntegrityRdfc() => Run(Samples.DataIntegrityRdfc.Program.RunAsync);
+ [Fact] public Task JoseEnvelope() => Run(Samples.JoseEnvelope.Program.RunAsync);
+ [Fact] public Task CoseEnvelope() => Run(Samples.CoseEnvelope.Program.RunAsync);
+ [Fact] public Task SdJwtVc() => Run(Samples.SdJwtVc.Program.RunAsync);
+ [Fact] public Task SdJwtPresentation() => Run(Samples.SdJwtPresentation.Program.RunAsync);
+ [Fact] public Task Bbs2023() => Run(Samples.Bbs2023.Program.RunAsync);
+ [Fact] public Task PresentationDataIntegrity() => Run(Samples.PresentationDataIntegrity.Program.RunAsync);
+ [Fact] public Task PresentationJose() => Run(Samples.PresentationJose.Program.RunAsync);
+ [Fact] public Task StatusList() => Run(Samples.StatusList.Program.RunAsync);
+ [Fact] public Task Schema() => Run(Samples.Schema.Program.RunAsync);
+ [Fact] public Task IssuerTrust() => Run(Samples.IssuerTrust.Program.RunAsync);
+ [Fact] public Task Vcdm11() => Run(Samples.Vcdm11.Program.RunAsync);
+ [Fact] public Task FullPipeline() => Run(Samples.FullPipeline.Program.RunAsync);
+}
diff --git a/tools/api-coverage/Credentials.Tools.ApiCoverage.csproj b/tools/api-coverage/Credentials.Tools.ApiCoverage.csproj
new file mode 100644
index 0000000..b500a29
--- /dev/null
+++ b/tools/api-coverage/Credentials.Tools.ApiCoverage.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+ Exe
+ Credentials.Tools.ApiCoverage
+ Credentials.Tools.ApiCoverage
+ false
+ false
+
+
+
+
+
+
+
diff --git a/tools/api-coverage/Program.cs b/tools/api-coverage/Program.cs
new file mode 100644
index 0000000..cde2922
--- /dev/null
+++ b/tools/api-coverage/Program.cs
@@ -0,0 +1,148 @@
+using System.Reflection;
+using System.Xml.Linq;
+
+namespace Credentials.Tools.ApiCoverage;
+
+// Usage: api-coverage ...
+// Exit 0 if every gateable public type of the libraries is covered (or exempted); 1 otherwise.
+internal static class Program
+{
+ private static int Main(string[] args)
+ {
+ if (args.Length < 3)
+ {
+ Console.Error.WriteLine("usage: api-coverage [ ...]");
+ return 2;
+ }
+
+ var coberturaPath = args[0];
+ var exclusionsPath = args[1];
+ var libPaths = args[2..];
+
+ var coveredRate = ParseCobertura(coberturaPath);
+ var exclusions = ReadExclusions(exclusionsPath);
+
+ var covered = new List();
+ var uncovered = new List();
+ var exempted = new List();
+ var notGateable = new List();
+
+ using var mlc = CreateLoadContext(libPaths);
+ foreach (var libPath in libPaths)
+ {
+ foreach (var type in mlc.LoadFromAssemblyPath(libPath).GetExportedTypes())
+ {
+ var name = (type.FullName ?? type.Name).Replace('+', '.');
+ if (IsCompilerGenerated(name)) continue;
+
+ if (exclusions.Contains(name)) { exempted.Add(name); continue; }
+
+ // Gateability is decided from REFLECTION, not from absence in cobertura: a type with no
+ // executable members (interface, enum, delegate, a const-only static class) has nothing to
+ // cover and is legitimately skipped. A gateable type (one with executable members) that is
+ // absent from cobertura is treated as UNCOVERED — coverlet should have emitted it, so its
+ // absence is a real gap, not a silent pass.
+ if (!IsGateable(type)) { notGateable.Add(name); continue; }
+
+ coveredRate.TryGetValue(name, out var rate); // absent => 0 => uncovered
+ if (rate > 0) covered.Add(name); else uncovered.Add(name);
+ }
+ }
+
+ Console.WriteLine($"api-coverage: {covered.Count} covered, {uncovered.Count} uncovered, {exempted.Count} exempted, {notGateable.Count} not-gateable (interface/enum/delegate/no-IL).");
+ if (exempted.Count > 0)
+ Console.WriteLine(" exempted (see exclusions file): " + string.Join(", ", exempted.OrderBy(x => x)));
+ // Print the not-gateable set so it is auditable — an upward drift (a new no-IL public type) is
+ // visible in the log rather than silently swallowed.
+ if (notGateable.Count > 0)
+ Console.WriteLine(" not-gateable (no executable IL to cover): " + string.Join(", ", notGateable.OrderBy(x => x)));
+
+ if (uncovered.Count > 0)
+ {
+ Console.Error.WriteLine("\nFAIL: these public types are exercised by no sample and are not exempted:");
+ foreach (var t in uncovered.OrderBy(x => x)) Console.Error.WriteLine(" - " + t);
+ Console.Error.WriteLine("\nAdd a sample that exercises each, or add it to " + exclusionsPath + " with a reason.");
+ return 1;
+ }
+
+ // Guard against a stale exclusion that now IS covered (keep the exempt list honest).
+ var staleExclusions = exclusions
+ .Where(e => coveredRate.TryGetValue(e, out var r) && r > 0)
+ .OrderBy(x => x).ToList();
+ if (staleExclusions.Count > 0)
+ {
+ Console.Error.WriteLine("\nFAIL: these exclusions are now covered by a sample — remove them from " + exclusionsPath + ":");
+ foreach (var t in staleExclusions) Console.Error.WriteLine(" - " + t);
+ return 1;
+ }
+
+ Console.WriteLine("api-coverage OK: every gateable public type is exercised by a sample.");
+ return 0;
+ }
+
+ private static Dictionary ParseCobertura(string path)
+ {
+ var rates = new Dictionary(StringComparer.Ordinal);
+ foreach (var cls in XDocument.Load(path).Descendants("class"))
+ {
+ var name = cls.Attribute("name")?.Value;
+ if (name is null) continue;
+ // Normalize nested-type separators to '.' to match reflection FullName (with '+' → '.').
+ name = name.Replace('/', '.').Replace('+', '.');
+ // Strip the compiler-generated nested state-machine suffix so it folds into its owner type.
+ var slash = name.IndexOf(".<", StringComparison.Ordinal);
+ if (slash >= 0) name = name[..slash];
+ if (IsCompilerGenerated(name)) continue;
+
+ var rate = double.TryParse(cls.Attribute("line-rate")?.Value,
+ System.Globalization.CultureInfo.InvariantCulture, out var r) ? r : 0;
+ rates[name] = Math.Max(rates.TryGetValue(name, out var existing) ? existing : 0, rate);
+ }
+ return rates;
+ }
+
+ private static HashSet ReadExclusions(string path)
+ {
+ var set = new HashSet(StringComparer.Ordinal);
+ if (!File.Exists(path)) return set;
+ foreach (var raw in File.ReadAllLines(path))
+ {
+ var line = raw;
+ var hash = line.IndexOf('#');
+ if (hash >= 0) line = line[..hash];
+ line = line.Trim();
+ if (line.Length > 0) set.Add(line);
+ }
+ return set;
+ }
+
+ // Async state machines and lambda closures both contain '<' in their type names.
+ private static bool IsCompilerGenerated(string name) => name.Contains('<');
+
+ // A type is gateable for code coverage iff it actually carries executable members. Interfaces,
+ // enums, delegates, and const-only static classes have no instrumentable IL, so they are not
+ // gateable (and need no exclusion); everything else (concrete/abstract/static classes, structs,
+ // records) is, and must therefore be exercised by a sample or explicitly exempted.
+ private static bool IsGateable(Type type)
+ {
+ if (type.IsInterface || type.IsEnum) return false;
+ if (type.BaseType?.FullName is "System.MulticastDelegate" or "System.Delegate") return false;
+
+ const BindingFlags flags =
+ BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
+ // Constructors (incl. a static cctor) and any non-abstract method have a body to cover.
+ return type.GetConstructors(flags).Length > 0
+ || type.GetMethods(flags).Any(m => !m.IsAbstract);
+ }
+
+ private static MetadataLoadContext CreateLoadContext(string[] libPaths)
+ {
+ var paths = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var dirs = new List { System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory() };
+ dirs.AddRange(libPaths.Select(p => Path.GetDirectoryName(Path.GetFullPath(p))!));
+ foreach (var dir in dirs.Where(Directory.Exists))
+ foreach (var dll in Directory.EnumerateFiles(dir, "*.dll"))
+ paths.TryAdd(Path.GetFileNameWithoutExtension(dll), dll);
+ return new MetadataLoadContext(new PathAssemblyResolver(paths.Values));
+ }
+}
diff --git a/tools/api-coverage/api-coverage-exclusions.txt b/tools/api-coverage/api-coverage-exclusions.txt
new file mode 100644
index 0000000..259c8f6
--- /dev/null
+++ b/tools/api-coverage/api-coverage-exclusions.txt
@@ -0,0 +1,12 @@
+# Public types of Credentials.Core / .Extensions.DependencyInjection that no sample exercises, each
+# exempted with a reason. The api-coverage gate fails if a NON-exempt public type is uncovered, and
+# also if an entry here becomes covered by a sample (a stale exclusion to remove). Internal types and
+# no-executable-code types (interfaces, enums) are auto-skipped and need no entry here.
+
+# Error-path types — only materialize on malformed input; the samples demonstrate success paths.
+Credentials.CredentialFormatException
+Credentials.Validation.CredentialStructureException
+Credentials.Validation.StructuralProblem
+
+# Engine-tuning options object — the samples rely on the defaults, so no sample calls Configure(...).
+Microsoft.Extensions.DependencyInjection.CredentialsOptions
diff --git a/tools/coverage.runsettings b/tools/coverage.runsettings
new file mode 100644
index 0000000..fab5f50
--- /dev/null
+++ b/tools/coverage.runsettings
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ cobertura
+ [Credentials.Core]*,[Credentials.Extensions.DependencyInjection]*
+
+
+
+
+
diff --git a/tools/run-api-coverage.sh b/tools/run-api-coverage.sh
new file mode 100755
index 0000000..552da45
--- /dev/null
+++ b/tools/run-api-coverage.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+#
+# The api-coverage gate: runs every sample (via Credentials.SampleSmokeTests) under coverlet scoped to
+# Credentials.Core + Credentials.Extensions.DependencyInjection, then asserts every gateable public type
+# is exercised by at least one sample (type-level), with documented exemptions in
+# tools/api-coverage/api-coverage-exclusions.txt. Exit 0 = covered; non-zero = an uncovered public type.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+RESULTS="$ROOT/artifacts/api-coverage"
+rm -rf "$RESULTS"
+
+echo ">> Building solution"
+dotnet build "$ROOT/Credentials.sln" -c Debug --nologo
+
+echo ">> Running samples under coverlet (scoped to Core + DI)"
+dotnet test "$ROOT/tests/Credentials.SampleSmokeTests/Credentials.SampleSmokeTests.csproj" -c Debug --no-build \
+ --collect:"XPlat Code Coverage" --settings "$ROOT/tools/coverage.runsettings" --results-directory "$RESULTS"
+
+COB="$(find "$RESULTS" -name '*.cobertura.xml' | head -1)"
+if [[ -z "$COB" ]]; then echo "no cobertura report produced" >&2; exit 2; fi
+# Derive the output dir from the built assembly rather than hardcoding the TFM, so a framework bump
+# can't silently break the gate (it would report a coverage gap, not crash on a missing path).
+BIN="$(dirname "$(find "$ROOT/tests/Credentials.SampleSmokeTests/bin/Debug" -name 'Credentials.Core.dll' | head -1)")"
+if [[ -z "$BIN" ]]; then echo "could not locate the built Credentials.Core.dll" >&2; exit 2; fi
+
+echo ">> Diffing the public surface against sample coverage"
+dotnet run --project "$ROOT/tools/api-coverage/Credentials.Tools.ApiCoverage.csproj" -c Debug --no-build -- \
+ "$COB" \
+ "$ROOT/tools/api-coverage/api-coverage-exclusions.txt" \
+ "$BIN/Credentials.Core.dll" \
+ "$BIN/Credentials.Extensions.DependencyInjection.dll"