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)