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"