fix(webvh): did-witness.json spec wire format — proof member + versionId signed document (#135) - #143
Conversation
…ember, versionId signed document) Two divergences made every witnessed did:webvh DID non-interoperable in both directions (issue #135, surfaced by the DIF didwebvh-test-suite replay of #134): - did-witness.json was written and parsed with a `proofs` member; the spec and all six reference implementations use `proof`. - Witness proofs were verified against the log entry serialized without its proof; the spec defines them over the {"versionId": "..."} input document (confirmed empirically against ts, rust, java and dart suite vectors). The versionId embeds the entry hash that chain validation independently recomputes, so an approval still binds the whole entry. ParseWitnessFile now rejects duplicate JSON members (same trust-boundary rule as the log-entry parser), narrows its blanket catch to the JSON-access set, and reports the parse reason so callers log it instead of surfacing a bare witnessValidationFailed with no diagnostic. Known-answer fixtures from the committed suite (ts implementation) pin the interop in-suite: witnessed happy-path vectors resolve, and the two witness negatives are rejected by real threshold logic instead of vacuously at parse time. DIF replay: witnessed acceptance 0/10 -> 10/10. Fixes #135 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PRD resolution step 8 and create step 8 now state the proof member and
the {"versionId": ...} witness input document; README's wire-fidelity
paragraph no longer claims witness verification consumes entry members
directly; CHANGELOG gains the #135 Fixed entries with the wire-compat
breaking note and migration guidance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
) Findings from the three-lens adversarial review of the #135 fix: - HIGH (spec agent, demonstrated): the ts reference implementation authors ONE witness-file entry PER proof, so a version's approvals legitimately arrive split across duplicate-versionId entries. FirstOrDefault consulted only the first, falsely rejecting validly witnessed threshold>=2 DIDs, and MergeWitnessProofs kept only the last duplicate, shedding proofs on republish. Both now aggregate across all entries carrying the chain-validated versionId. - MED: a proof-less entry (versionId only) rejected the whole file; the spec says such entries "SHOULD be removed", so they may appear transiently. They now parse and simply contribute no approvals. - LOW: `created` was hard-required at parse; it is optional in VC Data Integrity and absent from the spec's minimum witness proof properties. Now optional end to end, and a null Created is omitted from the serialized file instead of written as JSON null. - LOW: witness proofPurpose was not value-checked; did:webvh v1.0 fixes it to assertionMethod and reference resolvers reject others, so counting them diverged from conformant threshold arithmetic. - LOW (crypto agent, demonstrated): the parse reason echoed System.Text.Json exception messages, which carry hostile member names (U+2028/U+2029 forge log lines). The reason is now bounded by construction: type name + numeric position, or fixed library text via an internal marker exception. - Contract honesty: Update/Deactivate no longer publish a witness artifact stripped of the caller's existing proofs when CurrentWitnessContent fails to parse - they throw ArgumentException with the reason and the retry contract. A legacy `proofs` member is rejected as a deliberate migration tripwire so pre-#135 files fail loudly instead of parsing as inert empty entries. - Non-string versionId now fails the parse instead of flowing into a MergeWitnessProofs dictionary crash through public Update/Deactivate (pre-existing parity, closed while hardening the parser). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2a99bbe to
3784154
Compare
Rebase onto main (PR #142). Conflict resolutions: - NetDidPRD.md: kept this branch's witness wire-format text in resolution step 8 and main's rewritten steps 9-10 (implicit service projection + IncludeLog artifacts). - CHANGELOG.md: merged the two [Unreleased] "Fixed" sections into one, main's #136 entries first, then the #135 entries. - tasks/webvh-vector-divergence.md: regenerated from the combined state rather than resolved by hand - it is generated output, and both changes affect it. Witnessed happy-path replays stay 10/10 accepted alongside #136's service projection; 21/21 negatives still rejected. Re-verified after the rebase: 0-warning Release build; 1,760 tests green (Core 439, WebVh 467, Ethr 490, W3C 233/233, Key 52, Peer 48, DI 20, Ethr.Integration 11 +7 opt-in skips); all 5 samples exit 0; git diff --check clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
moisesja
left a comment
There was a problem hiding this comment.
Do not merge this as written. The title, description, and commit messages are clear, the implementation scope is coherent, and the 22 issue-specific tests are useful. That does not compensate for the following correctness and security failures in the witness trust boundary.
-
HIGH — witness proof options are dropped before verification and on republish
WitnessValidator.cs:404-413parses only six fields,WebVhProofVerifier.cs:51-65reconstructs a reduced proof, andWitnessValidator.cs:237-247serializes only that reduced shape. W3C Data Integrity proof configuration includes additional proof options such asid,expires,domain,challenge,nonce,previousProof, and extensions. Those fields are signature-bound. A conforming proof signed withexpiresis therefore rejected after NetDid dropsexpires; Update/Deactivate then republishes the proof without that field but with the oldproofValue, permanently corrupting it. Worse, an attacker can append an unsigned extra option to an otherwise valid proof and NetDid verifies the truncated original configuration. Preserve the complete proof configuration and verify/re-emit it through the Data Integrity implementation. Add tests for a genuinely signed additional option and for unsigned option injection. The current comment claiming dropped fields fail closed is false. -
HIGH — malformed
CurrentWitnessContentis still silently ignoredThe new parse-and-throw behavior is nested under
WitnessProofs is { Count: > 0 }atDidWebVhMethod.cs:633-654and:750-771. Pass garbage or legacyproofscontent withWitnessProofs = nullor[]: Update/Deactivate succeeds, never parses the supplied file, and returns no witness artifact. That is the same silent ignored-input/proof-shedding class that the PR body, XML docs, and CHANGELOG claim is eliminated. Consume and validateCurrentWitnessContentwhenever it is supplied, independent of whether a new proof batch is present, and add null/empty-batch tests for both public operations. -
HIGH — the revised merge is TOCTOU-vulnerable despite the PR claiming TOCTOU was refuted
MergeWitnessProofsaccepts a caller-controlledIReadOnlyListand enumerates it once atWitnessValidator.cs:291to delete versions, then again at:297to add entries. A switching implementation can yield version A on the first pass and version B on the second; existing A is deleted with no replacement. Create/Update/Deactivate also wait before consumingWitnessProofs, so ordinary concurrent mutation has the same class of problem. Snapshot the outer list and every nestedProofslist exactly once at the public operation boundary, before any await, and use only that copy. Add a hostile changing-enumerator regression test across all three public surfaces. -
HIGH — same-version incremental collection still deletes prior approvals
WitnessValidator.cs:290-298removes the entire existing aggregate for every version named by the new batch. Existing v2 proof A plus newly received v2 proof B produces only B. This makes threshold collection unable to accumulate incrementally and can turn a threshold-satisfying file into an unresolvable one. The new split-entry test only adds a different version, so it misses the collision; the old test explicitly pins replacement. did:webvh v1.0 describes publishing as proofs are added, while the public docs say merge/include. Append and deduplicate same-version proofs, or document an explicit complete-replacement contract everywhere and justify why it is compatible with the spec workflow. -
HIGH availability — duplicate aggregation has no cryptographic-work bound
ValidateAllWitnessesatWitnessValidator.cs:74-81nests governed entries over later versions;:127-149verifies every proof; and:207-211rescans every witness entry for each version. There is no proof budget, memoization, threshold early-exit, or cancellation check. A valid 5 MiB log plus a legal <=1 MiB witness response containing thousands of duplicate invalid proofs can force repeated Ed25519 verification for every earlier governed entry after the HTTP timeout is already over. The byte cap is not a CPU cap. Index once, memoize verification, stop once the threshold is met, and enforce a configurable verification-attempt budget with adversarial tests. -
MEDIUM — bare witness verification methods are incorrectly counted
WebVhProofVerifier.cs:83-123explicitly acceptsdid:key:<multibase>without a fragment. did:webvh v1.0 requires witness proof verification methods in the exactdid:key:<multibase>#<multibase>form with equal body and fragment. An authorized witness can sign with the bare form; NetDid counts it while conforming resolvers discard it. Add a witness-specific strict parser and a signed negative test; do not globally tighten the shared helper because configured witness IDs are bare DIDs.
CI is also currently red. The failure is the unrelated timing-sensitive did:ethr test Issue116_TerminalHop_SlowEnumerationPastDeadline_InternalError_NotSuccess; I reran that exact test five times locally and all five passed, so it looks flaky rather than caused by this diff. It still needs a green CI rerun before merge.
Requested changes: fix and test the six items above, rerun the full gate, and rerun the adversarial review against the revised surface.
F1: witness proofs are verified by DataIntegrityProofPipeline with their complete wire configuration (RawJson captured at parse; republished verbatim) — signature-bound members (expires, nonce, extensions) are no longer dropped before verification or lost on republish, and an unsigned injected member now fails verification instead of being truncated away. F2: CurrentWitnessContent is consumed and validated whenever supplied, independent of WitnessProofs; valid content republishes, garbage throws. F3: caller WitnessProofs (outer + nested lists) snapshot exactly once at each public operation boundary before any await. F4: MergeWitnessProofs appends and dedupes same-version proofs (incremental collection) instead of replacing the aggregate. F5: witness verification is bounded — per-version index built once, per-proof verdict memoized across governed entries, membership and purpose pre-filters before any cryptography, threshold early-exit, a configurable verification-attempt budget (new DidWebVhMethod ctor overload, default 1024, fail-closed), and cancellation checks. F6: witness verificationMethods must use the spec's strict did:key:<mb>#<mb> form via WebVhWitnessKeyResolver; the bare-DID form counts nowhere a conforming resolver would discard it. WebVhProofVerifier.VerifyAndExtractSigner (reduced-proof reconstruction) is removed; witness verification shares the pipeline the controller path already uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…HANGELOG) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tack (#135) Two properties the round-2 adversarial re-attack validated, promoted to permanent regressions: a proof genuinely signed over a foreign versionId but filed under the real version must not count (the secured document binds to the chain-validated versionId, so the reference-keyed memo can never return a foreign-document verdict), and a proof carrying id + expires + an extension member verifies through the pipeline with its complete configuration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks — all six findings are fixed on this branch (commits 1 (HIGH) — dropped proof options. Witness proofs are now verified by the same 2 (HIGH) — silently-ignored 3 (HIGH) — TOCTOU. 4 (HIGH) — same-version deletion. 5 (HIGH availability) — unbounded work. One 6 (MED) — bare verificationMethod. New Gate: 0-warning Release build; 1,777 tests green (W3C conformance 233/233); DIF vector replay byte-identical — witnessed 10/10 accepted, 21/21 negatives rejected. The flaky Re-review: an independent adversarial re-attack of the revised surface found no High or Medium defects; all six fixes hold. It confirmed the subtle one — the pipeline strips the |
…pplied lessons Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
moisesja
left a comment
There was a problem hiding this comment.
Second review: the original six findings appear fixed, but this is still not ready to merge. The new implementation introduced or exposed four concrete defects, two of which I reproduced with focused tests.
-
BLOCKER — valid Data Integrity proof chains are rejected (
WitnessValidator.cs:216-220, 309-320).VerifyWitnessProofAsyncserializes only the single proof being counted. That destroys the proof set needed to resolvepreviousProof. I generated a genuine two-proof chain with the pinned DataProofs pipeline: verifying{"versionId": ..., "proof": [A, B]}returnedVerified=truewith both proof results true; the PR's validator rejected the exact artifact because it isolated B and left itspreviousProofdangling. This is not malformed input—the VC Data Integrity model explicitly supports proof chains.Verify the complete per-version proof set (or at least the dependency closure) and count successful configured signers from the per-proof results. Add a regression where the configured witness proof depends on another valid proof.
-
BLOCKER — the advertised work bound only limits signature calls; attacker-controlled preprocessing remains quadratic (
WitnessValidator.cs:148-170, 276-295, 327-333).For N governed entries with a usable proof only at the latest version, the nested
i/jscans perform N(N+1)/2 version lookups. Separately, P syntactically valid but unconfigured proofs are each compared against W configured witnesses viaFirstOrDefault, giving O(P×W) work that does not consume the verification budget. Session construction and long proof buckets also do not observe cancellation until much later. Calling this “bounded” is false; only cryptographic invocations are bounded.Build indexed witness membership once, avoid rescanning the suffix for every governed entry (compute cumulative coverage once), and check cancellation while indexing and walking proof buckets. Add adversarial scale tests that assert operation counts, not elapsed time.
-
BUG — same-proof deduplication fails in the normal public API path (
WitnessValidator.cs:402, 415-416, 547;DataIntegrityProofValue.RawJsonhas an internal setter).A proof parsed from
CurrentWitnessContenthas non-nullRawJson. The identical caller-suppliedWitnessProofsobject cannot setRawJson, so includingRawJsonin the dedupe key makes them unequal. A focused Update merge produced two identical emitted proofs. The current regression test masks this by parsing both sides, which public callers cannot do.Deduplicate by normalized full proof content, not by a provenance/cache field, and test parsed existing content against a programmatically supplied proof.
-
BUG — the “per-resolution” witness budget resets inside one resolution (
DidWebVhMethod.cs:374, 425;WitnessValidator.cs:82).Historical resolution can validate the requested prefix and then validate the deactivated tail. Each call creates a fresh
VerificationSession, so one resolution can perform approximately 2× the configured maximum and reverify the same proofs. Share one session/budget for the entire resolution, or stop documenting this as a per-resolution cap.
Also, the new witness budget is only configurable through direct construction. NetDidBuilder.AddDidWebVh still exposes only maxControllerProofsPerEntry and instantiates the three-argument constructor (NetDidBuilder.cs:54-88), so normal DI consumers cannot set the policy this PR claims to expose.
Validation performed on 17eefa5:
- Existing focused review/interoperability tests: 41/41 passed.
- Full WebVh project suite: 486/486 passed.
- PR CI is green.
- Two throwaway regression probes above: 0/2 passed; both failed on the exact asserted behavior. The probe file was removed and the worktree is clean.
Green CI does not compensate for tests that encode the wrong boundary. Please fix these and add regressions before merging.
moisesja
left a comment
There was a problem hiding this comment.
One additional consequence of finding 1 needs to be fixed at the public API boundary, not only in validation.
DidWebVhCreateOptions.WitnessProofs / Update / Deactivate accept DataIntegrityProofValue, but that public type exposes only the six minimum members. Its only carrier for signed options such as id, expires, domain, nonce, and previousProof is RawJson, whose setter is internal (DataIntegrityProofValue.cs:48-59). WriteProofObject consequently emits only the reduced six-member shape for every programmatically supplied proof (WitnessValidator.cs:369-385).
So a caller holding a conforming externally produced proof cannot pass it to Create at all without stripping signed configuration and invalidating the signature. Update/Deactivate work only when the proof happened to arrive via parsed CurrentWitnessContent; Create has no equivalent raw witness-file input.
Expose a safe way to submit the complete proof configuration (prefer a parsed/validated JSON representation over a bag of lossy modeled fields), preserve it through the snapshot/merge path, and exercise a public Create call with a genuine chained or extended proof. Otherwise the PR still does not provide the spec wire-format interop its title claims.
Fixes #135
What was broken
The DIF
didwebvh-test-suitereplay (#134) showed no witnessed did:webvh DID interoperated in either direction. Assessment confirmed the issue's finding and uncovered a second divergence the issue did not name — both are required for interop:did-witness.jsonwas written and parsed withproofs; did:webvh v1.0 and all six reference implementations useproof(WitnessValidator.cs:196/:272pre-fix).proof; the spec defines them aseddsa-jcs-2022proofs over the{"versionId": "..."}input document. Verified three independent ways: a from-scratch Python verifier against the ts/rust/java/dart suite vectors (versionId-doc ⇒ all verify; entry-without-proof ⇒ all fail), a second independent verifier during adversarial review (all 6 implementations), and the ts source (witness.tssigns{ versionId }). Fixing only the key name left 10/10 witnessed replays rejected.Soundness: the
versionIdembeds the entry hash that chain validation independently recomputes over full entry content before witness validation runs, and the signed document is always built from the chain-validated entry's versionId — so a witness approval still binds the whole entry, and cross-DID/cross-version replay fails (pinned by suite negative vectors).Evidence
witness-threshold+witness-update× ts/rust/java/java-eecc/dart). The two witness negatives are now rejected by real threshold logic instead of vacuously at parse time. Regenerated report committed (tasks/webvh-vector-divergence.md); remaining 15 rejections are the pre-existing pythoncontrollerdivergence (out of scope).Issue135_*tests. Fail-first proven twice: 6/9 round-1 tests red againstmain; 10/22 round-2 tests red against the round-1 commit.Adversarial review (3 independent worktree-isolated agents)
Confirmed findings, all fixed in this PR:
versionIdentries;FirstOrDefaultconsulted only the first (falsely rejecting threshold≥2 ts deployments) andMergeWitnessProofskept only the last duplicate. Both now aggregate; pinned by a suite-genesis known-answer test.createdis optional end-to-end (VC-DI);proofPurposemust beassertionMethodto count (matching reference resolvers); nullCreatedis omitted on serialize.ex.Messageis never echoed, since System.Text.Json embeds hostile member names (incl. U+2028 line separators) in it.ArgumentExceptionon unparseableCurrentWitnessContentinstead of silently publishing a witness artifact stripped of the caller's existing proofs; the legacyproofsmember is rejected as an explicit migration tripwire. Non-stringversionIdno longer reaches a dictionary crash through public Update/Deactivate.Refuted (design held): replay/substitution, vote-accounting inflation, parse-path exception escapes (28-case hostile corpus, zero escapes), TOCTOU, duplicate-member strictness harming conformant emitters (0 duplicates in all suite artifacts).
Breaking (wire compatibility)
A
did-witness.jsonpublished by an earlier NetDid release is invalid under the corrected format — its proofs were signed over the wrong document, so no key rename can rescue them; affected DIDs must re-collect witness proofs. If an external witness toolchain minted spec-correct proofs that NetDid merely stored underproofs, renaming that member toproofin the published file restores validity. Tolerant reading ofproofswas deliberately not kept: it could never validate NetDid-minted legacy proofs and would only re-enable silent proof-shedding during migration. Details in CHANGELOG.🤖 Generated with Claude Code