From 0aca7c18a3df772b63244fc6d87e919f3ccc0aeb Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Mon, 26 Oct 2026 20:08:28 -0400 Subject: [PATCH 01/10] fix(deps): rebuild against DataProofs 1.3.0, NetCrypto 1.7.1, SourceLink 10.0.403 (#20), bump to v1.2.0 Credentials 1.1.1 was compiled against DataProofsDotnet.Jose 1.1.0. Jose 1.3.0 replaced JwsSigner..ctor(ISigner, string) with (ISigner, string, JwsKidPlacement) - source-compatible, binary-breaking - so every JOSE signing path (vc+jwt, SD-JWT VC, KB-JWT, vp+jwt) threw MissingMethodException once a consumer such as DidComm.Core 1.4.1 resolved Jose 1.3.0. - DataProofsDotnet.* 1.1.0 -> 1.3.0 - NetCrypto 1.2.0 -> 1.7.1 (DataProofs.Core 1.3.0 floors >= 1.4.0) - Microsoft.SourceLink.GitHub 10.0.300 -> 10.0.403 (GHSA-23fw-v26w-5fgq; NU1902 broke restore) - Remove the #18 AngleSharp pin: DataProofsDotnet.Rdfc 1.3.0 floors the patched AngleSharp >= 1.6.0 - CredentialsVersion 1.1.1 -> 1.2.0 No source or public API change. Co-Authored-By: Claude Opus 5.5 --- Directory.Build.props | 2 +- Directory.Packages.props | 39 ++++++++++++-------- src/Credentials.Rdfc/Credentials.Rdfc.csproj | 4 -- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index b3c6fb5..de4727c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ enable latest true - 1.1.1 + 1.2.0 true true true diff --git a/Directory.Packages.props b/Directory.Packages.props index 0d64f08..524c3d1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ - - - - - + + + + + + - - + + - - + @@ -50,8 +55,10 @@ - - + + - From ab851bd08b0ead2614528c3550fd8b84ee87dc4d Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Mon, 26 Oct 2026 20:26:15 -0400 Subject: [PATCH 02/10] docs: v1.2.0 CHANGELOG, issue #20 task record + adversarial review, lessons Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 56 ++++++ tasks/lessons.md | 12 ++ ...do-2026-10-26-issue-20-dataproofs-1.3.0.md | 182 ++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 tasks/todo-2026-10-26-issue-20-dataproofs-1.3.0.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cdd7c3f..b70c33e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,62 @@ All notable changes to `credentials-dotnet` are documented here. The format is b [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). +## v1.2.0 - 10/26/2026 + +### Fixed + +- **JOSE and SD-JWT signing no longer throw `MissingMethodException` when `DataProofsDotnet.Jose` 1.3.0 + is resolved** ([#20](https://github.com/moisesja/credentials-dotnet/issues/20)). 1.1.1 was compiled + against Jose 1.1.0. Jose 1.3.0 replaced `JwsSigner..ctor(ISigner, string)` with + `JwsSigner..ctor(ISigner, string, JwsKidPlacement)`. The added optional parameter keeps source + compatibility but breaks binary compatibility. Any application whose graph resolved Jose 1.3.0 failed + at runtime on all four JOSE signing paths: `vc+jwt` issuance, SD-JWT VC issuance, the holder's SD-JWT + Key Binding JWT, and the holder's `vp+jwt` presentation. Every application that also references + **`DidComm.Core` 1.4.1** resolves Jose 1.3.0, since that package requires Jose ≥ 1.3.0. This release + is built against DataProofs 1.3.0, which fixes it with no source change. The signed bytes are + unchanged: Jose 1.3.0's default `JwsKidPlacement.Auto` keeps the `kid` in the integrity-protected + header for the JWT, SD-JWT and KB-JWT media types. DataProofs states that it does not guarantee + binary compatibility across minor versions, so each future move of the DataProofs floor needs a + Credentials rebuild and release. + +### Changed + +- **Dependency floors raised:** + - `DataProofsDotnet.Core` / `.Jose` / `.Cose` / `.Rdfc` / `.Extensions.DependencyInjection`: + 1.1.0 → **1.3.0** + - `NetCrypto`: 1.2.0 → **1.7.1**. DataProofs.Core 1.3.0 requires ≥ 1.4.0, and the changes from 1.4.0 + to 1.7.1 only add APIs. **Behaviour change:** from NetCrypto 1.5.0, ES256K verification accepts + high-S signatures. For an ES256K `vc+jwt`, `vc+cose`, SD-JWT VC, KB-JWT or `vp+jwt`, the same + token with its signature `S` replaced by `n − S` now verifies, where it used to fail. P-256 tokens + already behaved this way. A replaced signature never yields a result the original token couldn't: + Credentials keys freshness on signed content (`nonce` / `aud` / `sd_hash` / challenge), and nothing + in it keys on signature bytes. **Applications that key replay caches, deduplication or credential + identity on the token bytes, or on a hash of them, should key on signed content instead** + (`jti`, nonce, credential `id`). + + **Applications still on `DataProofsDotnet.Jose` 1.2.x should stay on Credentials 1.1.1**, because + upgrading forces Jose ≥ 1.3.0. That is why this release is a minor version and not a patch. +- **Verified with NetDid 3.x at runtime:** `DidComm.Core` 1.4.1 also requires `NetDid.Core` ≥ 3.1.0. An + application using both libraries runs Credentials, built against NetDid 2.3.0, on NetDid.Core 3.1.0. + A consumer app referencing both packages ran all 14 samples successfully on that graph, including DID + resolution, Data Integrity, JOSE, COSE, SD-JWT and presentations. The NetDid floor stays at 2.3.0. +- **AngleSharp security pin removed.** The pin to 1.5.2 from #18 is no longer needed: + `DataProofsDotnet.Rdfc` 1.3.0 itself requires the patched `AngleSharp` ≥ 1.6.0. A local pin below an + upstream minimum version is an `NU1605` downgrade, so keeping it would have meant bumping it with + every DataProofs release. `Credentials.Rdfc` no longer declares an AngleSharp dependency. + + This release makes **no public API change**: all three packages pass ApiCompat against the 1.1.1 + baseline. The default (`Credentials.Core` / `Credentials.Extensions.DependencyInjection`) dependency + graph is still free of Newtonsoft. + +### Security + +- **`Microsoft.SourceLink.GitHub` bumped 10.0.300 → 10.0.403** to resolve + [GHSA-23fw-v26w-5fgq](https://github.com/advisories/GHSA-23fw-v26w-5fgq) in its + `Microsoft.Build.Tasks.Git` dependency. With `TreatWarningsAsErrors`, the `NU1902` audit warning + failed restore. Since 2026-09 that broke every CI run on `main`, push and scheduled alike. SourceLink is + used only at build time (`PrivateAssets=All`) and never ships to consumers. + ## v1.1.1 - 7/20/2026 ### Security diff --git a/tasks/lessons.md b/tasks/lessons.md index 84de729..ac0d0d7 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -2,6 +2,18 @@ Patterns learned while building credentials-dotnet. Reviewed at session start. +## For first-party stack dependencies, offer the latest version alongside the minimum floor (2026-10-26) + +For issue #20 I planned `NetCrypto` 1.2.0 → **1.4.0**, the lowest version `DataProofsDotnet.Core 1.3.0` +accepts, reusing #16's "minimum convergence" reasoning. The user overrode it to **1.7.1**, the latest. +NetCrypto, DataProofs, NetDid and DidComm are the user's own stack and move together, and consumers such +as `DidComm.Core` 1.4.1 already require more than the minimum (NetCrypto ≥ 1.5.0). **Rule:** when a bump +touches a first-party stack library (NetCrypto, NetCid, NetDid, DataProofs), put **both** options in the +plan, minimum floor and latest, with the trade-off, instead of choosing the floor silently. Whichever +version is taken, read every changelog entry between the old and new versions for verify-side behaviour +changes, not only API breaks. NetCrypto 1.5.0 started accepting high-S ES256K signatures, which makes +ECDSA signatures malleable; that is a security-relevant change with no API change. + ## In a gated PR workflow, a PR is not "done" when it's opened — wait for review and address comments before starting the next PR (2026-06-23) After opening PR #8 (M8a), I immediately branched and started PR-B (M8a → M8b stacked) and began diff --git a/tasks/todo-2026-10-26-issue-20-dataproofs-1.3.0.md b/tasks/todo-2026-10-26-issue-20-dataproofs-1.3.0.md new file mode 100644 index 0000000..0e2686f --- /dev/null +++ b/tasks/todo-2026-10-26-issue-20-dataproofs-1.3.0.md @@ -0,0 +1,182 @@ +# Task — Issue #20: Rebuild against DataProofs 1.3.0 (JOSE/SD-JWT `MissingMethodException`) + v1.2.0 release + +Status: **APPROVED 2026-10-26, with changes: version 1.2.0; NetCrypto 1.7.1 (latest) instead of the +1.4.0 floor. IMPLEMENTED on branch `fix/dataproofs-1.3.0-issue-20` and verified (see Review).** +The "Problem" and "Findings" evidence comes from a scratch worktree of `main@d06943c`. + +## Problem (verified, not taken on trust) + +`Credentials.Core` 1.1.1 is compiled against `DataProofsDotnet.Jose` 1.1.0. Jose 1.3.0 replaced +`JwsSigner..ctor(ISigner, string)` with `..ctor(ISigner, string, JwsKidPlacement)`. The change is +source-compatible but breaks binary compatibility; the Jose XML docs for both versions confirm this. Any +app that resolves Jose 1.3.0 hits `MissingMethodException` on every Credentials JOSE signing path. That +includes every app on `DidComm.Core` 1.4.1, which floors `DataProofsDotnet.Jose >= 1.3.0`. + +**Reproduced** with a scratch consumer app that references `DidComm.Core` 1.4.1 and the published +`Credentials.*` 1.1.1, running all 14 repo samples: **10/14 pass**. The 4 failures all throw +`Method not found: 'Void DataProofsDotnet.Jose.Signing.JwsSigner..ctor(NetCrypto.ISigner, System.String)'`: + +- `JoseEnvelope`: `vc+jwt` issuance +- `SdJwtVc`: SD-JWT VC issuance +- `SdJwtPresentation`: holder KB-JWT +- `PresentationJose`: holder `vp+jwt`. **The issue doesn't name this path.** It reaches the same constructor + through `JoseEnvelopingMechanism`. + +Root cause beyond this instance: DataProofs' 1.3.0 changelog states that *"binary compatibility is +explicitly out of scope for this project"*. Its minor releases can break binary compatibility, so +Credentials must be rebuilt whenever its DataProofs floor moves. + +## Findings beyond the issue + +1. **NetDid 3.x is in the same graph.** `DidComm.Core` 1.4.1 also floors `NetDid.Core >= 3.1.0` and + `NetCrypto >= 1.5.0`, so a combined app loads NetDid.Core **3.1.0** under Credentials, which is compiled + against 2.3.0. This was checked at runtime. With the trial build, the DidComm consumer passes **14/14** + samples, running NetDid.Core 3.1.0 alongside NetDid.Method.Key and NetDid.Extensions.DependencyInjection + 2.3.0. That covers did:key resolution for DI, JOSE and COSE verification, presentations and status lists. + The net-did 3.0.0 changelog agrees: every change 2.x consumers compile against is additive. + → **No NetDid bump in this release.** Moving to NetDid 3.x is an optional follow-up, not needed for #20. +2. **The AngleSharp security pin from #18 is now redundant.** `DataProofsDotnet.Rdfc` 1.3.0 itself floors + `AngleSharp >= 1.6.0`, a patched version. Credentials' 1.5.2 pin is what caused item 3 of the issue + (the `NU1605` downgrade). Kept, it has to be bumped every time DataProofs raises its floor. Trial check + without the pin: AngleSharp resolves to 1.6.0 in Rdfc and Conformance.VcApi, **0 vulnerable packages + across 29 projects**, and the build has 0 warnings. → **Remove the pin.** The alternative is a one-line + bump to 1.6.0, which leaves the same trap for the next DataProofs release. +3. **`main` CI is red on push too**, not just the scheduled job. Today's `AGENTS instructions` commit + (run 40422367272) fails restore with `NU1902` on `Microsoft.Build.Tasks.Git` 10.0.300 + (GHSA-23fw-v26w-5fgq). Bumping SourceLink fixes both. +4. **The scheduled Conformance workflow is `disabled_inactivity`.** GitHub disabled it after 60 days with + no repo activity; its last run was 2026-09-19. Merging this fix will not make it run again. It has to be + re-enabled (`gh workflow enable Conformance`) for the issue's "scheduled CI green" criterion to be met. +5. **DataProofs 1.2.0–1.3.0 also changed `kid` handling.** The changes: `kid` placement, unprotected-`kid` + reads, and header-disjointness enforcement. Its security note warns that an unprotected `kid` is not + a signer identity. Credentials parses only **compact** JWS (`JwsParser.ParseCompact`, + `JoseEnvelopingMechanism.cs:140`, and `VcJose` / SD-JWT compact), which has no unprotected header, + so the risk should not apply. The adversarial pass below has to confirm that rather than assume it. + +## Changes (branch `fix/dataproofs-1.3.0-issue-20`) + +- [x] `Directory.Packages.props` + - `DataProofsDotnet.Core/Jose/Cose/Rdfc/Extensions.DependencyInjection` 1.1.0 → **1.3.0**, with a + comment explaining why: #20, the binary break, and DataProofs' no-binary-compat policy. + - `NetCrypto` 1.2.0 → **1.7.1**, your choice; the plan proposed the 1.4.0 floor. + `DataProofsDotnet.Core 1.3.0` floors ≥ 1.4.0. The 1.4.0 → 1.7.1 changes are additive (key-store + custody APIs and hardening). One verify-side change matters: **from 1.5.0, high-S ES256K signatures + are accepted**, so ECDSA signatures are malleable. That's recorded in the props comment and added to + the adversarial brief. + - `Microsoft.SourceLink.GitHub` 10.0.300 → **10.0.403**, the latest stable 10.0.x, which patches + GHSA-23fw-v26w-5fgq. Build-only (`PrivateAssets=All`), so it doesn't appear in consumers' graphs. + - Remove the `AngleSharp` `PackageVersion` and its #18 security-pin comment (finding 2). + - Update the header comment's "verified against the local NuGet cache" date and issue reference. +- [x] `src/Credentials.Rdfc/Credentials.Rdfc.csproj`: remove the `AngleSharp` `PackageReference` and its comment. +- [x] `Directory.Build.props`: `CredentialsVersion` 1.1.1 → **1.2.0** (see the version decision below). +- [x] `CHANGELOG.md`: a `## v1.2.0 - 10/26/2026` entry covering: + - built against DataProofs 1.3.0; requires Jose ≥ 1.3.0; fixes `MissingMethodException` on all four + JOSE signing paths + - needed by apps on `DidComm.Core` ≥ 1.4.1; apps still on Jose 1.2.x stay on 1.1.1 + - SourceLink GHSA bump + - AngleSharp pin removed (now carried upstream) + - NetDid 3.1.0 verified to work at runtime + - no public API change (ApiCompat) +- No source or test changes are expected; the trial needed none. No docs pin these versions; grep of + `docs/` and `README.md` came back empty. + +### Version decision: recommend **1.2.0** (the issue suggested 1.1.2, or 1.2.0) + +The repo already treats a dependency-floor raise as a minor bump: v1.1.0 (#16) was purely a NetDid / +NetCrypto floor raise. This release does more. It forces consumers off Jose 1.2.x, and a consumer pinned +there gets `NU1605` on upgrade. A patch number would understate that. + +## Verification plan (on the real branch, not just the trial) + +- [x] `dotnet restore` clean; `dotnet build -c Release -warnaserror`: 0 warnings / 0 errors. +- [x] `dotnet test --filter "Category!=Conformance"`: expect 391 passed (Core 172, DI 161, Rdfc 21, + SampleSmoke 14, Architecture 9, RoundTrip 9, Interop 5). +- [x] `dotnet list … --vulnerable --include-transitive`: 0 across all projects. +- [x] `tools/check-no-newtonsoft-closure.sh`: NFR-002 OK. +- [x] `tools/check-api-compat.sh`: no breaking changes vs the published 1.1.1 for Core, DI and Rdfc. +- [x] `dotnet pack -p:CredentialsVersion=1.2.0`, then inspect the nuspecs: + - Core: DataProofs Core/Jose/Cose ≥ 1.3.0, NetCrypto ≥ 1.7.1 + - Rdfc: DataProofs.Rdfc ≥ 1.3.0 and **no** AngleSharp entry +- [x] **Consumer check** (issue "Done when" #2), rerun from the branch's packs in a scratch app with + `DidComm.Core` 1.4.1: + - the four JOSE / SD-JWT / KB-JWT / `vp+jwt` samples issue and verify with no `MissingMethodException` + - expected 14/14, plus loaded-assembly versions printed + - control with published 1.1.1: 10/14 +- [ ] **Adversarial agent** (CLAUDE.md requirement). Brief: + - attack Credentials' JOSE, SD-JWT, KB-JWT, `vp+jwt` and COSE verification under DataProofs 1.3.0 + - kid rewrites and unprotected / duplicate headers; General/Flattened JSON passed where compact is expected + - both post-sign tampering and self-consistent forged documents whose signed `kid`/`iss` disagree + with the issuer + - also confirm the signed bytes for `vc+jwt`, `dc+sd-jwt` and `kb+jwt` are unchanged from 1.1.0 + (`kid` still protected) + - report findings in detail; any real finding gets fixed with a regression test before the PR + +## Review: adversarial pass (8 probe tests, each run on the new AND the old dependencies) + +The upgrade introduces **no exploitable regression**. I checked each claim against the source before +recording it, as lessons.md requires. + +- **Unsigned / unprotected `kid` (DataProofs 1.2–1.3): not reachable.** The hypothesis holds, for a + stronger reason than "compact has no unprotected header". `EnvelopeDetector` routes `{…}` input to the + Data Integrity path, and only base64url text with exactly two dots reaches JOSE. Credentials also reads + the `kid` itself from the signed segment and passes DataProofs a constant resolver, so it never uses + `SignerKid`. Six compact `kid` forgeries were tried: each was rejected by `issuer_binding`, + `holder_binding` or `PROOF_VERIFICATION_ERROR`. +- **Signed bytes unchanged.** The `kid` stays in the protected header for vc+jwt, the SD-JWT issuer + JWT, KB-JWT and vp+jwt. Fixed-seed Ed25519 vc+jwt / vp+jwt / vc+cose output is byte-identical between + old and new dependencies. +- **ES256K high-S (NetCrypto ≥ 1.5.0): behaviour change, not exploitable.** Tokens whose signature + `S` is replaced by `n − S` now verify. Freshness and binding checks still reject a replay: a stale + nonce gives `holder_binding_replay`, and `sd_hash` covers the issuer signature. Recorded in the + CHANGELOG with consumer guidance. +- **Exception contract unchanged.** Hostile headers produce Failed, never an escaped exception, and + never a wrong Failed-vs-Indeterminate classification. +- **DataProofs 1.1.0 → 1.3.0 source diff** touches only `Jose/Signing/*` and the Rdfc csproj. Core, + COSE, SD-JWT and bbs-2023/RDFC are unchanged. + +**Findings outside #20's scope. None was caused by this upgrade; each reproduces identically on the old +dependencies:** + +- **Security-relevant findings in Credentials** were reported privately in a draft GitHub security + advisory, as `SECURITY.md` requires, rather than recorded here. They are targeted for v1.2.0, so the + `v1.2.0` tag waits for that fix. +- **Upstream (DataProofs), harmless here:** a signed protected header with an invalid-UTF-8 member name + is Accepted, which contradicts DataProofs 1.3.0's changelog. It is inside the signature, so it cannot be + exploited. Filed upstream. +- **Credentials, interop nit, fails closed:** a vc+jwt with a trailing `\n` is rejected + `envelope_malformed`. `JoseEnvelopingMechanism` doesn't trim it; `SdJwtVcMechanism` does. + +**Regression guards kept:** `tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs`. +This ports four of the adversarial probes into assertion-only tests: +- JSON-serialized JWS with an unsigned `kid` is never accepted through any entry point +- compact forgeries whose signed `kid` disagrees with the issuer or holder are rejected +- every emitted token carries its `kid` in the protected header +- hostile headers fail without an escaped exception, and a bad signature is Failed, not Indeterminate + +## Release / outward actions (each needs your go-ahead) + +- [ ] Commit, push, open a PR that closes #20. Then **wait for review and address comments before + anything else** (lesson 2026-06-23). +- [ ] After merge: `gh workflow enable Conformance` (finding 4), then trigger one `workflow_dispatch` run + to show the scheduled job is green. +- [ ] Push tag `v1.2.0` to publish to nuget.org. **Irreversible.** Left to you, as with v1.1.1. + **Delayed by your decision until the security-advisory fix lands, so it ships in v1.2.0.** Update the + CHANGELOG's v1.2.0 date when tagging. + +## Out of scope (proposed follow-ups, separate issues if you want them) + +- **Latest-dependencies CI job:** a scheduled job that floats DataProofs, NetDid and NetCrypto to the newest + packages and runs the signing tests plus the DidComm consumer check. Given DataProofs' stated + no-binary-compat policy, this is the structural guard against a repeat. It needs its own design + (floating CPM versions vs. a consumer-app harness), so it doesn't belong in a dependency-only patch. +- **`EnablePackageValidation`:** largely covered already by `tools/check-api-compat.sh`, and it would not + have caught a dependency-side break. +- **NetDid 3.x floor:** not required (finding 1); revisit when Credentials needs a 3.x API. + +## Trial evidence (scratch worktree, `main@d06943c`, the four issue bumps) + +- Restore clean; Release build 0 warnings / 0 errors; tests 391 passed (matches the issue). +- Consumer + `DidComm.Core` 1.4.1: trial build 14/14 vs. published 1.1.1 10/14. Loaded at runtime: Jose 1.3.0, + NetCrypto 1.5.0, NetDid.Core 3.1.0. +- ApiCompat: no breaking changes for any of the 3 packages vs 1.1.1. +- AngleSharp pin removed: 1.6.0 resolved transitively, 0 vulnerable, 0 warnings. From e2a869d88619845b654044aca426a627ecacd0c9 Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Mon, 26 Oct 2026 20:41:59 -0400 Subject: [PATCH 03/10] test: pin the JOSE substrate behaviors Credentials relies on (#20) Regression guards from the issue #20 adversarial pass over DataProofs 1.3.0 / NetCrypto 1.7.1: JSON-serialized JWS with an unsigned kid is never accepted through any entry point; compact forgeries whose signed kid disagrees with the issuer/holder are rejected; every emitted vc+jwt / SD-JWT issuer JWT / kb+jwt / vp+jwt keeps its kid in the protected header (JwsKidPlacement.Auto); hostile headers fail without escaping exceptions and a bad signature is Failed, not Indeterminate. Co-Authored-By: Claude Opus 5.5 --- .../JoseSubstrateRegressionTests.cs | 379 ++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs diff --git a/tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs b/tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs new file mode 100644 index 0000000..0082770 --- /dev/null +++ b/tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs @@ -0,0 +1,379 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Credentials; +using Credentials.Roles; +using Credentials.TestSupport; +using Credentials.Verification; +using DataProofsDotnet.Jose; +using DataProofsDotnet.Jose.Signing; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NetCrypto; +using NetDid.Core; +using NetDid.Core.Model; +using Xunit; +using Base64Url = System.Buffers.Text.Base64Url; + +namespace Credentials.Extensions.DependencyInjection.Tests; + +/// +/// Regression guards for the JOSE substrate behaviors Credentials relies on, added with the DataProofs +/// 1.1.0 → 1.3.0 rebuild (issue #20). DataProofs 1.2–1.3 changed kid handling: JwsKidPlacement, +/// reading an unprotected (unsigned) kid from JSON-serialized JWS, and header disjointness. +/// These tests pin the properties that make those changes unreachable from Credentials: JSON-serialized JWS +/// never reaches the JOSE path, only the signed kid is bound, every token Credentials emits +/// carries its kid in the protected header, and hostile headers fail without escaping exceptions. +/// Each DID publishes one key under an authentication VM and an assertionMethod VM (the +/// shape DataProofs' 1.3.0 security note uses), and every fixture signs under the VM the role requires. +/// +public sealed class JoseSubstrateRegressionTests +{ + private const string Challenge = "regression-challenge"; + private const string Domain = "https://verifier.example"; + private const string Vct = "https://credentials.example/regression"; + + private static readonly DefaultCryptoProvider Crypto = new(); + private static readonly DefaultKeyGenerator KeyGen = new(); + + private sealed record TestDid(string Did, KeyPair Pair, ISigner Signer, string AuthVm, string AssertVm); + + private sealed class MapDidResolver : IDidResolver + { + private readonly Dictionary _docs = new(StringComparer.Ordinal); + + public bool CanResolve(string did) => true; + + public Task ResolveAsync(string did, DidResolutionOptions? options = null, CancellationToken ct = default) => + Task.FromResult(_docs.TryGetValue(did, out var doc) + ? new DidResolutionResult { DidDocument = doc, ResolutionMetadata = new DidResolutionMetadata() } + : new DidResolutionResult { DidDocument = null, ResolutionMetadata = new DidResolutionMetadata { Error = "notFound" } }); + + public TestDid Add(string did) + { + var pair = KeyGen.Generate(KeyType.Ed25519); + var authVm = did + "#auth"; + var assertVm = did + "#assert"; + _docs[did] = new DidDocument + { + Id = did, + VerificationMethod = [Vm(authVm, did, pair), Vm(assertVm, did, pair)], + Authentication = [VerificationRelationshipEntry.FromReference(authVm)], + AssertionMethod = [VerificationRelationshipEntry.FromReference(assertVm)], + }; + return new TestDid(did, pair, new KeyPairSigner(pair, Crypto), authVm, assertVm); + } + + private static VerificationMethod Vm(string id, string controller, KeyPair pair) => new() + { + Id = id, + Type = "Multikey", + Controller = controller, + PublicKeyMultibase = pair.MultibasePublicKey, + }; + } + + private static ServiceProvider BuildProvider(MapDidResolver resolver) + { + var services = new ServiceCollection(); + services.AddSingleton(resolver); + services.AddCredentials(_ => { }); + return services.BuildServiceProvider(); + } + + private static Credential Unsecured(string issuerDid) => Credential.Build() + .WithId("urn:uuid:20202020-2020-2020-2020-202020202020") + .WithIssuer(issuerDid) + .AddSubject(new JsonObject { ["id"] = "did:example:subject", ["given_name"] = "Alice" }) + .Seal(); + + private static JsonObject ProtectedHeader(string compactJws) => + JsonNode.Parse(Base64Url.DecodeFromChars(compactJws.Split('.')[0]))!.AsObject(); + + private static async Task SignCompactAsync(byte[] header, ReadOnlyMemory payload, ISigner signer) + { + var signingInput = Base64Url.EncodeToString(header) + "." + Base64Url.EncodeToString(payload.Span); + var signature = await signer.SignAsync(Encoding.ASCII.GetBytes(signingInput)); + return signingInput + "." + Base64Url.EncodeToString(signature); + } + + [Fact] + [FrTag("FR-040")] + [FrTag("FR-041")] + public async Task Json_serialized_jws_with_an_unsigned_kid_is_never_accepted_through_any_entry_point() + { + var resolver = new MapDidResolver(); + var issuer = resolver.Add("did:example:issuer"); + var holder = resolver.Add("did:example:holder"); + using var provider = BuildProvider(resolver); + var holderRole = provider.GetRequiredService(); + var verifier = provider.GetRequiredService(); + + // Genuinely signed by the issuer, but the protected header carries no kid: the kid travels only in + // the unprotected header, so it is NOT covered by the signature. + var protectedB64 = Base64Url.EncodeToString( + JsonSerializer.SerializeToUtf8Bytes(new JsonObject { ["alg"] = "EdDSA", ["typ"] = "vc+jwt", ["cty"] = "vc" })); + var payloadB64 = Base64Url.EncodeToString(Unsecured(issuer.Did).ToUtf8().Span); + var signatureB64 = Base64Url.EncodeToString( + await issuer.Signer.SignAsync(Encoding.ASCII.GetBytes(protectedB64 + "." + payloadB64))); + var flattened = new JsonObject + { + ["payload"] = payloadB64, + ["protected"] = protectedB64, + ["header"] = new JsonObject { ["kid"] = issuer.AssertVm }, + ["signature"] = signatureB64, + }.ToJsonString(); + var general = new JsonObject + { + ["payload"] = payloadB64, + ["signatures"] = new JsonArray(new JsonObject + { + ["protected"] = protectedB64, + ["header"] = new JsonObject { ["kid"] = issuer.AssertVm }, + ["signature"] = signatureB64, + }), + }.ToJsonString(); + + // Premise: the substrate itself accepts this shape and reports the unsigned kid. The test is only + // meaningful while that holds. + var jwk = JwkConversion.FromMultikey(issuer.Pair.MultibasePublicKey, issuer.AssertVm); + var substrate = JwsParser.Parse(flattened, _ => jwk, new JoseCryptoProvider()); + substrate.SignerKid.Should().Be(issuer.AssertVm); + substrate.SignerKidIsProtected.Should().BeFalse(); + + foreach (var json in new[] { flattened, general, " \n" + flattened }) + { + var bytes = Encoding.UTF8.GetBytes(json); + + var credentialResult = await TryVerifyAsync(() => verifier.VerifyCredentialAsync(bytes)); + credentialResult?.Decision.Should().NotBe(VerificationDecision.Accepted); + + var presentationResult = await TryVerifyAsync(() => verifier.VerifyPresentationAsync( + bytes, new PresentationVerificationOptions { ExpectedChallenge = Challenge })); + presentationResult?.Decision.Should().NotBe(VerificationDecision.Accepted); + + try + { + holderRole.Ingest(bytes).Securing.Should().NotBe(SecuringState.Jose); + } + catch (CredentialFormatException) + { + // Refusing to ingest is also acceptable. + } + } + + // Smuggled as enveloped children of a validly bound vp+jwt: the presentation must not accept them. + var vpJwt = await holderRole.BindWithJoseEnvelopeAsync( + holderRole.BuildPresentation(new VpAssemblyRequest + { + Holder = holder.Did, + Credentials = [ContainedCredential.Enveloped(flattened), ContainedCredential.Enveloped(general)], + }), + new VpBindingRequest { HolderSigner = holder.Signer, VerificationMethod = holder.AuthVm, Challenge = Challenge, Domain = Domain }); + var vpResult = await verifier.VerifyPresentationAsync( + Encoding.UTF8.GetBytes(vpJwt), new PresentationVerificationOptions { ExpectedChallenge = Challenge, ExpectedDomain = Domain }); + + vpResult.Decision.Should().NotBe(VerificationDecision.Accepted); + vpResult.Credentials.Should().OnlyContain(c => c.Decision != VerificationDecision.Accepted); + } + + [Fact] + [FrTag("FR-040")] + [FrTag("FR-041")] + public async Task Compact_forgeries_whose_signed_kid_disagrees_with_the_issuer_or_holder_are_rejected() + { + var resolver = new MapDidResolver(); + var victim = resolver.Add("did:example:victim"); + var attacker = resolver.Add("did:example:attacker"); + using var provider = BuildProvider(resolver); + var issuerRole = provider.GetRequiredService(); + var holderRole = provider.GetRequiredService(); + var verifier = provider.GetRequiredService(); + var vpOptions = new PresentationVerificationOptions { ExpectedChallenge = Challenge, ExpectedDomain = Domain }; + + // vc+jwt claiming issuer = victim, self-consistently signed by the attacker under the attacker's kid. + var attackerKid = await issuerRole.IssueAsync(Unsecured(victim.Did), + new JoseEnvelopeIssuanceRequest { Signer = attacker.Signer, VerificationMethod = attacker.AssertVm }); + (await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(attackerKid.CompactJws!))) + .Decision.Should().Be(VerificationDecision.Rejected); + + // vc+jwt claiming issuer = victim whose signed kid names the victim's VM, but signed with the attacker's key. + var lyingKid = await issuerRole.IssueAsync(Unsecured(victim.Did), + new JoseEnvelopeIssuanceRequest { Signer = attacker.Signer, VerificationMethod = victim.AssertVm }); + (await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(lyingKid.CompactJws!))) + .Decision.Should().Be(VerificationDecision.Rejected); + + // Post-sign tampering: an honest attacker-issued vc+jwt whose protected kid is rewritten to the victim's VM. + var honest = await issuerRole.IssueAsync(Unsecured(attacker.Did), + new JoseEnvelopeIssuanceRequest { Signer = attacker.Signer, VerificationMethod = attacker.AssertVm }); + var parts = honest.CompactJws!.Split('.'); + var header = ProtectedHeader(honest.CompactJws!); + header["kid"] = victim.AssertVm; + parts[0] = Base64Url.EncodeToString(Encoding.UTF8.GetBytes(header.ToJsonString())); + (await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(string.Join('.', parts)))) + .Decision.Should().Be(VerificationDecision.Rejected); + + // SD-JWT VC with issuer = iss = victim, signed by the attacker under the attacker's kid. + var sdJwt = await issuerRole.IssueAsync(Unsecured(victim.Did), + new SdJwtVcIssuanceRequest { Vct = Vct, Signer = attacker.Signer, VerificationMethod = attacker.AssertVm }); + (await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(sdJwt.CompactSdJwt!))) + .Decision.Should().Be(VerificationDecision.Rejected); + + // vp+jwt with holder = victim, bound by the attacker under the attacker's kid, then under the victim's kid. + var vp = holderRole.BuildPresentation(new VpAssemblyRequest + { + Holder = victim.Did, + Credentials = [ContainedCredential.Enveloped(honest.CompactJws!)], + }); + foreach (var kid in new[] { attacker.AuthVm, victim.AuthVm }) + { + var forged = await holderRole.BindWithJoseEnvelopeAsync(vp, new VpBindingRequest + { + HolderSigner = attacker.Signer, + VerificationMethod = kid, + Challenge = Challenge, + Domain = Domain, + }); + (await verifier.VerifyPresentationAsync(Encoding.UTF8.GetBytes(forged), vpOptions)) + .Decision.Should().Be(VerificationDecision.Rejected, "holder bound under {0}", kid); + } + } + + [Fact] + [FrTag("FR-012")] + [FrTag("FR-013")] + [FrTag("FR-032")] + [FrTag("FR-034")] + public async Task Every_emitted_jose_token_carries_its_kid_in_the_protected_header() + { + // DataProofs 1.3.0's JwsKidPlacement.Auto may move the kid to the unprotected header for some media + // types. Every compact token Credentials emits must keep it inside the signature. + var resolver = new MapDidResolver(); + var issuer = resolver.Add("did:example:issuer"); + var holder = resolver.Add("did:example:holder"); + using var provider = BuildProvider(resolver); + var issuerRole = provider.GetRequiredService(); + var holderRole = provider.GetRequiredService(); + + var jose = await issuerRole.IssueAsync(Unsecured(issuer.Did), + new JoseEnvelopeIssuanceRequest { Signer = issuer.Signer, VerificationMethod = issuer.AssertVm }); + var sdJwt = await issuerRole.IssueAsync(Unsecured(issuer.Did), new SdJwtVcIssuanceRequest + { + Vct = Vct, + Signer = issuer.Signer, + VerificationMethod = issuer.AssertVm, + Disclosable = [DisclosureSelector.ObjectProperties("credentialSubject", "given_name")], + HolderBinding = HolderBindingKey.FromMultikey(holder.Pair.MultibasePublicKey), + }); + var kbPresentation = await holderRole.PresentSdJwtAsync( + holderRole.Ingest(Encoding.UTF8.GetBytes(sdJwt.CompactSdJwt!)), + new SdJwtPresentationRequest + { + DiscloseClaims = ["given_name"], + HolderSigner = holder.Signer, + VerificationMethod = holder.AuthVm, + Audience = Domain, + Nonce = Challenge, + }); + var vpJwt = await holderRole.BindWithJoseEnvelopeAsync( + holderRole.BuildPresentation(new VpAssemblyRequest { Holder = holder.Did, Credentials = [ContainedCredential.Enveloped(jose.CompactJws!)] }), + new VpBindingRequest { HolderSigner = holder.Signer, VerificationMethod = holder.AuthVm, Challenge = Challenge, Domain = Domain }); + + var tokens = new (string Label, string Compact, string ExpectedKid)[] + { + ("vc+jwt", jose.CompactJws!, issuer.AssertVm), + ("dc+sd-jwt issuer JWT", sdJwt.CompactSdJwt!.Split('~')[0], issuer.AssertVm), + ("kb+jwt", kbPresentation.Split('~')[^1], holder.AuthVm), + ("vp+jwt", vpJwt, holder.AuthVm), + }; + foreach (var (label, compact, expectedKid) in tokens) + { + compact.Split('.').Should().HaveCount(3, label); + ProtectedHeader(compact)["kid"]?.GetValue().Should().Be(expectedKid, label); + } + } + + [Fact] + [FrTag("FR-040")] + [FrTag("FR-041")] + public async Task Hostile_protected_headers_signed_with_the_real_key_fail_without_escaping() + { + var resolver = new MapDidResolver(); + var issuer = resolver.Add("did:example:issuer"); + using var provider = BuildProvider(resolver); + var verifier = provider.GetRequiredService(); + var vc = Unsecured(issuer.Did).ToUtf8(); + var vp = Encoding.UTF8.GetBytes(new JsonObject + { + ["@context"] = new JsonArray("https://www.w3.org/ns/credentials/v2"), + ["type"] = new JsonArray("VerifiablePresentation"), + ["holder"] = issuer.Did, + ["nonce"] = Challenge, + }.ToJsonString()); + var assertKid = issuer.AssertVm; + var authKid = issuer.AuthVm; + + var credentialHeaders = new[] + { + "{\"alg\":\"EdDSA\",\"typ\":\"vc+jwt\",\"cty\":\"vc\",\"kid\":null}", + "{\"alg\":\"EdDSA\",\"typ\":\"vc+jwt\",\"cty\":\"vc\",\"kid\":123}", + "[1]", + $"{{\"alg\":\"EdDSA\",\"typ\":\"vc+jwt\",\"cty\":\"vc\",\"kid\":\"{assertKid}\",\"kid\":\"{assertKid}\"}}", + $"{{\"alg\":\"EdDSA\",\"typ\":\"vc+jwt\",\"cty\":\"vc\",\"kid\":\"{assertKid}\",\"typ\":\"vp+jwt\"}}", + $"{{\"alg\":\"EdDSA\",\"typ\":\"vc+jwt\",\"cty\":\"vc\",\"kid\":\"{assertKid}\",\"crit\":[\"x\"],\"x\":1}}", + $"{{\"alg\":1,\"typ\":\"vc+jwt\",\"cty\":\"vc\",\"kid\":\"{assertKid}\"}}", + $"{{\"alg\":\"none\",\"typ\":\"vc+jwt\",\"cty\":\"vc\",\"kid\":\"{assertKid}\"}}", + $"{{\"alg\":\"EdDSA\",\"typ\":\"vc+jwt\",\"cty\":\"vc\",\"kid\":\"{assertKid}\",\"z\":{new string('[', 200)}{new string(']', 200)}}}", + }; + var presentationHeaders = new[] + { + "{\"alg\":\"EdDSA\",\"typ\":\"vp+jwt\",\"kid\":null}", + $"{{\"alg\":\"EdDSA\",\"typ\":\"vp+jwt\",\"kid\":\"{authKid}\",\"kid\":\"{authKid}\"}}", + }; + + foreach (var header in credentialHeaders) + { + var compact = await SignCompactAsync(Encoding.UTF8.GetBytes(header), vc, issuer.Signer); + var result = await TryVerifyAsync(() => verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(compact))); + result?.Decision.Should().NotBe(VerificationDecision.Accepted, header); + } + + foreach (var header in presentationHeaders) + { + var compact = await SignCompactAsync(Encoding.UTF8.GetBytes(header), vp, issuer.Signer); + var result = await TryVerifyAsync(() => verifier.VerifyPresentationAsync( + Encoding.UTF8.GetBytes(compact), + new PresentationVerificationOptions { ExpectedChallenge = Challenge, RequireAtLeastOneCredential = false })); + result?.Decision.Should().NotBe(VerificationDecision.Accepted, header); + } + + // A malformed signature over a well-formed header is a definitive failure (Failed), never Indeterminate. + var goodHeader = Encoding.UTF8.GetBytes($"{{\"alg\":\"EdDSA\",\"typ\":\"vc+jwt\",\"cty\":\"vc\",\"kid\":\"{assertKid}\"}}"); + var honest = await SignCompactAsync(goodHeader, vc, issuer.Signer); + (await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(honest))).Decision.Should().Be(VerificationDecision.Accepted); + + var signingInput = honest[..honest.LastIndexOf('.')]; + foreach (var badSignature in new[] { new byte[10], new byte[65], new byte[64] }) + { + var result = await verifier.VerifyCredentialAsync( + Encoding.UTF8.GetBytes(signingInput + "." + Base64Url.EncodeToString(badSignature))); + result.Decision.Should().Be(VerificationDecision.Rejected); + result.Check(CheckKinds.Proof)!.Status.Should().Be(CheckStatus.Failed); + } + } + + // A CredentialFormatException (refusing to parse) is an acceptable outcome; any other exception escaping + // the verifier is a contract violation and fails the test by propagating. + private static async Task TryVerifyAsync(Func> verify) + where T : class + { + try + { + return await verify(); + } + catch (CredentialFormatException) + { + return null; + } + } +} From fa4c3e4eae916f1615c80d3bc2f27710ee2b0a03 Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Mon, 26 Oct 2026 20:46:31 -0400 Subject: [PATCH 04/10] test: pin NetCrypto >= 1.5.0 ES256K high-S acceptance and its freshness invariants (#20) Addresses PR #21 review item 3. An ES256K (R, n-S) signature twin of a vc+jwt now verifies (pinned), but a twin never defeats freshness or disclosure binding: a malleated issuer JWT inside an SD-JWT+KB presentation breaks sd_hash, and malleated KB-JWT / vp+jwt twins still carry the nonce / challenge they signed. Co-Authored-By: Claude Opus 5.5 --- .../JoseSubstrateRegressionTests.cs | 94 ++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs b/tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs index 0082770..f0b4876 100644 --- a/tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs +++ b/tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Numerics; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -24,6 +26,7 @@ namespace Credentials.Extensions.DependencyInjection.Tests; /// These tests pin the properties that make those changes unreachable from Credentials: JSON-serialized JWS /// never reaches the JOSE path, only the signed kid is bound, every token Credentials emits /// carries its kid in the protected header, and hostile headers fail without escaping exceptions. +/// It also pins NetCrypto ≥ 1.5.0's ES256K high-S acceptance and the freshness invariants that keep it harmless. /// Each DID publishes one key under an authentication VM and an assertionMethod VM (the /// shape DataProofs' 1.3.0 security note uses), and every fixture signs under the VM the role requires. /// @@ -49,9 +52,9 @@ public Task ResolveAsync(string did, DidResolutionOptions? ? new DidResolutionResult { DidDocument = doc, ResolutionMetadata = new DidResolutionMetadata() } : new DidResolutionResult { DidDocument = null, ResolutionMetadata = new DidResolutionMetadata { Error = "notFound" } }); - public TestDid Add(string did) + public TestDid Add(string did, KeyType keyType = KeyType.Ed25519) { - var pair = KeyGen.Generate(KeyType.Ed25519); + var pair = KeyGen.Generate(keyType); var authVm = did + "#auth"; var assertVm = did + "#assert"; _docs[did] = new DidDocument @@ -90,6 +93,23 @@ private static Credential Unsecured(string issuerDid) => Credential.Build() private static JsonObject ProtectedHeader(string compactJws) => JsonNode.Parse(Base64Url.DecodeFromChars(compactJws.Split('.')[0]))!.AsObject(); + private static readonly BigInteger Secp256k1Order = BigInteger.Parse( + "0FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", NumberStyles.HexNumber); + + // The ECDSA signature twin: replace S with n − S in the R‖S signature segment of a compact JWS. + private static string MalleateEs256k(string compactJws) + { + var parts = compactJws.Split('.'); + var signature = Base64Url.DecodeFromChars(parts[2]); + var half = signature.Length / 2; + var s = new BigInteger(signature.AsSpan(half), isUnsigned: true, isBigEndian: true); + var negated = (Secp256k1Order - s).ToByteArray(isUnsigned: true, isBigEndian: true); + Array.Clear(signature, half, half); + negated.CopyTo(signature, signature.Length - negated.Length); + parts[2] = Base64Url.EncodeToString(signature); + return string.Join('.', parts); + } + private static async Task SignCompactAsync(byte[] header, ReadOnlyMemory payload, ISigner signer) { var signingInput = Base64Url.EncodeToString(header) + "." + Base64Url.EncodeToString(payload.Span); @@ -362,6 +382,76 @@ public async Task Hostile_protected_headers_signed_with_the_real_key_fail_withou } } + [Fact] + [FrTag("FR-040")] + [FrTag("FR-041")] + [FrTag("FR-042")] + public async Task Es256k_signature_twins_verify_but_never_defeat_freshness_or_disclosure_binding() + { + // NetCrypto >= 1.5.0 accepts high-S secp256k1 signatures, so (R, S) and (R, n − S) both verify for + // ES256K, as they always have for the NIST curves. A twin must never yield an outcome the original + // could not: freshness is keyed on signed content (nonce / aud / challenge), and sd_hash covers the + // issuer JWT including its signature. + var resolver = new MapDidResolver(); + var issuer = resolver.Add("did:example:issuer", KeyType.Secp256k1); + var holder = resolver.Add("did:example:holder", KeyType.Secp256k1); + using var provider = BuildProvider(resolver); + var issuerRole = provider.GetRequiredService(); + var holderRole = provider.GetRequiredService(); + var verifier = provider.GetRequiredService(); + + // Pinned behavior: a vc+jwt twin is a second valid encoding of the same credential. + var jose = await issuerRole.IssueAsync(Unsecured(issuer.Did), + new JoseEnvelopeIssuanceRequest { Signer = issuer.Signer, VerificationMethod = issuer.AssertVm }); + var joseTwin = MalleateEs256k(jose.CompactJws!); + joseTwin.Should().NotBe(jose.CompactJws); + (await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(joseTwin))) + .Decision.Should().Be(VerificationDecision.Accepted); + + // SD-JWT VC + KB-JWT: a twin of the issuer JWT breaks sd_hash; a twin of the KB-JWT is still bound to + // the nonce it signed. + var sdJwt = await issuerRole.IssueAsync(Unsecured(issuer.Did), new SdJwtVcIssuanceRequest + { + Vct = Vct, + Signer = issuer.Signer, + VerificationMethod = issuer.AssertVm, + Disclosable = [DisclosureSelector.ObjectProperties("credentialSubject", "given_name")], + HolderBinding = HolderBindingKey.FromMultikey(holder.Pair.MultibasePublicKey), + }); + var presentation = await holderRole.PresentSdJwtAsync( + holderRole.Ingest(Encoding.UTF8.GetBytes(sdJwt.CompactSdJwt!)), + new SdJwtPresentationRequest + { + DiscloseClaims = ["given_name"], + HolderSigner = holder.Signer, + VerificationMethod = holder.AuthVm, + Audience = Domain, + Nonce = Challenge, + }); + var kbOptions = new CredentialVerificationOptions { RequireHolderBinding = true, ExpectedAudience = Domain, ExpectedNonce = Challenge }; + (await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(presentation), kbOptions)) + .Decision.Should().Be(VerificationDecision.Accepted); + + var parts = presentation.Split('~'); + var issuerTwin = (string[])parts.Clone(); + issuerTwin[0] = MalleateEs256k(parts[0]); + (await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(string.Join('~', issuerTwin)), kbOptions)) + .Decision.Should().Be(VerificationDecision.Rejected, "sd_hash covers the issuer JWT, signature included"); + + var kbTwin = (string[])parts.Clone(); + kbTwin[^1] = MalleateEs256k(parts[^1]); + (await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(string.Join('~', kbTwin)), kbOptions with { ExpectedNonce = "a-fresh-nonce" })) + .Decision.Should().Be(VerificationDecision.Rejected, "the KB-JWT twin still carries the old nonce"); + + // vp+jwt: a twin still carries the challenge it was bound to. + var vpJwt = await holderRole.BindWithJoseEnvelopeAsync( + holderRole.BuildPresentation(new VpAssemblyRequest { Holder = holder.Did, Credentials = [ContainedCredential.Enveloped(jose.CompactJws!)] }), + new VpBindingRequest { HolderSigner = holder.Signer, VerificationMethod = holder.AuthVm, Challenge = Challenge, Domain = Domain }); + (await verifier.VerifyPresentationAsync(Encoding.UTF8.GetBytes(MalleateEs256k(vpJwt)), + new PresentationVerificationOptions { ExpectedChallenge = "a-fresh-challenge", ExpectedDomain = Domain })) + .Decision.Should().Be(VerificationDecision.Rejected, "the vp+jwt twin still carries the old challenge"); + } + // A CredentialFormatException (refusing to parse) is an acceptable outcome; any other exception escaping // the verifier is a contract violation and fails the test by propagating. private static async Task TryVerifyAsync(Func> verify) From e12819c28ab4fecd887e165176467cab8eb6d032 Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Thu, 29 Oct 2026 21:29:48 -0400 Subject: [PATCH 05/10] chore(deps): update every dependency to its latest stable release (#20) The first-party stack moved together (DataProofs 1.5.0, NetDid 4.0.0, DidComm.Core 1.5.0). This brings Credentials onto exactly that graph: - DataProofsDotnet.* 1.3.0 -> 1.5.0, NetDid.* 2.3.0 -> 4.0.0, JsonSchema.Net 7.3.2 -> 9.5.0 - Microsoft.Extensions.* / MetadataLoadContext 10.0.8 -> 10.0.14 (NetDid 4.0.0 floors 10.0.14) - PublicApiAnalyzers 5.6.0, Test.Sdk 18.11.0, xunit.runner.visualstudio 4.0.1, NSubstitute 6.2.0, coverlet 10.1.0, SkippableFact 1.5.85, ApiCompat tool 10.0.403 - FluentAssertions deliberately held at 7.0.0 (last Apache-2.0 release) Code changes the upgrade requires: - JsonSchema2020Validator ported to JsonSchema.Net 9. Each validation builds into a fresh local SchemaRegistry: v9 otherwise registers schemas process-wide, so one credential's schema could answer another's $ref, and a reused $id would throw. JsonSchemaException (v9's error for structurally invalid schemas) is caught, so the validator still never throws. New tests pin isolation, $id reuse, malformed schemas, external $ref and the ReDoS cut-off. - DataIntegrityMechanism wraps DataProofs' ProofGenerationException (thrown since 1.4.0) in the InvalidOperationException IIssuer/IHolder document, keeping the substrate type behind the seam. - DATA_LOSS_DETECTION_ERROR (DataProofs 1.5.0 RDFC data-loss fix) gets a verifier message. - The RDFC sample lists credentials/undefined-terms/v2 so its custom claims are signable. Co-Authored-By: Claude Opus 5.5 --- .config/dotnet-tools.json | 2 +- Directory.Packages.props | 103 ++++++++++-------- .../Program.cs | 3 + src/Credentials.Core/Roles/DefaultVerifier.cs | 1 + src/Credentials.Core/Roles/IHolder.cs | 1 + src/Credentials.Core/Roles/IIssuer.cs | 6 +- .../Internal/JsonSchema2020Validator.cs | 35 +++--- .../Internal/DataIntegrityMechanism.cs | 19 +++- .../SchemaValidatorTests.cs | 62 +++++++++++ .../RdfcIssueVerifyTests.cs | 72 ++++++++++++ 10 files changed, 234 insertions(+), 70 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 5be635f..4aa0436 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "microsoft.dotnet.apicompat.tool": { - "version": "10.0.301", + "version": "10.0.403", "commands": [ "apicompat" ], diff --git a/Directory.Packages.props b/Directory.Packages.props index 524c3d1..6ae0594 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,58 +4,64 @@ - - - - - - + + + + + + - + - - - - + + + + - - + + - - - - - - - + + + + + + + - @@ -63,24 +69,25 @@ - + - + - + - - + + - - + + - + diff --git a/samples/Credentials.Sample.DataIntegrityRdfc/Program.cs b/samples/Credentials.Sample.DataIntegrityRdfc/Program.cs index 885d13a..3e0f8ea 100644 --- a/samples/Credentials.Sample.DataIntegrityRdfc/Program.cs +++ b/samples/Credentials.Sample.DataIntegrityRdfc/Program.cs @@ -37,6 +37,9 @@ public static async Task RunAsync(TextWriter output, IServiceProvider? services .WithId("urn:uuid:22222222-2222-2222-2222-222222222222") .WithIssuer(issuerKey.Did) .AddType("UniversityDegreeCredential") + // RDFC signs only what the contexts define. Listed last (VCDM 2.0 §5.2), the undefined-terms + // context makes this sample's custom claims signable instead of silently dropped. + .AddContext("https://www.w3.org/ns/credentials/undefined-terms/v2") .AddSubject(new JsonObject { ["id"] = "did:example:subject", diff --git a/src/Credentials.Core/Roles/DefaultVerifier.cs b/src/Credentials.Core/Roles/DefaultVerifier.cs index a419129..027c31e 100644 --- a/src/Credentials.Core/Roles/DefaultVerifier.cs +++ b/src/Credentials.Core/Roles/DefaultVerifier.cs @@ -650,6 +650,7 @@ private static IReadOnlyList MapProofProblems(IReadOnlyList "The proof signature did not verify.", "PROOF_TRANSFORMATION_ERROR" => "The credential could not be canonicalized for verification.", + "DATA_LOSS_DETECTION_ERROR" => "Canonicalization would drop or alter part of the credential (for example, a term no @context defines), so that part is not covered by the proof.", "INVALID_VERIFICATION_METHOD" => "The proof's verification method is not authorized for its purpose.", "INVALID_CHALLENGE_ERROR" => "The proof challenge did not match.", "INVALID_DOMAIN_ERROR" => "The proof domain did not match.", diff --git a/src/Credentials.Core/Roles/IHolder.cs b/src/Credentials.Core/Roles/IHolder.cs index 255c337..90a7814 100644 --- a/src/Credentials.Core/Roles/IHolder.cs +++ b/src/Credentials.Core/Roles/IHolder.cs @@ -39,6 +39,7 @@ public interface IHolder /// Binds a presentation to the holder key with an embedded Data Integrity authentication proof /// carrying the verifier's challenge/domain (FR-034), returning the secured presentation. /// + /// The presentation cannot be secured with the requested cryptosuite. Task BindWithDataIntegrityAsync(VerifiablePresentation presentation, VpBindingRequest request, CancellationToken cancellationToken = default); /// diff --git a/src/Credentials.Core/Roles/IIssuer.cs b/src/Credentials.Core/Roles/IIssuer.cs index 8b337a0..91c7614 100644 --- a/src/Credentials.Core/Roles/IIssuer.cs +++ b/src/Credentials.Core/Roles/IIssuer.cs @@ -12,6 +12,10 @@ public interface IIssuer /// , returning the secured credential. /// /// The requested form/cryptosuite is not available. - /// The credential is already secured. + /// + /// The credential is already secured, or it cannot be secured in the requested form. One example is an RDFC + /// Data Integrity cryptosuite over a term no @context defines: canonicalization would drop it, so it + /// would go unsigned. + /// Task IssueAsync(Credential credential, IssuanceRequest request, CancellationToken cancellationToken = default); } diff --git a/src/Credentials.Core/Schema/Internal/JsonSchema2020Validator.cs b/src/Credentials.Core/Schema/Internal/JsonSchema2020Validator.cs index 99628b0..3be700c 100644 --- a/src/Credentials.Core/Schema/Internal/JsonSchema2020Validator.cs +++ b/src/Credentials.Core/Schema/Internal/JsonSchema2020Validator.cs @@ -1,6 +1,5 @@ using System.Text; using System.Text.Json; -using System.Text.Json.Nodes; using Credentials.Verification; using Json.Schema; @@ -20,6 +19,12 @@ namespace Credentials.Schema; /// The caller's resolver controls all schema fetching, and a declared digestSRI pins the bytes. /// /// +/// Registry isolation: each validation builds its schema into a fresh local +/// . JsonSchema.Net 9 otherwise registers every built schema in the process-wide +/// . A schema supplied with one credential could then answer another +/// credential's $ref, and reusing a $id would throw. +/// +/// /// ReDoS: a malicious pattern/format regex cannot hang the validator /// indefinitely — the BCL regex engine's match timeout fires and the evaluation is reported as /// . A pathological schema can still cost up to that timeout @@ -34,7 +39,6 @@ internal sealed class JsonSchema2020Validator : ICredentialSchemaValidator private static readonly EvaluationOptions Options = new() { - EvaluateAs = SpecVersion.Draft202012, RequireFormatValidation = true, OutputFormat = OutputFormat.List, }; @@ -50,27 +54,22 @@ public SchemaCheckResult Validate(ResolvedSchema schema, JsonElement credential) JsonSchema parsed; try { - parsed = JsonSchema.FromText(Encoding.UTF8.GetString(schema.Content.Span)); + // The dialect is fixed at 2020-12 at build time, whatever $schema the document declares. The local + // registry keeps this schema's $id out of the process-wide registry (see the remarks). + parsed = JsonSchema.FromText( + Encoding.UTF8.GetString(schema.Content.Span), + new BuildOptions { Dialect = global::Json.Schema.Dialect.Draft202012, SchemaRegistry = new SchemaRegistry() }); } - catch (Exception ex) when (ex is JsonException or ArgumentException) + catch (Exception ex) when (ex is JsonException or JsonSchemaException or ArgumentException) { + // JsonSchemaException: a structurally invalid schema ({"type":5}, {"$ref":1}, an unknown $schema). return SchemaCheckResult.Indeterminate("schema.unparseable", "The schema document is not valid JSON Schema."); } - JsonNode? instance; - try - { - instance = JsonNode.Parse(credential.GetRawText()); - } - catch (JsonException) - { - return SchemaCheckResult.Indeterminate("schema.instance_unparseable", "The credential could not be read for schema validation."); - } - EvaluationResults results; try { - results = parsed.Evaluate(instance, Options); + results = parsed.Evaluate(credential, Options); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -96,14 +95,14 @@ public SchemaCheckResult Validate(ResolvedSchema schema, JsonElement credential) private static void CollectErrors(EvaluationResults results, List diagnostics) { // With OutputFormat.List, Details is the flattened set of nodes; surface each node that has errors. - if (results.HasErrors && results.Errors is { } errors) + if (results.Errors is { Count: > 0 } errors) { AddErrors(results.InstanceLocation.ToString(), errors, diagnostics); } - foreach (var detail in results.Details) + foreach (var detail in results.Details ?? []) { - if (detail.HasErrors && detail.Errors is { } detailErrors) + if (detail.Errors is { Count: > 0 } detailErrors) { AddErrors(detail.InstanceLocation.ToString(), detailErrors, diagnostics); } diff --git a/src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs b/src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs index b672e55..46d468e 100644 --- a/src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs +++ b/src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Credentials.Resolution; using Credentials.Validation; +using DataProofsDotnet; using DataProofsDotnet.DataIntegrity; namespace Credentials.Securing; @@ -48,8 +49,22 @@ public async Task SecureAsync(SecureRequest request, Cancellation Domain = request.Domain, }; - var secured = await _pipeline.AddProofAsync(request.Document, proofOptions, request.Signer, cancellationToken) - .ConfigureAwait(false); + JsonElement secured; + try + { + secured = await _pipeline.AddProofAsync(request.Document, proofOptions, request.Signer, cancellationToken) + .ConfigureAwait(false); + } + catch (ProofGenerationException ex) + { + // Keep the substrate exception behind the seam (NFR-005) and the roles' documented contract. Since + // DataProofs 1.4.0, proof creation reports every input problem this way, including a document an + // RDFC suite cannot canonicalize without losing data (an undefined term, a malformed IRI). + // DataProofs' message names the cause and never quotes the input. + throw new InvalidOperationException( + $"The document cannot be secured with the '{request.Cryptosuite}' cryptosuite: {ex.Message}", ex); + } + return SecureOutcome.ForDocument(secured); } diff --git a/tests/Credentials.Core.Tests/SchemaValidatorTests.cs b/tests/Credentials.Core.Tests/SchemaValidatorTests.cs index 8e0b82b..d252c8b 100644 --- a/tests/Credentials.Core.Tests/SchemaValidatorTests.cs +++ b/tests/Credentials.Core.Tests/SchemaValidatorTests.cs @@ -79,6 +79,68 @@ public void Unparseable_schema_is_indeterminate() result.Outcome.Should().Be(SchemaCheckOutcome.Indeterminate); } + [Theory] + [InlineData("""{ "type": 5 }""")] + [InlineData("""{ "$ref": 1 }""")] + [InlineData("""{ "minimum": "x" }""")] + [InlineData("""{ "required": "a" }""")] + [InlineData("""{ "properties": [] }""")] + [InlineData("""{ "$schema": "https://unknown.example/dialect" }""")] + [InlineData("[1]")] + [InlineData("5")] + public void Structurally_invalid_schema_is_indeterminate_and_never_throws(string schemaJson) + { + // JsonSchema.Net 9 reports these as JsonSchemaException, which must not escape the validator. + var result = Validator.Validate(Schema(schemaJson), Credential("""{ "a": 1 }""")); + result.Outcome.Should().Be(SchemaCheckOutcome.Indeterminate); + } + + [Fact] + public void A_schema_id_from_one_validation_never_resolves_another_validations_ref() + { + // JsonSchema.Net 9 registers built schemas process-wide unless given a local registry. Without isolation, + // the first (attacker-suppliable) schema would answer the second schema's $ref. + const string id = "https://isolation.example/a"; + Validator.Validate(Schema($$"""{ "$id": "{{id}}", "type": "string" }"""), Credential("\"s\"")) + .Outcome.Should().Be(SchemaCheckOutcome.Success); + + var result = Validator.Validate(Schema($$"""{ "$ref": "{{id}}" }"""), Credential("123")); + result.Outcome.Should().Be(SchemaCheckOutcome.Indeterminate, "the $ref must stay unresolved, not borrow another validation's schema"); + } + + [Fact] + public void The_same_schema_id_can_be_validated_repeatedly_with_different_content() + { + const string id = "https://isolation.example/reused"; + Validator.Validate(Schema($$"""{ "$id": "{{id}}", "type": "string" }"""), Credential("\"s\"")) + .Outcome.Should().Be(SchemaCheckOutcome.Success); + Validator.Validate(Schema($$"""{ "$id": "{{id}}", "type": "integer" }"""), Credential("123")) + .Outcome.Should().Be(SchemaCheckOutcome.Success); + Validator.Validate(Schema($$"""{ "$id": "{{id}}", "type": "integer" }"""), Credential("\"s\"")) + .Outcome.Should().Be(SchemaCheckOutcome.Failure); + } + + [Fact] + public void An_external_ref_is_unresolved_not_fetched() + { + // No fetcher is wired (SSRF): an external $ref fails resolution instead of reaching the network. + var result = Validator.Validate(Schema("""{ "$ref": "https://schema.invalid/remote.json" }"""), Credential("""{ "a": 1 }""")); + result.Outcome.Should().Be(SchemaCheckOutcome.Indeterminate); + } + + [Fact] + public void A_catastrophic_backtracking_pattern_is_cut_off_as_indeterminate() + { + // ReDoS: the regex match timeout bounds a pathological pattern instead of hanging verification. + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result = Validator.Validate( + Schema("""{ "type": "string", "pattern": "^(a+)+$" }"""), + Credential("\"" + new string('a', 30) + "!\"")); + + result.Outcome.Should().Be(SchemaCheckOutcome.Indeterminate); + stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(30)); + } + [Fact] public void Registry_dispatches_by_type_and_is_immutable() { diff --git a/tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs b/tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs index d02a85b..92c9908 100644 --- a/tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs +++ b/tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs @@ -56,6 +56,78 @@ public async Task Rdfc_issue_then_verify_round_trips(string cryptosuite, KeyType result.Decision.Should().Be(VerificationDecision.Accepted, result.ToString()); } + private const string UndefinedTermsContext = "https://www.w3.org/ns/credentials/undefined-terms/v2"; + + private static Credential WithCustomClaim(string issuerDid, bool listUndefinedTermsContext) + { + var builder = Credential.Build().WithIssuer(issuerDid); + if (listUndefinedTermsContext) + { + builder.AddContext(UndefinedTermsContext); + } + + // "degree" is defined by no context: credentials/v2 has no @vocab. + return builder.AddSubject(new JsonObject { ["id"] = "did:example:subject", ["degree"] = "BSc" }).Seal(); + } + + private static async Task IssueRdfcAsync(IIssuer issuer, Credential credential, KeyPair keyPair) => + await issuer.IssueAsync(credential, new DataIntegrityIssuanceRequest + { + Cryptosuite = "eddsa-rdfc-2022", + Signer = new KeyPairSigner(keyPair, Crypto), + VerificationMethod = $"did:key:{keyPair.MultibasePublicKey}#{keyPair.MultibasePublicKey}", + }); + + [Fact] + [FrTag("FR-011")] + public async Task An_undefined_term_cannot_be_secured_and_the_failure_keeps_the_issuer_contract() + { + // DataProofs >= 1.5.0 refuses to sign what RDFC would drop. The substrate's ProofGenerationException must + // surface as the InvalidOperationException IIssuer documents, never as a DataProofs type (NFR-005). + using var provider = BuildProvider(); + var issuer = provider.GetRequiredService(); + var keyPair = KeyGen.Generate(KeyType.Ed25519); + + var act = () => IssueRdfcAsync(issuer, WithCustomClaim($"did:key:{keyPair.MultibasePublicKey}", listUndefinedTermsContext: false), keyPair); + + (await act.Should().ThrowExactlyAsync()) + .WithMessage("*eddsa-rdfc-2022*") + .WithInnerException(); + } + + [Fact] + [FrTag("FR-011")] + [FrTag("FR-040")] + public async Task The_undefined_terms_context_makes_custom_claims_signed_and_tamper_evident() + { + using var provider = BuildProvider(); + var issuer = provider.GetRequiredService(); + var verifier = provider.GetRequiredService(); + var keyPair = KeyGen.Generate(KeyType.Ed25519); + + var issued = await IssueRdfcAsync(issuer, WithCustomClaim($"did:key:{keyPair.MultibasePublicKey}", listUndefinedTermsContext: true), keyPair); + (await verifier.VerifyCredentialAsync(issued.Credential)).Decision.Should().Be(VerificationDecision.Accepted); + + // The custom claim is now inside the signature: changing it breaks the proof. + var changed = JsonNode.Parse(issued.Credential.ToUtf8().Span)!.AsObject(); + changed["credentialSubject"]!["degree"] = "PhD"; + var changedResult = await verifier.VerifyCredentialAsync(System.Text.Encoding.UTF8.GetBytes(changed.ToJsonString())); + changedResult.Decision.Should().Be(VerificationDecision.Rejected); + changedResult.Check(CheckKinds.Proof)!.Status.Should().Be(CheckStatus.Failed); + + // A holder who strips the undefined-terms context to make the claim unsigned again (the pre-1.5.0 + // DataProofs hole) gets a definitive data-loss failure, not an Indeterminate. + var stripped = JsonNode.Parse(issued.Credential.ToUtf8().Span)!.AsObject(); + var contexts = stripped["@context"]!.AsArray(); + contexts.RemoveAt(contexts.Count - 1); + stripped["credentialSubject"]!["degree"] = "PhD"; + var strippedResult = await verifier.VerifyCredentialAsync(System.Text.Encoding.UTF8.GetBytes(stripped.ToJsonString())); + strippedResult.Decision.Should().Be(VerificationDecision.Rejected); + var proof = strippedResult.Check(CheckKinds.Proof)!; + proof.Status.Should().Be(CheckStatus.Failed); + proof.Diagnostics.Should().Contain(d => d.Code == "DATA_LOSS_DETECTION_ERROR"); + } + [Fact] public void UseRdfcSuites_adds_the_rdfc_suites_to_capabilities() { From ac134243b30cb49db92e12b3eac5de04d26e3c4e Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Thu, 29 Oct 2026 22:37:24 -0400 Subject: [PATCH 06/10] fix(schema): isolate JsonSchema.Net 9 dialects and fetches per validation; never throw (#20) Found by the adversarial pass on the JsonSchema.Net 9 port (e12819c), before release: - Dialect-registry poisoning. JsonSchema.Net 9 records document-defined $schema dialects in the process-wide DialectRegistry.Global, first writer wins. A schema supplied with one credential could redefine a custom dialect as core-only (validation keywords become annotations), flipping a later credential that declares it from Rejected to Accepted. The unsynchronized dictionary also grew without bound and corrupted under concurrency. Each validation now builds into a fresh local DialectRegistry. - Fetch fallback. JsonSchema.Net falls back to SchemaRegistry.Global.Fetch, which any code in the host process may set. The local registry's Fetch now refuses, so no external reference is ever fetched. - Exceptions escaping Validate. A non-string $schema or a lone surrogate in a keyword value threw InvalidOperationException (v7 returned Indeterminate). The build step now maps every non-cancellation exception to Indeterminate schema.unparseable. Each fix is pinned by tests and mutation-checked (removing it fails the tests). The false comment that the dialect was fixed at 2020-12 is corrected: a declared $schema selects a known or same-document dialect. Co-Authored-By: Claude Opus 5.5 --- .../Internal/JsonSchema2020Validator.cs | 50 ++++++++--- .../Internal/DataIntegrityMechanism.cs | 4 +- .../SchemaValidatorTests.cs | 87 ++++++++++++++++++- 3 files changed, 126 insertions(+), 15 deletions(-) diff --git a/src/Credentials.Core/Schema/Internal/JsonSchema2020Validator.cs b/src/Credentials.Core/Schema/Internal/JsonSchema2020Validator.cs index 3be700c..cb15c1e 100644 --- a/src/Credentials.Core/Schema/Internal/JsonSchema2020Validator.cs +++ b/src/Credentials.Core/Schema/Internal/JsonSchema2020Validator.cs @@ -13,16 +13,29 @@ namespace Credentials.Schema; /// /// /// -/// SSRF: external $ref resolution is not wired to any network fetcher — -/// JsonSchema.Net's default registry fetch returns , so an unresolved external -/// $ref simply fails resolution (→ ) with no egress. -/// The caller's resolver controls all schema fetching, and a declared digestSRI pins the bytes. +/// SSRF: no external reference is ever fetched. Each validation's local registry has a +/// that refuses. That matters because JsonSchema.Net falls back to the +/// process-wide 's fetcher, which any other code in the host process may +/// set. An unresolved external $ref, $dynamicRef or meta-schema therefore yields +/// with no egress. The caller's resolver controls all schema +/// fetching, and a declared digestSRI pins the bytes. /// /// -/// Registry isolation: each validation builds its schema into a fresh local -/// . JsonSchema.Net 9 otherwise registers every built schema in the process-wide -/// . A schema supplied with one credential could then answer another -/// credential's $ref, and reusing a $id would throw. +/// Registry isolation: each validation builds into a fresh local +/// and a fresh local . JsonSchema.Net 9 otherwise records schemas and +/// custom dialects in process-wide registries, which leads to three problems: +/// +/// A schema supplied with one credential could answer another credential's $ref. +/// A schema could redefine a custom $schema dialect for every later validation, the first +/// writer winning. For example, a core-only vocabulary turns validation keywords into annotations, so a +/// non-conforming credential would pass. +/// The process-wide dialect dictionary grows without bound and is not thread-safe. +/// +/// +/// +/// Dialect: a schema without $schema is evaluated as 2020-12. A $schema the +/// document declares, at the root or in any subschema, selects that dialect if JsonSchema.Net knows it, or if +/// the same document defines it. Otherwise the result is . /// /// /// ReDoS: a malicious pattern/format regex cannot hang the validator @@ -43,6 +56,10 @@ internal sealed class JsonSchema2020Validator : ICredentialSchemaValidator OutputFormat = OutputFormat.List, }; + // Throwing, rather than returning null, keeps JsonSchema.Net from falling back to SchemaRegistry.Global.Fetch. + private static IBaseDocument? RefuseFetch(Uri uri, SchemaRegistry registry) => + throw new InvalidOperationException("Credential schemas never fetch external references."); + public string SchemaType => JsonSchemaType; public SchemaDialect Dialect => SchemaDialect.JsonSchema2020_12; @@ -54,15 +71,22 @@ public SchemaCheckResult Validate(ResolvedSchema schema, JsonElement credential) JsonSchema parsed; try { - // The dialect is fixed at 2020-12 at build time, whatever $schema the document declares. The local - // registry keeps this schema's $id out of the process-wide registry (see the remarks). + // Fresh local registries per validation keep this schema's $ids and dialects out of the process-wide + // registries and stop any fetch (see the remarks). 2020-12 is the default dialect. parsed = JsonSchema.FromText( Encoding.UTF8.GetString(schema.Content.Span), - new BuildOptions { Dialect = global::Json.Schema.Dialect.Draft202012, SchemaRegistry = new SchemaRegistry() }); + new BuildOptions + { + Dialect = global::Json.Schema.Dialect.Draft202012, + SchemaRegistry = new SchemaRegistry { Fetch = RefuseFetch }, + DialectRegistry = new DialectRegistry(), + }); } - catch (Exception ex) when (ex is JsonException or JsonSchemaException or ArgumentException) + catch (Exception ex) when (ex is not OperationCanceledException) { - // JsonSchemaException: a structurally invalid schema ({"type":5}, {"$ref":1}, an unknown $schema). + // JsonSchema.Net 9 reports a malformed schema in several ways: JsonException, + // JsonSchemaException ({"type":5}, {"$ref":1}, an unknown $schema), and InvalidOperationException + // (a non-string $schema, a lone surrogate in a keyword value). All of them mean the same thing here. return SchemaCheckResult.Indeterminate("schema.unparseable", "The schema document is not valid JSON Schema."); } diff --git a/src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs b/src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs index 46d468e..a41d289 100644 --- a/src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs +++ b/src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs @@ -60,7 +60,9 @@ public async Task SecureAsync(SecureRequest request, Cancellation // Keep the substrate exception behind the seam (NFR-005) and the roles' documented contract. Since // DataProofs 1.4.0, proof creation reports every input problem this way, including a document an // RDFC suite cannot canonicalize without losing data (an undefined term, a malformed IRI). - // DataProofs' message names the cause and never quotes the input. + // DataProofs' top-level message names the cause without quoting the document. Deeper + // InnerExceptions, such as the JSON-LD processor's, can quote context URLs or IRIs from the caller's + // own document. That is the caller's data, not a verification diagnostic (NFR-008 covers those). throw new InvalidOperationException( $"The document cannot be secured with the '{request.Cryptosuite}' cryptosuite: {ex.Message}", ex); } diff --git a/tests/Credentials.Core.Tests/SchemaValidatorTests.cs b/tests/Credentials.Core.Tests/SchemaValidatorTests.cs index d252c8b..d1c34cd 100644 --- a/tests/Credentials.Core.Tests/SchemaValidatorTests.cs +++ b/tests/Credentials.Core.Tests/SchemaValidatorTests.cs @@ -88,9 +88,15 @@ public void Unparseable_schema_is_indeterminate() [InlineData("""{ "$schema": "https://unknown.example/dialect" }""")] [InlineData("[1]")] [InlineData("5")] + [InlineData("""{ "$schema": 5 }""")] + [InlineData("""{ "properties": { "a": { "$schema": 5 } } }""")] + [InlineData("""{ "pattern": "\ud800" }""")] + [InlineData("""{ "$ref": "\ud800" }""")] + [InlineData("""{ "required": ["\ud800"] }""")] public void Structurally_invalid_schema_is_indeterminate_and_never_throws(string schemaJson) { - // JsonSchema.Net 9 reports these as JsonSchemaException, which must not escape the validator. + // JsonSchema.Net 9 reports these as JsonSchemaException or InvalidOperationException, which must not + // escape the validator. var result = Validator.Validate(Schema(schemaJson), Credential("""{ "a": 1 }""")); result.Outcome.Should().Be(SchemaCheckOutcome.Indeterminate); } @@ -120,6 +126,85 @@ public void The_same_schema_id_can_be_validated_repeatedly_with_different_conten .Outcome.Should().Be(SchemaCheckOutcome.Failure); } + // A `$defs` entry that defines a meta-schema at `uri` with the given vocabularies. + private static string MetaSchemaDefinition(string uri, string vocabularies) => + $$""" "m": { "$defs": { "meta": { "$id": "{{uri}}", "$vocabulary": {{vocabularies}} } } } """; + + // Defines a core-only dialect at `uri` and uses it in a $defs subschema, so the dialect is actually resolved. + private static string DialectPoisoningSchema(string uri) => + $$"""{ "$defs": { {{MetaSchemaDefinition(uri, CoreVocabularyOnly)}}, "x": { "$schema": "{{uri}}" } } }"""; + + private const string CoreVocabularyOnly = """{ "https://json-schema.org/draft/2020-12/vocab/core": true }"""; + + private const string FullVocabularies = + """{ "https://json-schema.org/draft/2020-12/vocab/core": true, "https://json-schema.org/draft/2020-12/vocab/applicator": true, "https://json-schema.org/draft/2020-12/vocab/validation": true }"""; + + [Fact] + public void A_dialect_defined_by_one_schema_never_redefines_that_dialect_for_another() + { + // JsonSchema.Net 9 otherwise records a document-defined dialect process-wide, first writer wins. A core-only + // vocabulary makes validation keywords annotations, so an attacker's schema could make a later victim + // schema that declares the same $schema pass anything. + var uri = $"https://dialect.example/meta-{Guid.NewGuid():N}"; + var victim = Schema($$"""{ "$schema": "{{uri}}", "type": "string" }"""); + Validator.Validate(victim, Credential("123")).Outcome.Should().Be(SchemaCheckOutcome.Indeterminate); + + Validator.Validate(Schema(DialectPoisoningSchema(uri)), Credential("{}")).Outcome.Should().Be(SchemaCheckOutcome.Success); + + Validator.Validate(victim, Credential("123")).Outcome.Should().Be(SchemaCheckOutcome.Indeterminate, + "another validation's dialect definition must not leak into this one"); + } + + [Fact] + public void A_self_contained_dialect_keeps_its_own_vocabularies_after_another_schema_defined_the_same_uri() + { + var uri = $"https://dialect.example/meta-{Guid.NewGuid():N}"; + Validator.Validate(Schema(DialectPoisoningSchema(uri)), Credential("{}")).Outcome.Should().Be(SchemaCheckOutcome.Success); + + var legit = Schema($$""" + { "$defs": { {{MetaSchemaDefinition(uri, FullVocabularies)}} }, + "properties": { "credentialSubject": { "$schema": "{{uri}}", "type": "object", + "properties": { "age": { "type": "integer", "minimum": 18 } }, "required": ["age"] } } } + """); + Validator.Validate(legit, Credential("""{ "credentialSubject": { "age": 5 } }""")).Outcome.Should().Be(SchemaCheckOutcome.Failure); + Validator.Validate(legit, Credential("""{ "credentialSubject": { "age": 30 } }""")).Outcome.Should().Be(SchemaCheckOutcome.Success); + } + + [Fact] + public void A_fetcher_installed_on_the_global_registry_is_never_used() + { + // JsonSchema.Net falls back to SchemaRegistry.Global.Fetch, which any code in the host process may set. + var calls = new List(); + var original = global::Json.Schema.SchemaRegistry.Global.Fetch; + global::Json.Schema.SchemaRegistry.Global.Fetch = (uri, _) => + { + lock (calls) + { + calls.Add(uri); + } + + return null; + }; + try + { + foreach (var schemaJson in new[] + { + """{ "$ref": "https://fetch.invalid/ref.json" }""", + """{ "$schema": "https://fetch.invalid/meta.json" }""", + """{ "$dynamicRef": "https://fetch.invalid/dyn.json#x" }""", + }) + { + Validator.Validate(Schema(schemaJson), Credential("1")).Outcome.Should().Be(SchemaCheckOutcome.Indeterminate, schemaJson); + } + } + finally + { + global::Json.Schema.SchemaRegistry.Global.Fetch = original; + } + + calls.Should().BeEmpty(); + } + [Fact] public void An_external_ref_is_unresolved_not_fetched() { From 2335ef741eab5a10508f41bcaaad5543a1bea59a Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Thu, 29 Oct 2026 22:37:24 -0400 Subject: [PATCH 07/10] docs: v1.2.0 CHANGELOG for the all-latest dependency update, plan record, lessons Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 149 +++++++++++----- tasks/lessons.md | 28 +++ tasks/plan-2026-10-29-all-deps-latest.md | 215 +++++++++++++++++++++++ 3 files changed, 344 insertions(+), 48 deletions(-) create mode 100644 tasks/plan-2026-10-29-all-deps-latest.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b70c33e..8dc468c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,61 +4,114 @@ All notable changes to `credentials-dotnet` are documented here. The format is b [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). -## v1.2.0 - 10/26/2026 +## v1.2.0 - Unreleased -### Fixed - -- **JOSE and SD-JWT signing no longer throw `MissingMethodException` when `DataProofsDotnet.Jose` 1.3.0 - is resolved** ([#20](https://github.com/moisesja/credentials-dotnet/issues/20)). 1.1.1 was compiled - against Jose 1.1.0. Jose 1.3.0 replaced `JwsSigner..ctor(ISigner, string)` with - `JwsSigner..ctor(ISigner, string, JwsKidPlacement)`. The added optional parameter keeps source - compatibility but breaks binary compatibility. Any application whose graph resolved Jose 1.3.0 failed - at runtime on all four JOSE signing paths: `vc+jwt` issuance, SD-JWT VC issuance, the holder's SD-JWT - Key Binding JWT, and the holder's `vp+jwt` presentation. Every application that also references - **`DidComm.Core` 1.4.1** resolves Jose 1.3.0, since that package requires Jose ≥ 1.3.0. This release - is built against DataProofs 1.3.0, which fixes it with no source change. The signed bytes are - unchanged: Jose 1.3.0's default `JwsKidPlacement.Auto` keeps the `kid` in the integrity-protected - header for the JWT, SD-JWT and KB-JWT media types. DataProofs states that it does not guarantee - binary compatibility across minor versions, so each future move of the DataProofs floor needs a - Credentials rebuild and release. +Every dependency moves to its latest stable release, so Credentials is built against the same graph the +rest of the stack resolves (DataProofs 1.5.0, NetDid 4.0.0, NetCrypto 1.7.1, Microsoft.Extensions 10.0.14, +matching `DidComm.Core` 1.5.0). FluentAssertions is the one deliberate exception. -### Changed +### Fixed -- **Dependency floors raised:** - - `DataProofsDotnet.Core` / `.Jose` / `.Cose` / `.Rdfc` / `.Extensions.DependencyInjection`: - 1.1.0 → **1.3.0** - - `NetCrypto`: 1.2.0 → **1.7.1**. DataProofs.Core 1.3.0 requires ≥ 1.4.0, and the changes from 1.4.0 - to 1.7.1 only add APIs. **Behaviour change:** from NetCrypto 1.5.0, ES256K verification accepts - high-S signatures. For an ES256K `vc+jwt`, `vc+cose`, SD-JWT VC, KB-JWT or `vp+jwt`, the same - token with its signature `S` replaced by `n − S` now verifies, where it used to fail. P-256 tokens - already behaved this way. A replaced signature never yields a result the original token couldn't: - Credentials keys freshness on signed content (`nonce` / `aud` / `sd_hash` / challenge), and nothing - in it keys on signature bytes. **Applications that key replay caches, deduplication or credential - identity on the token bytes, or on a hash of them, should key on signed content instead** - (`jti`, nonce, credential `id`). - - **Applications still on `DataProofsDotnet.Jose` 1.2.x should stay on Credentials 1.1.1**, because - upgrading forces Jose ≥ 1.3.0. That is why this release is a minor version and not a patch. -- **Verified with NetDid 3.x at runtime:** `DidComm.Core` 1.4.1 also requires `NetDid.Core` ≥ 3.1.0. An - application using both libraries runs Credentials, built against NetDid 2.3.0, on NetDid.Core 3.1.0. - A consumer app referencing both packages ran all 14 samples successfully on that graph, including DID - resolution, Data Integrity, JOSE, COSE, SD-JWT and presentations. The NetDid floor stays at 2.3.0. -- **AngleSharp security pin removed.** The pin to 1.5.2 from #18 is no longer needed: - `DataProofsDotnet.Rdfc` 1.3.0 itself requires the patched `AngleSharp` ≥ 1.6.0. A local pin below an - upstream minimum version is an `NU1605` downgrade, so keeping it would have meant bumping it with - every DataProofs release. `Credentials.Rdfc` no longer declares an AngleSharp dependency. - - This release makes **no public API change**: all three packages pass ApiCompat against the 1.1.1 - baseline. The default (`Credentials.Core` / `Credentials.Extensions.DependencyInjection`) dependency - graph is still free of Newtonsoft. +- **JOSE and SD-JWT signing no longer throw `MissingMethodException` when a newer `DataProofsDotnet.Jose` is + resolved** ([#20](https://github.com/moisesja/credentials-dotnet/issues/20)). 1.1.1 was compiled against + Jose 1.1.0. Jose 1.3.0 replaced `JwsSigner..ctor(ISigner, string)` with + `JwsSigner..ctor(ISigner, string, JwsKidPlacement)`; the added optional parameter keeps source + compatibility but breaks binary compatibility. + - Any application whose graph resolved Jose ≥ 1.3.0 failed at runtime on all four JOSE signing paths: + `vc+jwt` issuance, SD-JWT VC issuance, the holder's SD-JWT Key Binding JWT, and the holder's `vp+jwt`. + Every application that also references `DidComm.Core` ≥ 1.4.1 resolves such a Jose. + - This release is built against DataProofs 1.5.0. The signed bytes are unchanged: the default + `JwsKidPlacement.Auto` keeps the `kid` in the integrity-protected header for the JWT, SD-JWT and KB-JWT + media types, and a regression test pins this for every token Credentials emits. + - DataProofs states that it does not guarantee binary compatibility across minor versions, so each move + of the DataProofs floor needs a Credentials rebuild and release. ### Security +- **Inherited from DataProofs 1.5.0: RDFC proofs now fail closed on JSON-LD data loss.** Before, whatever + RDF canonicalization silently dropped or respelled was not signed, so a holder could change it without + breaking an `eddsa-rdfc-2022` / `ecdsa-rdfc-2019` / `bbs-2023` proof. That included every custom claim of + a credential whose only context was credentials/v2, which has no `@vocab`. + - Issuance now refuses such a document with `InvalidOperationException` (see Changed). + - Verification fails it with `DATA_LOSS_DETECTION_ERROR`: proof check Failed, decision Rejected. + - Also fixed upstream: two bbs-2023 issues that let a holder alter disclosed data, and RDFC proofs over + credentials/v1 (without `https://w3id.org/security/data-integrity/v2`) that left the proof + configuration unsigned. + - The JCS suites, JOSE, COSE and SD-JWT never used JSON-LD processing and are unaffected. - **`Microsoft.SourceLink.GitHub` bumped 10.0.300 → 10.0.403** to resolve - [GHSA-23fw-v26w-5fgq](https://github.com/advisories/GHSA-23fw-v26w-5fgq) in its - `Microsoft.Build.Tasks.Git` dependency. With `TreatWarningsAsErrors`, the `NU1902` audit warning - failed restore. Since 2026-09 that broke every CI run on `main`, push and scheduled alike. SourceLink is - used only at build time (`PrivateAssets=All`) and never ships to consumers. + [GHSA-23fw-v26w-5fgq](https://github.com/advisories/GHSA-23fw-v26w-5fgq) in its `Microsoft.Build.Tasks.Git` + dependency. With `TreatWarningsAsErrors`, the `NU1902` audit warning failed restore and broke every CI run + on `main` from 2026-09. SourceLink is used only at build time (`PrivateAssets=All`) and never ships to + consumers. + +### Changed + +- **Dependencies, all at the latest stable release:** + + | Package | From | To | + |---|---|---| + | `DataProofsDotnet.Core` / `.Jose` / `.Cose` / `.Rdfc` / `.Extensions.DependencyInjection` | 1.1.0 | **1.5.0** | + | `NetDid.Core` / `.Method.Key` / `.Extensions.DependencyInjection` | 2.3.0 | **4.0.0** | + | `NetCrypto` | 1.2.0 | **1.7.1** | + | `JsonSchema.Net` | 7.3.2 | **9.5.0** | + | `Microsoft.Extensions.*` | 10.0.8 | **10.0.14** (required: NetDid 4.0.0 needs at least this) | + | `Microsoft.SourceLink.GitHub` (build-only) | 10.0.300 | **10.0.403** | + | `AngleSharp` pin (`Credentials.Rdfc`) | 1.5.2 | **removed** (`DataProofsDotnet.Rdfc` itself requires the patched ≥ 1.6.0) | + + Development-only packages also moved to latest: PublicApiAnalyzers 5.6.0, Microsoft.NET.Test.Sdk 18.11.0, + xunit.runner.visualstudio 4.0.1, NSubstitute 6.2.0, coverlet 10.1.0, Xunit.SkippableFact 1.5.85, and the + ApiCompat tool 10.0.403. **FluentAssertions stays at 7.0.0**, the last release under the free Apache-2.0 + licence. It is test-only. + + **Applications pinned to older DataProofs (Jose < 1.5.0) or NetDid (< 4.0.0) should stay on Credentials + 1.1.1.** Upgrading raises those floors. +- **RDFC issuance and verification are stricter** (from DataProofs 1.5.0, above). + - **Credentials issued earlier with RDFC over undefined terms no longer verify**, including ones + Credentials ≤ 1.1.1 issued. + - To keep custom claims, define the terms in a context, or list + `https://www.w3.org/ns/credentials/undefined-terms/v2` last in `@context` (VCDM 2.0 §5.2). That context + is bundled offline, and the RDFC sample now does this. + - IRIs must be well formed, absolute and normalized. +- **Issuance keeps its documented exception contract.** Since DataProofs 1.4.0, proof creation reports input + problems with its own `ProofGenerationException`. `IIssuer.IssueAsync` and + `IHolder.BindWithDataIntegrityAsync` now rethrow it as the documented `InvalidOperationException`, keeping + the original as `InnerException`, so no DataProofs type escapes the public API. The message names the + cryptosuite and the cause without quoting the document. The `InnerException` chain keeps the substrate + detail, which can include context URLs or IRIs from the caller's own document. +- **More malformed inputs are definitive failures** (from DataProofs 1.4.0). Malformed UTF-8 or an unpaired + surrogate in a JOSE header, an SD-JWT disclosure, the issuer JWT or KB-JWT, or a Data Integrity document + now ends **Failed** (Rejected) instead of Indeterminate. A compact JWS header with an invalid-UTF-8 member + name, which used to verify, now fails. +- **DID resolution is stricter** (NetDid 3.0–4.0). DID syntax is checked more tightly, DID documents that + break DID Core fail to resolve, and a verification method must have a `controller`. A resolution error is + Indeterminate in Credentials, as before. No did:key verdict changes. +- **JSON Schema validation runs on JsonSchema.Net 9.** + - Each validation uses its own schema and dialect registries. A schema supplied with one credential can + never answer another credential's `$ref`, and it can never redefine a custom `$schema` dialect for + later validations. The same `$id` can be validated repeatedly. + - No external reference is ever fetched, even if other code in the process installs a fetcher on + JsonSchema.Net's global registry. + - Structurally invalid schemas stay Indeterminate (`schema.unparseable`) rather than throwing; this + includes a non-string `$schema` and a lone-surrogate escape in a keyword value. An unknown `$schema` + dialect now also reports `schema.unparseable` (it used to be `schema.evaluation_error`); both are + Indeterminate. + - A schema without `$schema` is evaluated as 2020-12. A declared `$schema` selects a dialect JsonSchema.Net + knows, or one the same document defines. For draft-07 schemas, keywords beside `$ref` are now evaluated + rather than ignored, which is stricter. + - A pathological `pattern` is still cut off by the per-match regex timeout. New tests pin all of these + properties. +- **ES256K signatures can be re-encoded** (from NetCrypto 1.5.0). ES256K verification accepts high-S + signatures: for an ES256K `vc+jwt`, `vc+cose`, SD-JWT VC, KB-JWT or `vp+jwt`, the same token with its + signature `S` replaced by `n − S` now verifies. P-256 tokens always behaved this way. + - A re-encoded token never gets a result the original could not. Credentials checks freshness against + signed content (`nonce` / `aud` / `sd_hash` / challenge) and never keys on signature bytes. + - **Applications that key replay caches, deduplication or credential identity on token bytes, or a hash + of them, should key on signed content instead** (`jti`, nonce, credential `id`). + +**No public API change.** All three packages pass ApiCompat against the 1.1.1 baseline, and the default +(`Credentials.Core` / `Credentials.Extensions.DependencyInjection`) consumer graph is still free of +Newtonsoft. This is a minor release, not a patch, because the dependency floors rise and verification +narrows. ## v1.1.1 - 7/20/2026 diff --git a/tasks/lessons.md b/tasks/lessons.md index ac0d0d7..ebb4de2 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -2,6 +2,34 @@ Patterns learned while building credentials-dotnet. Reviewed at session start. +## Mutation-check every security regression test: a test can pass because it never triggers the attack (2026-10-29) + +The JsonSchema.Net 9 adversarial pass found a dialect-registry poisoning. I added the fix and a regression +test, and the test passed. The mutation check (remove the fix, rerun) showed the test **still passed +without the fix**. My helper put the attacker's `{"$schema": U}` at the schema root, where an unknown +keyword is a plain annotation and not a subschema, so the dialect was never resolved and the attack never +happened. The reviewer's probe put it inside `$defs`. **Rule:** for every security regression test, run it +once against the unfixed code and watch it fail before trusting it. Prefer porting the adversarial probe's +exact input over re-deriving it. Assert the attacker step's own outcome too (here, that the poisoning +schema builds and evaluates as Success), so a test whose attack silently no-ops cannot pass. + +## A dependency PR updates the whole graph to latest, not just the pin the issue names (2026-10-29) + +For issue #20 I bumped only what the issue strictly needed: DataProofs, plus NetCrypto after the user's +override. NetDid stayed at 2.3.0, Microsoft.Extensions at 10.0.8, and JsonSchema.Net and the test stack +were stale. Meanwhile the upstream stack (DataProofs 1.5.0, NetDid 4.0.0, DidComm.Core 1.5.0) moved +together. The user called the result "a mess" and asked for **all** dependencies at their latest versions. +A partial bump leaves Credentials compiled against a graph consumers no longer resolve, which is exactly +how #20's `MissingMethodException` happened. **Rule:** whenever a task touches dependency pins, inventory +**every** `PackageVersion` and dotnet tool against nuget.org's latest stable version. Propose the +full-latest set, and list every hold-back explicitly with its reason; FluentAssertions' licence is one. + +For a dependency with an **API break**, "it compiles" is not the bar. Probe the security properties the +old version gave for free. JsonSchema.Net 9 started registering schemas in a process-wide global registry +and throwing a new exception type for malformed schemas. Both would have shipped silently, because the +existing tests covered neither. Run the same probe on the old and new versions, and pin each property with +a test. + ## For first-party stack dependencies, offer the latest version alongside the minimum floor (2026-10-26) For issue #20 I planned `NetCrypto` 1.2.0 → **1.4.0**, the lowest version `DataProofsDotnet.Core 1.3.0` diff --git a/tasks/plan-2026-10-29-all-deps-latest.md b/tasks/plan-2026-10-29-all-deps-latest.md new file mode 100644 index 0000000..52b78a1 --- /dev/null +++ b/tasks/plan-2026-10-29-all-deps-latest.md @@ -0,0 +1,215 @@ +# Plan — update every dependency to its latest stable version (v1.2.0) + +Status: **APPROVED 2026-10-29 and IMPLEMENTED** on `fix/dataproofs-1.3.0-issue-20` (folded into PR #21). +The pre-approval evidence came from a scratch trial worktree (`fa4c3e4` + these changes); the Review +section records the results on the real branch. + +## Owner's intent + +> "we have a mess here. I made a lot of changes upstream. Please update ALL your dependencies to their +> latest versions" + +The first-party stack moved forward in a coordinated way: DataProofs 1.5.0, NetDid 4.0.0 and +DidComm.Core 1.5.0, all released 2026-10-28. Credentials still pins the older stack: DataProofs 1.3.0, +NetDid 2.3.0, Microsoft.Extensions 10.0.8. So a consumer that combines Credentials with DidComm.Core 1.5.0 +resolves a graph Credentials was never built or tested against. The goal is that **every** dependency is +on its latest stable version, Credentials converges exactly with the rest of the stack, and it ships as v1.2.0. + +## Decisions already taken (earlier in this session) + +- Release as **v1.2.0**, a minor version. +- NetCrypto **1.7.1**, the latest, rather than the 1.4.0 floor. +- The **`v1.2.0` tag is held** until further fixes targeted at v1.2.0 land. +- Security findings go to private advisories, per `SECURITY.md`. Nothing about them appears in public commits. + +**Owner decisions on this plan (2026-10-29):** +1. FluentAssertions **stays at 7.0.0** (licence). +2. **Fold** the all-latest update into PR #21. +3. **Re-enable** the Conformance workflow. Done: `gh workflow enable Conformance`. + +## Inventory (current → latest stable) + +| Package | Now | Target | Notes | +|---|---|---|---| +| DataProofsDotnet.Core/Jose/Cose/Rdfc/Extensions.DependencyInjection | 1.3.0 | **1.5.0** | security fixes; source-compatible; rebuild required | +| NetDid.Core/Method.Key/Extensions.DependencyInjection | 2.3.0 | **4.0.0** | no source change; `NetDidBuilder` (in our public API) changes are additive only | +| JsonSchema.Net | 7.3.2 | **9.5.0** | **API break**: one file to port (below) | +| Microsoft.Extensions.* (6 packages) | 10.0.8 | **10.0.14** | **required**: NetDid 4.0.0 floors 10.0.14, so staying is an NU1605 downgrade | +| System.Reflection.MetadataLoadContext | 10.0.8 | 10.0.14 | | +| Microsoft.CodeAnalysis.PublicApiAnalyzers | 4.14.0 | 5.6.0 | no new diagnostics | +| Microsoft.NET.Test.Sdk | 17.12.0 | 18.11.0 | | +| xunit.runner.visualstudio | 2.8.2 | 4.0.1 | xunit itself is already at its latest, 2.9.3 | +| NSubstitute | 5.3.0 | 6.2.0 | | +| coverlet.collector | 6.0.3 | 10.1.0 | | +| Xunit.SkippableFact | 1.5.61 | 1.5.85 | | +| Microsoft.DotNet.ApiCompat.Tool (dotnet tool) | 10.0.301 | 10.0.403 | | +| FluentAssertions | 7.0.0 | 8.11.0 | **decision 1**: licence | +| NetCrypto 1.7.1, NetCid 1.6.0, xunit 2.9.3, SourceLink 10.0.403 | — | — | already latest | + +The resolved graph matches DidComm.Core 1.5.0's floors exactly: Jose 1.5.0, NetCrypto 1.7.1, NetDid.Core +4.0.0 and Microsoft.Extensions 10.0.14. + +## Required code changes (each one found by the trial, not assumed) + +1. **Port `JsonSchema2020Validator` to JsonSchema.Net 9** (`src/Credentials.Core/Schema/Internal/`). + - Mechanical part: v9 moved the dialect to `BuildOptions.Dialect`, `Evaluate` now takes a `JsonElement` + (so the extra `JsonNode` parse goes away), and `HasErrors` is gone. + - **Two security regressions a compile-only port would have shipped.** I found both by probing; the + existing tests catch neither. + - **Global registry.** v9 registers every built schema in the *process-wide* `SchemaRegistry.Global`. + - An attacker-supplied schema's `$id` would then resolve another credential's `$ref`. Probe: a later + `$ref` changed from Indeterminate to **Failure** using the earlier schema. + - Reusing a `$id` would throw `JsonSchemaException: Overwriting registered schemas is not permitted`. + - Fix: a fresh local `SchemaRegistry` per validation. + - **Escaping exception.** v9 throws `JsonSchemaException` for structurally invalid schemas (`{"type":5}`, + `{"$ref":1}`, an unknown `$schema`). The current `catch` misses it, so the exception escapes a + validator documented as never throwing. v7 returned Indeterminate for all of these. Fix: add + `JsonSchemaException` to the `catch`. + - With both fixes, **v9 matches v7 on every probe**: + - external `$ref` → Indeterminate, with **no network request** (checked with a local HTTP listener); + - no leakage across validations; + - `$id` reuse works; + - malformed schemas → Indeterminate; + - the ReDoS pattern is cut off by the 5 s regex timeout → Indeterminate. + - The only difference: an unknown `$schema` now reports `schema.unparseable` rather than + `schema.evaluation_error`. Both are Indeterminate. + - **New tests pin all of this**, since none existed: registry isolation, `$id` reuse, malformed schemas, + external `$ref`, ReDoS bounded. +2. **Keep `IIssuer.IssueAsync`'s documented exception contract.** + - DataProofs 1.4.0 changed `AddProofAsync` to throw its own `ProofGenerationException` where it used to + throw raw `InvalidOperationException`, `JsonException` or `ArgumentException`. + - `IIssuer` documents only `NotSupportedException` and `InvalidOperationException`, and NFR-005 keeps + DataProofs types behind the mechanisms. + - With 1.5.0 this becomes common: any RDFC credential with an undefined term throws it. + - Fix: `DataIntegrityMechanism.SecureAsync` wraps `ProofGenerationException` in `InvalidOperationException`, + with a clear message and the inner exception kept. Add a test. +3. **Message for the new problem code.** Add a `DATA_LOSS_DETECTION_ERROR` case to + `DefaultVerifier.MapProofMessage`; today it falls back to "The proof is invalid.". Verdicts already fail + closed: any unverified Data Integrity result maps to Failed. +4. **RDFC sample** (`samples/Credentials.Sample.DataIntegrityRdfc`): add + `https://www.w3.org/ns/credentials/undefined-terms/v2` as the last `@context`. Its custom `degree` claims + are undefined terms that DataProofs 1.5.0 now refuses to sign. The context is bundled in DataProofs' + offline loader. Verified in the trial: 14/14 samples pass. +5. Version pins (`Directory.Packages.props`, with updated rationale comments) and + `.config/dotnet-tools.json`. + +**Trial result with items 1, 4 and 5 applied:** build 0 warnings / 0 errors; **396/396 tests pass**. + +## Behaviour changes to document (CHANGELOG v1.2.0) + +**DataProofs 1.5.0 RDFC data-loss fix** (upstream security fix, #36). Before it, anything RDFC +canonicalization silently dropped or respelled was not signed, so a holder could change it. Every custom +claim of a credentials/v2-only credential fell into this category. +- RDFC issuance now refuses such documents; verification fails them with `DATA_LOSS_DETECTION_ERROR`. +- **RDFC credentials issued earlier with undefined terms will no longer verify.** That includes ones + Credentials issued on ≤ 1.1.1. +- Migration: define the terms in a context, or list `credentials/undefined-terms/v2` last. +- VCDM 1.1 RDFC proofs without `https://w3id.org/security/data-integrity/v2` are also refused. Their proof + configuration was unsigned. +- bbs-2023 fixes: literal relabeling, and the `@id` re-insertion. + +**DataProofs 1.4.0 strictness.** Malformed UTF-8 or a lone surrogate in a JOSE header, an SD-JWT +disclosure, or a Data Integrity document now ends **Failed** instead of Indeterminate. That closes +holder-triggerable paths to Indeterminate. Invalid-UTF-8 compact headers now fail (dataproofs#28, which we +filed, is closed). + +**NetDid 3.0–4.0:** +- stricter DID syntax and DID-document validation, so more inputs resolve as errors (→ Indeterminate); +- a verification method's `controller` is now required (#121); +- relative verification-method ids are now legal and kept as written. + +None of these change a did:key verdict. + +**Upstream API changes:** none affect Credentials. There is no public API change, and ApiCompat runs in +verification. The default (Core + DI) closure stays Newtonsoft-free; NetDid 4.0's DI package adds only +`NetDid.Method.Ethr.dll`, which has no Nethereum dependency. + +## Explicitly not in this PR + +The analysis also surfaced some pre-existing hardening items in the key-resolution code. They are tracked +privately with the security work targeted at v1.2.0, per `SECURITY.md`, and are not part of this +dependency update. + +## Verification + +- Release build with warnings as errors (PublicAPI analyzers 5.6); full suite; sample smoke tests. +- `dotnet list --vulnerable --include-transitive` = 0; `check-no-newtonsoft-closure.sh`. +- `check-api-compat.sh` against 1.1.1, with the 10.0.403 tool. +- Pack 1.2.0 and inspect the nuspec floors. +- **DidComm consumer check against `DidComm.Core` 1.5.0**: all 14 samples, printing the loaded versions. +- **Adversarial agent pass** on the changed code: the validator (registry poisoning, `$ref`/SSRF, ReDoS, + exception escape) and the issuance exception wrap. +- **W3C conformance:** I can't run it locally, because cloning the suite and `npm install` was blocked as + external code. It runs on GitHub for PRs touching `src/`/`tests/`, but the workflow is + `disabled_inactivity`, so it needs `gh workflow enable Conformance` (decision 3). The baseline is 57. + RDFC issuance by the shim could move it if the suite sends undefined terms; any drop is investigated, + never papered over. + +## Decisions for the owner + +1. **FluentAssertions 7.0.0 → 8.11.0.** + - The technical cost is trivial: 2 compile errors (`NotContainKey` on JSON-node assertions) in one test file. + - The catch is the licence: 8.x is under Xceed's licence, which needs a paid licence for commercial + use. Your lessons pin 7.0.0 as the last free (Apache-2.0) version. + - **Recommendation: keep 7.0.0** unless you hold or want a licence. Say so and I'll bump it. +2. **Release vehicle.** Recommendation: **fold this into the open PR #21.** + - v1.2.0 is unreleased, and #21 is a dependency PR for the same release. + - I'd retitle it "update all dependencies to latest (#20), v1.2.0" and rewrite its description and the + CHANGELOG entry. The #20 fix still holds, now built against DataProofs 1.5.0. + - Alternative: merge #21 as it is, then open a second PR. +3. **Re-enable the Conformance workflow** (`gh workflow enable Conformance`) so the PR runs the W3C suite. +4. **Order.** Land this first; the remaining v1.2.0 work follows. The tag waits for both. + +## Checklist (after approval) + +- [x] Version pins and tool manifest, with rationale comments +- [x] Port `JsonSchema2020Validator` (local registry, `JsonSchemaException` catch) + new security tests +- [x] Wrap `ProofGenerationException` at issuance + test +- [x] `DATA_LOSS_DETECTION_ERROR` message + test +- [x] RDFC sample `@context` +- [x] Gates (above) + adversarial pass + DidComm 1.5.0 consumer check +- [x] CHANGELOG v1.2.0 rewrite; task record; lessons; memory +- [ ] Push to #21 (or a new PR, per decision 2); reply on the PR; watch CI and conformance + +## Review (results on the real branch) + +- **Build and tests:** + - `dotnet build -c Release -warnaserror`: 0 warnings / 0 errors (PublicApiAnalyzers 5.6.0). + - `dotnet test --filter "Category!=Conformance"`: **418 passed**, 0 failed. That is the previous 396 + plus 20 schema-validator tests and 2 RDFC tests. +- **Package gates:** + - Vulnerable packages: 0 across 29 projects. + - `check-no-newtonsoft-closure.sh`: OK. NetDid 4.0's DI package adds only `NetDid.Method.Ethr.dll`. + - `check-api-compat.sh` (tool 10.0.403): **no breaking changes** for Core, DI and Rdfc against 1.1.1. + - Packed 1.2.0 nuspecs require DataProofs ≥ 1.5.0, NetDid ≥ 4.0.0, NetCrypto ≥ 1.7.1, JsonSchema.Net + ≥ 9.5.0 and Microsoft.Extensions ≥ 10.0.14. Rdfc declares no AngleSharp. +- **Consumer check:** a scratch app referencing **`DidComm.Core` 1.5.0** and the packed 1.2.0 runs + **14/14 samples**. The graph is uniform: DataProofs 1.5.0, NetCrypto 1.7.1, and NetDid.Core / Method.Key + / Extensions.DependencyInjection all at 4.0.0. +- **Adversarial pass on the changed code**, about 160 probes, with a v7 vs v9 differential: + - It found three regressions in the first port of the validator. All three are fixed, each pinned by a + test, and **each fix mutation-checked**: with the fix removed, the test fails. + 1. **Dialect-registry poisoning.** JsonSchema.Net 9 records document-defined `$schema` dialects in a + process-wide registry, first writer wins. An attacker's schema could redefine a custom dialect as + core-only, so a later victim credential flipped from Rejected to Accepted. The same unsynchronized + dictionary also grew without bound and corrupted under concurrency. Fix: a fresh local + `DialectRegistry` per validation. + 2. **Exceptions escaping `Validate`.** A non-string `$schema`, or a lone surrogate in a keyword value, + threw `InvalidOperationException` where v7 returned Indeterminate. Fix: the build step maps every + non-cancellation exception to Indeterminate `schema.unparseable`. + 3. **Global fetch fallback.** JsonSchema.Net falls back to `SchemaRegistry.Global.Fetch`, which other + code in the process may set. Fix: the local registry's `Fetch` refuses, so no external reference is + fetched under any configuration. + - Also corrected: a false code comment claiming the dialect was fixed at 2020-12; the CHANGELOG now + describes the actual `$schema` semantics. + - Confirmed sound: + - schema-registry isolation; + - no network egress across 14 network-shaped schemas; + - no DataProofs exception type escaping issuance or presentation binding (36 hostile documents × + 4 suites); + - RDFC data-loss tampering → Failed `DATA_LOSS_DETECTION_ERROR`; + - did:key edge inputs under NetDid 4.0 never Accepted. + - The pass also surfaced findings that **pre-date** this update (they reproduce at `fa4c3e4`). Per + `SECURITY.md` they were reported to the owner privately, outside this PR. +- **W3C conformance:** the workflow is re-enabled and runs on the PR, as I can't run it locally. Results go + in the PR thread. From 17fbc441bfcc0f004ab9719f00a7f628c794ce5d Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Thu, 29 Oct 2026 23:05:40 -0400 Subject: [PATCH 08/10] conformance: name the known W3C failures; RDFC @direction is an explicit v1.2.0 scope decision (#20) Addresses the PR #21 review. DataProofs 1.5.0 refuses to RDFC-sign a language value object with @direction (fail-closed: the Data Integrity specs define no lossless canonicalization of direction and JSON-LD's default drops it, so earlier versions signed bytes that did not carry it). The W3C suite's four issuer-side direction cases (name, description, issuer.name, issuer.description) therefore fail, 57 -> 54 of 59. - Scope decision: direction-bearing credentials are unsupported under the RDFC suites in v1.2.0; the JCS suites are the supported path. Documented in docs/conformance.md and the CHANGELOG; tracked upstream as dataproofs-dotnet#40 (opt-in rdfDirection: i18n-datatype). - Gate: W3cVcdm2SuiteTests keys on five NAMED known failures (the suite's own assertion messages) plus a floor of 54. A failure outside the list fails the gate however many tests pass, so a regression cannot hide behind the count. Fails closed if the mocha failure section cannot be parsed; prints a notice when a known failure starts passing. ParseFailures is pinned by unit tests on a captured mocha excerpt, since the suite only runs on CI. - Focused coverage (RdfcIssueVerifyTests, 8 cases over the four positions): RDFC refuses @direction naming the cause; JCS secures and verifies the same document; language-only secures under RDFC; adding @direction after RDFC signing is Rejected with DATA_LOSS_DETECTION_ERROR. - DataIntegrityMechanism's InvalidOperationException now includes the substrate's specific cause ("uses @direction") one level down, not only the generic data-loss sentence. - JoseSubstrateRegressionTests: the KB-JWT and vp+jwt ES256K twins are first Accepted with matching nonce/challenge, then Rejected for freshness (holder_binding_replay for the VP; the key-binding code, not an issuer-signature code, for the KB-JWT). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 17 +++ docs/conformance.md | 48 +++++-- .../Internal/DataIntegrityMechanism.cs | 13 +- tasks/lessons.md | 12 ++ ...n-2026-10-29-pr21-review-rdfc-direction.md | 121 ++++++++++++++++++ .../W3cVcdm2SuiteTests.Parsing.cs | 95 ++++++++++++++ .../W3cVcdm2SuiteTests.cs | 80 ++++++++++-- .../JoseSubstrateRegressionTests.cs | 26 +++- .../RdfcIssueVerifyTests.cs | 90 +++++++++++++ 9 files changed, 466 insertions(+), 36 deletions(-) create mode 100644 tasks/plan-2026-10-29-pr21-review-rdfc-direction.md create mode 100644 tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.Parsing.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dc468c..f52f9ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,23 @@ matching `DidComm.Core` 1.5.0). FluentAssertions is the one deliberate exception `https://www.w3.org/ns/credentials/undefined-terms/v2` last in `@context` (VCDM 2.0 §5.2). That context is bundled offline, and the RDFC sample now does this. - IRIs must be well formed, absolute and normalized. + - **Language value objects with a base direction (`@direction`) are unsupported under the RDFC suites** + (`eddsa-rdfc-2022`, `ecdsa-rdfc-2019`, `bbs-2023`). This is an explicit scope decision for this + release. The Data Integrity specs define no lossless canonicalization of direction, and JSON-LD's + default conversion drops it, so earlier versions signed bytes that did not carry the direction (a + holder could flip `ltr`/`rtl` undetected). DataProofs 1.5.0 fails closed instead: RDFC issuance of + such a document throws `InvalidOperationException` naming `@direction`, and verification of one, from + any issuer, fails with `DATA_LOSS_DETECTION_ERROR`. Use the JCS suites (`eddsa-jcs-2022`, + `ecdsa-jcs-2019`), which sign the JSON as written, or omit `@direction`. Tracked upstream as + [dataproofs-dotnet#40](https://github.com/moisesja/dataproofs-dotnet/issues/40). + - **W3C VCDM 2.0 conformance: 57 → 54 of 59.** The four suite cases that issue `name`, `description`, + `issuer.name` and `issuer.description` with `@direction` now fail, because the conformance shim issues + with `eddsa-rdfc-2022`. The `type` term-resolution case, a documented gap, now passes for RDFC-secured + input. The gate no longer relies on a pass count alone: every failing suite test must be one of the + **named** known failures in `W3cVcdm2SuiteTests`, so a regression cannot hide behind the count. See + `docs/conformance.md`. + - The `InvalidOperationException` thrown when a document cannot be secured now includes the specific + cause from the substrate (for example "uses @direction"), not only the generic data-loss sentence. - **Issuance keeps its documented exception contract.** Since DataProofs 1.4.0, proof creation reports input problems with its own `ProofGenerationException`. `IIssuer.IssueAsync` and `IHolder.BindWithDataIntegrityAsync` now rethrow it as the documented `InvalidOperationException`, keeping diff --git a/docs/conformance.md b/docs/conformance.md index 0a09ac9..13577dd 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -9,24 +9,50 @@ The [`w3c/vc-data-model-2.0-test-suite`](https://github.com/w3c/vc-data-model-2. interoperability suite) is run against a thin ASP.NET shim (`tests/Credentials.Conformance.VcApi`) that exposes `POST /credentials/issue`, `/credentials/verify`, and `/presentations/verify` over the engine's `IIssuer`/`IVerifier`. The shim issues with `eddsa-rdfc-2022`; the suite injects the shim's own `did:key` -as the credential issuer, so issuance satisfies the engine's issuer-binding. `Credentials.Conformance.Tests` -boots the shim on loopback, runs the suite, and asserts a passing **baseline of 57** so the conformance -level cannot regress; the full run is `conformance.yml` (PR + nightly). +as the credential issuer, so issuance satisfies the engine's issuer-binding. The full run is +`conformance.yml` (PR + nightly). -**Current result: 57 / 59 passing.** The structural / issue / verify core passes, including the v1.0 -compliance hardening — identifier-URL validation (`id`, `issuer`, `credentialStatus.id`, -`credentialSchema.id`, `credentialSubject.id`, `refreshService.id`, `relatedResource.id`), -`refreshService.type`, `relatedResource` structure, and `name`/`description` §11.1 language value objects. -The 2 not-yet-passing tests are explicit and individually named: +### The gate + +`Credentials.Conformance.Tests` boots the shim on loopback, runs the suite, and passes only if **both** +hold: + +1. **Every failing suite test is a named known failure.** The harness parses each mocha failure and matches + it against `KnownFailures` in `W3cVcdm2SuiteTests`, keyed on the suite's own assertion message. A failure + outside that list fails the gate however many tests pass, so a regression cannot hide behind the count. +2. **The passing count is at least the floor** (59 suite tests minus the known failures). This is a sanity + check beside the list, so a run that executes nothing cannot pass with zero failures. + +When a known failure starts passing, the run prints a notice: remove the entry, raise the floor, and update +this page. Lowering the floor without naming the failure is not how a regression is accepted. + +### Current result: 54 / 59 passing + +The structural / issue / verify core passes, including the v1.0 compliance hardening — identifier-URL +validation (`id`, `issuer`, `credentialStatus.id`, `credentialSchema.id`, `credentialSubject.id`, +`refreshService.id`, `relatedResource.id`), `refreshService.type`, `relatedResource` structure, and +`name`/`description` §11.1 language value objects **without a base direction**. The 5 known failures: | Test | Why it does not pass | Status | |---|---|---| -| `type` MUST be one or more terms and/or absolute URL strings (rejecting a `type` that is well-formed but unmapped by `@context`) | Detecting a `type` string that is neither an absolute URL nor a term mapped by `@context` requires JSON-LD context expansion / term resolution, which the System.Text.Json-only engine does not perform (NFR-002). | Documented **non-goal** for v1.0 (STJ-only design). | +| `name`, `description`, `issuer.name`, `issuer.description` MUST accept a language value object as described in §11.1 Language and Base Direction — the four cases whose value object carries `@direction` (`{"@value": …, "@language": "en", "@direction": "ltr"}`). | The shim issues with `eddsa-rdfc-2022`, and since DataProofs 1.5.0 the RDFC suites **refuse to sign a document that uses `@direction`**. The Data Integrity specs (`vc-data-integrity`, `vc-di-eddsa`) define no lossless canonicalization of direction, and JSON-LD 1.1's default JSON-LD→RDF conversion drops it. Earlier versions "passed" these cases by silently signing bytes that did not carry the direction, so a holder could flip `ltr` to `rtl` without breaking the proof. DataProofs now fails closed instead. | **Scope decision for v1.2.0: direction-bearing credentials are unsupported under the RDFC suites** (`eddsa-rdfc-2022`, `ecdsa-rdfc-2019`, `bbs-2023`). Issuance throws `InvalidOperationException` naming `@direction`; verification of such a credential, from any issuer, fails with `DATA_LOSS_DETECTION_ERROR`. The supported path is the JCS suites (`eddsa-jcs-2022`, `ecdsa-jcs-2019`), which sign the JSON as written, or a language value object without `@direction`. Tracked upstream as [dataproofs-dotnet#40](https://github.com/moisesja/dataproofs-dotnet/issues/40) (an opt-in `rdfDirection: i18n-datatype` mode), with the interop caveat that its output would not verify on implementations using the JSON-LD default. Regression coverage: `RdfcIssueVerifyTests` (the four positions, both suites, and the post-signing `@direction` tamper that is now caught). | | `relatedResource` digest MUST match the retrieved resource | The engine validates `relatedResource` **structure** (objects; a unique URL `id`; at least one of `digestSRI`/`digestMultibase`) but does not yet **fetch the resource and verify the digest hash**. | Deferred to a focused follow-up (a verifier-side fetch+hash on an optional resolver hook), then this gap closes. | +Two notes on what the count does and does not say: + +- Each of the four direction cases is the first direction assertion in its mocha `it` block, so the + assertions after it in the same block (a multi-language value object; rejecting an extra property in a + value object) are **not reached** in the current run. They passed before this change and are covered by + the engine's own structural tests, but the suite no longer exercises them. +- The `type` term-resolution test ("`type` MUST be one or more terms and/or absolute URL strings"), a + documented gap up to v1.1.1, now **passes**: RDFC data-loss detection rejects a `type` that no + `@context` maps. That holds for RDFC-secured input only; the JCS and enveloping forms still accept a + well-formed-but-unmapped `type`, because an STJ-only engine does not perform JSON-LD term resolution + (NFR-002). + This is reported as a tracked baseline rather than a "fully conformant" claim: the engine passes the -structural / issue / verify core of the suite, and the 2 gaps above are explicit, named, and individually -addressable. Raising the baseline as the engine improves is expected; a drop below it fails CI. +structural / issue / verify core of the suite, and the gaps above are explicit, named, and individually +addressable. ## Interoperability vectors (`tests/Credentials.InteropTests`) diff --git a/src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs b/src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs index a41d289..4acff26 100644 --- a/src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs +++ b/src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs @@ -59,12 +59,15 @@ public async Task SecureAsync(SecureRequest request, Cancellation { // Keep the substrate exception behind the seam (NFR-005) and the roles' documented contract. Since // DataProofs 1.4.0, proof creation reports every input problem this way, including a document an - // RDFC suite cannot canonicalize without losing data (an undefined term, a malformed IRI). - // DataProofs' top-level message names the cause without quoting the document. Deeper - // InnerExceptions, such as the JSON-LD processor's, can quote context URLs or IRIs from the caller's - // own document. That is the caller's data, not a verification diagnostic (NFR-008 covers those). + // RDFC suite cannot canonicalize without losing data (an undefined term, a malformed IRI, a + // `@direction`). DataProofs' top-level message is generic; the specific cause is one level down + // (e.g. JsonLdDataLossException), and both name the cause without quoting the document, so the + // message includes that one level. Deeper InnerExceptions, such as the JSON-LD processor's, can + // quote context URLs or IRIs from the caller's own document. That is the caller's data, not a + // verification diagnostic (NFR-008 covers those). + var cause = ex.InnerException is { Message: { Length: > 0 } inner } ? " " + inner : string.Empty; throw new InvalidOperationException( - $"The document cannot be secured with the '{request.Cryptosuite}' cryptosuite: {ex.Message}", ex); + $"The document cannot be secured with the '{request.Cryptosuite}' cryptosuite: {ex.Message}{cause}", ex); } return SecureOutcome.ForDocument(secured); diff --git a/tasks/lessons.md b/tasks/lessons.md index ebb4de2..4a067f5 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -2,6 +2,18 @@ Patterns learned while building credentials-dotnet. Reviewed at session start. +## A count-based gate that drops after a dependency change is fixed by naming the failures, never by lowering the count (2026-10-29) + +DataProofs 1.5.0 made the W3C suite drop from 57 to 54, and my first proposal was "lower the baseline to 54 +and document it". The reviewer rejected that: a lower count hides any later regression of the same size. +What was wanted: an explicit support contract for the lost capability (RDFC credentials with `@direction` +are unsupported; JCS is the path), focused regression tests for the exact positions, accurate docs, and a +gate that **names** each accepted failure (keyed on the suite's own assertion messages) so any failure +outside that list fails CI regardless of the count. **Rule:** when a gate is a number and the number must +move, first ask what the number was standing in for. Replace it with the named set it summarized, keep the +number only as a sanity floor, and pin the parsing of the named set with its own tests, because the gate +usually runs only on CI. + ## Mutation-check every security regression test: a test can pass because it never triggers the attack (2026-10-29) The JsonSchema.Net 9 adversarial pass found a dialect-registry poisoning. I added the fix and a regression diff --git a/tasks/plan-2026-10-29-pr21-review-rdfc-direction.md b/tasks/plan-2026-10-29-pr21-review-rdfc-direction.md new file mode 100644 index 0000000..349cfaa --- /dev/null +++ b/tasks/plan-2026-10-29-pr21-review-rdfc-direction.md @@ -0,0 +1,121 @@ +# Plan — PR #21 review: RDFC `@direction` support contract, conformance gate, twin test + +Status: **APPROVED 2026-10-29 (D1 yes, D2 file, D3 recommended) and IMPLEMENTED** on +`fix/dataproofs-1.3.0-issue-20` (PR #21). DataProofs issue filed: +[dataproofs-dotnet#40](https://github.com/moisesja/dataproofs-dotnet/issues/40). See Review at the end. + +## Owner's intent + +Address [PR #21 review comment 6513682713](https://github.com/moisesja/credentials-dotnet/pull/21#issuecomment-6513682713): + +1. **Blocking.** The W3C gate fails (54 passing, baseline 57). Four failures are language value objects + with `@direction` in `name`, `description`, `issuer.name` and `issuer.description`; RDFC issuance now + refuses them. The reviewer's requirements, verbatim in spirit: + - do **not** restore the unsafe behaviour (DataProofs ≤ 1.4 signed identical bytes for `ltr`/`rtl`); + - resolve the intended support contract: a DataProofs fix adopted here, **or** an explicit scope + decision with the exact limitation and conformance expectations documented; + - **not** a bare lowering of the pass-count threshold, which would hide regressions; + - focused coverage for the four affected positions; + - update `docs/conformance.md` (still claims 57/59 and passing language-value cases) and the release notes. +2. **Non-blocking.** `JoseSubstrateRegressionTests.cs:441-452`: the KB-JWT and VP-JWT twins are only + checked with the wrong nonce/challenge. Assert each twin succeeds with matching expectations first, then + fails for the freshness reason, so a signature rejection can't satisfy the assertion by accident. + +## Facts established (verified, not assumed) + +- The suite's four cases are **issuer-side** `assert.doesNotReject(endpoints.issue(fixture))`, with + `{"@value": "…", "@language": "en", "@direction": "ltr"}` (`tests/4.06-names-and-descriptions.js`, + fixtures `*-language-direction-en-ok.json`). Each is the first direction assertion in its `it` block, so + the block's later assertions (multi-language value, extra-property rejection) are **no longer reached**. +- The shim issues with `eddsa-rdfc-2022` (`Credentials.Conformance.VcApi/Program.cs:50`). +- DataProofs 1.5.0 refuses `@direction` deliberately (`JsonLdLossAudit.cs:175`, changelog #36): JSON-LD + 1.1's default JSON-LD→RDF conversion drops direction, so the old canonicalizer produced the same signed + bytes for `ltr` and `rtl`; a holder could flip it undetected. +- **The Data Integrity specs define no lossless canonicalization of direction.** `vc-di-eddsa` and + `vc-data-integrity` contain zero occurrences of "direction", `rdfDirection` or `i18n`. JSON-LD 1.1 offers + `rdfDirection: "i18n-datatype"` (dotNetRDF 3.5.1 implements it: `JsonLdRdfDirection.I18NDatatype`), but + any implementation that enables it unilaterally produces signatures other RDFC verifiers cannot check. + So "secure RDFC direction support" is an interop decision for DataProofs and the specs, not a bug fix. +- JCS suites (`eddsa-jcs-2022`, `ecdsa-jcs-2019`) sign the JSON as written, so direction-bearing + credentials issue and verify there (probed in this session). JOSE, COSE and SD-JWT are unaffected. +- The `type` term-resolution test, a documented gap at 57/59, now **passes** because RDFC data-loss + detection rejects an unmapped `type`. That holds only for RDFC-secured input. + +## Decisions for the owner + +**D1. Scope (recommended): direction-bearing RDFC credentials are unsupported in v1.2.0.** +- `eddsa-rdfc-2022`, `ecdsa-rdfc-2019` and `bbs-2023` refuse to issue a document containing `@direction` + (`InvalidOperationException` naming the cause) and fail to verify one (`DATA_LOSS_DETECTION_ERROR`, + Rejected), including third-party RDFC credentials signed by implementations that dropped the direction. +- Supported path: the JCS suites, or omit `@direction` (language-only value objects work under RDFC). +- Alternative: hold #21 until DataProofs offers an opt-in `rdfDirection` mode. Even then the output is not + interoperable with verifiers using the JSON-LD default, so I would still make the scope decision now. + +**D2. File a public DataProofs issue** (not a vulnerability): "RDFC: opt-in lossless `@direction` via +JSON-LD 1.1 `rdfDirection: i18n-datatype`", stating the interop caveat and that the DI specs are silent. +Cross-repo, so your call. + +**D3. Gate design: named known failures + a count floor** (addresses "lowering hides the regression"). +- `W3cVcdm2SuiteTests` parses the mocha failure section and keys each failure on the suite's own assertion + message (stable strings such as "Failed to accept a VC using `name` with language & direction expressed."). +- Pass requires **both**: every failure is in the documented known-failure list, **and** the passing count + is ≥ 54. A failure outside the list fails the gate with its message, however many tests pass. A listed + failure that starts passing prints a loud notice to remove it and raise the floor (not a failure: an + upstream suite change must not break CI by itself). +- The known-failure list has five entries: the four direction cases and `relatedResource` digest. + +## Changes + +- [ ] **`tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.cs`** — D3: `KnownFailures` list, failure + parsing, both assertions; doc comment rewritten (currently says 57/59 and names the wrong gaps). +- [ ] **`docs/conformance.md`** — 54/59; the gap table gains the direction row (the four positions, the + reason, the security meaning, the JCS alternative, the DataProofs issue, and the note that the later + assertions in those four `it` blocks are not reached); `relatedResource` row unchanged; `type` row moves + to "passes, via RDFC data-loss detection, for RDFC-secured input only"; the gate description explains + the named-failure design. +- [ ] **`tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs`** — focused coverage, a `[Theory]` over the + four positions (`name`, `description`, `issuer.name`, `issuer.description`): + 1. RDFC issuance of a direction-bearing value object throws `InvalidOperationException` whose message + names `@direction`, with the DataProofs exception as `InnerException`. + 2. The same document issues and verifies **Accepted** under `eddsa-jcs-2022` (the supported path). + 3. A language-only value object in the same position issues and verifies under RDFC (the limitation is + direction, not language value objects). + 4. **Security meaning:** issue under RDFC with language only, then add `@direction` after signing → + Rejected with `DATA_LOSS_DETECTION_ERROR`. Under DataProofs ≤ 1.4 this tampering verified Accepted. +- [ ] **`src/Credentials.Core/Securing/Internal/DataIntegrityMechanism.cs`** — the wrap message appends + the first inner exception's message, so callers (and the shim's HTTP 400 body) see "uses @direction…" + instead of only the generic "cannot be RDFC-canonicalized without losing data". One level only; + DataProofs states its data-loss messages name the cause without quoting the input. Test 1 pins it. +- [ ] **`CHANGELOG.md`** v1.2.0 — the RDFC strictness bullet states the exact limitation, the JCS + alternative, and the conformance effect (57 → 54, named). +- [ ] **`tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs`** — the + KB-JWT twin: Accepted with the matching nonce, then Rejected with a stale nonce **and** the holder-binding + diagnostic names the nonce/freshness code (not `sdjwt_key_binding_invalid` for the signature). The VP-JWT + twin: Accepted with the matching challenge, then Rejected with `holder_binding_replay`. +- [ ] File the DataProofs issue (D2) and reference it from the doc and CHANGELOG. +- [ ] Verification: `-warnaserror` build; full suite; the W3C job on the PR (I cannot run the suite locally; + cloning + `npm install` of external code was blocked). Then reply to the review comment point by point. + +## Review (results) + +- All checklist items done. Build `-warnaserror`: 0 warnings / 0 errors. +- **Gate:** `W3cVcdm2SuiteTests` now keys on five named known failures plus a floor of 54, fails closed if + the mocha failure section cannot be parsed, and prints a notice when a known failure starts passing. + `ParseFailures` is pinned by four unit tests against a captured mocha excerpt (they run locally; the suite + itself only runs on CI). +- **Focused coverage:** `RdfcIssueVerifyTests` gained two theories over the four suite positions (8 cases): + RDFC refuses `@direction` with a message naming it, JCS secures the same document, language-only secures + under RDFC, and adding `@direction` after RDFC signing is Rejected with `DATA_LOSS_DETECTION_ERROR`. +- **Twin test:** both twins are Accepted with matching expectations before the stale case; the stale VP + asserts `holder_binding_replay`, the stale KB-JWT asserts the key-binding code and not an issuer-signature + code. Credentials maps every KB-JWT problem to one code, so the Accepted assertion is what excludes a + signature failure. A distinct KB-JWT freshness code would be a small follow-up, not part of this PR. +- **Docs:** `docs/conformance.md` rewritten (54/59, the gate design, the direction row, the unreached + assertions, the `type` case now passing for RDFC input); CHANGELOG updated. +- The W3C job's verdict on the pushed head is recorded in the PR thread. + +## Explicitly not changing + +- The shim stays on `eddsa-rdfc-2022`. Switching it to JCS would make the four cases pass and the `type` + case fail again, changing what the suite measures to get a green run. +- No DataProofs guard is weakened and no `@direction` is stripped before signing. diff --git a/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.Parsing.cs b/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.Parsing.cs new file mode 100644 index 0000000..f985f1b --- /dev/null +++ b/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.Parsing.cs @@ -0,0 +1,95 @@ +using FluentAssertions; +using Xunit; + +namespace Credentials.Conformance.Tests; + +/// +/// Pins the mocha spec-reporter parsing the gate relies on. The suite itself only runs on CI, so this +/// runs everywhere: a parser that silently found no failures would otherwise let an unknown failure pass. +/// +public sealed partial class W3cVcdm2SuiteTests +{ + // A trimmed excerpt of a real run (2026-10-30): the tree lists failing tests by number, then the + // summary repeats each failure as a numbered block ending in the assertion line. + private const string MochaExcerpt = """ + Names and Descriptions + credentials-dotnet + 1) If present, the value of the name property MUST be a string or a language value object as described in 11.1 Language and Base Direction. + 2) If present, the value of the description property MUST be a string or a language value object as described in 11.1 Language and Base Direction. + + Related Resources + credentials-dotnet + ✔ The value of the relatedResource property MUST be one or more objects. (41ms) + 3) If the digest provided by the issuer does not match the digest computed for the retrieved resource, the conforming verifier implementation MUST produce an error. + + + 54 passing (3s) + 3 failing + + 1) Names and Descriptions + credentials-dotnet + If present, the value of the name property MUST be a string or a language value object as described in 11.1 Language and Base Direction.: + + AssertionError [ERR_ASSERTION]: Got unwanted rejection: Failed to accept a VC using `name` with language & direction expressed. + at async Context. (tests/4.06-names-and-descriptions.js:57:9) + + 2) Names and Descriptions + credentials-dotnet + If present, the value of the description property MUST be a string or a language value object as described in 11.1 Language and Base Direction.: + + AssertionError [ERR_ASSERTION]: Got unwanted rejection: Failed to accept a VC using `description` with language & direction expressed. + at async Context. (tests/4.06-names-and-descriptions.js:82:9) + + 3) Related Resources + credentials-dotnet + If the digest provided by the issuer does not match the digest computed for the retrieved resource, the conforming verifier implementation MUST produce an error.: + + AssertionError [ERR_ASSERTION]: Missing expected rejection: Failed to reject a VC with a relatedResource with wrong digest. + at async Context. (tests/4.10-related-resources.js:60:9) + + """; + + [Fact] + public void Failure_parsing_yields_one_assertion_message_per_failure() + { + var failures = ParseFailures(MochaExcerpt); + + failures.Should().Equal( + "Got unwanted rejection: Failed to accept a VC using `name` with language & direction expressed.", + "Got unwanted rejection: Failed to accept a VC using `description` with language & direction expressed.", + "Missing expected rejection: Failed to reject a VC with a relatedResource with wrong digest."); + failures.Should().HaveCount(ParseCount(MochaExcerpt, "failing")); + ParseCount(MochaExcerpt, "passing").Should().Be(54); + } + + [Fact] + public void Failure_parsing_matches_the_known_failure_list_by_message() + { + var failures = ParseFailures(MochaExcerpt); + + failures.Should().OnlyContain(f => KnownFailures.Any(k => f.Contains(k, StringComparison.Ordinal))); + } + + [Fact] + public void Failure_parsing_keeps_a_failure_without_an_assertion_line() + { + // A timeout or thrown TypeError has no "AssertionError:" line; it must still count as an (unknown) failure. + const string output = """ + 1 passing (1s) + 1 failing + + 1) Some suite + some test: + Error: Timeout of 15000ms exceeded. For async tests and hooks, ensure "done()" is called. + + """; + + ParseFailures(output).Should().Equal("Timeout of 15000ms exceeded. For async tests and hooks, ensure \"done()\" is called."); + } + + [Fact] + public void Failure_parsing_finds_nothing_when_the_run_had_no_failures() + { + ParseFailures(" 59 passing (3s)\n").Should().BeEmpty(); + } +} diff --git a/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.cs b/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.cs index 992faa8..df39651 100644 --- a/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.cs +++ b/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.cs @@ -9,19 +9,34 @@ namespace Credentials.Conformance.Tests; /// /// Runs the W3C VCDM 2.0 interoperability test suite (the Node suite) against the ASP.NET shim on -/// loopback and asserts a conformance baseline. The engine passes the structural/issue/verify core of -/// the suite. The two not-yet-passing tests exercise capabilities deferred from this engine: full JSON-LD -/// term mapping (detecting a well-formed-but-unmapped type, which an STJ-only engine cannot resolve -/// without context expansion) and relatedResource digest hash-verification (a verifier-side -/// fetch+hash deferred to a follow-up). See docs/conformance.md. The baseline guards against -/// regressions below the established pass count without claiming full conformance. +/// loopback and gates on two conditions: every failing suite test must be one of the named known +/// failures below, and the passing count must not drop below the floor. Naming the failures means a new +/// regression cannot hide behind the count: a failure outside the list fails the gate however many tests +/// pass. The engine passes the structural/issue/verify core of the suite; the known failures are documented +/// one by one, with their reasons, in docs/conformance.md. This is a tracked baseline, not a +/// full-conformance claim. /// public sealed partial class W3cVcdm2SuiteTests { - // The number of suite tests the engine currently passes (57 of 59; the 2 remaining are documented in - // docs/conformance.md). Raising this when the engine improves is expected; a drop below it is a - // regression and fails the gate. - private const int PassingBaseline = 57; + // The suite tests the engine does not pass (54 of 59 pass), keyed on the suite's own assertion message, + // which is the stable identifier the suite prints for a failed expectation. Each entry is explained in + // docs/conformance.md. When one starts passing, the run prints a notice: remove the entry, raise the + // floor, and update the doc. + private static readonly string[] KnownFailures = + [ + // RDFC suites refuse `@direction` since DataProofs 1.5.0 (fail-closed data-loss detection; the Data + // Integrity specs define no lossless canonicalization of direction). The shim issues eddsa-rdfc-2022. + "Failed to accept a VC using `name` with language & direction expressed.", + "Failed to accept a VC using `description` with language & direction expressed.", + "Failed to accept a VC using `issuer.name` with language & direction expressed.", + "Failed to accept a VC using `issuer.description` with language & direction expressed.", + // relatedResource digest verification (fetch + hash) is a deferred follow-up. + "Failed to reject a VC with a relatedResource with wrong digest.", + ]; + + // The passing count the engine must not drop below: 59 suite tests minus the 5 known failures. A + // sanity floor beside the named list, so a run that executes nothing cannot pass with zero failures. + private const int PassingFloor = 54; [SkippableFact] [Trait("Category", "Conformance")] @@ -43,10 +58,27 @@ public async Task W3C_VCDM2_suite_meets_the_conformance_baseline() var (output, exitCode) = await RunSuiteAsync(suiteDir!, baseUrl, issuerDid, TimeSpan.FromMinutes(8)); var passing = ParseCount(output, "passing"); var failing = ParseCount(output, "failing"); - Console.WriteLine($"W3C VCDM 2.0 suite: {passing} passing, {failing} failing (mocha exit {exitCode}, baseline {PassingBaseline})."); + var failures = ParseFailures(output); + Console.WriteLine($"W3C VCDM 2.0 suite: {passing} passing, {failing} failing (mocha exit {exitCode}, floor {PassingFloor}, {KnownFailures.Length} known failures)."); - passing.Should().BeGreaterThanOrEqualTo(PassingBaseline, - $"the engine must not regress below {PassingBaseline} passing W3C suite tests. Output:\n{output}"); + var unexpected = failures.Where(f => !KnownFailures.Any(k => f.Contains(k, StringComparison.Ordinal))).ToList(); + var nowPassing = KnownFailures.Where(k => !failures.Any(f => f.Contains(k, StringComparison.Ordinal))).ToList(); + if (nowPassing.Count > 0) + { + Console.WriteLine("NOTICE: known failures that now pass. Remove them from KnownFailures, raise PassingFloor, and update docs/conformance.md:\n " + + string.Join("\n ", nowPassing)); + } + + // Fail closed if the failure section could not be parsed: an unparsed failure is an unknown one. + if (failing > 0) + { + failures.Should().HaveCount(failing, $"every mocha failure must be parsed so it can be checked against the known list. Output:\n{output}"); + } + + unexpected.Should().BeEmpty( + $"every failing W3C suite test must be a documented known failure (docs/conformance.md). Unexpected:\n {string.Join("\n ", unexpected)}\nOutput:\n{output}"); + passing.Should().BeGreaterThanOrEqualTo(PassingFloor, + $"the engine must not regress below {PassingFloor} passing W3C suite tests. Output:\n{output}"); } finally { @@ -136,6 +168,28 @@ private static int ParseCount(string output, string label) return m.Success ? int.Parse(m.Groups[1].Value) : 0; } + // The spec reporter ends with "N failing" followed by one numbered block per failure: the suite/test + // title lines, then the error line ("AssertionError [ERR_ASSERTION]: "). The message is the + // suite's own stable identifier for the failed expectation; it is what KnownFailures is keyed on. + internal static List ParseFailures(string output) + { + var failures = new List(); + var summary = Regex.Match(output, @"(?m)^\s*\d+\s+failing\s*$"); + if (!summary.Success) + { + return failures; + } + + var section = output[(summary.Index + summary.Length)..]; + foreach (var block in Regex.Split(section, @"(?m)^\s*\d+\)\s").Skip(1)) + { + var error = Regex.Match(block, @"(?m)^\s*\w*Error(?:\s\[[^\]]*\])?:\s*(.+?)\s*$"); + failures.Add(error.Success ? error.Groups[1].Value : block.Trim().Split('\n')[0].Trim()); + } + + return failures; + } + private static string LocateShimDll() { var dir = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs b/tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs index f0b4876..910cc23 100644 --- a/tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs +++ b/tests/Credentials.Extensions.DependencyInjection.Tests/JoseSubstrateRegressionTests.cs @@ -438,18 +438,30 @@ public async Task Es256k_signature_twins_verify_but_never_defeat_freshness_or_di (await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(string.Join('~', issuerTwin)), kbOptions)) .Decision.Should().Be(VerificationDecision.Rejected, "sd_hash covers the issuer JWT, signature included"); + // The KB-JWT twin: first prove the twin's signature verifies and the binding holds (Accepted with the + // matching nonce), so the stale-nonce rejection below can only be the freshness check. Credentials maps + // every KB-JWT problem to sdjwt_key_binding_invalid, so the Accepted assertion is what excludes a + // signature failure; the code assertion excludes an issuer-signature failure. var kbTwin = (string[])parts.Clone(); kbTwin[^1] = MalleateEs256k(parts[^1]); - (await verifier.VerifyCredentialAsync(Encoding.UTF8.GetBytes(string.Join('~', kbTwin)), kbOptions with { ExpectedNonce = "a-fresh-nonce" })) - .Decision.Should().Be(VerificationDecision.Rejected, "the KB-JWT twin still carries the old nonce"); - - // vp+jwt: a twin still carries the challenge it was bound to. + var kbTwinBytes = Encoding.UTF8.GetBytes(string.Join('~', kbTwin)); + (await verifier.VerifyCredentialAsync(kbTwinBytes, kbOptions)) + .Decision.Should().Be(VerificationDecision.Accepted, "the KB-JWT twin is a valid signature over the same nonce and audience"); + var staleKb = await verifier.VerifyCredentialAsync(kbTwinBytes, kbOptions with { ExpectedNonce = "a-fresh-nonce" }); + staleKb.Decision.Should().Be(VerificationDecision.Rejected, "the KB-JWT twin still carries the old nonce"); + staleKb.Check(CheckKinds.Proof)!.Diagnostics.Select(d => d.Code).Should() + .Contain("sdjwt_key_binding_invalid").And.NotContain("PROOF_VERIFICATION_ERROR"); + + // vp+jwt: the same, with the distinct freshness code. var vpJwt = await holderRole.BindWithJoseEnvelopeAsync( holderRole.BuildPresentation(new VpAssemblyRequest { Holder = holder.Did, Credentials = [ContainedCredential.Enveloped(jose.CompactJws!)] }), new VpBindingRequest { HolderSigner = holder.Signer, VerificationMethod = holder.AuthVm, Challenge = Challenge, Domain = Domain }); - (await verifier.VerifyPresentationAsync(Encoding.UTF8.GetBytes(MalleateEs256k(vpJwt)), - new PresentationVerificationOptions { ExpectedChallenge = "a-fresh-challenge", ExpectedDomain = Domain })) - .Decision.Should().Be(VerificationDecision.Rejected, "the vp+jwt twin still carries the old challenge"); + var vpTwinBytes = Encoding.UTF8.GetBytes(MalleateEs256k(vpJwt)); + (await verifier.VerifyPresentationAsync(vpTwinBytes, new PresentationVerificationOptions { ExpectedChallenge = Challenge, ExpectedDomain = Domain })) + .Decision.Should().Be(VerificationDecision.Accepted, "the vp+jwt twin is a valid holder signature over the same challenge and domain"); + var staleVp = await verifier.VerifyPresentationAsync(vpTwinBytes, new PresentationVerificationOptions { ExpectedChallenge = "a-fresh-challenge", ExpectedDomain = Domain }); + staleVp.Decision.Should().Be(VerificationDecision.Rejected, "the vp+jwt twin still carries the old challenge"); + staleVp.Check(CheckKinds.HolderBinding)!.Diagnostics.Select(d => d.Code).Should().Contain("holder_binding_replay"); } // A CredentialFormatException (refusing to parse) is an acceptable outcome; any other exception escaping diff --git a/tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs b/tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs index 92c9908..61cb1f6 100644 --- a/tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs +++ b/tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs @@ -78,6 +78,96 @@ private static async Task IssueRdfcAsync(IIssuer issuer, Crede VerificationMethod = $"did:key:{keyPair.MultibasePublicKey}#{keyPair.MultibasePublicKey}", }); + // The four positions the W3C VCDM 2.0 suite exercises for §11.1 language value objects (its + // `*-language-direction-en-ok.json` fixtures): a JSON-pointer-like path into the credential object. + public static TheoryData LanguageValuePositions => new() { "name", "description", "issuer.name", "issuer.description" }; + + private static Credential WithLanguageValue(string issuerDid, string position, bool withDirection) + { + var value = new JsonObject { ["@value"] = "Example Credential", ["@language"] = "en" }; + if (withDirection) + { + value["@direction"] = "ltr"; + } + + var document = JsonNode.Parse(Credential.Build() + .WithIssuer(issuerDid) + .AddSubject(new JsonObject { ["id"] = "did:example:subject" }) + .Seal().ToUtf8().Span)!.AsObject(); + if (position.StartsWith("issuer.", StringComparison.Ordinal)) + { + // The suite's issuer fixtures use the object form: {"id": did, "name": {...}}. + document["issuer"] = new JsonObject { ["id"] = issuerDid, [position["issuer.".Length..]] = value }; + } + else + { + document[position] = value; + } + + return Credential.Parse(System.Text.Encoding.UTF8.GetBytes(document.ToJsonString())); + } + + private static DataIntegrityIssuanceRequest Issuance(string cryptosuite, KeyPair keyPair) => new() + { + Cryptosuite = cryptosuite, + Signer = new KeyPairSigner(keyPair, Crypto), + VerificationMethod = $"did:key:{keyPair.MultibasePublicKey}#{keyPair.MultibasePublicKey}", + }; + + [Theory] + [MemberData(nameof(LanguageValuePositions))] + [FrTag("FR-011")] + [FrTag("FR-040")] + public async Task Rdfc_refuses_a_language_value_with_direction_in_every_suite_position_and_jcs_secures_it(string position) + { + // Scope decision (v1.2.0, docs/conformance.md): the RDFC suites cannot carry @direction losslessly + // (DataProofs >= 1.5.0 fails closed), so issuance refuses it, naming the cause. The JCS suites sign the + // JSON as written and are the supported path. + using var provider = BuildProvider(); + var issuer = provider.GetRequiredService(); + var verifier = provider.GetRequiredService(); + var keyPair = KeyGen.Generate(KeyType.Ed25519); + var did = $"did:key:{keyPair.MultibasePublicKey}"; + + var withDirection = WithLanguageValue(did, position, withDirection: true); + var rdfc = () => issuer.IssueAsync(withDirection, Issuance("eddsa-rdfc-2022", keyPair)); + (await rdfc.Should().ThrowExactlyAsync()) + .WithMessage("*eddsa-rdfc-2022*@direction*") + .WithInnerException(); + + var jcs = await issuer.IssueAsync(withDirection, Issuance("eddsa-jcs-2022", keyPair)); + (await verifier.VerifyCredentialAsync(jcs.Credential)).Decision.Should().Be(VerificationDecision.Accepted, position); + + // The limitation is the direction, not the language value object: language-only secures under RDFC. + var languageOnly = await issuer.IssueAsync(WithLanguageValue(did, position, withDirection: false), Issuance("eddsa-rdfc-2022", keyPair)); + (await verifier.VerifyCredentialAsync(languageOnly.Credential)).Decision.Should().Be(VerificationDecision.Accepted, position); + } + + [Theory] + [MemberData(nameof(LanguageValuePositions))] + [FrTag("FR-040")] + public async Task Adding_a_direction_after_rdfc_signing_is_detected(string position) + { + // The security meaning of refusing @direction: under DataProofs <= 1.4 the signed bytes did not carry + // the direction, so adding or flipping it after signing still verified. It is now a definitive failure. + using var provider = BuildProvider(); + var issuer = provider.GetRequiredService(); + var verifier = provider.GetRequiredService(); + var keyPair = KeyGen.Generate(KeyType.Ed25519); + var did = $"did:key:{keyPair.MultibasePublicKey}"; + + var issued = await issuer.IssueAsync(WithLanguageValue(did, position, withDirection: false), Issuance("eddsa-rdfc-2022", keyPair)); + var tampered = JsonNode.Parse(issued.Credential.ToUtf8().Span)!.AsObject(); + var holder = position.StartsWith("issuer.", StringComparison.Ordinal) ? tampered["issuer"]!.AsObject() : tampered; + holder[position.Split('.')[^1]]!.AsObject()["@direction"] = "rtl"; + + var result = await verifier.VerifyCredentialAsync(System.Text.Encoding.UTF8.GetBytes(tampered.ToJsonString())); + result.Decision.Should().Be(VerificationDecision.Rejected, position); + var proof = result.Check(CheckKinds.Proof)!; + proof.Status.Should().Be(CheckStatus.Failed); + proof.Diagnostics.Should().Contain(d => d.Code == "DATA_LOSS_DETECTION_ERROR"); + } + [Fact] [FrTag("FR-011")] public async Task An_undefined_term_cannot_be_secured_and_the_failure_keeps_the_issuer_contract() From 460c675c0cab5e1abf7134023acda63397600368 Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Mon, 2 Nov 2026 21:14:33 -0500 Subject: [PATCH 09/10] chore(deps): DataProofsDotnet 1.5.0 -> 2.0.0, JsonSchema.Net 9.6.0 (#20) DataProofs 2.0.0 is a major upstream release; Credentials is rebuilt against it (the RDFC suite constructors changed shape: source-compatible, binary-breaking). No Credentials public API change. Owner decision: nothing issued by an earlier Credentials exists in the wild, so no compatibility shims. What 2.0.0 changes here: - RDFC now signs a language value's @direction (dataproofs #40; rdfDirection: i18n-datatype, the reference implementations' representation). Last round's scope decision is reversed: the direction tests now assert issue+verify under RDFC and JCS, and that flipping or dropping the direction after signing fails with PROOF_VERIFICATION_ERROR; a number with @direction is still DATA_LOSS. - The W3C gate drops the four direction cases from KnownFailures (1 known failure left: relatedResource digest), floor 54 -> 58; docs/conformance.md says 58/59. The parser test now shows the old direction messages as unknown failures. - bbs-2023 follows the reference algorithms. Two new refusals surfaced by the suite: a reveal that discloses no non-mandatory claim (NetCrypto's BBS provider limit) and a reveal pointer that matches nothing (1.5.0 ignored it). Bbs2023Deriver now carries the substrate's cause in its CredentialFormatException message; IBbsDeriver documents both; two tests pin them; four tests reveal one non-mandatory claim. - Inherited security fix GHSA-6fxh-mc39-94vc (proof members bound to the signature under RDFC/bbs) and the RDFC canonicalization hardening; RDFC bytes change for tied-blank-node documents. CHANGELOG states all of it. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 73 +++++++--- Directory.Packages.props | 19 ++- docs/conformance.md | 24 ++-- src/Credentials.Rdfc/IBbsDeriver.cs | 8 +- .../Internal/Bbs2023Deriver.cs | 5 +- tasks/lessons.md | 9 ++ tasks/plan-2026-11-02-dataproofs-2.0.0.md | 128 ++++++++++++++++++ .../W3cVcdm2SuiteTests.Parsing.cs | 10 +- .../W3cVcdm2SuiteTests.cs | 15 +- .../Bbs2023SelectiveDisclosureTests.cs | 45 ++++-- .../RdfcIssueVerifyTests.cs | 60 ++++---- 11 files changed, 310 insertions(+), 86 deletions(-) create mode 100644 tasks/plan-2026-11-02-dataproofs-2.0.0.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f52f9ea..ebe6865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ All notable changes to `credentials-dotnet` are documented here. The format is b ## v1.2.0 - Unreleased Every dependency moves to its latest stable release, so Credentials is built against the same graph the -rest of the stack resolves (DataProofs 1.5.0, NetDid 4.0.0, NetCrypto 1.7.1, Microsoft.Extensions 10.0.14, -matching `DidComm.Core` 1.5.0). FluentAssertions is the one deliberate exception. +rest of the stack resolves (DataProofs 2.0.0, NetDid 4.0.0, NetCrypto 1.7.1, Microsoft.Extensions 10.0.14, +matching `DidComm.Core` 1.5.0). FluentAssertions is the one deliberate exception. Nothing issued by an +earlier Credentials exists in the wild (owner decision), so where DataProofs 2.0.0 changes signed bytes +or proof formats, this release carries no compatibility shims or migration steps. ### Fixed @@ -20,7 +22,7 @@ matching `DidComm.Core` 1.5.0). FluentAssertions is the one deliberate exception - Any application whose graph resolved Jose ≥ 1.3.0 failed at runtime on all four JOSE signing paths: `vc+jwt` issuance, SD-JWT VC issuance, the holder's SD-JWT Key Binding JWT, and the holder's `vp+jwt`. Every application that also references `DidComm.Core` ≥ 1.4.1 resolves such a Jose. - - This release is built against DataProofs 1.5.0. The signed bytes are unchanged: the default + - This release is built against DataProofs 2.0.0. The JOSE signed bytes are unchanged: the default `JwsKidPlacement.Auto` keeps the `kid` in the integrity-protected header for the JWT, SD-JWT and KB-JWT media types, and a regression test pins this for every token Credentials emits. - DataProofs states that it does not guarantee binary compatibility across minor versions, so each move @@ -28,6 +30,20 @@ matching `DidComm.Core` 1.5.0). FluentAssertions is the one deliberate exception ### Security +- **Inherited from DataProofs 2.0.0: the proof members a verifier acts on are bound to the signature** + ([GHSA-6fxh-mc39-94vc](https://github.com/moisesja/dataproofs-dotnet/security/advisories/GHSA-6fxh-mc39-94vc)). + An RDFC or bbs-2023 signature covers the RDF a proof expands to, not its JSON, and `@context` is not + signed, so with DataProofs ≤ 1.5.0 anyone holding a proof could respell `expires`, `challenge`, + `domain` or `proofPurpose` through a context and have the verifier read a value the issuer never signed. + The RDFC and bbs-2023 suites now verify only if the members they read (`id`, `type`, `cryptosuite`, + `created`, `expires`, `verificationMethod`, `proofPurpose`, `challenge`, `domain`, `nonce`, + `previousProof`) are what was signed; a renamed-away or never-signed member fails with + `PROOF_VERIFICATION_ERROR`. Some spec-legal but unverifiable proof shapes are newly refused (a context + mapping a modeled member to another predicate, a proof without exactly one `rdf:type`, a compact-IRI + `verificationMethod`, a non-canonical `proofPurpose` spelling). The JCS suites, JOSE, COSE and SD-JWT + sign the JSON itself and were never affected. DataProofs also hardened RDFC canonicalization against + hostile blank-node graphs (a stack overflow that ended the process; permutation blow-ups) with a work + budget that refuses pathological input in about 50 ms. - **Inherited from DataProofs 1.5.0: RDFC proofs now fail closed on JSON-LD data loss.** Before, whatever RDF canonicalization silently dropped or respelled was not signed, so a holder could change it without breaking an `eddsa-rdfc-2022` / `ecdsa-rdfc-2019` / `bbs-2023` proof. That included every custom claim of @@ -50,10 +66,10 @@ matching `DidComm.Core` 1.5.0). FluentAssertions is the one deliberate exception | Package | From | To | |---|---|---| - | `DataProofsDotnet.Core` / `.Jose` / `.Cose` / `.Rdfc` / `.Extensions.DependencyInjection` | 1.1.0 | **1.5.0** | + | `DataProofsDotnet.Core` / `.Jose` / `.Cose` / `.Rdfc` / `.Extensions.DependencyInjection` | 1.1.0 | **2.0.0** | | `NetDid.Core` / `.Method.Key` / `.Extensions.DependencyInjection` | 2.3.0 | **4.0.0** | | `NetCrypto` | 1.2.0 | **1.7.1** | - | `JsonSchema.Net` | 7.3.2 | **9.5.0** | + | `JsonSchema.Net` | 7.3.2 | **9.6.0** | | `Microsoft.Extensions.*` | 10.0.8 | **10.0.14** (required: NetDid 4.0.0 needs at least this) | | `Microsoft.SourceLink.GitHub` (build-only) | 10.0.300 | **10.0.403** | | `AngleSharp` pin (`Credentials.Rdfc`) | 1.5.2 | **removed** (`DataProofsDotnet.Rdfc` itself requires the patched ≥ 1.6.0) | @@ -63,8 +79,9 @@ matching `DidComm.Core` 1.5.0). FluentAssertions is the one deliberate exception ApiCompat tool 10.0.403. **FluentAssertions stays at 7.0.0**, the last release under the free Apache-2.0 licence. It is test-only. - **Applications pinned to older DataProofs (Jose < 1.5.0) or NetDid (< 4.0.0) should stay on Credentials - 1.1.1.** Upgrading raises those floors. + **Applications pinned to older DataProofs (< 2.0.0) or NetDid (< 4.0.0) should stay on Credentials + 1.1.1.** Upgrading raises those floors. DataProofs 2.0.0's RDFC suite constructors changed shape, so + this release is rebuilt against it (an assembly built against 1.5.0 throws `MissingMethodException`). - **RDFC issuance and verification are stricter** (from DataProofs 1.5.0, above). - **Credentials issued earlier with RDFC over undefined terms no longer verify**, including ones Credentials ≤ 1.1.1 issued. @@ -72,20 +89,32 @@ matching `DidComm.Core` 1.5.0). FluentAssertions is the one deliberate exception `https://www.w3.org/ns/credentials/undefined-terms/v2` last in `@context` (VCDM 2.0 §5.2). That context is bundled offline, and the RDFC sample now does this. - IRIs must be well formed, absolute and normalized. - - **Language value objects with a base direction (`@direction`) are unsupported under the RDFC suites** - (`eddsa-rdfc-2022`, `ecdsa-rdfc-2019`, `bbs-2023`). This is an explicit scope decision for this - release. The Data Integrity specs define no lossless canonicalization of direction, and JSON-LD's - default conversion drops it, so earlier versions signed bytes that did not carry the direction (a - holder could flip `ltr`/`rtl` undetected). DataProofs 1.5.0 fails closed instead: RDFC issuance of - such a document throws `InvalidOperationException` naming `@direction`, and verification of one, from - any issuer, fails with `DATA_LOSS_DETECTION_ERROR`. Use the JCS suites (`eddsa-jcs-2022`, - `ecdsa-jcs-2019`), which sign the JSON as written, or omit `@direction`. Tracked upstream as - [dataproofs-dotnet#40](https://github.com/moisesja/dataproofs-dotnet/issues/40). - - **W3C VCDM 2.0 conformance: 57 → 54 of 59.** The four suite cases that issue `name`, `description`, - `issuer.name` and `issuer.description` with `@direction` now fail, because the conformance shim issues - with `eddsa-rdfc-2022`. The `type` term-resolution case, a documented gap, now passes for RDFC-secured - input. The gate no longer relies on a pass count alone: every failing suite test must be one of the - **named** known failures in `W3cVcdm2SuiteTests`, so a regression cannot hide behind the count. See + - **Language value objects with a base direction (`@direction`) are signed under the RDFC suites** + (from DataProofs 2.0.0, resolving + [dataproofs-dotnet#40](https://github.com/moisesja/dataproofs-dotnet/issues/40)). RDFC canonicalizes + with JSON-LD 1.1 `rdfDirection: i18n-datatype`, the representation the reference Data Integrity + implementations sign with, so the direction is inside the signature: flipping `ltr`/`rtl` or dropping + the direction after signing fails the proof with `PROOF_VERIFICATION_ERROR`. Earlier versions signed + `ltr` and `rtl` as the same bytes, and DataProofs 1.5.0 refused `@direction` outright. A number or + boolean with `@direction` is still refused as data loss. Tests cover the four W3C positions (`name`, + `description`, `issuer.name`, `issuer.description`) under both RDFC and JCS. + - **RDFC signed bytes changed for some documents** (DataProofs 2.0.0 corrected tied-blank-node labelling + and Unicode code-point ordering to match `rdf-canonize`). Ordinary credentials without tied blank + nodes are unaffected; a document with two same-looking unnamed objects now canonicalizes to the + reference implementation's bytes. + - **bbs-2023 follows the reference algorithms** (DataProofs 2.0.0): proofs now interoperate with the + reference implementation, and the W3C `derivedRevealDocument.json` vector verifies. Base and derived + proofs from earlier versions are not compatible. New limitation: a derivation must reveal at least one + non-mandatory claim (NetCrypto's BBS provider cannot prove a mandatory-only disclosure); `IBbsDeriver` + refuses such a request with `CredentialFormatException`, and its message now carries the substrate's + specific cause (an invalid pointer, a malformed base, or the mandatory-only case) instead of a generic + sentence. A reveal pointer that matches nothing in the document is now refused too (the reference + selection algorithm's behaviour); 1.5.0 silently revealed nothing for it. Documents with `@list` values + or language maps are refused at derivation. + - **W3C VCDM 2.0 conformance: 57 → 58 of 59.** The `type` term-resolution case, a documented gap, now + passes for RDFC-secured input; `relatedResource` digest verification remains the one named gap. The + gate no longer relies on a pass count alone: every failing suite test must be one of the **named** + known failures in `W3cVcdm2SuiteTests`, so a regression cannot hide behind the count. See `docs/conformance.md`. - The `InvalidOperationException` thrown when a document cannot be secured now includes the specific cause from the substrate (for example "uses @direction"), not only the generic data-loss sentence. @@ -102,7 +131,7 @@ matching `DidComm.Core` 1.5.0). FluentAssertions is the one deliberate exception - **DID resolution is stricter** (NetDid 3.0–4.0). DID syntax is checked more tightly, DID documents that break DID Core fail to resolve, and a verification method must have a `controller`. A resolution error is Indeterminate in Credentials, as before. No did:key verdict changes. -- **JSON Schema validation runs on JsonSchema.Net 9.** +- **JSON Schema validation runs on JsonSchema.Net 9** (9.6.0). - Each validation uses its own schema and dialect registries. A schema supplied with one credential can never answer another credential's `$ref`, and it can never redefine a custom `$schema` dialect for later validations. The same `$id` can be validated repeatedly. diff --git a/Directory.Packages.props b/Directory.Packages.props index 6ae0594..2aed051 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,12 +18,17 @@ rebuild + release, never just a consumer-side version float. 1.4.0 made malformed-input failures definitive (Failed, not Indeterminate) and throws ProofGenerationException at proof creation, which DataIntegrityMechanism wraps. 1.5.0 makes RDFC fail closed on JSON-LD data - loss: undefined terms, malformed or respelled IRIs (security fix, dataproofs #36). --> - - - - - + loss: undefined terms, malformed or respelled IRIs (security fix, dataproofs #36). 2.0.0: the + Rdfc suites' constructors changed (binary break, source-compatible; rebuilt here), RDFC now + carries @direction (dataproofs #40, rdfDirection: i18n-datatype), proof members a verifier acts on + are bound to the signature (GHSA-6fxh-mc39-94vc), RDFC bytes changed for tied-blank-node documents, + and bbs-2023 was reworked to interoperate with the reference implementation (earlier proofs do not + verify; nothing issued before this release exists in the wild, by owner decision). --> + + + + + @@ -47,7 +52,7 @@ is an API break, ported in JsonSchema2020Validator. v9 registers built schemas in a process-wide registry unless the build gets its own local SchemaRegistry, and it throws JsonSchemaException for structurally invalid schemas. The validator isolates and catches both; SchemaValidatorTests pin it. --> - + diff --git a/docs/conformance.md b/docs/conformance.md index 13577dd..f163a15 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -26,24 +26,28 @@ hold: When a known failure starts passing, the run prints a notice: remove the entry, raise the floor, and update this page. Lowering the floor without naming the failure is not how a regression is accepted. -### Current result: 54 / 59 passing +### Current result: 58 / 59 passing The structural / issue / verify core passes, including the v1.0 compliance hardening — identifier-URL validation (`id`, `issuer`, `credentialStatus.id`, `credentialSchema.id`, `credentialSubject.id`, `refreshService.id`, `relatedResource.id`), `refreshService.type`, `relatedResource` structure, and -`name`/`description` §11.1 language value objects **without a base direction**. The 5 known failures: +`name`/`description` §11.1 language value objects, with and without a base direction. The 1 known failure: | Test | Why it does not pass | Status | |---|---|---| -| `name`, `description`, `issuer.name`, `issuer.description` MUST accept a language value object as described in §11.1 Language and Base Direction — the four cases whose value object carries `@direction` (`{"@value": …, "@language": "en", "@direction": "ltr"}`). | The shim issues with `eddsa-rdfc-2022`, and since DataProofs 1.5.0 the RDFC suites **refuse to sign a document that uses `@direction`**. The Data Integrity specs (`vc-data-integrity`, `vc-di-eddsa`) define no lossless canonicalization of direction, and JSON-LD 1.1's default JSON-LD→RDF conversion drops it. Earlier versions "passed" these cases by silently signing bytes that did not carry the direction, so a holder could flip `ltr` to `rtl` without breaking the proof. DataProofs now fails closed instead. | **Scope decision for v1.2.0: direction-bearing credentials are unsupported under the RDFC suites** (`eddsa-rdfc-2022`, `ecdsa-rdfc-2019`, `bbs-2023`). Issuance throws `InvalidOperationException` naming `@direction`; verification of such a credential, from any issuer, fails with `DATA_LOSS_DETECTION_ERROR`. The supported path is the JCS suites (`eddsa-jcs-2022`, `ecdsa-jcs-2019`), which sign the JSON as written, or a language value object without `@direction`. Tracked upstream as [dataproofs-dotnet#40](https://github.com/moisesja/dataproofs-dotnet/issues/40) (an opt-in `rdfDirection: i18n-datatype` mode), with the interop caveat that its output would not verify on implementations using the JSON-LD default. Regression coverage: `RdfcIssueVerifyTests` (the four positions, both suites, and the post-signing `@direction` tamper that is now caught). | | `relatedResource` digest MUST match the retrieved resource | The engine validates `relatedResource` **structure** (objects; a unique URL `id`; at least one of `digestSRI`/`digestMultibase`) but does not yet **fetch the resource and verify the digest hash**. | Deferred to a focused follow-up (a verifier-side fetch+hash on an optional resolver hook), then this gap closes. | Two notes on what the count does and does not say: -- Each of the four direction cases is the first direction assertion in its mocha `it` block, so the - assertions after it in the same block (a multi-language value object; rejecting an extra property in a - value object) are **not reached** in the current run. They passed before this change and are covered by - the engine's own structural tests, but the suite no longer exercises them. +- **Language value objects with `@direction`** (`name`, `description`, `issuer.name`, `issuer.description`) + pass because DataProofs ≥ 2.0.0 canonicalizes RDFC with JSON-LD 1.1 `rdfDirection: i18n-datatype`, the + representation the reference Data Integrity implementations sign with + ([dataproofs-dotnet#40](https://github.com/moisesja/dataproofs-dotnet/issues/40)). The direction is + inside the signature: flipping or dropping it after signing fails the proof. That is a stronger result + than before DataProofs 1.5.0, when these cases "passed" by silently signing bytes that did not carry the + direction, and than 1.5.0, when the RDFC suites refused `@direction` and the cases were listed here as + known failures. A number or boolean with `@direction` is still refused as data loss. Regression coverage: + `RdfcIssueVerifyTests` (the four positions, both RDFC and JCS, and the post-signing flip/removal). - The `type` term-resolution test ("`type` MUST be one or more terms and/or absolute URL strings"), a documented gap up to v1.1.1, now **passes**: RDFC data-loss detection rejects a `type` that no `@context` maps. That holds for RDFC-secured input only; the JCS and enveloping forms still accept a @@ -51,7 +55,7 @@ Two notes on what the count does and does not say: (NFR-002). This is reported as a tracked baseline rather than a "fully conformant" claim: the engine passes the -structural / issue / verify core of the suite, and the gaps above are explicit, named, and individually +structural / issue / verify core of the suite, and the gap above is explicit, named, and individually addressable. ## Interoperability vectors (`tests/Credentials.InteropTests`) @@ -63,7 +67,9 @@ addressable. `dc+sd-jwt` media type / `typ` are drift sentinels. - **bbs-2023 (vc-di-bbs)** — `IsAvailable`-gated. A derived proof pins the wire-format drift sentinels (the `bbs-2023` cryptosuite name and a multibase-base64url `proofValue`), verifies, and a tampered - revealed-mandatory value is rejected (the cryptographically-enforced mandatory group). + revealed-mandatory value is rejected (the cryptographically-enforced mandatory group). DataProofs ≥ 2.0.0 + follows the reference `bbs-2023` algorithms (its own conformance tests verify the W3C + `derivedRevealDocument.json` vector and cross-verify with the reference verifier). The no-Newtonsoft consumer-closure guarantee (NFR-002) is asserted separately by `Credentials.ConsumerProbe` (`tools/check-no-newtonsoft-closure.sh`). diff --git a/src/Credentials.Rdfc/IBbsDeriver.cs b/src/Credentials.Rdfc/IBbsDeriver.cs index 65a57fc..0fd9291 100644 --- a/src/Credentials.Rdfc/IBbsDeriver.cs +++ b/src/Credentials.Rdfc/IBbsDeriver.cs @@ -26,6 +26,12 @@ namespace Credentials.Rdfc; /// gated), so the mandatory-group choice is the third-party issuer's responsibility; this engine will /// enforce it when bbs-2023 issuance ships. Mandatory claims, by contrast, cannot be dropped or altered — /// that is cryptographically enforced. +/// +/// At least one non-mandatory claim must be revealed, and every pointer must match. The BBS +/// provider cannot prove a disclosure that reveals only the mandatory group, so a request with no +/// is refused with +/// whose message names the remedy; a pointer that matches nothing in the document is refused the same way +/// (the reference selection algorithm's behaviour). /// public interface IBbsDeriver { @@ -39,7 +45,7 @@ public interface IBbsDeriver /// Which additional claims to reveal. /// Cancels the derivation. /// The derived (minimally-disclosing) credential, ready to present. - /// The base credential is not a valid bbs-2023 base, or the pointers are invalid. + /// The base credential is not a valid bbs-2023 base, the pointers are invalid, or the request reveals no non-mandatory claim. The message carries the specific cause. /// The BBS native library is unavailable on this host. Task DeriveAsync(Credential baseCredential, BbsDisclosureRequest request, CancellationToken cancellationToken = default); } diff --git a/src/Credentials.Rdfc/Internal/Bbs2023Deriver.cs b/src/Credentials.Rdfc/Internal/Bbs2023Deriver.cs index b0c8c69..3e0abf4 100644 --- a/src/Credentials.Rdfc/Internal/Bbs2023Deriver.cs +++ b/src/Credentials.Rdfc/Internal/Bbs2023Deriver.cs @@ -67,8 +67,11 @@ private Credential Derive(Credential baseCredential, BbsDisclosureRequest reques } catch (ProofGenerationException ex) { + // The substrate names the cause without quoting the document: an invalid pointer, a malformed base, + // or a reveal that discloses no non-mandatory statement, which NetCrypto's BBS provider cannot prove + // (DataProofs >= 2.0.0). Carry it, so the caller can tell those apart. throw new CredentialFormatException( - "The credential is not a valid bbs-2023 base credential, or the disclosure pointers are invalid.", ex); + $"The credential is not a valid bbs-2023 base credential, or the disclosure pointers are invalid: {ex.Message}", ex); } catch (ArgumentException ex) { diff --git a/tasks/lessons.md b/tasks/lessons.md index 4a067f5..1af7f7b 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -2,6 +2,15 @@ Patterns learned while building credentials-dotnet. Reviewed at session start. +## "Open PR" from this owner means "get it onto the release PR", not "open another one" (2026-11-02) + +With PR #21 (v1.2.0, every dependency at latest) still open, the owner said "update your dependencies and +open PR" for DataProofs 2.0.0. I planned a second PR stacked on #21 and started a branch; the owner +stopped it: "add the current work to the existing PR … no, one PR only". The same thing happened the +round before (the all-latest update was folded into #21). **Rule:** while a release PR is open, every +change for that release goes onto it. Don't branch or stack; propose folding and only ask if the change +would make the PR unreviewable. Keep the folded PR's description current for each addition. + ## A count-based gate that drops after a dependency change is fixed by naming the failures, never by lowering the count (2026-10-29) DataProofs 1.5.0 made the W3C suite drop from 57 to 54, and my first proposal was "lower the baseline to 54 diff --git a/tasks/plan-2026-11-02-dataproofs-2.0.0.md b/tasks/plan-2026-11-02-dataproofs-2.0.0.md new file mode 100644 index 0000000..5eadd8f --- /dev/null +++ b/tasks/plan-2026-11-02-dataproofs-2.0.0.md @@ -0,0 +1,128 @@ +# Plan — DataProofsDotnet 1.5.0 → 2.0.0 (+ JsonSchema.Net 9.6.0), new PR + +Status: **APPROVED 2026-11-02 and IMPLEMENTED on PR #21's branch** (`fix/dataproofs-1.3.0-issue-20`). +Owner decisions: **fold into the existing PR #21** (D1); **v1.2.0 stays** (D2) — "there are no existing +credentials in the wild, no backwards compatibility is necessary", so the CHANGELOG states the +incompatibilities in one line and carries no migration or inventory guidance; D3 (`KnownContexts` +follow-up issue) left open. Pre-approval evidence came from a scratch worktree of `17fbc44` with the two +pins moved; the Review section records the results on the real branch. + +## Owner's intent + +> "DataProofsDotnet are now 2.0.0. Update your dependencies and open PR" + +Keep Credentials on the latest stack and open a PR for it. PR #21 (every dependency at latest, v1.2.0) is +still **open and unmerged**; its head is `17fbc44`, all checks green. + +## Inventory + +| Package | Now (#21) | Latest | Action | +|---|---|---|---| +| `DataProofsDotnet.*` (Core/Jose/Cose/Rdfc/DI) | 1.5.0 | **2.0.0** | bump; rebuild required (binary break in Rdfc constructors) | +| `JsonSchema.Net` | 9.5.0 | **9.6.0** | bump (minor) | +| NetDid 4.0.0, NetCrypto 1.7.1, NetCid 1.6.0, M.E. 10.0.14, test stack | — | same | already latest | +| `FluentAssertions` | 7.0.0 | 8.11.0 | **held** (owner decision, licence) | +| `DidComm.Core` (consumer, not pinned) | — | 1.5.0 | compiled against Jose 1.5.0; Jose's public API is unchanged in 2.0.0, so it binds | + +DataProofs 2.0.0 floors: NetCrypto 1.7.1, NetCid 1.6.0 (unchanged). No new transitive packages in the +default closure. + +## What 2.0.0 changes for Credentials (from its changelog and public-API diff, checked by the trial) + +1. **Builds clean, no source change needed for the API.** The Rdfc suites and canonicalizer now have one + constructor each with an optional parameter; our `new EddsaRdfc2022Cryptosuite()` etc. compile and bind. + It is a binary break, so a rebuild + release is required (as with Jose 1.3.0). +2. **RDFC now carries `@direction`** (our issue #40, implemented upstream with `rdfDirection: i18n-datatype`, + the representation the reference implementations sign with). **This reverses last round's scope + decision.** Trial: a direction-bearing credential issues and verifies; flipping or removing the direction + fails with `PROOF_VERIFICATION_ERROR`; a number or boolean with `@direction` is still refused + (`DATA_LOSS_DETECTION_ERROR`). The 8 direction tests from #21 now fail and must be rewritten to the + new contract. The W3C suite's four direction cases should pass again (DataProofs states this; CI confirms). +3. **Security fix inherited (GHSA-6fxh-mc39-94vc):** the proof members a verifier acts on (`proofPurpose`, + `challenge`, `domain`, `expires`, …) are now bound to the signature; with 1.5.0, a holder could respell + them via `@context` with no key. Affects RDFC and bbs-2023; JCS/JOSE/COSE/SD-JWT were never affected. + Some spec-legal but unverifiable proof shapes are newly refused (listed upstream). +4. **Signed bytes change for some RDFC documents** (tied blank-node labelling and Unicode code-point + ordering corrected to match `rdf-canonize`). Ordinary credentials with no tied blank nodes are + unaffected; a document with two same-looking unnamed objects is. **An RDFC proof made by any earlier + Credentials over such a document no longer verifies** (it never verified on the reference + implementation either). Reissuance by the issuer is the only repair. +5. **bbs-2023 reworked to interoperate with the reference implementation.** **Base and derived proofs + made by any earlier version do not verify or derive on 2.0.0.** New limitation: a derivation that + reveals only the mandatory statements is refused ("the BBS provider needs at least one disclosed + message", a NetCrypto 1.7.1 limit). Our `Bbs2023Deriver` maps that `ProofGenerationException` to a + `CredentialFormatException` whose fixed message ("not a valid base credential, or the pointers are + invalid") is wrong for this case. 4 bbs tests fail because they reveal mandatory-only. +6. **RDFC DoS hardening inherited:** hostile blank-node graphs no longer overflow the stack; a work budget + refuses pathological inputs in ~50 ms. (Known limitation upstream: JSON-LD expansion is slow on long + arrays; size-limit untrusted documents.) +7. **New upstream option `ProofVerificationOptions.KnownContexts`** (context allowlisting, VC-DI §4.6). + Credentials does not expose it; unset means unchanged behaviour. Public-API addition → **follow-up**, + not this PR. +8. Legacy suites (`Ed25519Signature2020`, …) changed too; Credentials does not use them. + +Trial results: build 0 warnings / 0 errors; 431/443 tests pass, the 12 failures are items 2 and 5; +0 vulnerable packages; default closure Newtonsoft-free; ApiCompat vs 1.1.1: no breaking changes; +DidComm.Core 1.5.0 consumer harness 14/14 with DataProofs 2.0.0 loaded (note: the harness loads DidComm +but does not exercise DidComm's own signing paths). + +## Changes + +- [ ] `Directory.Packages.props`: DataProofs 2.0.0, JsonSchema.Net 9.6.0; rationale comments updated + (binary break, direction now carried, bytes change, bbs incompatibility). +- [ ] **Direction tests rewritten** (`RdfcIssueVerifyTests`, theory over the four W3C positions): RDFC + issues and verifies a direction-bearing value; flipping `ltr`→`rtl` and removing the direction after + signing → Rejected `PROOF_VERIFICATION_ERROR` (the property last round's scope decision could not + give); a number with `@direction` → issuance refused; JCS still secures the same document. +- [ ] **Conformance gate:** remove the four direction entries from `KnownFailures`, floor 54 → 58; + `docs/conformance.md` → 58/59 with the direction row replaced by a "now passes" note and #40 marked + resolved. CI's W3C job is the proof; if any direction case still fails there, that is investigated, not + re-listed. +- [ ] **bbs-2023 deriver:** the `CredentialFormatException` message includes the substrate's cause (one + level, as `DataIntegrityMechanism` does), and `IBbsDeriver` docs state the mandatory-only limitation. + The 4 failing tests reveal one non-mandatory claim (their purpose is unchanged: mandatory group always + present, issuer binding, withheld-validity residual, unknown pointer ignored); one new test pins the + mandatory-only refusal and its message. +- [ ] `CHANGELOG.md` v1.2.0: DataProofs 2.0.0 section (items 2–7 above, with the stored-proof inventory + guidance), the direction scope decision replaced by "supported", JsonSchema.Net 9.6.0. +- [ ] Verification: `-warnaserror` build; full suite; vulnerability audit; Newtonsoft closure; ApiCompat; + pack + DidComm 1.5.0 consumer check from the branch's packs; **adversarial pass** on the changed + behaviours (direction binding through Credentials, proof-member binding through our DI verify path, + bbs derive/verify under the rework, the deriver's error mapping); push; CI incl. the W3C job. +- [ ] Follow-up issue (this repo): expose `KnownContexts` through `CredentialVerificationOptions`. + +## Decisions for the owner + +**D1. PR vehicle (recommended: a new PR stacked on #21).** Branch `chore/dataproofs-2.0.0` from `17fbc44`, +PR base = `fix/dataproofs-1.3.0-issue-20`, so the diff shows only this change and #21 stays reviewable +as is; GitHub retargets it to `main` when #21 merges. Alternative: fold into #21 again. Say which. + +**D2. Version.** #21 is v1.2.0 by your earlier decision. 2.0.0 adds two compatibility effects for stored +artifacts: RDFC proofs over tied-blank-node documents and **all** bbs-2023 proofs issued by ≤ 1.1.1 no +longer verify. The repo's own precedent (v1.0.0) put "newly rejecting previously accepted input" in a +major. Keeping **1.2.0** is consistent with your earlier call (the undefined-terms narrowing already had +this shape); **2.0.0** is the semver-strict reading. I'll go with 1.2.0 unless you say 2.0.0. + +**D3. `KnownContexts`:** file the follow-up issue now (recommended), or leave it. + +## Review (results on the real branch) + +- Build `-warnaserror`: 0 warnings / 0 errors. **428 tests pass** (the 443 of the trial minus the 8 + direction tests folded into 5, plus the new bbs tests). +- Gates: 0 vulnerable packages across 29 projects; default closure Newtonsoft-free; ApiCompat vs 1.1.1: no + breaking changes for Core, DI, Rdfc. +- Direction: `Rdfc_signs_a_language_value_with_direction_in_every_suite_position` (4 positions × RDFC and + JCS; post-signing flip and removal → `PROOF_VERIFICATION_ERROR`) and + `Rdfc_still_refuses_a_direction_on_a_non_string_value` (→ `DATA_LOSS_DETECTION_ERROR`). +- bbs-2023: four tests reveal one non-mandatory claim; `Bbs_mandatory_only_reveal_is_refused_with_the_cause` + and `Bbs_reveal_pointer_matching_nothing_is_refused_with_the_cause` pin the two new refusals (the second + is a behaviour change found by the suite: 1.5.0 ignored an unmatched pointer, 2.0.0 refuses it like the + reference implementation). `Bbs2023Deriver` carries the substrate's cause in its message. +- Conformance gate: known failures = 1 (`relatedResource` digest), floor 58; parser test updated to show the + direction messages as *unknown*. CI's W3C job decides; result recorded in the PR thread. +- DidComm.Core 1.5.0 consumer harness and the adversarial pass: see the PR thread. + +## Explicitly not changing + +- No Credentials public API change. `KnownContexts` stays a follow-up. +- The conformance shim stays on `eddsa-rdfc-2022`. diff --git a/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.Parsing.cs b/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.Parsing.cs index f985f1b..1e2ba57 100644 --- a/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.Parsing.cs +++ b/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.Parsing.cs @@ -63,11 +63,17 @@ public void Failure_parsing_yields_one_assertion_message_per_failure() } [Fact] - public void Failure_parsing_matches_the_known_failure_list_by_message() + public void Failure_parsing_tells_known_failures_from_unknown_ones() { var failures = ParseFailures(MochaExcerpt); + var unknown = failures.Where(f => !KnownFailures.Any(k => f.Contains(k, StringComparison.Ordinal))).ToList(); - failures.Should().OnlyContain(f => KnownFailures.Any(k => f.Contains(k, StringComparison.Ordinal))); + // The excerpt predates DataProofs 2.0.0, when the direction cases were known failures. Today only the + // relatedResource digest case is, so the two direction failures must surface as unknown: exactly what + // the gate reports when a regression hides among the counts. + unknown.Should().Equal( + "Got unwanted rejection: Failed to accept a VC using `name` with language & direction expressed.", + "Got unwanted rejection: Failed to accept a VC using `description` with language & direction expressed."); } [Fact] diff --git a/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.cs b/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.cs index df39651..37dec17 100644 --- a/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.cs +++ b/tests/Credentials.Conformance.Tests/W3cVcdm2SuiteTests.cs @@ -18,25 +18,20 @@ namespace Credentials.Conformance.Tests; /// public sealed partial class W3cVcdm2SuiteTests { - // The suite tests the engine does not pass (54 of 59 pass), keyed on the suite's own assertion message, + // The suite tests the engine does not pass (58 of 59 pass), keyed on the suite's own assertion message, // which is the stable identifier the suite prints for a failed expectation. Each entry is explained in // docs/conformance.md. When one starts passing, the run prints a notice: remove the entry, raise the - // floor, and update the doc. + // floor, and update the doc. (The four "language & direction" cases were listed here while DataProofs + // 1.5.0 refused @direction; 2.0.0 signs it, and they pass again.) private static readonly string[] KnownFailures = [ - // RDFC suites refuse `@direction` since DataProofs 1.5.0 (fail-closed data-loss detection; the Data - // Integrity specs define no lossless canonicalization of direction). The shim issues eddsa-rdfc-2022. - "Failed to accept a VC using `name` with language & direction expressed.", - "Failed to accept a VC using `description` with language & direction expressed.", - "Failed to accept a VC using `issuer.name` with language & direction expressed.", - "Failed to accept a VC using `issuer.description` with language & direction expressed.", // relatedResource digest verification (fetch + hash) is a deferred follow-up. "Failed to reject a VC with a relatedResource with wrong digest.", ]; - // The passing count the engine must not drop below: 59 suite tests minus the 5 known failures. A + // The passing count the engine must not drop below: 59 suite tests minus the 1 known failure. A // sanity floor beside the named list, so a run that executes nothing cannot pass with zero failures. - private const int PassingFloor = 54; + private const int PassingFloor = 58; [SkippableFact] [Trait("Category", "Conformance")] diff --git a/tests/Credentials.Rdfc.Tests/Bbs2023SelectiveDisclosureTests.cs b/tests/Credentials.Rdfc.Tests/Bbs2023SelectiveDisclosureTests.cs index 8b83523..c38f4cb 100644 --- a/tests/Credentials.Rdfc.Tests/Bbs2023SelectiveDisclosureTests.cs +++ b/tests/Credentials.Rdfc.Tests/Bbs2023SelectiveDisclosureTests.cs @@ -81,8 +81,10 @@ public async Task Bbs_mandatory_group_is_always_revealed() var (baseCredential, _) = await CraftBaseAsync(["/issuer", "/credentialSubject/id"]); - // Reveal nothing extra: the mandatory group is still present. - var derived = await deriver.DeriveAsync(baseCredential, new BbsDisclosureRequest()); + // Reveal one non-mandatory claim (DataProofs >= 2.0.0 refuses a mandatory-only reveal; see + // Bbs_mandatory_only_reveal_is_refused_with_the_cause): the mandatory group is still present. + var derived = await deriver.DeriveAsync( + baseCredential, new BbsDisclosureRequest { RevealPointers = ["/credentialSubject/favoriteColor"] }); derived.Issuer.Should().NotBeNull(); derived.ToElement().GetProperty("credentialSubject").TryGetProperty("id", out _).Should().BeTrue(); @@ -150,7 +152,8 @@ public async Task Bbs_issuer_binding_rejects_a_mismatched_verification_method() // but the proof's verificationMethod base DID ≠ the credential issuer ⇒ issuer binding fails. var (baseCredential, _) = await CraftBaseAsync( ["/issuer", "/credentialSubject/id"], issuerDid: "did:example:not-the-signer"); - var derived = await deriver.DeriveAsync(baseCredential, new BbsDisclosureRequest()); + var derived = await deriver.DeriveAsync( + baseCredential, new BbsDisclosureRequest { RevealPointers = ["/credentialSubject/gpa"] }); var result = await verifier.VerifyCredentialAsync(derived); result.Decision.Should().Be(VerificationDecision.Rejected); @@ -214,18 +217,20 @@ public async Task Bbs_null_reveal_pointer_element_maps_to_credential_format_exce } [Fact] - public async Task Bbs_nonexistent_reveal_pointer_reveals_nothing_extra_and_still_verifies() + public async Task Bbs_reveal_pointer_matching_nothing_is_refused_with_the_cause() { if (!BbsAvailable) { return; } using var provider = BuildProvider(); var deriver = provider.GetRequiredService(); - var verifier = provider.GetRequiredService(); var (baseCredential, _) = await CraftBaseAsync(["/issuer", "/credentialSubject/id"]); - // A well-formed pointer to an absent path is not an error — it just reveals nothing extra. - var derived = await deriver.DeriveAsync( - baseCredential, new BbsDisclosureRequest { RevealPointers = ["/credentialSubject/doesNotExist"] }); - (await verifier.VerifyCredentialAsync(derived)).Decision.Should().Be(VerificationDecision.Accepted); + // A well-formed pointer to an absent path is refused (DataProofs >= 2.0.0 follows the reference + // selection algorithm; 1.5.0 silently revealed nothing for it). The contract exception names the cause. + var act = () => deriver.DeriveAsync( + baseCredential, new BbsDisclosureRequest { RevealPointers = ["/credentialSubject/doesNotExist", "/credentialSubject/gpa"] }); + (await act.Should().ThrowExactlyAsync()) + .WithMessage("*pointer*does not match*") + .WithInnerException(); } [Fact] @@ -242,18 +247,36 @@ public async Task Bbs_withheld_validity_claim_not_in_mandatory_group_is_an_inher // cannot tell "no expiry" from "expiry hidden" — so it Accepts. The defence is issuer-side: validity // claims MUST be mandatory (documented on IBbsDeriver). This engine does not issue bbs-2023 bases. var notMandatory = await CraftExpiringBaseAsync(validUntilMandatory: false); - var hidden = await deriver.DeriveAsync(notMandatory, new BbsDisclosureRequest()); // withhold validUntil + var hidden = await deriver.DeriveAsync( // withhold validUntil; reveal a non-mandatory claim (2.0.0 needs one) + notMandatory, new BbsDisclosureRequest { RevealPointers = ["/credentialSubject/alumniOf"] }); (await verifier.VerifyCredentialAsync(hidden)).Decision.Should().Be(VerificationDecision.Accepted); // Mitigation: when the issuer puts validUntil in the MANDATORY group, the holder cannot hide it // (mandatory disclosure is cryptographically enforced) and the expired credential is correctly Rejected. var mandatory = await CraftExpiringBaseAsync(validUntilMandatory: true); - var forced = await deriver.DeriveAsync(mandatory, new BbsDisclosureRequest()); + var forced = await deriver.DeriveAsync( + mandatory, new BbsDisclosureRequest { RevealPointers = ["/credentialSubject/alumniOf"] }); var result = await verifier.VerifyCredentialAsync(forced); result.Decision.Should().Be(VerificationDecision.Rejected); result.Check(CheckKinds.Validity)!.Status.Should().Be(CheckStatus.Failed); } + [Fact] + public async Task Bbs_mandatory_only_reveal_is_refused_with_the_cause() + { + if (!BbsAvailable) { return; } + using var provider = BuildProvider(); + var deriver = provider.GetRequiredService(); + var (baseCredential, _) = await CraftBaseAsync(["/issuer", "/credentialSubject/id"]); + + // DataProofs >= 2.0.0 (NetCrypto's BBS provider) cannot derive a proof that discloses no non-mandatory + // statement. The contract exception carries the substrate's remedy instead of a generic "invalid". + var act = () => deriver.DeriveAsync(baseCredential, new BbsDisclosureRequest()); + (await act.Should().ThrowExactlyAsync()) + .WithMessage("*non-mandatory*") + .WithInnerException(); + } + // ---- issuance gate (FR-014) -------------------------------------------------------------------- [Fact] diff --git a/tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs b/tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs index 61cb1f6..bfc29ee 100644 --- a/tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs +++ b/tests/Credentials.Rdfc.Tests/RdfcIssueVerifyTests.cs @@ -114,55 +114,69 @@ private static Credential WithLanguageValue(string issuerDid, string position, b VerificationMethod = $"did:key:{keyPair.MultibasePublicKey}#{keyPair.MultibasePublicKey}", }; + private static JsonObject LanguageValueAt(JsonObject document, string position) + { + var holder = position.StartsWith("issuer.", StringComparison.Ordinal) ? document["issuer"]!.AsObject() : document; + return holder[position.Split('.')[^1]]!.AsObject(); + } + [Theory] [MemberData(nameof(LanguageValuePositions))] [FrTag("FR-011")] [FrTag("FR-040")] - public async Task Rdfc_refuses_a_language_value_with_direction_in_every_suite_position_and_jcs_secures_it(string position) + public async Task Rdfc_signs_a_language_value_with_direction_in_every_suite_position(string position) { - // Scope decision (v1.2.0, docs/conformance.md): the RDFC suites cannot carry @direction losslessly - // (DataProofs >= 1.5.0 fails closed), so issuance refuses it, naming the cause. The JCS suites sign the - // JSON as written and are the supported path. + // DataProofs >= 2.0.0 canonicalizes with rdfDirection: i18n-datatype, the representation the reference + // implementations sign with, so the direction is inside the signature: flipping or dropping it after + // signing breaks the proof. 1.5.0 refused @direction; earlier versions signed ltr and rtl as the same bytes. using var provider = BuildProvider(); var issuer = provider.GetRequiredService(); var verifier = provider.GetRequiredService(); var keyPair = KeyGen.Generate(KeyType.Ed25519); var did = $"did:key:{keyPair.MultibasePublicKey}"; - var withDirection = WithLanguageValue(did, position, withDirection: true); - var rdfc = () => issuer.IssueAsync(withDirection, Issuance("eddsa-rdfc-2022", keyPair)); - (await rdfc.Should().ThrowExactlyAsync()) - .WithMessage("*eddsa-rdfc-2022*@direction*") - .WithInnerException(); - var jcs = await issuer.IssueAsync(withDirection, Issuance("eddsa-jcs-2022", keyPair)); - (await verifier.VerifyCredentialAsync(jcs.Credential)).Decision.Should().Be(VerificationDecision.Accepted, position); + foreach (var cryptosuite in new[] { "eddsa-rdfc-2022", "eddsa-jcs-2022" }) + { + var issued = await issuer.IssueAsync(withDirection, Issuance(cryptosuite, keyPair)); + (await verifier.VerifyCredentialAsync(issued.Credential)).Decision.Should().Be(VerificationDecision.Accepted, $"{cryptosuite} at {position}"); + } - // The limitation is the direction, not the language value object: language-only secures under RDFC. - var languageOnly = await issuer.IssueAsync(WithLanguageValue(did, position, withDirection: false), Issuance("eddsa-rdfc-2022", keyPair)); - (await verifier.VerifyCredentialAsync(languageOnly.Credential)).Decision.Should().Be(VerificationDecision.Accepted, position); + var rdfc = await issuer.IssueAsync(withDirection, Issuance("eddsa-rdfc-2022", keyPair)); + foreach (var (label, tamper) in new (string, Action)[] + { + ("direction flipped to rtl", value => value["@direction"] = "rtl"), + ("direction removed", value => value.Remove("@direction")), + }) + { + var tampered = JsonNode.Parse(rdfc.Credential.ToUtf8().Span)!.AsObject(); + tamper(LanguageValueAt(tampered, position)); + var result = await verifier.VerifyCredentialAsync(System.Text.Encoding.UTF8.GetBytes(tampered.ToJsonString())); + result.Decision.Should().Be(VerificationDecision.Rejected, $"{label} at {position}"); + var proof = result.Check(CheckKinds.Proof)!; + proof.Status.Should().Be(CheckStatus.Failed); + proof.Diagnostics.Should().Contain(d => d.Code == "PROOF_VERIFICATION_ERROR"); + } } - [Theory] - [MemberData(nameof(LanguageValuePositions))] + [Fact] [FrTag("FR-040")] - public async Task Adding_a_direction_after_rdfc_signing_is_detected(string position) + public async Task Rdfc_still_refuses_a_direction_on_a_non_string_value() { - // The security meaning of refusing @direction: under DataProofs <= 1.4 the signed bytes did not carry - // the direction, so adding or flipping it after signing still verified. It is now a definitive failure. + // dotNetRDF (like jsonld.js) types a number as an XSD literal and drops its direction, so DataProofs + // treats it as data loss rather than sign or verify less than the document says. using var provider = BuildProvider(); var issuer = provider.GetRequiredService(); var verifier = provider.GetRequiredService(); var keyPair = KeyGen.Generate(KeyType.Ed25519); var did = $"did:key:{keyPair.MultibasePublicKey}"; - var issued = await issuer.IssueAsync(WithLanguageValue(did, position, withDirection: false), Issuance("eddsa-rdfc-2022", keyPair)); + var issued = await issuer.IssueAsync(WithLanguageValue(did, "name", withDirection: true), Issuance("eddsa-rdfc-2022", keyPair)); var tampered = JsonNode.Parse(issued.Credential.ToUtf8().Span)!.AsObject(); - var holder = position.StartsWith("issuer.", StringComparison.Ordinal) ? tampered["issuer"]!.AsObject() : tampered; - holder[position.Split('.')[^1]]!.AsObject()["@direction"] = "rtl"; + tampered["name"] = new JsonObject { ["@value"] = 1, ["@direction"] = "ltr" }; var result = await verifier.VerifyCredentialAsync(System.Text.Encoding.UTF8.GetBytes(tampered.ToJsonString())); - result.Decision.Should().Be(VerificationDecision.Rejected, position); + result.Decision.Should().Be(VerificationDecision.Rejected); var proof = result.Check(CheckKinds.Proof)!; proof.Status.Should().Be(CheckStatus.Failed); proof.Diagnostics.Should().Contain(d => d.Code == "DATA_LOSS_DETECTION_ERROR"); From b0f4eef26de43ca8c53e39fc3f606f04f1d7da44 Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Mon, 2 Nov 2026 23:25:55 -0500 Subject: [PATCH 10/10] plans and todos --- tasks/plan20261029-pr21-review.md | 22 ++++++++++++++++++++++ tasks/plan20261102-pr21-review.md | 22 ++++++++++++++++++++++ tasks/todo20261029-pr21-review.md | 5 +++++ tasks/todo20261102-pr21-review.md | 7 +++++++ 4 files changed, 56 insertions(+) create mode 100644 tasks/plan20261029-pr21-review.md create mode 100644 tasks/plan20261102-pr21-review.md create mode 100644 tasks/todo20261029-pr21-review.md create mode 100644 tasks/todo20261102-pr21-review.md diff --git a/tasks/plan20261029-pr21-review.md b/tasks/plan20261029-pr21-review.md new file mode 100644 index 0000000..eb5079d --- /dev/null +++ b/tasks/plan20261029-pr21-review.md @@ -0,0 +1,22 @@ +# PR #21 review plan + +Owner intent: review the newly opened PR critically and post a specific review comment or a brief approving comment on GitHub. + +Decisions: review head 2335ef741eab5a10508f41bcaaad5543a1bea59a against d06943c2ea7f0f11876cb373dd4ca1d86b5d3cb8. This is a review, not an implementation task. Source, tests, PRD, and release documentation will not be edited. The user explicitly authorized posting the review. Any proposed code fix requires a separate approved implementation plan. + +- [x] Read title, description, complete diff, commits, and existing review discussion. +- [x] Independently examine schema isolation and proof/dependency behavior, including adversarial analysis. +- [x] Run existing tests and inspect CI; reproduce actionable concerns in scratch space. +- [x] Check the PR head has not changed and post evidence-backed findings or approval. + +## Review results + +Posted: https://github.com/moisesja/credentials-dotnet/pull/21#issuecomment-6513682713 + +- Local tests: 418 passed; W3C harness skipped because its external suite was not configured locally. +- GitHub ordinary CI, API compatibility, API coverage and closure checks passed. W3C job 123858993882 failed with 54 passing / 5 failing, below baseline 57. +- Blocking concern: DataProofs 1.5.0 intentionally rejects direction-bearing language values; this breaks four W3C cases and leaves the conformance documentation stale. Differential scratch probe confirmed old canonical bytes omit direction, so bypassing the new guard is unsafe. Secure RDFC support belongs upstream; integration, coverage and accurate compatibility documentation belong here. +- Non-blocking concern: high-S KB/VP freshness tests need successful matching-expectation controls and assertions of the failure reason. +- Separate conditional schema-isolation findings were delivered privately outside the repository, following SECURITY.md. No vulnerability details were posted publicly. +- GitHub integration refused review/comment writes with HTTP 403; the authorized comment succeeded through the authenticated local gh CLI. Comment was fetched back and verified. +- No source, test, PRD, or CHANGELOG edits: this task reviews an unreleased PR and does not change product behavior. Implementation remediation requires a separately approved plan. diff --git a/tasks/plan20261102-pr21-review.md b/tasks/plan20261102-pr21-review.md new file mode 100644 index 0000000..7963fac --- /dev/null +++ b/tasks/plan20261102-pr21-review.md @@ -0,0 +1,22 @@ +# PR #21 review plan + +Owner intent: critically review the open PR and post specific concerns or a brief approving comment. + +Decisions: review head 460c675c0cab5e1abf7134023acda63397600368 against base d06943c2ea7f0f11876cb373dd4ca1d86b5d3cb8. This is a review-only task. Posting the review is explicitly authorized. No source, tests, PRD or release documentation edits are planned; implementation requires separate approval. Scratch probes may be created outside the checkout. Preserve existing untracked review records. + +- [x] Read title, description, complete diff, commits and prior discussion. +- [x] Independently assess schema isolation, proof changes, conformance gate and test coverage using adversarial reviewers. +- [x] Run existing tests, check CI at the reviewed head, reproduce actionable concerns in scratch space. +- [x] Recheck head and publish an evidence-backed review comment. + +## Review results + +Posted review: https://github.com/moisesja/credentials-dotnet/pull/21#issuecomment-6593758531 + +- No new blocking defect confirmed. Nonblocking request: supply the promised DataProofs 2.0.0 adversarial report and preferably retain representative proof-member/context-binding integration regressions. +- Prior direction and high-S freshness review findings are resolved. +- Local test run: 428 passed, zero failures, one external W3C test skipped. Log: /private/tmp/credentials-pr21-review-tests-elevated.log. Initial sandbox attempt could not create MSBuild sockets; elevated run succeeded. +- Exact-head CI passed: Windows, Ubuntu, API compatibility, API coverage and default consumer closure. W3C job 125683995385 reports 58 passing, one documented failure; named-failure gate passed. +- Independent schema and proof reviewers found no new confirmed regression. Previously reported private findings remain outside the public comment. +- Rechecked remote head 460c675 before posting. Connector review write returned HTTP 403; authenticated gh posted the authorized comment, then connector readback verified it. +- No source, tests, PRD or CHANGELOG changes. Existing untracked October review records preserved. diff --git a/tasks/todo20261029-pr21-review.md b/tasks/todo20261029-pr21-review.md new file mode 100644 index 0000000..9f630d7 --- /dev/null +++ b/tasks/todo20261029-pr21-review.md @@ -0,0 +1,5 @@ +# PR #21 review tracking + +Plan and checkable review steps: [plan20261029-pr21-review.md](plan20261029-pr21-review.md). + +No implementation changes are authorized by this review task. Review results will be recorded in the linked plan and GitHub comment. diff --git a/tasks/todo20261102-pr21-review.md b/tasks/todo20261102-pr21-review.md new file mode 100644 index 0000000..3bbf054 --- /dev/null +++ b/tasks/todo20261102-pr21-review.md @@ -0,0 +1,7 @@ +# PR #21 review tracking + +Plan and checkable steps: [plan20261102-pr21-review.md](plan20261102-pr21-review.md). + +## Review + +Completed. Review posted at https://github.com/moisesja/credentials-dotnet/pull/21#issuecomment-6593758531. No new blocker; one nonblocking security test/evidence request. Validation: 428 tests passed and exact-head CI/conformance passed. See the linked plan for details. No product changes or CHANGELOG entry.