diff --git a/CHANGELOG.md b/CHANGELOG.md index 69c3c21..9cfc1a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.0-preview.2] - 2026-06-13 + +### Added + +- **`ICryptosuite.SupportedProofTypes`** — a default interface member (defaults to the single + `DataIntegrityProof` type) by which a suite declares the proof `type`(s) it verifies. Existing + suites are unaffected and continue to be dispatched by `cryptosuite` name. +- **`CryptosuiteRegistry.GetByProofType(string?)`** — resolves a legacy/type-named suite by its + declared proof `type`, backed by a secondary `type → suite` index that excludes the default + Data Integrity type (so the JCS suites stay unambiguous and name-dispatched). + +### Fixed + +- **Verify pipeline can now dispatch legacy / type-named proofs (issue #4, FR-4).** The verify + path previously dispatched a proof to a cryptosuite only when it carried a `cryptosuite` member + **and** `type == "DataIntegrityProof"`, making it impossible to register a suite that verifies + pre–Data-Integrity Linked-Data-Signature proofs (`Ed25519Signature2020`, + `EcdsaSecp256r1Signature2019`, …) — which name their algorithm by `type` and carry no + `cryptosuite`. The pipeline could therefore emit (via a custom suite) a legacy-shaped proof it + then refused to verify. Dispatch now falls back to the proof `type` when no `cryptosuite` is + present (`cryptosuite`-named suites still always win), closing the create→verify inconsistency + and honoring the FR-4 "registering a new suite requires no pipeline changes" contract. The + library still **creates** only conformant 2022/2019 proofs; this is verification-only and + non-breaking. Suite selection only *routes* — each suite still fully validates + `type`/`cryptosuite`/key/encoding/signature. Regression tests in + `LegacyProofTypeVerificationTests` and `CryptosuiteRegistryTests`. + ## [0.1.0-preview.1] - 2026-06-13 First preview release of all five packages (`Core`, `Jose`, `Cose`, `Rdfc`, diff --git a/Directory.Build.props b/Directory.Build.props index 1341f1a..e068d33 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,7 +8,7 @@ - 0.1.0-preview.1 + 0.1.0-preview.2 true $(NoWarn);1591 true diff --git a/dataproofs-prd.md b/dataproofs-prd.md index d0c1e1e..63b1b50 100644 --- a/dataproofs-prd.md +++ b/dataproofs-prd.md @@ -94,7 +94,7 @@ Forbidden everywhere: `net-did` packages, `jose-jwt`, SdJwt.Net (runtime), NSec, **FR-3 — Verification algorithm, controller authorization, and results.** Verification executes the full spec algorithm, including `@context` validation, `proofPurpose` matching, `domain`/`challenge` checking, and expiry. **Controller authorization (resolver path).** When verification runs through a resolver (FR-7), the algorithm additionally confirms that the proof's `verificationMethod` is *authorized* for the asserted `proofPurpose`: the resolved controller document must associate that method with the verification relationship corresponding to the purpose (`assertionMethod` proofs require the method under `assertionMethod`, `authentication` under `authentication`, ZCAP invocation/delegation under `capabilityInvocation`/`capabilityDelegation`), and the named controller must actually control the method. A method whose signature checks out but that is **not** listed under the required relationship fails verification — this is a distinct failure from a `proofPurpose` *field* mismatch, and the two are tested separately (AC-1). Verification returns a structured result (verified flag + problem details aligned with the processing-error codes defined by VC Data Integrity — `PROOF_VERIFICATION_ERROR`, `PROOF_TRANSFORMATION_ERROR`, `INVALID_DOMAIN_ERROR`, `INVALID_CHALLENGE_ERROR`, and `INVALID_VERIFICATION_METHOD` for the controller-authorization failure above) rather than throwing on invalid proofs; exceptions are reserved for malformed inputs and misconfiguration. -**FR-4 — Cryptosuite registry.** An open registry keyed by cryptosuite name. Each suite supplies its transformation, hashing, and proof-serialization implementations to the pipeline. Registering a 1.1-track revision or a deferred suite (`ecdsa-sd-2023`) must require no pipeline changes. Built-in registrations per package: `Core` registers the JCS suites; `Rdfc` registers the RDFC suites and `bbs-2023`. *Port reference:* zcap-dotnet's `ICryptoSuiteProvider`/`CryptoSuiteProvider` and `IDocumentCanonicalizer`/`DocumentCanonicalizerProvider` abstractions. +**FR-4 — Cryptosuite registry.** An open registry keyed by cryptosuite name. Each suite supplies its transformation, hashing, and proof-serialization implementations to the pipeline. Registering a 1.1-track revision or a deferred suite (`ecdsa-sd-2023`) must require no pipeline changes. Built-in registrations per package: `Core` registers the JCS suites; `Rdfc` registers the RDFC suites and `bbs-2023`. *Port reference:* zcap-dotnet's `ICryptoSuiteProvider`/`CryptoSuiteProvider` and `IDocumentCanonicalizer`/`DocumentCanonicalizerProvider` abstractions. **Legacy/type-named suites (verification).** The "no pipeline changes" contract also covers *verifying* pre–Data-Integrity Linked-Data-Signature proofs that name their algorithm by `type` (`Ed25519Signature2020`, `EcdsaSecp256r1Signature2019`, …) and carry **no** `cryptosuite` member. A suite declares the proof `type`(s) it verifies via `ICryptosuite.SupportedProofTypes` (a default interface member returning the single `DataIntegrityProof` type, so the 2022/2019 suites are unchanged and still dispatched by `cryptosuite` name). The registry maintains a secondary `type → suite` index for the *non-default* types only, and the verify pipeline dispatches by `cryptosuite` name first, falling back to proof `type` for legacy proofs — `cryptosuite`-named suites always win, the default type is never type-indexed, and each suite still re-validates `type`/`cryptosuite`/key/encoding/signature itself. The library still **creates** only conformant 2022/2019 proofs; legacy support is verification-only, and shipping concrete legacy suites is out of scope (a consumer or a future `DataProofsDotnet.Legacy` package supplies them). **FR-5 — JCS suites.** Conformant `eddsa-jcs-2022` and `ecdsa-jcs-2019` (P-256 and P-384): proof-configuration canonicalization with JCS via NetCid, document canonicalization with JCS, `hashData = hash(canonicalProofConfig) ‖ hash(canonicalDocument)` with the hash function the suite mandates per curve, signing via NetCrypto, `proofValue` as base58-btc multibase. Verified against the W3C test vectors and interop-suite fixtures. **Porting caveat:** zcap-dotnet's JCS canonicalizer transfers, but its JCS *signing payload* uses the 2020-era embedded-proof convention (document signed with proof nested in it); `eddsa-jcs-2022`/`ecdsa-jcs-2019` require the separate-proof-config canonicalization + hash-concat that zcap currently applies only on its RDFC path — extend that mechanic to JCS here (§1.4). diff --git a/src/DataProofsDotnet.Core/DataIntegrity/CryptosuiteRegistry.cs b/src/DataProofsDotnet.Core/DataIntegrity/CryptosuiteRegistry.cs index 2de1403..d48dfa0 100644 --- a/src/DataProofsDotnet.Core/DataIntegrity/CryptosuiteRegistry.cs +++ b/src/DataProofsDotnet.Core/DataIntegrity/CryptosuiteRegistry.cs @@ -7,10 +7,32 @@ namespace DataProofsDotnet.DataIntegrity; /// Thread-safe; registration replaces any existing suite of the same name (last /// registration wins). /// +/// +/// A secondary index maps a proof type to the suite that declares it via +/// , so the verify pipeline can dispatch +/// legacy Linked-Data-Signature proofs (which name their algorithm by type and carry +/// no cryptosuite) through . The default Data Integrity +/// type () is deliberately excluded +/// from this index: the shipped 2022/2019 suites all share that single type and are always +/// disambiguated by cryptosuite name, never by type. +/// +/// If two suites registered under different names both declare the same non-default +/// proof type, the type index is last-registration-wins (mirroring the name index). +/// This is benign: both suites claim the type and each fully re-validates the proof in its +/// own , so a "wrong" selection fails closed rather than +/// accepting. Shipping suites that overlap on a legacy type is therefore discouraged. +/// +/// public sealed class CryptosuiteRegistry { private readonly ConcurrentDictionary _suites = new(StringComparer.Ordinal); + /// Secondary index: non-default proof type → suite (legacy suites only). + private readonly ConcurrentDictionary _suitesByProofType = new(StringComparer.Ordinal); + + /// Serializes registration so the two indexes update atomically; reads stay lock-free. + private readonly object _registrationLock = new(); + /// Creates an empty registry. public CryptosuiteRegistry() { @@ -33,13 +55,64 @@ public void Register(ICryptosuite suite) { ArgumentNullException.ThrowIfNull(suite); ArgumentException.ThrowIfNullOrEmpty(suite.Name); - _suites[suite.Name] = suite; + + var newTypes = NonDefaultProofTypes(suite).ToHashSet(StringComparer.Ordinal); + + lock (_registrationLock) + { + _suites.TryGetValue(suite.Name, out var previous); + + // Publish the new suite's type entries BEFORE pruning the old ones, so a type + // claimed by both the replaced and the replacing suite is repointed atomically — + // a concurrent GetByProofType never observes it transiently absent. + foreach (var type in newTypes) + { + _suitesByProofType[type] = suite; + } + + _suites[suite.Name] = suite; + + // Prune the previous suite's type entries that the new suite no longer claims. + // Value-matched so a different-named suite that owns the same type is left intact + // (last-registration-wins; see the class remarks), and re-registering a suite that + // still claims the type stays idempotent. + if (previous is not null) + { + foreach (var type in NonDefaultProofTypes(previous)) + { + if (!newTypes.Contains(type)) + { + ((ICollection>)_suitesByProofType) + .Remove(new KeyValuePair(type, previous)); + } + } + } + } } /// Returns the suite registered under , or null. public ICryptosuite? GetByName(string? name) => string.IsNullOrEmpty(name) ? null : _suites.GetValueOrDefault(name); + /// + /// Returns the suite that declares in its + /// , or null. Resolves only + /// legacy/type-named suites: the default Data Integrity type + /// (), null, and empty + /// always return null, because those proofs are dispatched by cryptosuite + /// name (), never by type. When two differently named suites + /// declare the same type, the most recently registered one is returned (see the class + /// remarks); the verify pipeline re-confirms the returned suite claims the type. + /// + public ICryptosuite? GetByProofType(string? type) + => string.IsNullOrEmpty(type) ? null : _suitesByProofType.GetValueOrDefault(type); + /// The names of all registered suites. public IReadOnlyCollection RegisteredNames => _suites.Keys.ToArray(); + + /// The suite's declared proof types excluding the default Data Integrity type. + private static IEnumerable NonDefaultProofTypes(ICryptosuite suite) + => (suite.SupportedProofTypes ?? []).Where( + type => !string.IsNullOrEmpty(type) + && !string.Equals(type, DataIntegrityProof.DataIntegrityProofType, StringComparison.Ordinal)); } diff --git a/src/DataProofsDotnet.Core/DataIntegrity/DataIntegrityProofPipeline.cs b/src/DataProofsDotnet.Core/DataIntegrity/DataIntegrityProofPipeline.cs index 3f69345..461c791 100644 --- a/src/DataProofsDotnet.Core/DataIntegrity/DataIntegrityProofPipeline.cs +++ b/src/DataProofsDotnet.Core/DataIntegrity/DataIntegrityProofPipeline.cs @@ -381,8 +381,14 @@ private async Task VerifySingleProofAsync( } } - var suite = _registry.GetByName(proof.Cryptosuite); - if (suite is null || !string.Equals(proof.Type, DataIntegrityProof.DataIntegrityProofType, StringComparison.Ordinal)) + // Suite dispatch (FR-4): the on-wire cryptosuite name always wins (the 2022/2019 + // generation), falling back to the proof type for legacy Linked-Data-Signature + // proofs that name their algorithm by type and carry no cryptosuite. The suite must + // claim the type; it then re-validates type/cryptosuite/key/encoding/signature + // itself, so this gate only confirms the dispatch target is willing to handle it. + var suite = _registry.GetByName(proof.Cryptosuite) + ?? _registry.GetByProofType(proof.Type); + if (suite is null || !(suite.SupportedProofTypes ?? []).Contains(proof.Type)) { return ProofVerificationResult.Failure( ProofProblemCodes.ProofVerificationError, diff --git a/src/DataProofsDotnet.Core/DataIntegrity/ICryptosuite.cs b/src/DataProofsDotnet.Core/DataIntegrity/ICryptosuite.cs index b094c7b..254c34b 100644 --- a/src/DataProofsDotnet.Core/DataIntegrity/ICryptosuite.cs +++ b/src/DataProofsDotnet.Core/DataIntegrity/ICryptosuite.cs @@ -18,6 +18,24 @@ public interface ICryptosuite /// The cryptosuite identifier this suite registers under (e.g. eddsa-jcs-2022). string Name { get; } + /// + /// The proof type values this suite verifies. Defaults to the Data Integrity + /// generation's single type (), + /// so existing suites continue to be dispatched by cryptosuite name exactly as + /// today. A legacy Linked-Data-Signature suite (e.g. Ed25519Signature2020, + /// EcdsaSecp256r1Signature2019) overrides this to its type string and + /// leaves its proofs' cryptosuite null — the verify pipeline then dispatches such + /// proofs to it by type. The suite itself remains responsible for fully validating + /// the proof's type/cryptosuite/key/encoding inside + /// ; this collection only declares which types it claims. + /// + IReadOnlyCollection SupportedProofTypes => DefaultSupportedProofTypes; + + /// The default value — the single + /// Data Integrity proof type — shared so the common case allocates nothing per call. + private static readonly IReadOnlyCollection DefaultSupportedProofTypes = + [DataIntegrityProof.DataIntegrityProofType]; + /// /// Creates a proof over per this suite's /// transform → hash → serialize pipeline. For proof chains the pipeline passes a diff --git a/src/DataProofsDotnet.Core/PublicAPI.Unshipped.txt b/src/DataProofsDotnet.Core/PublicAPI.Unshipped.txt index cc2c359..301cc62 100644 --- a/src/DataProofsDotnet.Core/PublicAPI.Unshipped.txt +++ b/src/DataProofsDotnet.Core/PublicAPI.Unshipped.txt @@ -16,6 +16,7 @@ const DataProofsDotnet.DataIntegrity.ProofPurposes.KeyAgreement = "keyAgreement" DataProofsDotnet.DataIntegrity.CryptosuiteRegistry DataProofsDotnet.DataIntegrity.CryptosuiteRegistry.CryptosuiteRegistry() -> void DataProofsDotnet.DataIntegrity.CryptosuiteRegistry.GetByName(string? name) -> DataProofsDotnet.DataIntegrity.ICryptosuite? +DataProofsDotnet.DataIntegrity.CryptosuiteRegistry.GetByProofType(string? type) -> DataProofsDotnet.DataIntegrity.ICryptosuite? DataProofsDotnet.DataIntegrity.CryptosuiteRegistry.Register(DataProofsDotnet.DataIntegrity.ICryptosuite! suite) -> void DataProofsDotnet.DataIntegrity.CryptosuiteRegistry.RegisteredNames.get -> System.Collections.Generic.IReadOnlyCollection! DataProofsDotnet.DataIntegrity.DataIntegrityProof @@ -74,6 +75,7 @@ DataProofsDotnet.DataIntegrity.EddsaJcs2022Cryptosuite.VerifyProof(System.Text.J DataProofsDotnet.DataIntegrity.ICryptosuite DataProofsDotnet.DataIntegrity.ICryptosuite.CreateProofAsync(System.Text.Json.JsonElement unsecuredDocument, DataProofsDotnet.DataIntegrity.DataIntegrityProof! proofOptions, NetCrypto.ISigner! signer, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! DataProofsDotnet.DataIntegrity.ICryptosuite.Name.get -> string! +DataProofsDotnet.DataIntegrity.ICryptosuite.SupportedProofTypes.get -> System.Collections.Generic.IReadOnlyCollection! DataProofsDotnet.DataIntegrity.ICryptosuite.VerifyProof(System.Text.Json.JsonElement unsecuredDocument, DataProofsDotnet.DataIntegrity.DataIntegrityProof! proof, DataProofsDotnet.PublicKeyMaterial! publicKey) -> DataProofsDotnet.DataIntegrity.ProofVerificationResult! DataProofsDotnet.DataIntegrity.IVerificationMethodResolver DataProofsDotnet.DataIntegrity.IVerificationMethodResolver.ResolveAsync(string! verificationMethodUrl, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/tasks/todo-2026-06-13-issue-4.md b/tasks/todo-2026-06-13-issue-4.md new file mode 100644 index 0000000..43999cf --- /dev/null +++ b/tasks/todo-2026-06-13-issue-4.md @@ -0,0 +1,118 @@ +# Issue #4 — Verify pipeline cannot dispatch legacy / non-DataIntegrityProof-type proofs + +GitHub: https://github.com/moisesja/dataproofs-dotnet/issues/4 + +## Problem (confirmed against `main`) + +`DataIntegrityProofPipeline.VerifySingleProofAsync` (`DataIntegrityProofPipeline.cs:384-390`) +dispatches a proof to a suite ONLY when the proof carries a `cryptosuite` member **and** +`type == "DataIntegrityProof"`. Legacy LD-Signature proofs (`Ed25519Signature2020`, …) +identify their algorithm by `type` and carry **no** `cryptosuite`, so they can never be +dispatched — violating the FR-4 "registering a new suite requires no pipeline changes" +contract, and producing a create→verify inconsistency (a custom suite can emit a +legacy-shaped proof through `AddProofAsync` that the same pipeline then refuses to verify). + +## Chosen approach — Option A (issue's recommendation) + +Suite declares the proof `type`(s) it verifies; verify path dispatches by `cryptosuite` +name first (current behavior, always wins), then falls back to `type` for legacy proofs. + +### Changes + +1. **`ICryptosuite.cs`** — add a default interface member (non-breaking): + `IReadOnlyCollection SupportedProofTypes => [DataIntegrityProof.DataIntegrityProofType];` + backed by a `private static readonly` field to avoid per-call allocation. + +2. **`CryptosuiteRegistry.cs`** — add a secondary `type → suite` index containing only + NON-default types (so the JCS suites, all `DataIntegrityProof`, never enter it and stay + unambiguous). Maintain it under a registration lock with replace-aware cleanup + (remove-if-value-matches). Add `ICryptosuite? GetByProofType(string? type)` (returns + null for null/empty/`DataIntegrityProof`). Reads stay lock-free on `ConcurrentDictionary`. + +3. **`DataIntegrityProofPipeline.cs:384-390`** — replace the dispatch + hardcoded + `type == DataIntegrityProofType` gate with: + ```csharp + var suite = _registry.GetByName(proof.Cryptosuite) + ?? _registry.GetByProofType(proof.Type); + if (suite is null || !suite.SupportedProofTypes.Contains(proof.Type)) { ...fail... } + ``` + Each suite still re-validates type/cryptosuite/key/encoding/signature inside `VerifyProof`. + +4. **PublicAPI** — add the two new public members to `Core/PublicAPI.Unshipped.txt`. + +### Tests (acceptance criteria) + +- `tests/.../Constructed/LegacyProofTypeVerificationTests.cs` (new): + - Custom `Ed25519Signature2020` suite (real Ed25519 via `Fx.Crypto`, emits no + `cryptosuite`): `AddProofAsync` → wire has `type:"Ed25519Signature2020"`, **no** + `cryptosuite` → `Verify` raw-key returns `Verified == true` (closes create→verify gap). + - Resolver form (`VerifyAsync`) still enforces controller authorization for the legacy + type (authorized relationship verifies; missing relationship → `INVALID_VERIFICATION_METHOD`). + - Negative: proof `type` X when only a suite declaring type Y is registered → fail with + `PROOF_VERIFICATION_ERROR`. + - Registering a legacy suite alongside the JCS suites leaves JCS create+verify unchanged. +- `CryptosuiteRegistryTests.cs` (extend): `GetByProofType` resolves a legacy type; returns + null for `DataIntegrityProof`/null/empty/unknown; default suites are absent from the type + index; replacement removes stale type entries; multi-type suite indexed under each type. + +### Docs / version + +- `ICryptosuite.cs` + `CryptosuiteRegistry.cs` XML docs describe the new member/index. +- `dataproofs-prd.md` FR-4: note legacy/type-named suites are supported for **verification**; + the library still **creates** only conformant 2022/2019 proofs. +- `CHANGELOG.md`: new `## [0.1.0-preview.2]` section (Added/Fixed). +- `Directory.Build.props`: `DataProofsVersion` `0.1.0-preview.1` → `0.1.0-preview.2`. + +## Verify before done + +- `dotnet build` (PublicApiAnalyzer + warnings-as-errors must pass). +- `dotnet test` (new tests green; existing JCS/W3C vector + registry tests unchanged). +- Adversarial subagent review of the dispatch change (type-confusion / bypass). + +## Review + +**Status: complete.** Branch `fix/issue-4-legacy-proof-verify-dispatch`, version bumped to +`0.1.0-preview.2`. + +### What changed +- `ICryptosuite.SupportedProofTypes` default interface member (defaults to `DataIntegrityProof`; + backed by a shared static array — zero per-call allocation for the common case). +- `CryptosuiteRegistry`: secondary `type → suite` index (non-default types only) under a + registration lock with replace-aware, value-matched cleanup; `GetByProofType`. Reads stay + lock-free. +- `DataIntegrityProofPipeline.VerifySingleProofAsync`: dispatch `GetByName(cryptosuite) ?? + GetByProofType(type)`, gate on `(suite.SupportedProofTypes ?? []).Contains(proof.Type)`. +- PublicAPI.Unshipped.txt (+2 members), PRD FR-4 note, CHANGELOG `0.1.0-preview.2`, version bump. + +### Verification +- `dotnet build -c Release`: 0 warnings / 0 errors (PublicApiAnalyzer + warnings-as-errors clean). +- `dotnet test -c Release`: **736 passed, 0 failed** across all six test projects. +- New tests: `LegacyProofTypeVerificationTests` (8 — round-trip raw-key, wire shape has no + `cryptosuite`, tamper/wrong-key fail closed, unregistered-type rejected, JCS coexistence, + resolver-path authorized + controller-authorization enforced) and 6 added + `CryptosuiteRegistryTests` (type-index resolve/exclusion/ordinal/multi-type/replacement). +- **Adversarial review** (subagent) attempted type-confusion, the `DataIntegrityProof`+no-cryptosuite + regression, gate relaxation, stale/mis-pointed registry entries, missing-type and gate-evasion, + and null/empty edges. **Verdict: no exploitable acceptance bypass** — every path fails closed, + enforced redundantly (type index excludes the default type → no JCS dispatch by type; gate + re-confirms the live suite claims the type; each suite re-validates type/cryptosuite/key/ + encoding/signature). Applied its one non-exploitable hardening note (null-tolerant gate). + +### Reviewer follow-up (PR #5, 2 minor non-blocking notes) +- **Same-type collision across different suite names** → documented as last-registration-wins + on the class remarks + `GetByProofType` (benign: each suite re-validates). Tests: + `Register_SameTypeUnderDifferentNames_IsLastRegistrationWins`, + `Register_ReplacingASuite_DoesNotStealAnotherNamesTypeEntry`. +- **Transient registration null window** → eliminated by reordering `Register` to publish the + new suite's type entries *before* pruning the previous suite's (set-difference + value-matched + remove), so a type both suites claim is repointed atomically. Idempotent re-registration + covered by `Register_SameInstanceTwice_IsIdempotent`. Full suite: **739 passed, 0 failed**. + +### Acceptance criteria — all met +- [x] Legacy `Ed25519Signature2020` suite (no `cryptosuite`) round-trips create→verify. +- [x] Create→verify inconsistency closed (same emitted document verifies). +- [x] JCS create+verify unchanged (existing W3C/vector tests + coexistence test green). +- [x] Unmatched type/cryptosuite still fails with `PROOF_VERIFICATION_ERROR`. +- [x] Negative: type X when only a suite for type Y is registered → rejected. +- [x] `VerifyAsync` still enforces controller authorization for legacy-type proofs. +- [x] Docs note legacy/type-named suites are verification-only; library still creates 2022/2019. diff --git a/tests/DataProofsDotnet.Core.Tests/Constructed/LegacyProofTypeVerificationTests.cs b/tests/DataProofsDotnet.Core.Tests/Constructed/LegacyProofTypeVerificationTests.cs new file mode 100644 index 0000000..e08c059 --- /dev/null +++ b/tests/DataProofsDotnet.Core.Tests/Constructed/LegacyProofTypeVerificationTests.cs @@ -0,0 +1,240 @@ +using System.Text.Json; +using DataProofsDotnet; +using DataProofsDotnet.Core.Tests.TestSupport; +using DataProofsDotnet.DataIntegrity; +using FluentAssertions; +using NetCid; +using NetCrypto; +using Xunit; + +namespace DataProofsDotnet.Core.Tests.Constructed; + +/// +/// Issue #4 / FR-4: the verify pipeline must dispatch a proof to a registered cryptosuite +/// by proof type when the proof carries no cryptosuite member — the shape of +/// legacy Linked-Data-Signature proofs (e.g. Ed25519Signature2020). A custom +/// that emits and verifies that legacy shape must round-trip on +/// the same pipeline, closing the create→verify inconsistency, without disturbing the +/// shipped 2022/2019 JCS suites. +/// +public class LegacyProofTypeVerificationTests +{ + private const string LegacyType = "Ed25519Signature2020"; + private const string VerificationMethod = "did:example:legacy#key-1"; + private const string Controller = "did:example:legacy"; + + private static JsonElement UnsignedDocument() + => Fx.Json("constructed", "controller", "unsigned-credential.json"); + + private static (DataIntegrityProofPipeline Pipeline, KeyPair Key) LegacyPipeline() + { + // The legacy suite registered ALONGSIDE the shipped JCS suites, so coexistence is + // exercised: dispatch by cryptosuite name (JCS) and by type (legacy) on one registry. + var registry = CryptosuiteRegistry.CreateDefault(); + registry.Register(new LegacyEd25519Suite()); + return (new DataIntegrityProofPipeline(registry), Fx.SeedKey(0x42)); + } + + private static async Task SignLegacyAsync(DataIntegrityProofPipeline pipeline, KeyPair key) + => await pipeline.AddProofAsync( + UnsignedDocument(), + new DataIntegrityProof + { + Cryptosuite = LegacyType, // dispatch key on the create path + Type = LegacyType, + Created = "2026-01-02T00:00:00Z", + VerificationMethod = VerificationMethod, + ProofPurpose = ProofPurposes.AssertionMethod, + }, + Fx.Signer(key)); + + [Fact] + public async Task AddProof_EmitsLegacyShape_NoCryptosuiteMember() + { + var (pipeline, key) = LegacyPipeline(); + + var secured = await SignLegacyAsync(pipeline, key); + + var proof = secured.GetProperty("proof"); + proof.GetProperty("type").GetString().Should().Be(LegacyType); + proof.TryGetProperty("cryptosuite", out _).Should().BeFalse("a legacy proof names its algorithm by type"); + proof.GetProperty("proofValue").GetString().Should().StartWith("z", "base58-btc multibase"); + } + + [Fact] + public async Task LegacyProof_RoundTrips_OnTheSamePipeline_RawKey() + { + // The create→verify inconsistency from issue #4: the pipeline emits a legacy-shaped + // proof and must then verify that very document. + var (pipeline, key) = LegacyPipeline(); + var secured = await SignLegacyAsync(pipeline, key); + + var result = pipeline.Verify(secured, PublicKeyMaterial.FromRaw(KeyType.Ed25519, key.PublicKey)); + + result.Verified.Should().BeTrue(); + } + + [Fact] + public async Task LegacyProof_TamperedDocument_FailsClosed() + { + var (pipeline, key) = LegacyPipeline(); + var secured = await SignLegacyAsync(pipeline, key); + var tampered = Fx.Mutate(secured, node => node["credentialSubject"] = "tampered"); + + var result = pipeline.Verify(tampered, PublicKeyMaterial.FromRaw(KeyType.Ed25519, key.PublicKey)); + + result.Verified.Should().BeFalse(); + } + + [Fact] + public async Task LegacyProof_WrongKey_FailsClosed() + { + var (pipeline, key) = LegacyPipeline(); + var secured = await SignLegacyAsync(pipeline, key); + + var result = pipeline.Verify( + secured, PublicKeyMaterial.FromRaw(KeyType.Ed25519, Fx.SeedKey(0x01).PublicKey)); + + result.Verified.Should().BeFalse(); + } + + [Fact] + public async Task ProofType_WithNoRegisteredSuite_IsRejected() + { + // Negative: a proof whose type matches no registered suite (here a different legacy + // type) is rejected with PROOF_VERIFICATION_ERROR — no silent acceptance. + var (pipeline, key) = LegacyPipeline(); + var secured = await SignLegacyAsync(pipeline, key); + var retyped = Fx.Mutate(secured, node => node["proof"]!["type"] = "Ed25519Signature2018"); + + var result = pipeline.Verify(retyped, PublicKeyMaterial.FromRaw(KeyType.Ed25519, key.PublicKey)); + + result.Verified.Should().BeFalse(); + result.ProofResults.Single().Problems.Should() + .ContainSingle(p => p.Code == ProofProblemCodes.ProofVerificationError); + } + + [Fact] + public async Task JcsSuites_StillRoundTrip_AlongsideARegisteredLegacySuite() + { + // Coexistence: registering a legacy type-named suite must not disturb dispatch of + // the shipped JCS suites (still keyed by cryptosuite name). + var (pipeline, _) = LegacyPipeline(); + var key = Fx.SeedKey(0x42); + + var secured = await pipeline.AddProofAsync( + UnsignedDocument(), + new DataIntegrityProof + { + Cryptosuite = EddsaJcs2022Cryptosuite.CryptosuiteName, + Created = "2026-01-02T00:00:00Z", + VerificationMethod = VerificationMethod, + ProofPurpose = ProofPurposes.AssertionMethod, + }, + Fx.Signer(key)); + + secured.GetProperty("proof").GetProperty("cryptosuite").GetString() + .Should().Be(EddsaJcs2022Cryptosuite.CryptosuiteName); + pipeline.Verify(secured, PublicKeyMaterial.FromRaw(KeyType.Ed25519, key.PublicKey)) + .Verified.Should().BeTrue(); + } + + [Fact] + public async Task LegacyProof_VerifiesOnResolverPath_WhenAuthorized() + { + var (pipeline, key) = LegacyPipeline(); + var secured = await SignLegacyAsync(pipeline, key); + + var result = await pipeline.VerifyAsync( + secured, + Resolver(key, ProofPurposes.AssertionMethod), + new ProofVerificationOptions { ExpectedProofPurpose = ProofPurposes.AssertionMethod }); + + result.Verified.Should().BeTrue(); + } + + [Fact] + public async Task LegacyProof_ResolverPath_EnforcesControllerAuthorization() + { + // FR-7 controller authorization still gates legacy-type proofs: the method's + // signature is valid, but the controller lists it under authentication only. + var (pipeline, key) = LegacyPipeline(); + var secured = await SignLegacyAsync(pipeline, key); + + var result = await pipeline.VerifyAsync( + secured, + Resolver(key, ProofPurposes.Authentication), + new ProofVerificationOptions { ExpectedProofPurpose = ProofPurposes.AssertionMethod }); + + result.Verified.Should().BeFalse(); + result.ProofResults.Single().Problems.Should() + .ContainSingle(p => p.Code == ProofProblemCodes.InvalidVerificationMethod); + } + + private static StaticVerificationMethodResolver Resolver(KeyPair key, string relationship) + => new( + [ + new ResolvedVerificationMethod + { + Id = VerificationMethod, + Controller = Controller, + PublicKey = PublicKeyMaterial.FromRaw(KeyType.Ed25519, key.PublicKey), + ControllerControlsMethod = true, + Relationships = new HashSet(StringComparer.Ordinal) { relationship }, + }, + ]); + + /// + /// A minimal custom suite that emits and verifies the legacy Ed25519Signature2020 + /// shape (type-named, no cryptosuite). Its internal signing payload is a real + /// Ed25519 signature over SHA-256(documentBytes ‖ proofConfigBytes); the mechanic + /// only needs to be self-consistent — the point under test is the pipeline's dispatch. + /// + private sealed class LegacyEd25519Suite : ICryptosuite + { + public string Name => LegacyType; // registry key for the create-path dispatch + + public IReadOnlyCollection SupportedProofTypes => [LegacyType]; + + public async Task CreateProofAsync( + JsonElement unsecuredDocument, DataIntegrityProof proofOptions, ISigner signer, + CancellationToken cancellationToken = default) + { + var proofConfig = proofOptions with { Type = LegacyType, Cryptosuite = null, ProofValue = null }; + var signature = await signer.SignAsync(Payload(unsecuredDocument, proofConfig), cancellationToken) + .ConfigureAwait(false); + return proofConfig with { ProofValue = Multibase.Encode(signature, MultibaseEncoding.Base58Btc) }; + } + + public ProofVerificationResult VerifyProof( + JsonElement unsecuredDocument, DataIntegrityProof proof, PublicKeyMaterial publicKey) + { + if (!string.Equals(proof.Type, LegacyType, StringComparison.Ordinal) || proof.Cryptosuite is not null) + { + return ProofVerificationResult.Failure( + ProofProblemCodes.ProofVerificationError, "Not a legacy Ed25519Signature2020 proof.", proof); + } + + if (string.IsNullOrEmpty(proof.ProofValue) + || !Multibase.TryDecode(proof.ProofValue, out var signature, out var encoding) + || encoding != MultibaseEncoding.Base58Btc) + { + return ProofVerificationResult.Failure( + ProofProblemCodes.ProofVerificationError, "The proofValue is not base58-btc multibase.", proof); + } + + var payload = Payload(unsecuredDocument, proof with { ProofValue = null }); + return Fx.Crypto.Verify(publicKey.KeyType, publicKey.KeyBytes.Span, payload, signature) + ? ProofVerificationResult.Success(proof) + : ProofVerificationResult.Failure( + ProofProblemCodes.ProofVerificationError, "The signature did not verify.", proof); + } + + private static byte[] Payload(JsonElement document, DataIntegrityProof proofConfig) + { + var documentBytes = JsonSerializer.SerializeToUtf8Bytes(document, DataProofsJsonOptions.Default); + var proofConfigBytes = JsonSerializer.SerializeToUtf8Bytes(proofConfig, DataProofsJsonOptions.Default); + return Hash.Sha256([.. documentBytes, .. proofConfigBytes]); + } + } +} diff --git a/tests/DataProofsDotnet.Core.Tests/Unit/CryptosuiteRegistryTests.cs b/tests/DataProofsDotnet.Core.Tests/Unit/CryptosuiteRegistryTests.cs index 73dca7b..876a15e 100644 --- a/tests/DataProofsDotnet.Core.Tests/Unit/CryptosuiteRegistryTests.cs +++ b/tests/DataProofsDotnet.Core.Tests/Unit/CryptosuiteRegistryTests.cs @@ -67,10 +67,137 @@ public void GetByName_ReturnsNull_ForNullEmptyOrUnknownNames() registry.GetByName("EDDSA-JCS-2022").Should().BeNull("matching is ordinal, not case-insensitive"); } - private sealed class FakeSuite(string name) : ICryptosuite + [Fact] + public void GetByProofType_ResolvesLegacySuiteByDeclaredType() + { + var registry = new CryptosuiteRegistry(); + var legacy = new FakeSuite("Ed25519Signature2020", proofTypes: ["Ed25519Signature2020"]); + + registry.Register(legacy); + + registry.GetByProofType("Ed25519Signature2020").Should().BeSameAs(legacy); + } + + [Fact] + public void GetByProofType_ReturnsNull_ForDefaultTypeNullEmptyAndUnknown() + { + // Default-type suites (the JCS suites) are dispatched by cryptosuite name, never by + // type, so they are deliberately absent from the type index. + var registry = CryptosuiteRegistry.CreateDefault(); + registry.Register(new FakeSuite("Ed25519Signature2020", proofTypes: ["Ed25519Signature2020"])); + + registry.GetByProofType(DataIntegrityProof.DataIntegrityProofType).Should().BeNull(); + registry.GetByProofType(null).Should().BeNull(); + registry.GetByProofType("").Should().BeNull(); + registry.GetByProofType("Ed25519Signature2018").Should().BeNull("no suite declares this type"); + } + + [Fact] + public void GetByProofType_IsCaseSensitive_Ordinal() + { + var registry = new CryptosuiteRegistry(); + registry.Register(new FakeSuite("Ed25519Signature2020", proofTypes: ["Ed25519Signature2020"])); + + registry.GetByProofType("ED25519SIGNATURE2020").Should().BeNull("matching is ordinal, not case-insensitive"); + } + + [Fact] + public void Register_IndexesSuiteUnderEachDeclaredNonDefaultType() + { + var registry = new CryptosuiteRegistry(); + var multi = new FakeSuite( + "multi", + proofTypes: [DataIntegrityProof.DataIntegrityProofType, "Ed25519Signature2020", "Ed25519Signature2018"]); + + registry.Register(multi); + + registry.GetByProofType("Ed25519Signature2020").Should().BeSameAs(multi); + registry.GetByProofType("Ed25519Signature2018").Should().BeSameAs(multi); + registry.GetByProofType(DataIntegrityProof.DataIntegrityProofType) + .Should().BeNull("the default type is never indexed, even when a suite also declares it"); + } + + [Fact] + public void Register_ReplacingLegacySuite_RemovesItsStaleTypeIndexEntries() + { + var registry = new CryptosuiteRegistry(); + registry.Register(new FakeSuite("legacy", proofTypes: ["Ed25519Signature2020"])); + + // Replace the same name with a default-type suite: the old type→suite mapping must go. + var replacement = new FakeSuite("legacy"); + registry.Register(replacement); + + registry.GetByName("legacy").Should().BeSameAs(replacement); + registry.GetByProofType("Ed25519Signature2020") + .Should().BeNull("the replaced suite no longer claims the legacy type"); + } + + [Fact] + public void Register_ReplacingLegacySuite_RepointsTypeIndexToTheNewSuite() + { + var registry = new CryptosuiteRegistry(); + registry.Register(new FakeSuite("legacy", proofTypes: ["Ed25519Signature2020"])); + + var replacement = new FakeSuite("legacy", proofTypes: ["Ed25519Signature2020"]); + registry.Register(replacement); + + registry.GetByProofType("Ed25519Signature2020").Should().BeSameAs(replacement); + } + + [Fact] + public void Register_SameTypeUnderDifferentNames_IsLastRegistrationWins() + { + // Two differently named suites declaring the same legacy type: the type index is + // last-registration-wins, mirroring the name index. (Benign — both claim the type + // and each re-validates in VerifyProof.) + var registry = new CryptosuiteRegistry(); + var first = new FakeSuite("suite-a", proofTypes: ["Ed25519Signature2020"]); + var second = new FakeSuite("suite-b", proofTypes: ["Ed25519Signature2020"]); + + registry.Register(first); + registry.Register(second); + + registry.GetByName("suite-a").Should().BeSameAs(first, "both names remain registered"); + registry.GetByName("suite-b").Should().BeSameAs(second); + registry.GetByProofType("Ed25519Signature2020").Should().BeSameAs(second, "last registration wins"); + } + + [Fact] + public void Register_ReplacingASuite_DoesNotStealAnotherNamesTypeEntry() + { + // suite-b currently owns the type. Replacing the unrelated suite-a (which also once + // declared the type) must NOT remove suite-b's entry — the prune is value-matched. + var registry = new CryptosuiteRegistry(); + registry.Register(new FakeSuite("suite-a", proofTypes: ["Ed25519Signature2020"])); + var owner = new FakeSuite("suite-b", proofTypes: ["Ed25519Signature2020"]); + registry.Register(owner); // type -> suite-b + + // Replace suite-a with a default-type suite: it no longer claims the legacy type. + registry.Register(new FakeSuite("suite-a")); + + registry.GetByProofType("Ed25519Signature2020") + .Should().BeSameAs(owner, "the value-matched prune leaves the other name's entry intact"); + } + + [Fact] + public void Register_SameInstanceTwice_IsIdempotent() + { + var registry = new CryptosuiteRegistry(); + var suite = new FakeSuite("legacy", proofTypes: ["Ed25519Signature2020"]); + + registry.Register(suite); + registry.Register(suite); + + registry.GetByProofType("Ed25519Signature2020").Should().BeSameAs(suite); + } + + private sealed class FakeSuite(string name, IReadOnlyCollection? proofTypes = null) : ICryptosuite { public string Name => name; + public IReadOnlyCollection SupportedProofTypes + => proofTypes ?? [DataIntegrityProof.DataIntegrityProofType]; + public Task CreateProofAsync( JsonElement unsecuredDocument, DataIntegrityProof proofOptions, ISigner signer, CancellationToken cancellationToken = default)