Skip to content

Fix #4: verify pipeline dispatches legacy / type-named proofs#5

Merged
moisesja merged 2 commits into
mainfrom
fix/issue-4-legacy-proof-verify-dispatch
Jun 14, 2026
Merged

Fix #4: verify pipeline dispatches legacy / type-named proofs#5
moisesja merged 2 commits into
mainfrom
fix/issue-4-legacy-proof-verify-dispatch

Conversation

@moisesja

Copy link
Copy Markdown
Owner

Closes #4.

Problem

DataIntegrityProofPipeline.VerifySingleProofAsync dispatched a proof to a registered cryptosuite only when the proof carried a cryptosuite member and type == "DataIntegrityProof". Legacy / pre–Data-Integrity Linked-Data-Signature proofs (Ed25519Signature2020, EcdsaSecp256r1Signature2019, Ed25519Signature2018, JsonWebSignature2020) identify their algorithm by type and carry no cryptosuite member, so they could never be dispatched.

This violated the FR-4 extensibility contract ("registering a new suite requires no pipeline changes") and produced a create→verify inconsistency: a custom suite could emit a legacy-shaped proof through AddProofAsync that the same pipeline then refused to verify.

Fix (Option A from the issue)

A suite declares the proof type(s) it verifies; the verify path dispatches by cryptosuite name first (current behavior, always wins), then falls back to type for legacy proofs.

  • ICryptosuite.SupportedProofTypes — new default interface member (defaults to the single DataIntegrityProof type, backed by a shared static array). Non-breaking: existing suites compile unchanged and are still dispatched by cryptosuite name.
  • CryptosuiteRegistry — secondary type → suite index for non-default types only (the JCS suites all share DataIntegrityProof and stay unambiguous, name-dispatched). New GetByProofType(string?). Maintained under a registration lock with replace-aware, value-matched cleanup; reads stay lock-free.
  • VerifySingleProofAsyncGetByName(proof.Cryptosuite) ?? GetByProofType(proof.Type), gated on the suite actually claiming the type.

Dispatch only routes; each suite still re-validates type/cryptosuite/key/encoding/signature inside its own VerifyProof. The library still creates only conformant 2022/2019 proofs — this is verification-only.

Security

Default type is excluded from the type index, so no new acceptance path opens for DataIntegrityProof proofs with a missing/unknown cryptosuite (still rejected exactly as before). An adversarial subagent review 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. Its one non-exploitable hardening note (null-tolerant gate) was applied.

Tests & verification

  • dotnet build -c Release — 0 warnings / 0 errors (PublicApiAnalyzer + warnings-as-errors clean).
  • dotnet test -c Release736 passed, 0 failed across all six test projects.
  • New: LegacyProofTypeVerificationTests (8 — round-trip raw-key, wire shape carries no cryptosuite, tamper/wrong-key fail closed, unregistered-type rejected, JCS coexistence, resolver-path authorized + controller-authorization enforced); +6 CryptosuiteRegistryTests.

Acceptance criteria

  • Legacy Ed25519Signature2020 suite (no cryptosuite) round-trips create→verify.
  • Create→verify inconsistency closed.
  • Existing eddsa-jcs-2022 / ecdsa-jcs-2019 round-trips unchanged.
  • Unmatched type/cryptosuite still fails with PROOF_VERIFICATION_ERROR.
  • Negative: type X presented when only a suite for type Y is registered → rejected.
  • VerifyAsync still enforces controller authorization for legacy-type proofs.
  • Docs note legacy/type-named suites are verification-only.

Version bumped to 0.1.0-preview.2.

🤖 Generated with Claude Code

The verify path previously dispatched a proof to a cryptosuite only when it
carried a `cryptosuite` member AND `type == "DataIntegrityProof"`. Legacy
Linked-Data-Signature proofs (Ed25519Signature2020, EcdsaSecp256r1Signature2019,
...) name their algorithm by `type` and carry no `cryptosuite`, so they could
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 could emit a legacy-shaped proof the same pipeline then refused to verify).

Option A: a suite declares the proof type(s) it verifies via the new
`ICryptosuite.SupportedProofTypes` default interface member (defaults to the
single `DataIntegrityProof` type, so existing suites are unchanged and still
dispatched by `cryptosuite` name). `CryptosuiteRegistry` maintains a secondary
`type → suite` index for non-default types only (the JCS suites stay unambiguous,
name-dispatched) exposed via `GetByProofType`. The verify pipeline dispatches by
`cryptosuite` name first, falling back to proof `type` for legacy proofs.

