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).
///