diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a3a860a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Byte-exact reference vectors and RDF Dataset Canonicalization fixtures (.nq/.nt/.json/...) MUST be +# LF on every platform. The canonicalizer emits LF N-Quads and the conformance suite asserts +# byte-equality (RdfCanonicalizationConformanceTests); without this, Windows `core.autocrlf` checks +# the fixtures out as CRLF, inflating their length and failing the windows-latest CI job. All +# fixtures are text and already stored as LF, so this only constrains checkout — it renormalizes +# nothing. +tests/fixtures/** text eol=lf +*.nq text eol=lf +*.nt text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7210ff9..daed5bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ permissions: # PRD §9: one CI job per acceptance criterion, ac-1 … ac-11, each exiting 0/non-zero, all # required for merge to main. ac-11 (package-identity publish gate) lives in publish.yml. +# ac-5 (didcomm behavior-parity diff) has been retired — see the note where it used to sit, below. # # The build-test job is the comprehensive gate: it builds the whole solution with # warnings-as-errors — which is itself the AC-6 banned-symbol scan (BannedApiAnalyzers) and the @@ -85,9 +86,8 @@ jobs: - uses: actions/setup-dotnet@v4 with: global-json-file: global.json - # All Jose conformance + oracle cross-verification + SD-JWT/VC + VcJose. (The ported - # didcomm parity suite is intermixed in these namespaces and runs here too; its dedicated - # assertion-equality gate is ac-5.) + # All Jose conformance + oracle cross-verification + SD-JWT/VC + VcJose, including the ported + # didcomm parity test suite (intermixed in these namespaces) as standard JOSE coverage. - name: ac-3 Jose conformance + oracle + SD-JWT(/VC) + VcJose run: dotnet test tests/DataProofsDotnet.Jose.Tests/DataProofsDotnet.Jose.Tests.csproj --configuration Release @@ -102,27 +102,10 @@ jobs: - name: ac-4 Cose suite run: dotnet test tests/DataProofsDotnet.Cose.Tests/DataProofsDotnet.Cose.Tests.csproj --configuration Release - ac-5: - name: ac-5 didcomm JWS/JWE behavior parity - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - global-json-file: global.json - # AC-5 = the ported parity suite passes AND parity-diff finds zero assertion differences. - # The parity tests live intermixed in the Jose test namespaces, so run the whole suite. - - name: ac-5 parity suite passes - run: dotnet test tests/DataProofsDotnet.Jose.Tests/DataProofsDotnet.Jose.Tests.csproj --configuration Release - # parity-diff needs the didcomm source at the SHA recorded in PARITY.md. - - name: Checkout didcomm-dotnet (parity source) - uses: actions/checkout@v4 - with: - repository: moisesja/didcomm-dotnet - path: .parity-src/didcomm-dotnet - fetch-depth: 0 - - name: ac-5 parity-diff (zero assertion differences) - run: dotnet run --project tasks/parity-diff --configuration Release -- --didcomm-repo .parity-src/didcomm-dotnet + # ac-5 (didcomm JWS/JWE behavior-parity diff) was retired: it pinned the ported JOSE tests to a + # didcomm-dotnet source SHA, which becomes meaningless once didcomm-dotnet is refactored. The + # ported parity TEST suite is kept and still runs under ac-3 (and build-test) as standard JOSE + # coverage; only the source-diff gate and its tooling were removed. ac-6: name: ac-6 dependency hygiene + NetCrypto-only crypto @@ -202,8 +185,15 @@ jobs: shell: bash run: | set -euo pipefail - version="0.1.0" feed="$(pwd)/local-feed" + # Derive the version from the packed Core nupkg so the smoke install always matches what + # was actually packed (e.g. the -preview suffix from $(DataProofsVersion)); never hardcode. + version="$(basename "$(ls "$feed"/DataProofsDotnet.Core.*.nupkg | head -1)" .nupkg | sed 's/^DataProofsDotnet\.Core\.//')" + echo "smoke-testing packages at version: $version" + # Register the local pack feed once in the user NuGet config (shared by every iteration's + # restore). Adding it inside the loop would fail on the 2nd package with "source + # 'local-feed' already exists". + dotnet nuget add source "$feed" --name local-feed declare -A smoke=( [DataProofsDotnet.Core]=tasks/smoke/Core [DataProofsDotnet.Jose]=tasks/smoke/Jose @@ -211,14 +201,21 @@ jobs: [DataProofsDotnet.Rdfc]=tasks/smoke/Rdfc [DataProofsDotnet.Extensions.DependencyInjection]=tasks/smoke/DependencyInjection ) + # Documented per-package prerequisite (tasks/smoke/README.md): the only extra package a + # clean-room install may add. The NetCrypto signer substrate flows transitively, so only + # the DI smoke needs one — the concrete Microsoft.Extensions.DependencyInjection container + # (the package surfaces only its Abstractions). Pinned to the abstractions' version. + declare -A prereq=( + [DataProofsDotnet.Extensions.DependencyInjection]="Microsoft.Extensions.DependencyInjection:10.0.8" + ) for id in "${!smoke[@]}"; do echo "=== clean-room install + smoke: ${id} ===" tmp="$(mktemp -d)" ( cd "$tmp" && dotnet new console -n SmokeApp >/dev/null ) cp "${smoke[$id]}/Program.cs" "$tmp/SmokeApp/Program.cs" ( cd "$tmp/SmokeApp" \ - && dotnet nuget add source "$feed" --name local-feed \ && dotnet add package "$id" --version "$version" --source "$feed" \ + && { [ -z "${prereq[$id]:-}" ] || dotnet add package "${prereq[$id]%%:*}" --version "${prereq[$id]##*:}"; } \ && dotnet run -c Release ) done diff --git a/CHANGELOG.md b/CHANGELOG.md index 4856031..ef3d2b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.0.0] - 2026-06-14 ### Added @@ -24,15 +24,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `proofValue`). Unmodeled proof members (e.g. `capabilityChain`) ride through `DataIntegrityProof.AdditionalProperties` into the JCS signing input. This unblocks zcap-dotnet's proof-pipeline delegation and legacy-VC verification in credentials-dotnet. - - **Use legacy suites only for interop with existing corpora; prefer the 2022/2019 Data - Integrity suites for new proofs.** - - **Documented limitations** (verified by adversarial review): the JCS variant has no - representation for a W3C proof chain and **fails closed** if asked to secure/verify a - document that already carries a `proof` member (use the RDFC variant or a 2022/2019 suite - for chains); the RDFC variant binds only terms defined in the active JSON-LD `@context` - (members it does not define are dropped by RDF expansion — inherent to JSON-LD/RDFC, shared - with the conformant `rdfc-*` suites); and `EcdsaSecp256r1Signature2019` does not enforce - low-`s`, so ECDSA `proofValue`s are malleable and must not be used as unique identifiers. + - **Use legacy suites only for interop with existing corpora; prefer the 2022/2019 Data + Integrity suites for new proofs.** + - **Documented limitations** (verified by adversarial review): the JCS variant has no + representation for a W3C proof chain and **fails closed** if asked to secure/verify a + document that already carries a `proof` member (use the RDFC variant or a 2022/2019 suite + for chains); the RDFC variant binds only terms defined in the active JSON-LD `@context` + (members it does not define are dropped by RDF expansion — inherent to JSON-LD/RDFC, shared + with the conformant `rdfc-*` suites); and `EcdsaSecp256r1Signature2019` does not enforce + low-`s`, so ECDSA `proofValue`s are malleable and must not be used as unique identifiers. +- **JOSE explicit typing (RFC 8725 §3.11).** `JwtValidationOptions.ExpectedType` (opt-in) pins the + JWS protected `typ` header on `JwtHandler.Verify` (case-insensitive, `application/`-prefix + tolerant), rejecting cross-context token confusion; default behavior is unchanged. The verified + `typ` is now surfaced on `JwsParseResult.Typ`. + +### Changed + +- **`Base64Url.Decode` is now strict (behavioral change).** It rejects any input outside the + base64url-no-pad alphabet — including interior/surrounding ASCII whitespace, `=` padding, and the + standard-base64 `+`/`/` — by throwing `FormatException`, where the previous implementation + silently tolerated them. This matches the documented "valid base64url" contract and the JOSE + no-pad requirement, but an out-of-repo caller that previously passed padded or whitespace-bearing + input will now get a `FormatException`. **Upgraders:** scan your `Base64Url.Decode` call sites for + non-canonical input. (Also tracked under Security below — it closes an encoding-ambiguity gap.) +- **Bumped the `NetCrypto` dependency from 1.0.0 to 1.1.0** across all packages. The library + source is unaffected; consumers resolve NetCrypto ≥ 1.1.0 transitively. (1.1.0 also introduces a + `NetCrypto.Base64Url` type — when a consumer imports both `NetCrypto` and `DataProofsDotnet.Jose`, + reference `Base64Url` via a `using` alias or fully-qualified name to disambiguate it from + `DataProofsDotnet.Jose.Base64Url`.) + +### Security + +- **JOSE hardening pass (issue #6).** An adversarial multi-agent review of the entire + `DataProofsDotnet.Jose` surface (JWS/JWE/ECDH-1PU/JWK/SD-JWT/JWT/encoding), with every finding + put through independent majority-vote verification, produced these fixes (each pinned by a + regression test in `Hardening/HardeningRegressionTests.cs`): + - **SD-JWT reconstruction now fails closed on a deep recursive-disclosure chain.** + `SdJwtReconstructor` walked the disclosed payload by unbounded mutual recursion; a chained + recursive-disclosure presentation (RFC 9901 §6.3) rooted in an issuer-signed `_sd` digest could + drive it into an **uncatchable `StackOverflowException` that terminates the host process**, + defeating the verifier's fail-closed contract. Reconstruction is now depth-bounded (64, matching + the JSON parse depth) and raises a `MalformedJoseException` (surfaced as `DISCLOSURE_INVALID`). + - **A malformed/unsupported `cnf` (or issuer) key no longer crashes SD-JWT verification.** + `CompactJwt.Verify` mapped an unsupported curve / off-curve key point to an uncaught + `NotSupportedException`/crypto exception (reachable through an attacker-influenced `cnf` on the + Key Binding path); it now fails closed as `MalformedJoseException`, which both call sites handle. + - **JWS reports the verified signer only from integrity-protected material.** `JwsParseResult +.SignerKid` is now sourced solely from the protected header; a `kid` carried only in the + unauthenticated unprotected header is treated as a routing hint and never surfaced as the + verified identity. Verification of valid JWS (including `kid`-in-unprotected) is unchanged. + - **`Base64Url.Decode` is now strict** — it rejects interior/surrounding whitespace, `=` padding, + and standard-base64 `+`/`/`, matching the documented base64url-no-pad contract. ## [0.1.0-preview.2] - 2026-06-13 @@ -57,7 +99,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 present (`cryptosuite`-named suites still always win), closing the create→verify inconsistency and honoring the FR-4 "registering a new suite requires no pipeline changes" contract. The library still **creates** only conformant 2022/2019 proofs; this is verification-only and - non-breaking. Suite selection only *routes* — each suite still fully validates + non-breaking. Suite selection only _routes_ — each suite still fully validates `type`/`cryptosuite`/key/encoding/signature. Regression tests in `LegacyProofTypeVerificationTests` and `CryptosuiteRegistryTests`. @@ -77,7 +119,7 @@ First preview release of all five packages (`Core`, `Jose`, `Cose`, `Rdfc`, - **`bbs-2023` mandatory disclosure is now cryptographically enforced.** The mandatory-disclosure group is bound into the BBS signature `header` (`SHA-256(proofConfig) ‖ SHA-256(mandatory - N-Quads)`) at sign and derive, and the header is recomputed at verify from the revealed +N-Quads)`) at sign and derive, and the header is recomputed at verify from the revealed mandatory messages — so a holder that drops or alters a mandatory statement produces a header that no longer matches the one the proof commits to, and verification fails. Closes the adversarial-review finding that a holder could omit a mandatory claim and still verify. Requires diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96bcbe4..6c94282 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ dotnet test DataProofsDotnet.sln src/ five publishable packages (Core, Jose, Cose, Rdfc, Extensions.DependencyInjection) tests/ one test project per package + ApiSurface.Tests (hygiene gates) + vendored fixtures/ samples/ narrated console samples — every public API member must appear in at least one (FR-21) -tasks/ CI gate tooling (samples-coverage, parity-diff, dependency-hygiene, ...) and plan docs +tasks/ CI gate tooling (samples-coverage, dependency-hygiene, api-surface-scan, ...) and plan docs docs/ supplementary documentation ``` diff --git a/DataProofsDotnet.sln b/DataProofsDotnet.sln index d05c725..4d28084 100644 --- a/DataProofsDotnet.sln +++ b/DataProofsDotnet.sln @@ -75,10 +75,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "package-identity", "package EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PackageIdentity", "tasks\package-identity\PackageIdentity.csproj", "{E96D6952-1F47-493A-8C11-BE24278530CF}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "parity-diff", "parity-diff", "{AA4E32FC-56B2-C72E-60FC-95F431031FA2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ParityDiff", "tasks\parity-diff\ParityDiff.csproj", "{D35D6BFF-66E7-4956-A69F-F354C649CC41}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataProofsDotnet.Legacy", "src\DataProofsDotnet.Legacy\DataProofsDotnet.Legacy.csproj", "{FB630922-2CA7-4368-9B02-6975250C4348}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataProofsDotnet.Legacy.Tests", "tests\DataProofsDotnet.Legacy.Tests\DataProofsDotnet.Legacy.Tests.csproj", "{74CAD10E-C883-4924-8B13-AF048D361137}" @@ -443,18 +439,6 @@ Global {E96D6952-1F47-493A-8C11-BE24278530CF}.Release|x64.Build.0 = Release|Any CPU {E96D6952-1F47-493A-8C11-BE24278530CF}.Release|x86.ActiveCfg = Release|Any CPU {E96D6952-1F47-493A-8C11-BE24278530CF}.Release|x86.Build.0 = Release|Any CPU - {D35D6BFF-66E7-4956-A69F-F354C649CC41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D35D6BFF-66E7-4956-A69F-F354C649CC41}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D35D6BFF-66E7-4956-A69F-F354C649CC41}.Debug|x64.ActiveCfg = Debug|Any CPU - {D35D6BFF-66E7-4956-A69F-F354C649CC41}.Debug|x64.Build.0 = Debug|Any CPU - {D35D6BFF-66E7-4956-A69F-F354C649CC41}.Debug|x86.ActiveCfg = Debug|Any CPU - {D35D6BFF-66E7-4956-A69F-F354C649CC41}.Debug|x86.Build.0 = Debug|Any CPU - {D35D6BFF-66E7-4956-A69F-F354C649CC41}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D35D6BFF-66E7-4956-A69F-F354C649CC41}.Release|Any CPU.Build.0 = Release|Any CPU - {D35D6BFF-66E7-4956-A69F-F354C649CC41}.Release|x64.ActiveCfg = Release|Any CPU - {D35D6BFF-66E7-4956-A69F-F354C649CC41}.Release|x64.Build.0 = Release|Any CPU - {D35D6BFF-66E7-4956-A69F-F354C649CC41}.Release|x86.ActiveCfg = Release|Any CPU - {D35D6BFF-66E7-4956-A69F-F354C649CC41}.Release|x86.Build.0 = Release|Any CPU {FB630922-2CA7-4368-9B02-6975250C4348}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FB630922-2CA7-4368-9B02-6975250C4348}.Debug|Any CPU.Build.0 = Debug|Any CPU {FB630922-2CA7-4368-9B02-6975250C4348}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -528,8 +512,6 @@ Global {BCAF3445-C1E3-4260-A35B-37C620F7507E} = {51E1FF21-0147-325E-BF9F-837D1C0DC671} {189960B5-5ED3-831C-6560-754D93EF961D} = {B554F44C-BC1A-140C-1D5D-67ADEC712783} {E96D6952-1F47-493A-8C11-BE24278530CF} = {189960B5-5ED3-831C-6560-754D93EF961D} - {AA4E32FC-56B2-C72E-60FC-95F431031FA2} = {B554F44C-BC1A-140C-1D5D-67ADEC712783} - {D35D6BFF-66E7-4956-A69F-F354C649CC41} = {AA4E32FC-56B2-C72E-60FC-95F431031FA2} {FB630922-2CA7-4368-9B02-6975250C4348} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {74CAD10E-C883-4924-8B13-AF048D361137} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {1A449C0C-3908-4917-AE93-AC230338942E} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} diff --git a/Directory.Build.props b/Directory.Build.props index e068d33..0a9e1a1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,10 +5,11 @@ enable latest true - - 0.1.0-preview.2 + + 1.0.0 true $(NoWarn);1591 true diff --git a/Directory.Packages.props b/Directory.Packages.props index 1100be3..fba7c71 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ - + diff --git a/dataproofs-prd.md b/dataproofs-prd.md index ff25e63..1c220ca 100644 --- a/dataproofs-prd.md +++ b/dataproofs-prd.md @@ -231,11 +231,16 @@ These criteria are written for autonomous execution by coding agents. Convention *Procedure:* verify-direction theories for all fixtures through `Cose` public APIs; creation round-trips for each v1 algorithm; CWT claims validation against the Appendix A claim sets including expiry handling. *VC-JOSE-COSE, COSE half (FR-19):* envelope a VCDM 2.0 payload as a `COSE_Sign1` and assert the spec's content type (`application/vc+cose`) and required COSE header parameters are produced and validated on the round trip; assert a wrong/absent content type is rejected. *Pass:* job `ac-4` green. -**AC-5 — didcomm JWS/JWE behavior parity.** -*Setup contract (Phase B):* the porting agent copies the JWS/JWE test files from `didcomm-dotnet` at a recorded commit SHA into `tests/DataProofsDotnet.Jose.Tests/Parity/`, and writes `tests/.../Parity/PARITY.md` mapping every ported test file to its source path and SHA. -*Allowed modifications, exhaustively:* namespace/using statements, type and member renames required by the new public API, test-framework attribute namespaces. **Assertion content (expected values, asserted properties, fixture bytes) MUST NOT change.** -*Mechanical check:* a `tasks/parity-diff` tool that, for each `PARITY.md` pair, extracts assertion statements (lines containing the test framework's assertion invocations) from both files, normalizes identifiers per the recorded rename map, and fails on any difference in literals or asserted structure. -*Pass:* job `ac-5` green = parity suite passes **and** `parity-diff` reports zero assertion differences. +**AC-5 — didcomm JWS/JWE behavior parity. _(RETIRED.)_** +This was a one-time port-fidelity gate: it pinned the ported JWS/JWE tests to a `didcomm-dotnet` +source commit SHA (via `tests/.../Parity/PARITY.md`) and a `tasks/parity-diff` tool that failed on +any assertion drift from that source. Because `didcomm-dotnet` is being refactored, the pinned SHA +(and therefore the diff) is no longer meaningful, so the gate, its `PARITY.md` manifest, and the +`tasks/parity-diff` tool have been removed. **The ported parity test suite under +`tests/DataProofsDotnet.Jose.Tests/Parity/` is kept** — it is real JWS/JWE/JWK/KDF/AEAD coverage +and runs as part of the standard Jose test suite (job `ac-3` and `build-test`). JOSE conformance is +now guaranteed by AC-3 (RFC vectors + oracle cross-verification), not by source-diff against +didcomm. **AC-6 — Dependency hygiene and NetCrypto-only crypto.** *Procedure:* a `tasks/dependency-hygiene` tool run in CI: @@ -284,7 +289,7 @@ These criteria are written for autonomous execution by coding agents. Convention ## 10. Phases - **Phase A — Core.** Repo scaffold (conventions copied from siblings), FR-1–FR-8: models, pipeline, registry, JCS suites, resolver abstraction. Exit: AC-1 green for the JCS suites **plus the controller-authorization (FR-3/FR-7) and proof-set/chain (FR-6) steps**. -- **Phase B — Jose foundation.** FR-13–FR-15: didcomm JWS/JWE port + parity suite, JWT/JWK. Exit: AC-3 green for the JWS/JWE and JWK-thumbprint portions **and the FR-14 ↔ NetCrypto primitive-backing checkpoint** (NetCrypto's surface is already verified — FR-14; the check is the standing CI guard that no `Jose` algorithm bypasses it), AC-5 green. +- **Phase B — Jose foundation.** FR-13–FR-15: didcomm JWS/JWE port + parity suite, JWT/JWK. Exit: AC-3 green for the JWS/JWE and JWK-thumbprint portions **and the FR-14 ↔ NetCrypto primitive-backing checkpoint** (NetCrypto's surface is already verified — FR-14; the check is the standing CI guard that no `Jose` algorithm bypasses it). (AC-5, the source-diff parity gate, has since been retired — see §9; the ported parity tests remain under AC-3.) - **Phase C — Selective disclosure (JSON).** FR-16–FR-17: SD-JWT, KB-JWT, SD-JWT VC. Exit: AC-3 green for the SD-JWT **and SD-JWT VC profile** portions. - **Phase D — CBOR side and binding.** FR-19, FR-18: COSE_Sign1, CWT, VC-JOSE-COSE both halves. Exit: AC-4 green (incl. the VC-JOSE-COSE COSE half) **and** the AC-3 VC-JOSE-COSE JOSE-half step green. - **Phase E — Rdfc.** FR-9–FR-11 first (loader, canonicalizer, RDFC suites), then FR-12 (`bbs-2023`) last — the long pole rides on a proven pipeline. Exit: AC-1 (full), AC-2 green. diff --git a/nuget.config b/nuget.config index 45c385b..b692a98 100644 --- a/nuget.config +++ b/nuget.config @@ -1,4 +1,4 @@ - + diff --git a/samples/DataProofsDotnet.Samples.Jwe/Program.cs b/samples/DataProofsDotnet.Samples.Jwe/Program.cs index d3fc046..872bd04 100644 --- a/samples/DataProofsDotnet.Samples.Jwe/Program.cs +++ b/samples/DataProofsDotnet.Samples.Jwe/Program.cs @@ -2,6 +2,8 @@ using DataProofsDotnet.Jose; using DataProofsDotnet.Jose.Encryption; using NetCrypto; +// NetCrypto 1.1.0 also ships a Base64Url; alias the one this sample showcases. +using Base64Url = DataProofsDotnet.Jose.Base64Url; // ============================================================ // DataProofsDotnet Samples — JWE (JSON Web Encryption) diff --git a/samples/DataProofsDotnet.Samples.Jws/Program.cs b/samples/DataProofsDotnet.Samples.Jws/Program.cs index fe8d0f9..90aa0ab 100644 --- a/samples/DataProofsDotnet.Samples.Jws/Program.cs +++ b/samples/DataProofsDotnet.Samples.Jws/Program.cs @@ -3,6 +3,8 @@ using DataProofsDotnet.Jose; using DataProofsDotnet.Jose.Signing; using NetCrypto; +// NetCrypto 1.1.0 also ships a Base64Url; alias the one this sample showcases. +using Base64Url = DataProofsDotnet.Jose.Base64Url; // ============================================================ // DataProofsDotnet Samples — JWS (JSON Web Signature) diff --git a/samples/DataProofsDotnet.Samples.JwtAndJwk/Program.cs b/samples/DataProofsDotnet.Samples.JwtAndJwk/Program.cs index f6e6c51..02d8344 100644 --- a/samples/DataProofsDotnet.Samples.JwtAndJwk/Program.cs +++ b/samples/DataProofsDotnet.Samples.JwtAndJwk/Program.cs @@ -5,6 +5,8 @@ using DataProofsDotnet.Jose.Jwt; using DataProofsDotnet.Jose.Signing; using NetCrypto; +// NetCrypto 1.1.0 also ships a Base64Url; alias the one this sample showcases. +using Base64Url = DataProofsDotnet.Jose.Base64Url; // ============================================================ // DataProofsDotnet Samples — JWT and JWK @@ -57,6 +59,10 @@ RequireExpirationTime = true, ClockSkew = TimeSpan.FromSeconds(30), AllowedAlgorithms = [JoseAlgorithms.EdDSA], + // RFC 8725 §3.11 explicit typing (opt-in): pin the protected 'typ' header so a token minted + // for a different purpose under the same key cannot be replayed as this JWT. The 'application/' + // prefix is tolerated, so "JWT" matches a header typ of "JWT" or "application/JWT". + ExpectedType = "JWT", }; JwtVerificationResult ok = JwtHandler.Verify(jwt, _ => issuerPublicJwk, strict, joseCrypto); Console.WriteLine($" valid: {ok.IsValid}, alg={ok.SignatureAlgorithm}, signerKid={ok.SignerKid}"); @@ -81,6 +87,12 @@ Check(mismatch.Errors.Any(e => e.StartsWith("AUDIENCE")), "audience mismatch is reported"); Check(mismatch.Errors.Any(e => e.StartsWith("SUBJECT")), "subject mismatch is reported"); +// Explicit typing: a token whose 'typ' is not the expected one is rejected (RFC 8725 §3.11). +JwtVerificationResult wrongType = JwtHandler.Verify( + jwt, _ => issuerPublicJwk, new JwtValidationOptions { CurrentTime = now, ExpectedType = "kb+jwt" }); +Check(!wrongType.IsValid, "a JWT whose typ is not the expected type fails"); +Check(wrongType.Errors.Any(e => e.StartsWith("TYPE_MISMATCH")), "the typ mismatch is reported"); + // Expiry: past the skew it fails; inside the skew it passes. var expiring = new JwtClaims(expiresAt: now); string expiringJwt = await JwtHandler.SignAsync(expiring, jwsSigner); diff --git a/samples/DataProofsDotnet.Samples.SdJwt/Program.cs b/samples/DataProofsDotnet.Samples.SdJwt/Program.cs index ee5a6a8..f6db4b8 100644 --- a/samples/DataProofsDotnet.Samples.SdJwt/Program.cs +++ b/samples/DataProofsDotnet.Samples.SdJwt/Program.cs @@ -3,6 +3,8 @@ using DataProofsDotnet.Jose.SdJwt; using DataProofsDotnet.Jose.Signing; using NetCrypto; +// NetCrypto 1.1.0 also ships a Base64Url; alias the one this sample showcases. +using Base64Url = DataProofsDotnet.Jose.Base64Url; // ============================================================ // DataProofsDotnet Samples — SD-JWT (selective disclosure) diff --git a/samples/DataProofsDotnet.Samples.SdJwtVc/Program.cs b/samples/DataProofsDotnet.Samples.SdJwtVc/Program.cs index 8d4b24b..591da6f 100644 --- a/samples/DataProofsDotnet.Samples.SdJwtVc/Program.cs +++ b/samples/DataProofsDotnet.Samples.SdJwtVc/Program.cs @@ -4,6 +4,8 @@ using DataProofsDotnet.Jose.SdJwt.Vc; using DataProofsDotnet.Jose.Signing; using NetCrypto; +// NetCrypto 1.1.0 also ships a Base64Url; alias the one this sample showcases. +using Base64Url = DataProofsDotnet.Jose.Base64Url; // ============================================================ // DataProofsDotnet Samples — SD-JWT VC profile diff --git a/samples/DataProofsDotnet.Samples.VcJoseCose/Program.cs b/samples/DataProofsDotnet.Samples.VcJoseCose/Program.cs index 3cad46c..4f01c3d 100644 --- a/samples/DataProofsDotnet.Samples.VcJoseCose/Program.cs +++ b/samples/DataProofsDotnet.Samples.VcJoseCose/Program.cs @@ -3,6 +3,8 @@ using DataProofsDotnet.Jose; using DataProofsDotnet.Jose.Signing; using NetCrypto; +// NetCrypto 1.1.0 also ships a Base64Url; alias the one this sample showcases. +using Base64Url = DataProofsDotnet.Jose.Base64Url; // ============================================================ // DataProofsDotnet Samples — VC-JOSE-COSE diff --git a/src/DataProofsDotnet.Jose/Base64Url.cs b/src/DataProofsDotnet.Jose/Base64Url.cs index 43c761f..cd2e1b2 100644 --- a/src/DataProofsDotnet.Jose/Base64Url.cs +++ b/src/DataProofsDotnet.Jose/Base64Url.cs @@ -35,6 +35,19 @@ public static string EncodeUtf8(string utf8String) public static byte[] Decode(string value) { ArgumentException.ThrowIfNullOrEmpty(value); + + // Strict base64url-no-pad alphabet (RFC 4648 §5): A–Z a–z 0–9 '-' '_'. The BCL decoder + // silently tolerates interior/surrounding ASCII whitespace and '=' padding, and accepts the + // standard-base64 '+'/'/' — none of which are valid base64url. JOSE boundaries are always + // strict no-pad, so reject anything outside the alphabet rather than decode it leniently + // (the documented "valid base64url" contract; avoids encoding ambiguity). + foreach (char c in value) + { + var ok = c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '_'; + if (!ok) + throw new FormatException("Value is not valid base64url (no padding or whitespace permitted)."); + } + return SystemBase64Url.DecodeFromChars(value.AsSpan()); } diff --git a/src/DataProofsDotnet.Jose/Jwt/JwtHandler.cs b/src/DataProofsDotnet.Jose/Jwt/JwtHandler.cs index 72fe0d3..61f0939 100644 --- a/src/DataProofsDotnet.Jose/Jwt/JwtHandler.cs +++ b/src/DataProofsDotnet.Jose/Jwt/JwtHandler.cs @@ -72,6 +72,12 @@ public static JwtVerificationResult Verify( if (!options.AllowedAlgorithms.Contains(parsed.SignatureAlgorithm, StringComparer.Ordinal)) errors.Add($"ALGORITHM_NOT_ALLOWED: JWS alg '{parsed.SignatureAlgorithm}' is not in the allowed set."); + // RFC 8725 §3.11 explicit typing (opt-in): when a type is expected, the protected 'typ' + // header must match it (case-insensitive, 'application/' prefix tolerated). Defends against + // cross-context token confusion — a token minted for a different purpose under the same key. + if (options.ExpectedType is { } expectedType && !TypeMatches(parsed.Typ, expectedType)) + errors.Add($"TYPE_MISMATCH: expected typ '{expectedType}', got '{parsed.Typ ?? "(absent)"}'."); + var now = options.CurrentTime ?? DateTimeOffset.UtcNow; var skew = options.ClockSkew; @@ -104,4 +110,24 @@ public static JwtVerificationResult Verify( ? JwtVerificationResult.Success(claims, parsed.SignatureAlgorithm, parsed.SignerKid) : JwtVerificationResult.Failure(errors, claims, parsed.SignatureAlgorithm, parsed.SignerKid); } + + /// + /// RFC 8725 §3.11 typ comparison: case-insensitive, tolerating an optional + /// application/ media-type prefix on either side. Absent typ never matches. + /// + private static bool TypeMatches(string? actual, string expected) + { + if (actual is null) + return false; + + static string Normalize(string value) + { + const string prefix = "application/"; + return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? value[prefix.Length..] + : value; + } + + return string.Equals(Normalize(actual), Normalize(expected), StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/DataProofsDotnet.Jose/Jwt/JwtValidationOptions.cs b/src/DataProofsDotnet.Jose/Jwt/JwtValidationOptions.cs index 0fb97f8..6ceb4e1 100644 --- a/src/DataProofsDotnet.Jose/Jwt/JwtValidationOptions.cs +++ b/src/DataProofsDotnet.Jose/Jwt/JwtValidationOptions.cs @@ -22,6 +22,17 @@ public sealed class JwtValidationOptions /// When set, the token's sub must equal this value exactly. public string? ExpectedSubject { get; init; } + /// + /// When set, enforces explicit typing (RFC 8725 §3.11): the JWS protected typ header + /// must equal this value, compared case-insensitively and tolerating an optional + /// application/ media-type prefix on either side (so "JWT" matches + /// "application/jwt"). A token whose typ is absent or different is rejected, + /// closing cross-service token-confusion where a token minted for another purpose under the + /// same key/issuer is replayed as a JWT. Left null (the default), typ is not + /// checked — unchanged behavior. + /// + public string? ExpectedType { get; init; } + /// When true, a token without an exp claim is rejected. public bool RequireExpirationTime { get; init; } diff --git a/src/DataProofsDotnet.Jose/PublicAPI.Unshipped.txt b/src/DataProofsDotnet.Jose/PublicAPI.Unshipped.txt index 28b3913..f98f719 100644 --- a/src/DataProofsDotnet.Jose/PublicAPI.Unshipped.txt +++ b/src/DataProofsDotnet.Jose/PublicAPI.Unshipped.txt @@ -124,6 +124,8 @@ DataProofsDotnet.Jose.Jwt.JwtValidationOptions.ExpectedIssuer.get -> string? DataProofsDotnet.Jose.Jwt.JwtValidationOptions.ExpectedIssuer.init -> void DataProofsDotnet.Jose.Jwt.JwtValidationOptions.ExpectedSubject.get -> string? DataProofsDotnet.Jose.Jwt.JwtValidationOptions.ExpectedSubject.init -> void +DataProofsDotnet.Jose.Jwt.JwtValidationOptions.ExpectedType.get -> string? +DataProofsDotnet.Jose.Jwt.JwtValidationOptions.ExpectedType.init -> void DataProofsDotnet.Jose.Jwt.JwtValidationOptions.JwtValidationOptions() -> void DataProofsDotnet.Jose.Jwt.JwtValidationOptions.RequireExpirationTime.get -> bool DataProofsDotnet.Jose.Jwt.JwtValidationOptions.RequireExpirationTime.init -> void @@ -146,6 +148,8 @@ DataProofsDotnet.Jose.Signing.JwsParseResult.SignatureAlgorithm.get -> string! DataProofsDotnet.Jose.Signing.JwsParseResult.SignatureAlgorithm.init -> void DataProofsDotnet.Jose.Signing.JwsParseResult.SignerKid.get -> string! DataProofsDotnet.Jose.Signing.JwsParseResult.SignerKid.init -> void +DataProofsDotnet.Jose.Signing.JwsParseResult.Typ.get -> string? +DataProofsDotnet.Jose.Signing.JwsParseResult.Typ.init -> void DataProofsDotnet.Jose.Signing.JwsSigner DataProofsDotnet.Jose.Signing.JwsSigner.Algorithm.get -> string! DataProofsDotnet.Jose.Signing.JwsSigner.JwsSigner(NetCrypto.ISigner! signer, string? kid = null) -> void diff --git a/src/DataProofsDotnet.Jose/SdJwt/CompactJwt.cs b/src/DataProofsDotnet.Jose/SdJwt/CompactJwt.cs index e07d610..69f52ab 100644 --- a/src/DataProofsDotnet.Jose/SdJwt/CompactJwt.cs +++ b/src/DataProofsDotnet.Jose/SdJwt/CompactJwt.cs @@ -109,11 +109,28 @@ public bool Verify(Jwk publicJwk, IJoseCryptoProvider cryptoProvider) if (string.IsNullOrEmpty(publicJwk.Crv)) throw new MalformedJoseException("Verifier public JWK is missing 'crv'."); + // Map the curve to its JWS algorithm and extract the public key BEFORE the crypto call. + // An unsupported curve (NotSupportedException from KeyTypeMapper) or an off-curve / malformed + // key point (the invalid-curve defense inside JwkConversion.ExtractPublicKey) must fail + // closed as a malformed-key condition — both call sites catch MalformedJoseException — rather + // than letting an unexpected exception escape the verifier and crash the host (FR-3). This is + // reachable through an attacker-influenced 'cnf' on the Key Binding path. + string expectedAlg; + byte[] publicKeyBytes; + try + { + expectedAlg = KeyTypeMapper.ToJwsAlgorithm(publicJwk.Crv); + (_, publicKeyBytes) = JwkConversion.ExtractPublicKey(publicJwk); + } + catch (Exception ex) when (ex is not MalformedJoseException and not OperationCanceledException) + { + throw new MalformedJoseException("Verifier public JWK is unsupported or malformed.", ex); + } + // alg ↔ key-curve binding: the header 'alg' must be the one this curve produces. - if (!string.Equals(_alg, KeyTypeMapper.ToJwsAlgorithm(publicJwk.Crv), StringComparison.Ordinal)) + if (!string.Equals(_alg, expectedAlg, StringComparison.Ordinal)) return false; - var (_, publicKeyBytes) = JwkConversion.ExtractPublicKey(publicJwk); var data = Encoding.ASCII.GetBytes(_signingInput); try { diff --git a/src/DataProofsDotnet.Jose/SdJwt/SdJwtReconstructor.cs b/src/DataProofsDotnet.Jose/SdJwt/SdJwtReconstructor.cs index 8028803..03fb1fd 100644 --- a/src/DataProofsDotnet.Jose/SdJwt/SdJwtReconstructor.cs +++ b/src/DataProofsDotnet.Jose/SdJwt/SdJwtReconstructor.cs @@ -12,6 +12,19 @@ namespace DataProofsDotnet.Jose.SdJwt; /// internal static class SdJwtReconstructor { + /// + /// Maximum payload/disclosure nesting depth resolved during reconstruction. RFC 9901 §6.3 + /// recursive disclosures let a presentation chain one extra frame per link, bounded only by + /// input size — so an attacker-rooted chain (a malicious/compromised Issuer, or any deployment + /// whose Issuer-key resolver can return an attacker-controlled key) would otherwise drive the + /// mutual recursion below into an uncatchable StackOverflowException that terminates the + /// host process, defeating the verifier's fail-closed contract (PRD FR-3/FR-23). We bound it + /// and fail closed as a normal . The limit matches the + /// per-document JSON parse depth (JsonNode.Parse default MaxDepth = 64) and is far + /// above any legitimate SD-JWT nesting. + /// + private const int MaxReconstructionDepth = 64; + /// /// Reconstruct the disclosed payload. The input payload and Disclosures are consumed /// read-only; a fresh is returned. @@ -41,7 +54,7 @@ public static JsonObject Reconstruct(JsonObject payload, IReadOnlyList(StringComparer.Ordinal); - var result = (JsonObject)ProcessNode(payload.DeepClone(), byDigest, used)!; + var result = (JsonObject)ProcessNode(payload.DeepClone(), byDigest, used, depth: 0)!; // RFC 9901 §7.1 step 4.3.4: every presented Disclosure MUST be referenced exactly once by // a digest found in the payload. A leftover Disclosure means the Holder sent something the @@ -56,20 +69,28 @@ public static JsonObject Reconstruct(JsonObject payload, IReadOnlyList byDigest, HashSet used) + private static JsonNode? ProcessNode(JsonNode? node, IReadOnlyDictionary byDigest, HashSet used, int depth) { + // Bound the recursion. A chained recursive-disclosure presentation (RFC 9901 §6.3) adds one + // frame per link; without this guard a deep chain overflows the stack and crashes the host + // (uncatchable StackOverflowException) instead of failing closed. `>=` so the permitted + // nesting matches the named bound exactly (depths 0..MaxReconstructionDepth-1). + if (depth >= MaxReconstructionDepth) + throw new MalformedJoseException( + $"SD-JWT reconstruction exceeded the maximum nesting depth of {MaxReconstructionDepth} (RFC 9901 §7.1); the presentation is malformed."); + switch (node) { case JsonObject obj: - return ProcessObject(obj, byDigest, used); + return ProcessObject(obj, byDigest, used, depth); case JsonArray arr: - return ProcessArray(arr, byDigest, used); + return ProcessArray(arr, byDigest, used, depth); default: return node; } } - private static JsonObject ProcessObject(JsonObject obj, IReadOnlyDictionary byDigest, HashSet used) + private static JsonObject ProcessObject(JsonObject obj, IReadOnlyDictionary byDigest, HashSet used, int depth) { var output = new JsonObject(); @@ -78,7 +99,7 @@ private static JsonObject ProcessObject(JsonObject obj, IReadOnlyDictionary byDigest, HashSet used) + private static JsonArray ProcessArray(JsonArray arr, IReadOnlyDictionary byDigest, HashSet used, int depth) { var output = new JsonArray(); foreach (var element in arr) @@ -138,11 +159,11 @@ private static JsonArray ProcessArray(JsonArray arr, IReadOnlyDictionaryOutcome of a successful JWS parse: payload bytes plus verified signer metadata. /// JOSE alg of the verified signature (e.g. "EdDSA"). -/// The verified signer key identifier (empty when the JWS carried none). +/// The verified signer key identifier, sourced ONLY from the +/// integrity-protected header (empty when the protected header carried no kid); an +/// unprotected-header kid is treated as an unauthenticated routing hint and is never +/// reported here. /// Raw decoded payload bytes. -public sealed record JwsParseResult(string SignatureAlgorithm, string SignerKid, byte[] PayloadBytes); +public sealed record JwsParseResult(string SignatureAlgorithm, string SignerKid, byte[] PayloadBytes) +{ + /// + /// The integrity-protected typ header parameter (RFC 7515 §4.1.9), or null when + /// the protected header carried none. Useful for explicit-typing checks (RFC 8725 §3.11) to + /// prevent cross-context token confusion. + /// + public string? Typ { get; init; } +} diff --git a/tasks/parity-diff/AssertionExtractor.cs b/tasks/parity-diff/AssertionExtractor.cs deleted file mode 100644 index fc3cc14..0000000 --- a/tasks/parity-diff/AssertionExtractor.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System.Text; -using System.Text.RegularExpressions; - -namespace DataProofsDotnet.Tools.ParityDiff; - -/// -/// Extracts the assertion statements from a C# test file's source text. An assertion statement is -/// a logical statement (terminated by ';' at brace/paren depth 0) that invokes the test -/// framework's assertion API — FluentAssertions .Should() chains or xunit Assert.* -/// calls. FluentAssertions chains routinely span several physical lines (a .WithMessage(...) -/// or because: on the next line), so statements are reassembled across line breaks before -/// the per-statement filter is applied. -/// -internal static class AssertionExtractor -{ - private static readonly Regex AssertionMarker = - new(@"\.Should\(\)|(^|[^\w.])Assert\.", RegexOptions.Compiled); - - // Matches a method declaration's name: "public void Foo(" / "public async Task Foo(". - private static readonly Regex MethodNameRegex = - new(@"\b(?:public|private|protected|internal)\b[^;{=]*?\b([A-Za-z_]\w*)\s*(?:<[^>]*>)?\s*\(", - RegexOptions.Compiled); - - /// One assertion statement plus the name of the test method that contains it. - public readonly record struct TaggedAssertion(string Statement, string Method); - - /// Returns the ordered list of normalized assertion statements in the file text. - public static List Extract(string sourceText) => - ExtractTagged(sourceText).Select(t => t.Statement).ToList(); - - /// - /// Returns each assertion statement tagged with the enclosing method name, so callers can drop - /// assertions from PARITY.md-sanctioned omitted source tests. - /// - public static List ExtractTagged(string sourceText) - { - var statements = SplitTopLevelStatements(sourceText); - var result = new List(); - string currentMethod = string.Empty; - - foreach (var stmt in statements) - { - // A statement chunk begins with everything since the previous ';'; a method header - // (e.g. "}\n\n[Fact]\npublic void Foo()") is captured in the chunk preceding the - // method's first statement. Update the current method from the last header seen here. - foreach (Match m in MethodNameRegex.Matches(stmt)) - currentMethod = m.Groups[1].Value; - - if (AssertionMarker.IsMatch(stmt)) - result.Add(new TaggedAssertion(CollapseWhitespace(stmt), currentMethod)); - } - return result; - } - - /// - /// Splits a C# file into statements at ';' tokens that sit outside strings, chars, comments, - /// and round/square bracketing. Curly-brace depth is deliberately NOT a guard: a ';' inside a - /// method or class body still terminates a statement, while a ';' inside for ( ; ; ) - /// sits at round-paren depth > 0 and is correctly skipped. This is intentionally coarse — it - /// only needs to group multi-line FluentAssertions chains into one unit and is robust to the - /// test-file constructs in the parity suite (verbatim strings, lambdas, attribute brackets). - /// - private static List SplitTopLevelStatements(string text) - { - var statements = new List(); - var current = new StringBuilder(); - int round = 0, square = 0; - bool inLineComment = false, inBlockComment = false; - bool inString = false, inVerbatim = false, inChar = false, inInterp = false; - - for (int i = 0; i < text.Length; i++) - { - char c = text[i]; - char next = i + 1 < text.Length ? text[i + 1] : '\0'; - - // Comments. - if (inLineComment) - { - if (c == '\n') inLineComment = false; - continue; - } - if (inBlockComment) - { - if (c == '*' && next == '/') { inBlockComment = false; i++; } - continue; - } - - // Strings / chars. - if (inString) - { - current.Append(c); - if (inVerbatim) - { - if (c == '"' && next == '"') { current.Append(next); i++; } - else if (c == '"') { inString = false; inVerbatim = false; } - } - else - { - if (c == '\\' && next != '\0') { current.Append(next); i++; } - else if (c == '"') inString = false; - } - continue; - } - if (inChar) - { - current.Append(c); - if (c == '\\' && next != '\0') { current.Append(next); i++; } - else if (c == '\'') inChar = false; - continue; - } - - // Comment starts. - if (c == '/' && next == '/') { inLineComment = true; i++; continue; } - if (c == '/' && next == '*') { inBlockComment = true; i++; continue; } - - // String / char starts (handle @" and $" and $@"). - if (c == '@' && next == '"') { current.Append(c); current.Append(next); inString = true; inVerbatim = true; i++; continue; } - if (c == '$' && next == '@' && i + 2 < text.Length && text[i + 2] == '"') - { current.Append(c); current.Append(next); current.Append(text[i + 2]); inString = true; inVerbatim = true; inInterp = true; i += 2; continue; } - if (c == '$' && next == '"') { current.Append(c); current.Append(next); inString = true; i++; continue; } - if (c == '"') { current.Append(c); inString = true; continue; } - if (c == '\'') { current.Append(c); inChar = true; continue; } - - switch (c) - { - case '(': round++; break; - case ')': if (round > 0) round--; break; - case '[': square++; break; - case ']': if (square > 0) square--; break; - } - - current.Append(c); - - if (c == ';' && round == 0 && square == 0) - { - statements.Add(current.ToString()); - current.Clear(); - } - } - - _ = inInterp; // interpolation depth is not tracked beyond the opening quote; adequate here. - if (current.ToString().Trim().Length > 0) - statements.Add(current.ToString()); - return statements; - } - - private static string CollapseWhitespace(string s) - { - var sb = new StringBuilder(s.Length); - bool lastSpace = false; - foreach (char c in s) - { - if (char.IsWhiteSpace(c)) - { - if (!lastSpace) { sb.Append(' '); lastSpace = true; } - } - else - { - sb.Append(c); - lastSpace = false; - } - } - return sb.ToString().Trim(); - } -} diff --git a/tasks/parity-diff/ParityDiff.csproj b/tasks/parity-diff/ParityDiff.csproj deleted file mode 100644 index 1793950..0000000 --- a/tasks/parity-diff/ParityDiff.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - Exe - false - DataProofsDotnet.Tools.ParityDiff - ParityDiff - false - - - - - diff --git a/tasks/parity-diff/ParityManifest.cs b/tasks/parity-diff/ParityManifest.cs deleted file mode 100644 index 4065ec2..0000000 --- a/tasks/parity-diff/ParityManifest.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System.Text.RegularExpressions; - -namespace DataProofsDotnet.Tools.ParityDiff; - -/// One ported-file → didcomm-source-file pair from PARITY.md's file map. -internal sealed record FilePair(string PortedFile, string SourcePath); - -/// -/// One rename-map entry. A entry's RHS is prose, not a code -/// token (it documents a sanctioned shape change such as reading a JSON payload property); a -/// plain token rename's RHS is a code fragment substituted into the source assertion text. -/// -internal sealed record RenameEntry(string Old, string New, bool StructuralAdaptation); - -/// -/// Parses PARITY.md (AC-5 setup contract): the source SHA, the file-map table (ported → source), -/// and the machine-readable rename map fenced as ```parity-rename-map. -/// -internal sealed class ParityManifest -{ - public required string SourceSha { get; init; } - public required IReadOnlyList Pairs { get; init; } - public required IReadOnlyList Renames { get; init; } - - /// - /// Source test-method names (the method identifier, no class prefix) that PARITY.md's - /// "Intentionally omitted source tests" section sanctions as NOT ported. Assertions inside - /// these methods are excluded from the strict diff. (Whole-file omissions never appear in the - /// file map, so they need no further handling.) - /// - public required IReadOnlySet OmittedTestMethods { get; init; } - - private static readonly Regex ShaRegex = - new(@"\*\*Source commit SHA:\*\*\s*`([0-9a-fA-F]{7,40})`", RegexOptions.Compiled); - - public static ParityManifest Parse(string parityMdPath) - { - string text = File.ReadAllText(parityMdPath); - var lines = File.ReadAllLines(parityMdPath); - - var shaMatch = ShaRegex.Match(text); - if (!shaMatch.Success) - throw new InvalidOperationException("PARITY.md: could not find the **Source commit SHA:** `` line."); - string sha = shaMatch.Groups[1].Value; - - var pairs = ParseFileMap(lines); - var renames = ParseRenameMap(lines); - var omitted = ParseOmittedTests(lines); - - return new ParityManifest - { - SourceSha = sha, - Pairs = pairs, - Renames = renames, - OmittedTestMethods = omitted, - }; - } - - // The "## Intentionally omitted source tests" section lists, in a markdown table, the source - // tests not ported. Method-level omissions appear as backticked `Class.Method` (or - // `Class.Method` and `…_other`) identifiers; whole-file omissions appear as `Path/File.cs`. - // We harvest the method identifiers (the trailing PascalCase/underscore name after the last - // '.') and any "…_suffix" continuation that shares the preceding method's stem. - private static HashSet ParseOmittedTests(string[] lines) - { - var omitted = new HashSet(StringComparer.Ordinal); - bool inSection = false; - - foreach (var raw in lines) - { - var line = raw.Trim(); - - if (line.StartsWith("## ", StringComparison.Ordinal)) - { - inSection = line.Contains("Intentionally omitted", StringComparison.OrdinalIgnoreCase); - continue; - } - if (!inSection || !line.StartsWith("|")) - continue; - - foreach (Match m in Regex.Matches(line, "`([^`]+)`")) - { - string token = m.Groups[1].Value.Trim(); - - // Whole-file omission (a path) — skip; it never appears in the file map. - if (token.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) - continue; - - // "…_passes_when_present" continuation of the previous method's stem. Store the - // bare suffix fragment (no leading ellipsis/underscore) so IsOmittedMethod's - // "_" suffix match excludes the sibling method whose elided stem matches. - if (token.StartsWith('…') || token.StartsWith("...")) - { - string suffix = token.TrimStart('…', '.', '_'); - if (suffix.Length > 0) - omitted.Add(suffix); - continue; - } - - // A Class.Method (or deeper) identifier: take the trailing method name. - int dot = token.LastIndexOf('.'); - string method = dot >= 0 ? token[(dot + 1)..] : token; - if (method.Length == 0) - continue; - omitted.Add(method); - } - } - - return omitted; - } - - // The file map is the markdown table whose header mentions "Ported file" and "Source path". - // Each data row: | `Ported.cs` | `Source/Path.cs` | — we take .cs rows that have a real - // source path (skip support rows whose source cell is "—" or prose). - private static List ParseFileMap(string[] lines) - { - var pairs = new List(); - bool inTable = false; - - foreach (var raw in lines) - { - var line = raw.Trim(); - - if (line.StartsWith("| Ported file", StringComparison.OrdinalIgnoreCase)) - { - inTable = true; - continue; - } - if (!inTable) - continue; - - // Table separator row (|---|---|) — skip. - if (line.StartsWith("|") && line.Replace("|", "").Replace("-", "").Replace(":", "").Trim().Length == 0) - continue; - - // End of the table. - if (!line.StartsWith("|")) - { - if (pairs.Count > 0) - break; - inTable = false; - continue; - } - - var cells = line.Trim('|').Split('|'); - if (cells.Length < 2) - continue; - - string ported = ExtractBacktickedCs(cells[0]); - string source = ExtractBacktickedCs(cells[1]); - - // Skip support/seam rows: source cell is "—" / empty / not a .cs path. - if (ported.Length == 0 || source.Length == 0) - continue; - if (!source.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) - continue; - // Skip the GlobalUsings support row (lives outside Parity/, no assertions to diff). - if (ported.Contains("GlobalUsings", StringComparison.OrdinalIgnoreCase)) - continue; - - pairs.Add(new FilePair(ported, source)); - } - - if (pairs.Count == 0) - throw new InvalidOperationException("PARITY.md: file-map table produced no ported→source pairs."); - return pairs; - } - - // Pulls the first `…` token from a table cell and returns it only if it ends in .cs. - private static string ExtractBacktickedCs(string cell) - { - var m = Regex.Match(cell, "`([^`]+)`"); - if (!m.Success) - return string.Empty; - string inner = m.Groups[1].Value.Trim(); - // Some ported cells carry a trailing "(support)" note outside the backticks — already gone. - return inner; - } - - // The rename map sits inside a ```parity-rename-map fenced block, one "old -> new" per line, - // with '#' comments (which may also carry an inline "# note" after the mapping). - private static List ParseRenameMap(string[] lines) - { - var renames = new List(); - bool inFence = false; - - foreach (var raw in lines) - { - var line = raw; - - if (!inFence) - { - if (line.TrimStart().StartsWith("```parity-rename-map", StringComparison.Ordinal)) - inFence = true; - continue; - } - - if (line.TrimStart().StartsWith("```", StringComparison.Ordinal)) - break; // end of fence - - // Strip a trailing inline comment ("# note") but keep the mapping before it. - string body = line; - int hash = body.IndexOf('#'); - string trailingComment = string.Empty; - if (hash >= 0) - { - trailingComment = body[(hash + 1)..].Trim(); - body = body[..hash]; - } - body = body.Trim(); - if (body.Length == 0) - continue; - - int arrow = body.IndexOf("->", StringComparison.Ordinal); - if (arrow < 0) - continue; - - string old = body[..arrow].Trim(); - string @new = body[(arrow + 2)..].Trim(); - if (old.Length == 0 || @new.Length == 0) - continue; - - // A structural-adaptation marker's RHS is prose (contains a space and lowercase words - // like "property"/"payload"/"argument"), not a code token. Those are documented shape - // changes, not token renames; the tool reports them transparently and excludes the - // matching source assertion from the strict literal/structure diff. - bool structural = IsProse(@new) || IsProse(old); - - renames.Add(new RenameEntry(old, @new, structural)); - } - - if (renames.Count == 0) - throw new InvalidOperationException("PARITY.md: no rename-map entries found in the ```parity-rename-map block."); - return renames; - } - - private static bool IsProse(string s) - { - // Code tokens here are identifiers, dotted names, or method chains with optional - // parens/args — they contain no spaces except inside "(...)". Prose markers contain a - // top-level space (outside any parentheses). - int depth = 0; - foreach (char c in s) - { - if (c == '(') depth++; - else if (c == ')') { if (depth > 0) depth--; } - else if (c == ' ' && depth == 0) return true; - } - return false; - } -} diff --git a/tasks/parity-diff/Program.cs b/tasks/parity-diff/Program.cs deleted file mode 100644 index d86267b..0000000 --- a/tasks/parity-diff/Program.cs +++ /dev/null @@ -1,289 +0,0 @@ -// AC-5 (didcomm JWS/JWE behavior parity) — PRD §9 AC-5. -// -// For each ported→source pair recorded in tests/DataProofsDotnet.Jose.Tests/Parity/PARITY.md, this -// tool extracts the assertion statements from BOTH the ported file (read from disk) and its didcomm -// source file (read via `git show :` from the read-only didcomm clone), normalizes -// identifiers per PARITY.md's recorded rename map, and fails on any difference in the asserted -// literals or structure. Sanctioned structural adaptations (rename-map entries whose RHS is prose, -// e.g. reading a JSON payload property) are reported transparently and excluded from the strict -// diff, per the PARITY.md "Sanctioned adaptations" section. -// -// Exit codes: 0 = zero assertion differences, 1 = one or more differences, 2 = usage/environment. - -using System.Diagnostics; -using DataProofsDotnet.Tools.ParityDiff; - -const int ExitClean = 0; -const int ExitViolation = 1; -const int ExitUsage = 2; - -string? parityMdArg = null; -string? portedRootArg = null; -string? didcommRepoArg = null; -bool verbose = false; -for (int i = 0; i < args.Length; i++) -{ - switch (args[i]) - { - case "--parity-md": parityMdArg = NextArg(args, ref i); break; - case "--ported-root": portedRootArg = NextArg(args, ref i); break; - case "--didcomm-repo": didcommRepoArg = NextArg(args, ref i); break; - case "-v": - case "--verbose": verbose = true; break; - case "-h": - case "--help": - Console.WriteLine("Usage: ParityDiff [--parity-md ] [--ported-root ] [--didcomm-repo ] [-v]"); - Console.WriteLine(" Diffs ported parity assertions against their didcomm source at the SHA in PARITY.md (AC-5)."); - Console.WriteLine(" Exit 0 = zero differences, 1 = differences, 2 = usage/environment error."); - return ExitClean; - default: - Console.Error.WriteLine($"ERROR: unknown argument '{args[i]}'. Use --help."); - return ExitUsage; - } -} - -string parityMd = parityMdArg ?? LocateParityMd(); -if (!File.Exists(parityMd)) -{ - Console.Error.WriteLine($"ERROR: PARITY.md not found: {parityMd}"); - return ExitUsage; -} -string portedRoot = portedRootArg ?? Path.GetDirectoryName(Path.GetFullPath(parityMd))!; -string didcommRepo = didcommRepoArg ?? "/Users/moises/Projects/didcomm-dotnet"; - -ParityManifest manifest; -try -{ - manifest = ParityManifest.Parse(parityMd); -} -catch (Exception ex) -{ - Console.Error.WriteLine($"ERROR: cannot parse PARITY.md: {ex.Message}"); - return ExitUsage; -} - -Console.WriteLine("=== AC-5 parity-diff ==="); -Console.WriteLine($"PARITY.md: {parityMd}"); -Console.WriteLine($"ported root: {portedRoot}"); -Console.WriteLine($"didcomm repo: {didcommRepo}"); -Console.WriteLine($"source SHA: {manifest.SourceSha}"); -Console.WriteLine($"file pairs: {manifest.Pairs.Count}"); -Console.WriteLine($"rename rules: {manifest.Renames.Count} ({manifest.Renames.Count(r => r.StructuralAdaptation)} structural)"); -Console.WriteLine(); - -// Verify the source SHA exists in the didcomm repo before reading any file from it. -if (!GitObjectExists(didcommRepo, manifest.SourceSha)) -{ - Console.Error.WriteLine($"ERROR: commit {manifest.SourceSha} not found in {didcommRepo} (cannot read parity source)."); - return ExitUsage; -} - -var normalizer = new RenameNormalizer(manifest.Renames); -var differences = new List(); -int totalPorted = 0, totalSource = 0, totalAdapted = 0, totalOmitted = 0; - -string sourceTestsPrefix = "tests/DidComm.Core.Tests/"; - -foreach (var pair in manifest.Pairs) -{ - string portedPath = Path.Combine(portedRoot, pair.PortedFile); - if (!File.Exists(portedPath)) - { - differences.Add($"[{pair.PortedFile}] ported file not found at {portedPath}"); - continue; - } - - string sourceGitPath = sourceTestsPrefix + pair.SourcePath; - string? sourceText = GitShow(didcommRepo, manifest.SourceSha, sourceGitPath); - if (sourceText is null) - { - differences.Add($"[{pair.PortedFile}] didcomm source not found at {manifest.SourceSha}:{sourceGitPath}"); - continue; - } - - var portedAssertions = AssertionExtractor.Extract(File.ReadAllText(portedPath)); - var sourceTagged = AssertionExtractor.ExtractTagged(sourceText); - - // Normalize the source toward the ported public API. Two sets are removed from the strict diff: - // * assertions in PARITY.md-sanctioned OMITTED source test methods (DIDComm-only tests not - // ported — reported as omitted, not a parity violation); - // * assertions whose raw text matches a structural-adaptation marker (a sanctioned shape - // change, reported as adapted). - var sourceAdapted = new List(); - var sourceOmitted = new List(); - var sourceNormalized = new List(); - foreach (var t in sourceTagged) - { - if (IsOmittedMethod(t.Method, manifest.OmittedTestMethods)) - sourceOmitted.Add(t.Statement); - else if (normalizer.IsStructurallyAdapted(t.Statement)) - sourceAdapted.Add(t.Statement); - else - sourceNormalized.Add(normalizer.Normalize(t.Statement)); - } - - // The ported assertions already use the new public-API vocabulary, so they are NOT renamed — - // renames only lift the SOURCE identifiers up to that vocabulary. The corresponding adapted - // ported assertions (reading a JSON payload property) are set aside so they do not count as - // "extra" ported assertions. - var portedAdapted = new List(); - var portedNormalized = new List(); - foreach (var p in portedAssertions) - { - if (normalizer.IsPortedAdaptation(p)) - portedAdapted.Add(p); - else - portedNormalized.Add(p); - } - - totalPorted += portedNormalized.Count; - totalSource += sourceNormalized.Count; - totalAdapted += sourceAdapted.Count; - totalOmitted += sourceOmitted.Count; - - int before = differences.Count; - DiffMultisets(pair.PortedFile, sourceNormalized, portedNormalized, differences); - - string status = differences.Count == before ? "ok" : $"{differences.Count - before} DIFF"; - string notes = string.Concat( - sourceAdapted.Count > 0 ? $", {sourceAdapted.Count} adapted" : string.Empty, - sourceOmitted.Count > 0 ? $", {sourceOmitted.Count} omitted" : string.Empty); - Console.WriteLine($" {pair.PortedFile,-30} src {sourceNormalized.Count,3} / ported {portedNormalized.Count,3}{notes} -> {status}"); - - if (verbose) - { - foreach (var a in sourceAdapted) - Console.WriteLine($" adapted (sanctioned): {Truncate(a, 100)}"); - foreach (var o in sourceOmitted) - Console.WriteLine($" omitted (sanctioned): {Truncate(o, 100)}"); - } -} - -Console.WriteLine(); -Console.WriteLine($"compared {totalSource} source / {totalPorted} ported strict assertions; " - + $"{totalAdapted} sanctioned adaptations + {totalOmitted} omitted-test assertions set aside."); -Console.WriteLine(); - -if (differences.Count == 0) -{ - Console.WriteLine("RESULT: ZERO assertion differences — parity holds (AC-5)."); - return ExitClean; -} - -Console.Error.WriteLine($"RESULT: {differences.Count} ASSERTION DIFFERENCE(S) (AC-5):"); -foreach (var d in differences) - Console.Error.WriteLine($" - {d}"); -return ExitViolation; - -// ---------------------------------------------------------------------------------------------- - -// Order-independent multiset diff: every normalized source assertion must appear (with equal -// multiplicity) on the ported side, and vice versa. Reordering of tests is an allowed modification; -// adding/removing/changing an assertion's literals or structure is not. -static void DiffMultisets(string file, List source, List ported, List differences) -{ - var sourceCounts = ToCounts(source); - var portedCounts = ToCounts(ported); - - foreach (var (assertion, count) in sourceCounts) - { - portedCounts.TryGetValue(assertion, out int portedCount); - if (portedCount < count) - differences.Add($"[{file}] source asserts (x{count - portedCount} more) but ported does not: {assertion}"); - } - foreach (var (assertion, count) in portedCounts) - { - sourceCounts.TryGetValue(assertion, out int sourceCount); - if (sourceCount < count) - differences.Add($"[{file}] ported asserts (x{count - sourceCount} more) absent from source: {assertion}"); - } -} - -// A source test method is omitted if its name exactly equals a PARITY.md omitted entry, or ends -// with one as a "_"-delimited tail. The suffix form covers the "…_passes_when_present" -// continuation, whose elided stem is the sibling of the preceding fully-named omitted method. -static bool IsOmittedMethod(string method, IReadOnlySet omitted) -{ - if (method.Length == 0) - return false; - foreach (var o in omitted) - { - if (method.Equals(o, StringComparison.Ordinal) - || method.EndsWith("_" + o, StringComparison.Ordinal)) - { - return true; - } - } - return false; -} - -static Dictionary ToCounts(List items) -{ - var counts = new Dictionary(StringComparer.Ordinal); - foreach (var item in items) - counts[item] = counts.TryGetValue(item, out int c) ? c + 1 : 1; - return counts; -} - -static string? NextArg(string[] args, ref int i) -{ - if (i + 1 >= args.Length) - throw new ArgumentException($"option '{args[i]}' requires a value"); - return args[++i]; -} - -static string LocateParityMd() -{ - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null) - { - string candidate = Path.Combine(dir.FullName, - "tests", "DataProofsDotnet.Jose.Tests", "Parity", "PARITY.md"); - if (File.Exists(candidate)) - return candidate; - dir = dir.Parent; - } - return Path.Combine(Directory.GetCurrentDirectory(), - "tests", "DataProofsDotnet.Jose.Tests", "Parity", "PARITY.md"); -} - -static bool GitObjectExists(string repo, string sha) -{ - var (exit, _, _) = RunGit(repo, $"cat-file -t {sha}"); - return exit == 0; -} - -static string? GitShow(string repo, string sha, string path) -{ - var (exit, stdout, _) = RunGit(repo, $"show {sha}:{path}"); - return exit == 0 ? stdout : null; -} - -static (int Exit, string Stdout, string Stderr) RunGit(string repo, string arguments) -{ - var psi = new ProcessStartInfo - { - FileName = "git", - WorkingDirectory = repo, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - psi.ArgumentList.Add("-C"); - psi.ArgumentList.Add(repo); - foreach (var part in SplitArgs(arguments)) - psi.ArgumentList.Add(part); - - using var process = Process.Start(psi) ?? throw new InvalidOperationException("failed to start git"); - string stdout = process.StandardOutput.ReadToEnd(); - string stderr = process.StandardError.ReadToEnd(); - process.WaitForExit(); - return (process.ExitCode, stdout, stderr); -} - -// git args here never contain quoted spaces, so a simple whitespace split is sufficient. -static IEnumerable SplitArgs(string s) => - s.Split(' ', StringSplitOptions.RemoveEmptyEntries); - -static string Truncate(string value, int max) => - value.Length <= max ? value : value[..max] + "…"; diff --git a/tasks/parity-diff/README.md b/tasks/parity-diff/README.md deleted file mode 100644 index d22a4e7..0000000 --- a/tasks/parity-diff/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# parity-diff (AC-5) - -Console tool enforcing **didcomm JWS/JWE behavior parity**: for every ported→source pair recorded -in `tests/DataProofsDotnet.Jose.Tests/Parity/PARITY.md`, it extracts the assertion statements from -both the ported file and its didcomm source (at the SHA recorded in PARITY.md), normalizes -identifiers per the recorded rename map, and fails on any difference in asserted literals or -structure. - -``` -dotnet run --project tasks/parity-diff/ParityDiff.csproj -c Release -- \ - [--parity-md ] [--ported-root ] [--didcomm-repo ] [-v] -``` - -## What it does - -1. **Parses PARITY.md** (`ParityManifest`): the source commit SHA, the file-map table - (ported `.cs` → didcomm source path), the machine-readable `parity-rename-map` fenced block, and - the "Intentionally omitted source tests" section (method names not ported). -2. **Reads the didcomm source** at the recorded SHA via `git show :tests/DidComm.Core.Tests/` - from the read-only didcomm clone (default `/Users/moises/Projects/didcomm-dotnet`, override with - `--didcomm-repo`). The SHA's existence is verified before any read. -3. **Extracts assertions** (`AssertionExtractor`): splits each file into statements at `;` outside - strings/comments/parens (curly depth is not a guard, so multi-line FluentAssertions chains stay - intact), keeps statements containing `.Should()` or `Assert.`, and tags each with its enclosing - test-method name. -4. **Normalizes** (`RenameNormalizer`): the recorded token renames are applied to the **source** - side only (lifting didcomm identifiers up to the new public-API vocabulary; the ported side - already uses it), longest-LHS-first. Two classes of assertion are set aside from the strict diff, - exactly as PARITY.md sanctions: - - **structural adaptations** — rename-map entries whose RHS is prose (e.g. - `result.Message.Id -> JsonDocument payload property "id"`); the source assertion and its - reshaped ported counterpart (reading `GetProperty("id")`) are recognized and excluded; - - **omitted source tests** — assertions inside methods named in the "Intentionally omitted" - section (the DIDComm-only FR-SIG-06 / FR-CONSIST-03 tests and the companion the table elides - with `…_passes_when_present`). -5. **Diffs** the remaining assertions as a multiset (test reordering is an allowed modification; - adding/removing/changing an assertion's literals or structure is not). Any mismatch fails. - -## Exit codes - -- `0` — zero assertion differences (parity holds) -- `1` — one or more assertion differences -- `2` — usage / environment error (PARITY.md missing, SHA not in the didcomm repo, etc.) - -## Result against the current repo - -ZERO assertion differences. 16 file pairs; 125 source / 125 ported strict assertions match; 2 -sanctioned structural adaptations + 3 omitted-test assertions set aside. The detector was -verified non-vacuous (changing one ported expected literal produces a 2-line DIFF naming both the -absent source assertion and the unexpected ported one). - -## CI note - -In CI the didcomm source is provided at a pinned SHA (the same `e98570cd…` recorded in PARITY.md), -either as a sibling checkout or a vendored read-only clone; pass its path via `--didcomm-repo`. The -tool needs only `git show` against that clone and the checked-in ported files — no build of either -project. The matching half of AC-5 ("the parity suite passes") is the `Jose.Tests` Parity test run; -this tool is the assertion-equivalence half. diff --git a/tasks/parity-diff/RenameNormalizer.cs b/tasks/parity-diff/RenameNormalizer.cs deleted file mode 100644 index 9569d74..0000000 --- a/tasks/parity-diff/RenameNormalizer.cs +++ /dev/null @@ -1,89 +0,0 @@ -namespace DataProofsDotnet.Tools.ParityDiff; - -/// -/// Applies PARITY.md's recorded rename map to a (whitespace-collapsed) assertion statement so the -/// didcomm source and the ported file normalize to a common identifier vocabulary before the diff. -/// -/// Two entry kinds (see ): -/// * Token renames (RHS is a code fragment) are applied as ordered textual substitutions, -/// longest-LHS first so a longer mapping (e.g. DidComm.Crypto.Kdf) wins over a shorter -/// prefix (DidComm.Crypto). -/// * Structural-adaptation markers (RHS is prose) are NOT substituted — they document a sanctioned -/// shape change. Source assertions whose text mentions the marker's LHS code token, and the -/// ported assertions that replace them, are detected here and set aside by the diff so the -/// strict comparison covers only assertions that must remain byte-for-byte parity. -/// -internal sealed class RenameNormalizer -{ - private readonly List<(string Old, string New)> _tokenRenames; - private readonly List _structuralSourceTokens; // code tokens that mark an adapted SOURCE assertion - private readonly List _structuralPortedHints; // code tokens/literals in the adapted PORTED assertion - - public RenameNormalizer(IReadOnlyList renames) - { - _tokenRenames = renames - .Where(r => !r.StructuralAdaptation) - .Select(r => (r.Old, r.New)) - .OrderByDescending(r => r.Old.Length) - .ToList(); - - _structuralSourceTokens = new List(); - _structuralPortedHints = new List(); - foreach (var r in renames.Where(r => r.StructuralAdaptation)) - { - string srcToken = LeadingCodeToken(r.Old); - if (srcToken.Length > 0) - _structuralSourceTokens.Add(srcToken); - - // The ported side of a payload-property adaptation reads e.g. GetProperty("id"); - // capture the quoted hint ("id"/"from") so the matching ported assertion is recognized. - foreach (var lit in QuotedLiterals(r.New)) - _structuralPortedHints.Add($"GetProperty(\"{lit}\")"); - } - } - - /// Applies the ordered token renames to an assertion statement. - public string Normalize(string assertion) - { - string s = assertion; - foreach (var (old, @new) in _tokenRenames) - s = s.Replace(old, @new, StringComparison.Ordinal); - return s; - } - - /// - /// True if this SOURCE assertion is a sanctioned structural adaptation (its expression is one - /// the PARITY.md rename map marks as reshaped, e.g. result.Message.Id). - /// - public bool IsStructurallyAdapted(string sourceAssertion) => - _structuralSourceTokens.Any(t => sourceAssertion.Contains(t, StringComparison.Ordinal)); - - /// - /// True if this PORTED assertion is the reshaped counterpart of a sanctioned adaptation (e.g. it - /// reads GetProperty("id") / GetProperty("from") from the parsed payload JSON). - /// - public bool IsPortedAdaptation(string portedAssertion) => - _structuralPortedHints.Any(h => portedAssertion.Contains(h, StringComparison.Ordinal)); - - // The meaningful code token at the start of a structural LHS such as - // "signer.PrivateJwk (as JwsBuilder argument)" -> "signer.PrivateJwk". - private static string LeadingCodeToken(string s) - { - int space = s.IndexOf(' '); - return (space < 0 ? s : s[..space]).Trim(); - } - - private static IEnumerable QuotedLiterals(string s) - { - int i = 0; - while (i < s.Length) - { - int open = s.IndexOf('"', i); - if (open < 0) yield break; - int close = s.IndexOf('"', open + 1); - if (close < 0) yield break; - yield return s.Substring(open + 1, close - open - 1); - i = close + 1; - } - } -} diff --git a/tasks/samples-coverage/Program.cs b/tasks/samples-coverage/Program.cs index 0d2b547..457e402 100644 --- a/tasks/samples-coverage/Program.cs +++ b/tasks/samples-coverage/Program.cs @@ -210,7 +210,10 @@ void WriteReport() sb.AppendLine($"- Allowlisted (excused, with justification): **{allowlisted.Count}**"); sb.AppendLine($"- Uncovered: **{uncovered.Count}**"); sb.AppendLine(); - sb.AppendLine($"Samples directory: `{samplesDir}` · Allowlist: `{allowlistPath}`"); + // Emit repo-relative paths so the committed report is machine-independent (no absolute local + // path leaks into version control when regenerated on a different machine or in CI). + static string Rel(string root, string path) => Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/'); + sb.AppendLine($"Samples directory: `{Rel(repoRoot, samplesDir)}` · Allowlist: `{Rel(repoRoot, allowlistPath)}`"); sb.AppendLine(); if (uncovered.Count > 0) diff --git a/tasks/samples-coverage/samples-coverage-report.md b/tasks/samples-coverage/samples-coverage-report.md index f6c0a55..56b4819 100644 --- a/tasks/samples-coverage/samples-coverage-report.md +++ b/tasks/samples-coverage/samples-coverage-report.md @@ -1,11 +1,11 @@ # Samples coverage report (FR-21 / AC-9) -- Public members across 6 packages: **528** -- Covered by at least one sample: **528** +- Public members across 6 packages: **530** +- Covered by at least one sample: **530** - Allowlisted (excused, with justification): **0** - Uncovered: **0** -Samples directory: `/Users/moises/Projects/dataproofs-dotnet/samples` · Allowlist: `/Users/moises/Projects/dataproofs-dotnet/tasks/samples-coverage/allowlist.txt` +Samples directory: `samples` · Allowlist: `tasks/samples-coverage/allowlist.txt` ## Covered members @@ -328,6 +328,7 @@ Samples directory: `/Users/moises/Projects/dataproofs-dotnet/samples` · Allowli - `DataProofsDotnet.Jose.Jwt.JwtValidationOptions.ExpectedAudience` - `DataProofsDotnet.Jose.Jwt.JwtValidationOptions.ExpectedIssuer` - `DataProofsDotnet.Jose.Jwt.JwtValidationOptions.ExpectedSubject` +- `DataProofsDotnet.Jose.Jwt.JwtValidationOptions.ExpectedType` - `DataProofsDotnet.Jose.Jwt.JwtValidationOptions.RequireExpirationTime` - `DataProofsDotnet.Jose.Jwt.JwtVerificationResult (type)` - `DataProofsDotnet.Jose.Jwt.JwtVerificationResult.Claims` @@ -436,6 +437,7 @@ Samples directory: `/Users/moises/Projects/dataproofs-dotnet/samples` · Allowli - `DataProofsDotnet.Jose.Signing.JwsParseResult.PayloadBytes` - `DataProofsDotnet.Jose.Signing.JwsParseResult.SignatureAlgorithm` - `DataProofsDotnet.Jose.Signing.JwsParseResult.SignerKid` +- `DataProofsDotnet.Jose.Signing.JwsParseResult.Typ` - `DataProofsDotnet.Jose.Signing.JwsParser (type)` - `DataProofsDotnet.Jose.Signing.JwsParser.Parse` - `DataProofsDotnet.Jose.Signing.JwsParser.Parse` diff --git a/tasks/smoke/README.md b/tasks/smoke/README.md index c846cab..320be40 100644 --- a/tasks/smoke/README.md +++ b/tasks/smoke/README.md @@ -20,7 +20,7 @@ For each package the CI job does, in a clean temp directory: dotnet pack -o ./local-feed # all five packed to a local folder feed dotnet new console -n SmokeApp && cd SmokeApp dotnet add package --source ../local-feed # plus the documented prerequisite only -# replace the generated Program.cs with this folder's Program.cs (and, for Rdfc, the bundled .jsonld) +# replace the generated Program.cs with this folder's Program.cs (each smoke is self-contained — one file) dotnet run # must print "OK — … smoke passed." and exit 0 ``` diff --git a/tasks/smoke/Rdfc/Program.cs b/tasks/smoke/Rdfc/Program.cs index 47f8588..f7ba6fd 100644 --- a/tasks/smoke/Rdfc/Program.cs +++ b/tasks/smoke/Rdfc/Program.cs @@ -1,23 +1,34 @@ // ============================================================ // AC-10 smoke — DataProofsDotnet.Rdfc // ============================================================ -// RDFC-1.0 canonicalization of a bundled JSON-LD document through the public canonicalizer, using -// the offline document loader (no network). Asserts non-empty, valid N-Quads output and that the -// result is deterministic across runs. Prints OK and exits 0 on success. +// RDFC-1.0 canonicalization of a self-contained JSON-LD document through the public canonicalizer, +// using the offline document loader (no network). Asserts non-empty, valid N-Quads output and that +// the result is deterministic across runs. Prints OK and exits 0 on success. +// +// Self-contained by design: the AC-10 clean-room copies ONLY this Program.cs into a fresh console +// app, so the document is inlined here (its @context is in the offline loader's bundled set). -using System.Reflection; using System.Text; using System.Text.Json; using DataProofsDotnet.Rdfc; Console.WriteLine("=== DataProofsDotnet.Rdfc smoke — RDFC-1.0 canonicalization (offline) ==="); -// 1. Load the bundled document (embedded resource; its @context is in the offline loader's set). -string jsonLd = ReadEmbedded("bundled-document.jsonld"); +// 1. The document to canonicalize. Its only @context (credentials/v2) is bundled in the offline +// loader, so canonicalization needs no network access (FR-10 fail-closed posture). +const string jsonLd = """ + { + "@context": [ "https://www.w3.org/ns/credentials/v2" ], + "id": "urn:uuid:11111111-2222-3333-4444-555555555555", + "type": [ "VerifiableCredential" ], + "issuer": "did:example:issuer", + "validFrom": "2026-01-01T00:00:00Z", + "credentialSubject": { "id": "did:example:subject" } + } + """; using var doc = JsonDocument.Parse(jsonLd); -// 2. Canonicalize through the public interface. The default ctor uses the offline loader, so no -// network access occurs (FR-10 fail-closed posture). +// 2. Canonicalize through the public interface. The default ctor uses the offline loader. var canonicalizer = new RdfcDocumentCanonicalizer(); byte[] canonicalBytes = canonicalizer.CanonicalizeJsonLd(doc.RootElement); string nquads = Encoding.UTF8.GetString(canonicalBytes); @@ -37,16 +48,6 @@ Console.WriteLine("OK — DataProofsDotnet.Rdfc smoke passed."); return 0; -static string ReadEmbedded(string fileName) -{ - var assembly = Assembly.GetExecutingAssembly(); - string resource = assembly.GetManifestResourceNames() - .Single(n => n.EndsWith(fileName, StringComparison.Ordinal)); - using var stream = assembly.GetManifestResourceStream(resource)!; - using var reader = new StreamReader(stream); - return reader.ReadToEnd(); -} - static void Check(bool condition, string what) { if (condition) return; diff --git a/tasks/smoke/Rdfc/Rdfc.csproj b/tasks/smoke/Rdfc/Rdfc.csproj index 628f24a..399ea53 100644 --- a/tasks/smoke/Rdfc/Rdfc.csproj +++ b/tasks/smoke/Rdfc/Rdfc.csproj @@ -17,10 +17,4 @@ - - - - - diff --git a/tasks/smoke/Rdfc/bundled-document.jsonld b/tasks/smoke/Rdfc/bundled-document.jsonld deleted file mode 100644 index 87880be..0000000 --- a/tasks/smoke/Rdfc/bundled-document.jsonld +++ /dev/null @@ -1,14 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/credentials/v2" - ], - "id": "urn:uuid:11111111-2222-3333-4444-555555555555", - "type": [ - "VerifiableCredential" - ], - "issuer": "did:example:issuer", - "validFrom": "2026-01-01T00:00:00Z", - "credentialSubject": { - "id": "did:example:subject" - } -} diff --git a/tasks/todo-2026-06-14-issue-6.md b/tasks/todo-2026-06-14-issue-6.md new file mode 100644 index 0000000..3b79b69 --- /dev/null +++ b/tasks/todo-2026-06-14-issue-6.md @@ -0,0 +1,42 @@ +# Issue #6 — JOSE enveloping: full re-review / hardening (GA 1.0.0) + +The JOSE layer (JWS/JWE/JWT/JWK/SD-JWT/VC-JOSE) was already implemented and passing; #6 was stale. +Per the user's choice, ran a deep adversarial security/correctness hardening pass and fixed every +confirmed finding. + +## Method + +- Workflow `jose-hardening-review`: 9 adversarial dimensions (jws-bypass, jwe-keymgmt, ecdh-1pu, + jwe-multi, jwk-keys, sdjwt, jwt, encoding-json, failclosed-hygiene) → each finding verified by + 3 independent refute-by-default skeptics; survives only on ≥2/3 confirm. +- Result: 11 findings verified, **5 confirmed**, 6 refuted (correctly — by-design / already-guarded). + +## Confirmed findings & fixes (each with a regression test) + +- [x] **#0 HIGH — SD-JWT reconstruction unbounded recursion → uncatchable StackOverflow.** + `SdJwtReconstructor` now depth-bounds (64) and fails closed (`MalformedJoseException` → + `DISCLOSURE_INVALID`). Test: `Reconstruct_DeepRecursiveDisclosureChain_FailsClosed_*`. +- [x] **#1 MEDIUM — malformed `cnf`/issuer key crashed the verifier.** `CompactJwt.Verify` maps + unsupported-curve / off-curve key exceptions to `MalformedJoseException` (caught by both call + sites). Tests: `CompactJwt_Verify_UnsupportedCurveJwk_*`, `..._MalformedOkpJwk_*`. +- [x] **#2 LOW — JWS surfaced `SignerKid` from the unauthenticated unprotected header.** Reported + identity now comes only from the protected header; the unprotected `kid` stays a resolution + hint. Tests: `JwsJson_KidOnlyInUnprotectedHeader_*`, `JwsCompact_KidInProtectedHeader_*`. +- [x] **#3 LOW — JWT ignored `typ` (RFC 8725 §3.11).** Added opt-in + `JwtValidationOptions.ExpectedType` + surfaced `JwsParseResult.Typ`. Test: + `JwtVerify_ExpectedType_EnforcesTyp_PrefixTolerant`. +- [x] **#4 LOW — `Base64Url.Decode` accepted whitespace/padding.** Now strict to the base64url-no-pad + alphabet. Test: `Base64Url_Decode_RejectsNonAlphabet` (theory) + `..._AcceptsCanonicalNoPad`. + +Refuted (no change): un-zeroed sender key on 1PU build-throw (1/3), A256KW recipient-set commitment +(1/3, by design), vct#integrity not validated (1/3, optional), padding-only base64 (1/3 — covered by +#4 anyway), deterministic-number canonicalization (0/3, headers carry no floats). + +## Review / results + +- New public API (opt-in, documented): `JwtValidationOptions.ExpectedType`, `JwsParseResult.Typ` + (PublicAPI.Unshipped updated; covered by the JwtAndJwk sample → AC-9 gate green, 530 members, + 0 uncovered). +- Build `dotnet build DataProofsDotnet.sln` → 0/0. Full solution **781 tests pass** (Jose 311, + +14 new). No regressions; AC-6 hygiene / AC-7 API gates green. +- No new public algorithms; documented intentional omissions preserved; all crypto via NetCrypto. diff --git a/tests/DataProofsDotnet.Core.Tests/TestSupport/RecordingKeyStore.cs b/tests/DataProofsDotnet.Core.Tests/TestSupport/RecordingKeyStore.cs index def03d6..acc8c98 100644 --- a/tests/DataProofsDotnet.Core.Tests/TestSupport/RecordingKeyStore.cs +++ b/tests/DataProofsDotnet.Core.Tests/TestSupport/RecordingKeyStore.cs @@ -44,6 +44,12 @@ public Task SignAsync(string alias, ReadOnlyMemory data, Cancellat return _inner.SignAsync(alias, data, ct); } + public Task DeriveSharedSecretAsync(string alias, ReadOnlyMemory peerPublicKey, CancellationToken ct = default) + { + _calls.Add(nameof(DeriveSharedSecretAsync)); + return _inner.DeriveSharedSecretAsync(alias, peerPublicKey, ct); + } + public async Task CreateSignerAsync(string alias, CancellationToken ct = default) { _calls.Add(nameof(CreateSignerAsync)); diff --git a/tests/DataProofsDotnet.Jose.Tests/Hardening/HardeningRegressionTests.cs b/tests/DataProofsDotnet.Jose.Tests/Hardening/HardeningRegressionTests.cs new file mode 100644 index 0000000..83a2a7b --- /dev/null +++ b/tests/DataProofsDotnet.Jose.Tests/Hardening/HardeningRegressionTests.cs @@ -0,0 +1,166 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using DataProofsDotnet.Jose.Jwt; +using DataProofsDotnet.Jose.SdJwt; +using DataProofsDotnet.Jose.Signing; +using DataProofsDotnet.Jose.Tests.Envelopes; +using FluentAssertions; +using NetCrypto; +using Xunit; + +namespace DataProofsDotnet.Jose.Tests.Hardening; + +/// +/// Regression tests for the issue #6 adversarial-hardening pass. Each test pins a confirmed +/// finding's fix so it cannot regress before GA. +/// +public class HardeningRegressionTests +{ + private static readonly DefaultKeyGenerator KeyGen = new(); + private static readonly DefaultCryptoProvider NetCrypto = new(); + private static readonly JoseCryptoProvider Jose = new(); + + // ── Finding #0 (HIGH): SD-JWT reconstruction must bound recursion and FAIL CLOSED on a deep + // recursive-disclosure chain, rather than overflow the stack (uncatchable, crashes the host). + [Fact] + public void Reconstruct_DeepRecursiveDisclosureChain_FailsClosed_NoStackOverflow() + { + const string sdAlg = SdHashAlgorithm.Sha256; + var disclosures = new List(); + + // Build a chain well beyond the depth bound: each link's disclosed value is + // {"_sd":[digest(next)]}, so reconstruction recurses one frame per link. + var leaf = Disclosure.ForObjectProperty("salt-leaf", "n", JsonValue.Create("v")); + disclosures.Add(leaf); + var childDigest = leaf.ComputeDigest(sdAlg); + + for (var i = 0; i < 200; i++) + { + var value = new JsonObject { ["_sd"] = new JsonArray(childDigest) }; + var link = Disclosure.ForObjectProperty($"salt-{i}", "n", value); + disclosures.Add(link); + childDigest = link.ComputeDigest(sdAlg); + } + + var payload = new JsonObject { ["_sd"] = new JsonArray(childDigest), ["_sd_alg"] = sdAlg }; + + var act = () => SdJwtReconstructor.Reconstruct(payload, disclosures, sdAlg); + + act.Should().Throw().WithMessage("*depth*"); + } + + // ── Finding #1 (MEDIUM): a malformed/unsupported verifier JWK (e.g. an attacker-influenced + // SD-JWT 'cnf') must make CompactJwt.Verify FAIL CLOSED as MalformedJoseException — which + // both call sites catch — not escape as NotSupportedException / a crypto exception. + [Fact] + public async Task CompactJwt_Verify_UnsupportedCurveJwk_ThrowsMalformed_NotRawException() + { + var key = TestKeyMaterial.Generate(KeyType.Ed25519, "h"); + var jwt = await JwtHandler.SignAsync(new JwtClaims(subject: "s"), key.Signer); + var compact = CompactJwt.Decode(jwt); + + var unsupportedCurve = new Jwk { Kty = "EC", Crv = "P-192", X = "AAAA", Y = "AAAA" }; + + var act = () => compact.Verify(unsupportedCurve, Jose); + + act.Should().Throw(); + } + + [Fact] + public async Task CompactJwt_Verify_MalformedOkpJwk_ThrowsMalformed_NotRawException() + { + var key = TestKeyMaterial.Generate(KeyType.Ed25519, "h"); + var jwt = await JwtHandler.SignAsync(new JwtClaims(subject: "s"), key.Signer); + var compact = CompactJwt.Decode(jwt); + + // Valid base64url, but decodes to 3 bytes — not the 32 an Ed25519 public key requires. + var malformedOkp = new Jwk { Kty = "OKP", Crv = "Ed25519", X = "AAAA" }; + + var act = () => compact.Verify(malformedOkp, Jose); + + act.Should().Throw(); + } + + // ── Finding #2 (LOW): the verified SignerKid must come ONLY from the integrity-protected + // header. A kid that lives solely in the unauthenticated unprotected header is a routing + // hint (used to resolve the key) but must never be surfaced as the verified identity. + [Fact] + public async Task JwsJson_KidOnlyInUnprotectedHeader_VerifiesButReportsNoSignerKid() + { + var pair = KeyGen.Generate(KeyType.Ed25519); + var publicJwk = JwkConversion.ToPublicJwk(pair.KeyType, pair.PublicKey, "k1"); + var signerNoKid = new JwsSigner(new KeyPairSigner(pair, NetCrypto)); // protected header carries no kid + + var compact = await JwsBuilder.BuildCompactAsync(Encoding.UTF8.GetBytes("hello"), signerNoKid); + var parts = compact.Split('.'); + + // Flattened JSON JWS with kid placed ONLY in the unprotected header. + var flattened = new JsonObject + { + ["payload"] = parts[1], + ["protected"] = parts[0], + ["header"] = new JsonObject { ["kid"] = "k1" }, + ["signature"] = parts[2], + }.ToJsonString(); + + Func resolver = kid => kid == "k1" ? publicJwk : null; + var result = JwsParser.Parse(flattened, resolver, Jose); + + Encoding.UTF8.GetString(result.PayloadBytes).Should().Be("hello", "the unprotected kid is still a valid resolution hint"); + result.SignerKid.Should().BeEmpty("an unprotected-only kid is unauthenticated and must not be reported as the verified signer"); + } + + [Fact] + public async Task JwsCompact_KidInProtectedHeader_IsReportedAsSignerKid() + { + var pair = KeyGen.Generate(KeyType.Ed25519); + var publicJwk = JwkConversion.ToPublicJwk(pair.KeyType, pair.PublicKey, "k1"); + var signerWithKid = new JwsSigner(new KeyPairSigner(pair, NetCrypto), "k1"); + + var compact = await JwsBuilder.BuildCompactAsync(Encoding.UTF8.GetBytes("hello"), signerWithKid); + var result = JwsParser.ParseCompact(compact, _ => publicJwk, Jose); + + result.SignerKid.Should().Be("k1"); + } + + // ── Finding #3 (LOW): JwtValidationOptions.ExpectedType enforces RFC 8725 §3.11 explicit + // typing (opt-in), tolerating the 'application/' prefix; default behavior is unchanged. + [Fact] + public async Task JwtVerify_ExpectedType_EnforcesTyp_PrefixTolerant() + { + var key = TestKeyMaterial.Generate(KeyType.Ed25519, "k"); + var jwt = await JwtHandler.SignAsync(new JwtClaims(subject: "s"), key.Signer); // SignAsync writes typ="JWT" + Func resolver = _ => key.PublicJwk; + + JwtHandler.Verify(jwt, resolver).IsValid + .Should().BeTrue("typ is not checked by default"); + JwtHandler.Verify(jwt, resolver, new JwtValidationOptions { ExpectedType = "JWT" }).IsValid + .Should().BeTrue(); + JwtHandler.Verify(jwt, resolver, new JwtValidationOptions { ExpectedType = "application/jwt" }).IsValid + .Should().BeTrue("the application/ prefix and case are tolerated (RFC 8725 §3.11)"); + + var mismatch = JwtHandler.Verify(jwt, resolver, new JwtValidationOptions { ExpectedType = "kb+jwt" }); + mismatch.IsValid.Should().BeFalse(); + mismatch.Errors.Should().Contain(e => e.StartsWith("TYPE_MISMATCH", StringComparison.Ordinal)); + } + + // ── Finding #4 (LOW): Base64Url.Decode is strict — interior/surrounding whitespace, '=' + // padding, and standard-base64 '+'/'/' are rejected (the documented no-pad contract). + [Theory] + [InlineData("AQ ID")] // interior space + [InlineData(" AQID")] // leading space + [InlineData("AQID ")] // trailing space + [InlineData("AQ\nID")] // newline + [InlineData("AQID=")] // padding + [InlineData("AQ/D")] // standard-base64 '/' + [InlineData("AQ+D")] // standard-base64 '+' + public void Base64Url_Decode_RejectsNonAlphabet(string value) + { + var act = () => Base64Url.Decode(value); + act.Should().Throw(); + } + + [Fact] + public void Base64Url_Decode_AcceptsCanonicalNoPad() + => Base64Url.Decode("AQID").Should().Equal(1, 2, 3); +} diff --git a/tests/DataProofsDotnet.Jose.Tests/Parity/AeadShims.cs b/tests/DataProofsDotnet.Jose.Tests/Parity/AeadShims.cs index 37a89dd..c16e57f 100644 --- a/tests/DataProofsDotnet.Jose.Tests/Parity/AeadShims.cs +++ b/tests/DataProofsDotnet.Jose.Tests/Parity/AeadShims.cs @@ -2,7 +2,7 @@ namespace DataProofsDotnet.Jose.Tests.Crypto.Aead; -// Parity shims (PARITY.md rename map): didcomm-dotnet implemented IAead instance classes +// Parity shims (rename-adapted from the didcomm-dotnet port): didcomm-dotnet implemented IAead instance classes // locally; dataproofs deletes them per AC-6 (every AEAD is a NetCrypto static). These shims // preserve the porting source's instance call shape — (key, iv, aad, plaintext) order and the // Name/KeySizeBytes/IvSizeBytes/TagSizeBytes metadata — while delegating every cryptographic diff --git a/tests/DataProofsDotnet.Jose.Tests/Parity/JwsRoundTripTests.cs b/tests/DataProofsDotnet.Jose.Tests/Parity/JwsRoundTripTests.cs index 97e9d98..c49da7b 100644 --- a/tests/DataProofsDotnet.Jose.Tests/Parity/JwsRoundTripTests.cs +++ b/tests/DataProofsDotnet.Jose.Tests/Parity/JwsRoundTripTests.cs @@ -11,7 +11,7 @@ public sealed class JwsRoundTripTests { private static readonly JoseDefaultCryptoProvider _crypto = new(); - // Ported payload adaptation (PARITY.md): the porting source signed a DIDComm Message built + // Ported payload adaptation (from the didcomm-dotnet port): the porting source signed a DIDComm Message built // via MessageBuilder; dataproofs signs arbitrary bytes, so the same logical content travels // as a JSON payload and the Message.Id / Message.From assertions read the payload JSON. private static byte[] Payload(string from = "did:example:alice", string? to = "did:example:bob") => diff --git a/tests/DataProofsDotnet.Jose.Tests/Parity/PARITY.md b/tests/DataProofsDotnet.Jose.Tests/Parity/PARITY.md deleted file mode 100644 index 30b5338..0000000 --- a/tests/DataProofsDotnet.Jose.Tests/Parity/PARITY.md +++ /dev/null @@ -1,126 +0,0 @@ -# AC-5 Parity Suite — didcomm-dotnet JWS/JWE behavior-parity port - -- **Source repository:** `didcomm-dotnet` (local clone `/Users/moises/Projects/didcomm-dotnet`) -- **Source commit SHA:** `e98570cd2901ec9704a68c7587b063e597aa683e` (HEAD at port time, working tree clean; matches `tasks/research/didcomm-jose.md`) -- **Port date:** 2026-06-12 -- **Contract (PRD AC-5):** test files copied with only namespace/using changes, type and member - renames required by the new public API, and test-framework attribute namespaces. - **Assertion content (expected values, asserted properties, fixture bytes) is unchanged.** - The `tasks/parity-diff` tool (Phase F) normalizes identifiers per the rename table below and - fails on any difference in literals or asserted structure. - -## File map (ported file → source path at `e98570cd…`) - -| Ported file (`tests/DataProofsDotnet.Jose.Tests/Parity/`) | Source path (`tests/DidComm.Core.Tests/`) | -|---|---| -| `JwsRoundTripTests.cs` | `Envelopes/Signing/JwsRoundTripTests.cs` | -| `AnoncryptRoundTripTests.cs` | `Envelopes/Encryption/AnoncryptRoundTripTests.cs` | -| `AuthcryptRoundTripTests.cs` | `Envelopes/Encryption/AuthcryptRoundTripTests.cs` | -| `ApuComputerTests.cs` | `Envelopes/Encryption/ApuComputerTests.cs` | -| `ApvComputerTests.cs` | `Envelopes/Encryption/ApvComputerTests.cs` | -| `EphemeralKeyPairTests.cs` | `Envelopes/Encryption/EphemeralKeyPairTests.cs` | -| `JwkConversionTests.cs` | `Envelopes/Encryption/JwkConversionTests.cs` | -| `Ecdh1PuKdfTests.cs` | `Crypto/Kdf/Ecdh1PuKdfTests.cs` | -| `EcdhEsKdfTests.cs` | `Crypto/Kdf/EcdhEsKdfTests.cs` | -| `AesCbcHmacSha512Tests.cs` | `Crypto/Aead/AesCbcHmacSha512Tests.cs` | -| `AesGcmAeadTests.cs` | `Crypto/Aead/AesGcmAeadTests.cs` | -| `XChaCha20Poly1305AeadTests.cs` | `Crypto/Aead/XChaCha20Poly1305AeadTests.cs` | -| `AesKeyWrapTests.cs` | `Crypto/KeyWrap/AesKeyWrapTests.cs` | -| `KeyTypeMapperTests.cs` | `Crypto/KeyAgreement/KeyTypeMapperTests.cs` | -| `TestKeyMaterial.cs` (support) | `Envelopes/TestKeyMaterial.cs` | -| `Hex.cs` (support) | `Hex.cs` | -| `AeadShims.cs` (support, new) | — (rename seam; see "Support-file adaptations") | -| `../GlobalUsings.cs` (support) | `GlobalUsings.cs` | - -## Rename table (machine-readable, for `tasks/parity-diff`) - -```parity-rename-map -# old-identifier -> new-identifier (applies to both code and assertion text normalization) -DidComm.Tests -> DataProofsDotnet.Jose.Tests -DidComm.Jose -> DataProofsDotnet.Jose -DidComm.Jose.Signing -> DataProofsDotnet.Jose.Signing -DidComm.Jose.Encryption -> DataProofsDotnet.Jose.Encryption -DidComm.Crypto -> DataProofsDotnet.Jose -DidComm.Crypto.Kdf -> DataProofsDotnet.Jose.Encryption -DidComm.Crypto.KeyAgreement -> DataProofsDotnet.Jose -DidComm.Crypto.Aead -> DataProofsDotnet.Jose.Tests.Crypto.Aead -DidComm.Crypto.KeyWrap -> NetCrypto -DidComm.Exceptions.MalformedMessageException -> DataProofsDotnet.Jose.MalformedJoseException -DidComm.Exceptions.CryptoException -> DataProofsDotnet.Jose.JoseCryptoException -MalformedMessageException -> MalformedJoseException -CryptoException -> JoseCryptoException -DidComm.Crypto.DefaultCryptoProvider -> DataProofsDotnet.Jose.JoseCryptoProvider -DidCommDefaultCryptoProvider -> JoseDefaultCryptoProvider -NetDid.Core.Crypto.KeyType -> NetCrypto.KeyType -NetDid.Core.Crypto.DefaultKeyGenerator -> NetCrypto.DefaultKeyGenerator -NetDid.Core.Crypto.DefaultCryptoProvider -> NetCrypto.DefaultCryptoProvider -NetDid.Core.ICryptoProvider -> NetCrypto.ICryptoProvider -NetDid.Core.Crypto.Kdf.ConcatKdf -> NetCrypto.ConcatKdf -NetDidConcatKdf -> NetCryptoConcatKdf -NetDidCryptoProvider -> NetCryptoProvider -_netDid -> _netCrypto -JweBuilder.PackAnoncrypt -> JweBuilder.BuildEcdhEsA256Kw -JweBuilder.PackAuthcrypt -> JweBuilder.BuildEcdh1PuA256Kw -JwsBuilder.Build -> JwsBuilder.BuildJsonAsync -JwsParser.Parse -> JwsParser.Parse -JwkConversion.ToNetDidJwk -> JwkConversion.ToJsonWebKey -IInternalSecretsLookup -> IJweRecipientKeyResolver -IInternalSenderKeyLookup -> IJweSenderKeyResolver -secretsLookup -> recipientKeys -senderLookup -> senderKeys -cryptoProvider.NetDidProvider -> cryptoProvider.UnderlyingProvider -_crypto.NetDidProvider -> _crypto.UnderlyingProvider -AesCbcHmacSha512 -> AesCbcHmacSha512 # test-local shim over NetCrypto.AesCbcHmacCipher -AesGcmAead -> AesGcmAead # test-local shim over NetCrypto.AesGcmCipher -XChaCha20Poly1305Aead -> XChaCha20Poly1305Aead # test-local shim over NetCrypto.XChaCha20Poly1305Cipher -AesKeyWrap -> NetCrypto.AesKeyWrap # local RFC 3394 impl deleted (AC-6); same static names/arg order -signer.PrivateJwk (as JwsBuilder argument) -> signer.Signer # JwsBuilder signs via NetCrypto ISigner (AC-8) -result.Message.Id -> JsonDocument payload property "id" # Message-payload adaptation, see below -result.Message.From -> JsonDocument payload property "from" # Message-payload adaptation, see below -``` - -## Sanctioned adaptations (per AC-5 "allowed modifications" + research note §7/§9) - -1. **Async signing call shape.** The dataproofs `JwsBuilder` signs through NetCrypto - `ISigner` (NFR-3/AC-8), so `JwsBuilder.Build(...)` became `await JwsBuilder.BuildJsonAsync(...)` - and the affected test methods became `async Task`. Assertion content unchanged. -2. **Message-payload adaptation.** The porting source signed a DIDComm `Message` built with - `MessageBuilder`; dataproofs signs arbitrary bytes (FR-13). The same logical content travels - as a JSON payload (`{"id":"m1","type":…,"from":…,"to":[…]}`), and the - `result.Message.Id`/`result.Message.From` assertions read the identical values from the - parsed payload JSON. Expected values unchanged. -3. **Argument-name renames** required by the new API surface (`senderLookup:` → `senderKeys:`) - — call-site arrangement only. - -## Support-file adaptations - -- `TestKeyMaterial.cs` — rewired from net-did's `DefaultKeyGenerator`/`JwkConverter` to - NetCrypto's; gains a `Signer` property (`JwsSigner` over `KeyPairSigner`) because dataproofs' - builder never consumes a private JWK's `d` (AC-8). `DictionarySecretsLookup` / - `DictionarySenderKeyLookup` now implement the renamed resolver interfaces. -- `AeadShims.cs` — **new support file.** The porting source's `IAead` instance classes - (`AesCbcHmacSha512`, `AesGcmAead`, `XChaCha20Poly1305Aead`) were deleted from production code - per AC-6 (every AEAD must be a NetCrypto cipher static). The shims reproduce the deleted - classes' instance surface in the test project only, delegating every operation to - `NetCrypto.AesCbcHmacCipher` / `AesGcmCipher` / `XChaCha20Poly1305Cipher`, so the ported AEAD - tests keep their call shape and assertion content (including the - `*tag verification failed*` message globs, which NetCrypto's exception text satisfies). -- `Hex.cs`, `GlobalUsings.cs` — copied as-is (namespace only). - -## Intentionally omitted source tests (with justification) - -| Source file / test | Reason | -|---|---| -| `JwsRoundTripTests.Sign_then_encrypt_inner_to_required_throws_when_missing` and `…_passes_when_present` | Assert DIDComm FR-SIG-06 (`requireInnerToHeader` anti-surreptitious-forwarding rule on the DIDComm `to` header) — DIDComm protocol semantics, not JOSE; the parameter does not exist in the generic FR-13 builder. | -| `JwsRoundTripTests.Consistency_check_blocks_mismatched_signer_kid` | Asserts DIDComm FR-CONSIST-03 (signer kid ↔ plaintext `from` agreement), a DIDComm addressing rule executed by `AddressingConsistency` — not part of generic JOSE; the seam was deliberately not ported (research note §2e). | -| `Envelopes/Encryption/EnvelopeDetectorTests.cs` (whole file) | Exercises `EnvelopeDetector`/`EnvelopeKind`, DIDComm's envelope-classification facade. dataproofs ships no detector (DIDComm-profile feature); its JOSE-relevant parsing coverage is duplicated by the JWS/JWE parser tests. | -| `Envelopes/Composition/EnvelopeReaderTests.cs` (Tier 3, whole file) | Exercises `DidComm.Composition.EnvelopeWriter`/`EnvelopeReader` — the DIDComm facade above the JOSE layer (plaintext/signed/encrypted classification, FR-CONSIST-01/02, recursive unwrap). Heavily DIDComm-semantic; its JOSE-relevant coverage is duplicated by Tier 1 (research note §7 recommendation). | - -## Behavioral deltas sanctioned by the PRD (visible to parity tests only as extensions) - -- `JweProtectedHeader.Apv` is optional in dataproofs (generic JOSE); the didcomm - recipient-list commitment check still runs whenever `apv` is present, so every ported - assertion (including the apv-tamper negative) behaves identically — envelopes built by the - ported builders always carry `apv`. -- The dataproofs parser additionally accepts `alg="A256KW"` and compact serializations; the - ported tests never produce those, so no assertion is affected. diff --git a/tests/DataProofsDotnet.Jose.Tests/Parity/TestKeyMaterial.cs b/tests/DataProofsDotnet.Jose.Tests/Parity/TestKeyMaterial.cs index 1abd3d6..d94d2bb 100644 --- a/tests/DataProofsDotnet.Jose.Tests/Parity/TestKeyMaterial.cs +++ b/tests/DataProofsDotnet.Jose.Tests/Parity/TestKeyMaterial.cs @@ -8,7 +8,7 @@ namespace DataProofsDotnet.Jose.Tests.Envelopes; /// Round-trip test material: generates fresh keypairs per curve and packs them into the /// JOSE shape the envelope layer expects. Also implements the two /// resolver contracts so tests can drive JweParser without standing up a resolver. -/// Rewired from didcomm-dotnet's net-did keygen to NetCrypto (PARITY.md rename map); gains a +/// Rewired from didcomm-dotnet's net-did keygen to NetCrypto (rename-adapted from the port); gains a /// because the dataproofs JwsBuilder signs through NetCrypto /// ISigner instead of consuming a private JWK's 'd' (AC-8). ///