Dispatch only routes; each suite still re-validates type/cryptosuite/key/encoding/
signature itself. The library still creates only conformant 2022/2019 proofs —
this is verification-only and non-breaking. Adversarial review found no acceptance
bypass (default type excluded from the type index → no JCS dispatch by type; gate
re-confirms the live suite claims the type; per-suite re-validation unchanged).

Tests: LegacyProofTypeVerificationTests (round-trip, wire shape, fail-closed,
unregistered-type rejection, JCS coexistence, resolver-path controller auth) and
6 added CryptosuiteRegistryTests. Bumps version to 0.1.0-preview.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@moisesja

Copy link
Copy Markdown
Owner Author

Review — looks solid ✅

Reviewed the dispatch change on this security-critical verify path (VerifySingleProofAsync). The design is sound and fails closed; independently reproduced both verification claims below.

Independently verified

  • dotnet build -c Release0 warnings / 0 errors (PublicApiAnalyzer + warnings-as-errors clean).
  • dotnet test -c Release736 passed, 0 failed across all six test projects (3+129+11+172+297+124).

Security analysis of the dispatch (traced each path)

The new GetByName(proof.Cryptosuite) ?? GetByProofType(proof.Type) + gate !(suite.SupportedProofTypes ?? []).Contains(proof.Type) holds up against the cases that matter:

  • DataIntegrityProof + missing/unknown cryptosuite → still rejected. The default type is excluded from the type index, so the GetByProofType fallback returns null (no regression vs. main).
  • Type confusion — valid JCS cryptosuite name + legacy type → name resolves the JCS suite, but the gate rejects because the JCS suite doesn't claim the legacy type. Fail closed.
  • Type confusion — legacy suite's Name used as cryptosuite + DataIntegrityProof type → name resolves the legacy suite, gate rejects (it doesn't claim the default type). Fail closed.
  • Legacy proof (no cryptosuite + legacy type) → dispatches via the type index, gate passes. The fix.

Two redundant layers (default-type exclusion from the index + the gate re-confirming the live suite claims the type), and each suite still re-validates type/cryptosuite/key/encoding/signature in VerifyProof — defense in depth is intact. The Register cleanup using value-matched ICollection<KVP>.Remove is the right call: it correctly preserves a different-named suite's entry when an unrelated suite is replaced (verified by Register_ReplacingLegacySuite_* tests). The create↔verify asymmetry (create requires a cryptosuite name; verify falls back to type) is intentional and internally consistent.

Tests, docs (FR-4 + XML), CHANGELOG, version bump, and PublicAPI tracking are all in order. Scope is tight and the commit message is clear.

Minor, non-blocking (optional)

  1. Same-type collision across two different suite names. If suites A and B register different Names but both declare the same non-default type, the type index silently does last-writer-wins (no warning). This mirrors name-collision semantics, but unlike name collisions the caller doesn't explicitly pick — a one-line doc note on GetByProofType/Register, or a debug log on overwrite, would help a future maintainer. (No security impact: both suites claim the type, and each re-validates.)
  2. Transient registration window. During a re-registration, GetByName returns the new suite while GetByProofType for its types is briefly null (between the remove and the add under the lock). Only observable if suites are registered concurrently with active verification, which isn't the expected startup-time usage — flagging for awareness only.

Nice work — the adversarial pass, fail-closed layering, and coexistence tests are exactly what this path needed.

🤖 Reviewed with Claude Code

…ll window

Two minor, non-blocking notes from the PR review:

1. Same-type collision across two different suite names is silently
   last-registration-wins. Documented this on the CryptosuiteRegistry class
   remarks and GetByProofType (benign — each suite re-validates in VerifyProof);
   added Register_SameTypeUnderDifferentNames_IsLastRegistrationWins and
   Register_ReplacingASuite_DoesNotStealAnotherNamesTypeEntry.

2. Transient registration window where GetByProofType could briefly return null
   for a type the suite still claims. Reordered Register to publish the new
   suite's type entries BEFORE pruning the previous suite's, using a set
   difference so only types the new suite no longer claims are removed
   (value-matched, leaving other names' entries intact and re-registration
   idempotent). Added Register_SameInstanceTwice_IsIdempotent.

Full suite: 739 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@moisesja moisesja self-assigned this Jun 14, 2026
@moisesja
moisesja merged commit 5de796a into main Jun 14, 2026
8 of 12 checks passed
@moisesja
moisesja deleted the fix/issue-4-legacy-proof-verify-dispatch branch June 14, 2026 03:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Verify pipeline cannot dispatch legacy / non-DataIntegrityProof-type proofs (breaks the 'no pipeline changes' suite contract)

1 participant