diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5b58c..8156451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Preserve-mode did:webvh updates carry the previous document verbatim into the new signed + entry** (#101). An `UpdateAsync` with `NewDocument == null` previously rebuilt the appended head + from the reduced typed model, silently dropping signed nested document members the model does not + surface (for example a member inside a `verificationMethod` object) before hashing and signing the + reduced state. The preserved state is now re-emitted from its retained wire JSON, so signed nested + members survive into the new head. A state-level provenance fingerprint falls back to modeled + serialization for a deliberately changed document, so a genuine consumer edit still wins over stale + wire bytes. +- **A mutable or dynamic `NewDocument` can no longer diverge the published log from the signed + bytes** (#101). `UpdateCoreAsync` now deep-copies the caller's `DidWebVhUpdateOptions.NewDocument` + exactly once at the trust boundary (one JSON-LD round-trip). Entry hashing, signing across the + async boundary, the published `did.jsonl`, the `did.json` compatibility artifact, and + `DidUpdateResult.DidDocument` all observe that single private snapshot, so a caller-supplied + collection that returns different contents per enumeration cannot publish bytes that differ from + what was hashed and signed (the same trust-boundary treatment already applied to parameter + collections). Observable change: `DidUpdateResult.DidDocument` is now the private snapshot, not the + caller's supplied instance. +- **Resolution enforces the SCID identity of every validated entry, not just the target** (#101). + did:webvh v1.0 requires the SCID segment of `state.id` to be byte-for-byte identical to the DID's + SCID and the first entry's `parameters.scid` "for every entry's `state.id`, not just the first," + independently of portability. `LogChainValidator` now checks this for the genesis and every + subsequent entry it validates (only the host/path portion may change under portability), so a + genuinely signed log whose intermediate or genesis entry claims a foreign SCID — or omits + `state.id` — is rejected as `invalidDidLog`. Historical resolution still validates only the + selected prefix, so a corrupt later tail does not revoke an already-established version. + **Compatibility**: Update/Deactivate on an SCID-inconsistent (e.g. forged) log now throw + `LogChainValidationException` during chain validation rather than the previous `ArgumentException`; + both still reject the log. +- **did:webvh entries with invalid or unauthorized extra controller proofs are now rejected** + (#101). `LogChainValidator` previously accepted an entry as soon as one proof verified from an + active update key, silently skipping every other supplied proof; did:webvh v1.0 requires + "Resolvers MUST reject an entry whose proof fails any check." Every supplied controller proof + is now processed by DataProofsDotnet's `DataIntegrityProofPipeline` and authorized by a + did:webvh resolver: the `verificationMethod` + must be a `did:key` (DID==fragment anti-spoof) whose Ed25519 multibase appears verbatim in the + active `updateKeys`, used for `proofPurpose` `assertionMethod`. One authorized signer still + authorizes the entry — multiple valid authorized proofs remain supported and no threshold + semantics were introduced. `proofPurpose` was previously never checked at all. **Compatibility**: + NetDid's writer emits exactly one proof, and conforming single-proof logs validate unchanged; a + multi-proof log carrying invalid or unauthorized extras is now rejected as `invalidDidLog` — + intentional stricter conformance. +- **Schema-defined proof members are preserved and supported where their semantics are enforced** + (#101). The + did:webvh v1.0 log-entry schema requires the controller-proof members "at minimum" and leaves + additional properties open. A present proof `id` must pass NetDid's conservative + `System.Uri`-compatible absolute-URI check without surrounding whitespace; DID, URN, and HTTPS + forms are accepted, but this is explicitly not full WHATWG valid-URL-string conformance. Duplicate + proof ids are rejected so an unordered proof set cannot resolve a `previousProof` differently by + array order. A `previousProof` chain is resolved (a dangling reference is rejected), and an + `expires` at or before the entry's `versionTime` is treated as expired. Other extensions are + preserved verbatim and remain signature-bound, but NetDid does not claim application semantics + for every extension. + Parsed proof-object JSON round-trips byte-for-byte when Update/Deactivate republish a fetched log; + a single-object enclosing `proof` value is normalized to an array, which is also schema-valid and + does not change the proof signature. Array-valued `domain` remains unsupported by the pinned + DataProofsDotnet model and is an explicit upstream compatibility limitation. + `DataIntegrityProofValue.Created` is now optional (`string?`). +- **Fetched entries are verified from their complete semantic JSON, not a reduced typed model** + (#101). Private parser provenance retains nested `parameters` and `state` members that the public + model does not surface. Entry hashes, controller proofs, and witness proofs all use that retained + content with only the protocol-required `versionId` replacement or `proof` removal. This closes a + reproduced post-sign nested-member injection that previously resolved and appeared in + `IncludeLog`. Update/Deactivate preserve those members in prior fetched entries. A modeled + fingerprint prevents stale provenance from overriding a public clone or in-place model mutation. +- **Duplicate JSON members, invalid UTF-8, and malformed content in a fetched log are rejected as `invalidDidLog`** + (#101). Entries now parse with `AllowDuplicateProperties = false` (recursive), so a decoy + duplicate `proof` — or any duplicate member — can no longer silently drop a supplied member + (`System.Text.Json` keeps the last of a pair). JSON-access failures at the parse boundary, + including a string carrying an unpaired surrogate that parses as a token but throws on decode, + now map to `FormatException` → `invalidDidLog` instead of `notFound` (or, for Update/Deactivate, + an unhandled exception). did.jsonl and witness files use strict UTF-8 decoding, so invalid bytes + cannot normalize into a different signed string. The `proof` member parses as a single object or + an array (schema `oneOf`). +- **Per-entry controller-proof verification work is explicitly bounded** (#101). Verifying each + proof re-canonicalizes the entry, and `created` is attacker-chosen and part of the signed + configuration, so one active key can mint arbitrarily many *distinct* valid proofs. The resolver + therefore imposes an explicit, documented resource limit on controller proofs per entry + (default 8); an entry beyond the limit is rejected as `invalidDidLog`. Direct callers configure + it with the additive `DidWebVhMethod(client, logger, maxControllerProofsPerEntry)` constructor and + DI callers with `AddDidWebVh(httpClientOptions, maxControllerProofsPerEntry)`. The existing + signatures remain source- and binary-compatible. Values must be positive; raising the limit + increases attacker-controlled canonicalization and signature work. This is a resource policy, + not a conformance rule (real entries carry one proof). +- **Historical `IncludeLog` no longer exposes an unvalidated tail** (#101). Resolution by + `versionId` or `versionTime` returns only the exact raw and parsed log prefix through the selected + version. Latest resolution still returns the complete fetched log text. Once an earlier target + has been selected and validated, a later cryptographically invalid or unauthorized entry does + not appear in that target's artifacts. + ### Security - Fresh-key creation in `DidKeyMethod` (did:key) and `Numalgo0Handler` (did:peer:0) now disposes diff --git a/NetDidPRD.md b/NetDidPRD.md index ad15b43..650eba5 100644 --- a/NetDidPRD.md +++ b/NetDidPRD.md @@ -1073,6 +1073,58 @@ chains to the predecessor `versionId`. The SCID and genesis entry hash are disti computed from the placeholder-bearing preliminary genesis, while the genesis entry hash is computed after SCID substitution with `versionId` temporarily set to the SCID. +**Controller proofs (issue #101).** A did:webvh entry requires at least one controller proof. One +active update key is sufficient to authorize the entry. Every supplied controller proof is processed +by DataProofsDotnet's `DataIntegrityProofPipeline` and authorized by NetDid's did:webvh policy: it +must use `DataIntegrityProof`, `eddsa-jcs-2022`, and `assertionMethod`; its `verificationMethod` must +be an anti-spoofed `did:key` whose Ed25519 multibase appears verbatim in the active `updateKeys`; and +its signature must verify. If any supplied proof fails any check the entry is rejected, per the +spec's Authorized Keys rule ("Resolvers MUST reject an entry whose proof fails any check"). +Controller proofs do not use threshold semantics (that exception is witness-specific). The official +log-entry schema permits `proof` as a single proof object or an array; NetDid parses both and treats +`created` as optional per that schema. + +The did:webvh v1.0 schema requires the controller-proof members *at minimum* and leaves additional +properties open. NetDid preserves the exact JSON for each parsed proof object and includes unknown +members in signature verification, but it claims semantic enforcement only where a policy exists. +A present proof `id` must pass a conservative `System.Uri`-compatible absolute-URI check without +surrounding whitespace. This accepts the DID, URN, and HTTPS forms covered by NetDid, but is not full +WHATWG valid-URL-string conformance and can reject other standards-valid forms. Present proof ids +must also be unique under ordinal equality so `previousProof` resolution cannot depend on proof-array +order. The pipeline resolves `previousProof` references and rejects dangling references; a proof +whose `expires` is at or before the entry's `versionTime` is expired (verification time is pinned to +`versionTime`). Other extension members are signature-bound, not generically application-validated. +A single-object `proof` container is +normalized to a one-element array when Update/Deactivate reserialize the log; the proof object's +JSON itself remains verbatim, so normalization does not change its signature input. The pinned +DataProofsDotnet model supports scalar `domain` only; a standards-valid array-valued `domain` is a +documented upstream compatibility limitation and is rejected by this version of NetDid. +To keep universal validation sound the parser rejects duplicate JSON members anywhere in a fetched +entry (`AllowDuplicateProperties = false`) — `System.Text.Json` keeps the last of a duplicate pair, +so a decoy `proof` beside a valid one would otherwise be silently dropped — and maps JSON-access +failures at the parse boundary (e.g. a string carrying an unpaired surrogate that decodes with an +error) to `invalidDidLog`, never `notFound`. did.jsonl and witness files are decoded as strict UTF-8; +malformed byte sequences are never silently replaced before hash or proof verification. + +Fetched-entry integrity input MUST retain every semantic JSON member, including nested members under +`parameters` and `state` that the public typed model does not surface. `LogEntrySerializer` keeps +private, reference-identity wire provenance plus a fingerprint of the modeled entry at parse time. +For an unchanged parsed entry, entry-hash/SCID input replaces only top-level `versionId`, proofless +hash/witness input removes only top-level `proof`, and controller verification consumes the complete +secured entry. Update/Deactivate re-emission retains those fetched nested members. A public `with` +clone has no provenance, and an in-place modeled mutation changes the fingerprint, so either case +falls back to modeled serialization instead of allowing stale wire data to override caller intent. + +Verifying each proof re-canonicalizes the entry, and `created` is attacker-chosen and part of the +signed configuration, so one active key can mint arbitrarily many *distinct* valid proofs. The +resolver therefore imposes an explicit, configurable resource limit on controller proofs per entry +(default 8); an entry beyond the limit is rejected as `invalidDidLog`. Direct construction configures +it with `new DidWebVhMethod(client, logger: null, maxControllerProofsPerEntry: value)` and dependency +injection with `builder.AddDidWebVh(httpClientOptions: null, maxControllerProofsPerEntry: value)`. +The existing constructor and registration signatures are retained to avoid source and binary +breaks. Values must be positive. This is a resource policy, not a conformance rule; raising it +deliberately increases attacker-controlled canonicalization and signature work. + `versionTime` is part of the hash- and proof-protected entry. NetDid formats it in UTC with the invariant Gregorian calendar, preserving fractional seconds when present while retaining the existing whole-second form when the fraction is zero. Parsing requires an explicit UTC `Z` @@ -1126,11 +1178,12 @@ well-known placeholder string (`{SCID}`) that stands in for the real SCID during 4. Validate the genesis entry by independently reconstructing both hashes: restore the SCID as the preliminary `versionId` and verify the published genesis entry hash, then restore `{SCID}` in all SCID-bearing values and verify the SCID. The SCID and genesis entry hash MUST NOT be conflated. -5. For each subsequent entry: require `versionTime` to be strictly later than the previous entry, verify the proof signature against an authorized update key, verify the hash chain links to the previous entry, and verify parameter constraints (pre-rotation key commitments and witness policy shape). +5. For each subsequent entry: require `versionTime` to be strictly later than the previous entry, validate **every** supplied controller proof (required type/cryptosuite/purpose, valid signature, signer verbatim in the active `updateKeys` — one authorized signer authorizes the entry, but any invalid or unauthorized extra proof invalidates it; see §7.4), verify the hash chain links to the previous entry, and verify parameter constraints (pre-rotation key commitments and witness policy shape). The same universal proof rule applies to the genesis entry against its own declared `updateKeys`. 6. **Bind the DID's self-certifying SCID to the genesis (issue #82).** The SCID segment of the requested DID string MUST equal the genesis entry's SCID (`entries[0].Parameters.Scid`, already proven equal to the recomputed genesis hash by step 4). The target entry's `state.id` (an author-controlled field) matching `did` is necessary but **not** sufficient: without the SCID check, a host/CDN/MITM adversary can serve a self-consistent genesis signed by their own key with `state.id` set to the victim's DID and impersonate it, collapsing did:webvh's security to plain did:web. Reject with `invalidDidLog` on mismatch. + - **Per-entry SCID identity (issue #101).** did:webvh v1.0 additionally requires the SCID segment of `state.id` to be byte-for-byte identical to `parameters.scid` **for every entry, not just the first**, "independently of whether portability is enabled" — only the host/path portion may change under portability. `LogChainValidator` enforces this for the genesis and every subsequent validated entry (an entry whose `state.id` carries a foreign SCID, is not a `did:webvh` DID, or omits `state.id` fails chain validation → `invalidDidLog`). Because historical resolution validates only the prefix through the selected version (step 9), a corrupt later tail does not revoke an already-established version; latest resolution validates the whole chain. 7. Validate every explicitly declared witness policy before it can take effect, at genesis and on every later transition. The disabling form is the empty object `{}`. A configured policy MUST contain both `threshold` and a non-empty `witnesses` list; `threshold` MUST be in the inclusive range `1..count(distinct witness ids)`; every id MUST already be Unicode NFC-normalized, MUST be unique under ordinal comparison in that canonical form, MUST be a bare `did:key` DID, and MUST encode an Ed25519 key compatible with `eddsa-jcs-2022`. Missing fields, duplicate or non-canonical ids, zero/negative/out-of-range thresholds, malformed keys, and unsupported key types invalidate the log rather than being coerced to “no witnesses.” 8. Determine the witness authority for every entry. Genesis is governed by the witness policy it declares. The first later entry that changes from no active witnesses to a configured policy is immediately governed by that new policy and MUST itself be witnessed. Once a policy is active, it governs the entry that lowers, removes, or replaces it, and the replacement takes effect only after that entry is published. If any entry through the requested version requires witnessing, fetch `did-witness.json` and validate cumulative witness coverage against those authorizing policies. Count a configured witness only after its proof verifies and binds to the exact configured `did:key` signer (never a `verificationMethod` prefix), and count each distinct signer at most once per required entry. -9. Return the DID Document from the final valid entry. When `DidResolutionOptions.IncludeLog == true`, also surface the fetched log as `Artifacts["did.jsonl"]` (UTF-8 string, matching the Create/Update/Deactivate artifact convention) and the parsed chain as `Artifacts["log.entries"]` (`IReadOnlyList`). The log is parsed and validated regardless; the flag only controls whether it is exposed to the caller. +9. Return the DID Document from the selected valid entry. When `DidResolutionOptions.IncludeLog == true`, also surface the validated resolution scope as `Artifacts["did.jsonl"]` (UTF-8 JSONL) and `Artifacts["log.entries"]` (`IReadOnlyList`). Latest resolution returns the complete fetched log after complete validation. A successfully selected historical `versionId`/`versionTime` resolution returns only the exact raw and parsed prefix through the selected entry; later entries are not resolution evidence and MUST NOT be exposed in those artifacts. #### Witness weight compatibility and migration @@ -1148,8 +1201,9 @@ upgrading; conforming legacy policies with `threshold <= count(distinct ids)` re 1. Load the current DID log and validate the chain. 2. **Bind the inputs to the target DID (issue #82).** The supplied `CurrentLogContent` must belong to the `did` being updated — its latest entry's `state.id` MUST equal `did`, its **genesis SCID MUST equal the SCID in `did`** (the same self-certification binding resolution enforces, so an attacker-owned log cannot authorize an update of the victim DID merely by claiming its id), and the log MUST NOT already be deactivated. If `NewDocument` is supplied, `NewDocument.id` MUST equal `did`. This guarantees Update cannot emit a log that Resolve would reject **on DID/SCID identity grounds**. Witness validation remains a publication/resolution concern: a transition is governed by the prior-effective witness policy described in §7.6, including a transition that weakens or removes that policy. Violations throw `ArgumentException`. + - *Exception note (issue #101):* the per-entry SCID identity rule is enforced during chain validation (step 1), which runs before this binding. A log that is internally SCID-inconsistent (e.g. a forged genesis whose `state.id` claims the victim DID while `parameters.scid` is the attacker's own hash) is therefore rejected earlier with `LogChainValidationException`, not the `ArgumentException` this step would otherwise raise. Both reject the log; the identity-binding `ArgumentException` covers the residual cases the chain rule does not (e.g. an internally consistent but unrelated log whose SCID simply differs from `did`). - *Portability note:* net-did does not implement did:webvh portability (there is no `Portable` field in `DidWebVhParameterUpdates`). If portability is added later, the `state.id == did` / `NewDocument.id == did` binding needs a carve-out for the domain-change entry. -3. Build a new log entry with the updated DID Document and/or parameters. +3. Build a new log entry with the updated DID Document and/or parameters. When `NewDocument` is supplied it is **deep-copied exactly once** at the start of the update (one JSON-LD round-trip); hashing, signing, the published log, the `did.json` artifact, and `DidUpdateResult.DidDocument` all observe that single private snapshot, so a caller-supplied collection that returns different contents per enumeration cannot publish bytes that diverge from the hashed and signed bytes (the same trust-boundary treatment given to parameter collections). When `NewDocument` is `null`, the previous document is preserved **verbatim** from its retained wire JSON, so signed nested members the typed model does not surface survive into the new signed head rather than being silently dropped by a modeled rewrite (issue #101). 4. Determine whether pre-rotation governs the new entry from the previous effective `nextKeyHashes`. If it is non-empty, the new entry MUST explicitly contain non-empty `updateKeys` and an explicit `nextKeyHashes` array (which MAY be `[]` to end pre-rotation); every current `updateKeys` member MUST match a previous commitment, and the proof MUST be signed by one of those current keys. Otherwise, the proof signer MUST be in the previous effective `updateKeys`; an entry that first sets non-empty `nextKeyHashes` is therefore still authorized by the prior keys. Each commitment is `base58btc(multihash(SHA-256, UTF8(multikey)))`: bare base58btc over the complete `0x12 0x20 <32-byte digest>` multihash, without a multibase `z` prefix. diff --git a/README.md b/README.md index 3a588f0..79437a9 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,14 @@ Console.WriteLine(resolved.DocumentMetadata!.VersionId); // "1-z6Rk8Rx..." Resolution fetches the `did.jsonl` log over HTTPS, validates the hash chain and Data Integrity Proofs, and returns the latest DID Document. +Every supplied controller proof on an entry is processed by DataProofsDotnet's Data Integrity pipeline and authorized against the active `updateKeys`: NetDid requires an anti-spoofed `did:key` verification method, Ed25519 `eddsa-jcs-2022`, `assertionMethod`, a valid signature, and an active update key. One authorized signer authorizes the entry, but any invalid or unauthorized extra proof rejects the log as `invalidDidLog`; controller proofs have no threshold semantics. NetDid applies a conservative `System.Uri`-compatible absolute-URI check to a present proof `id` (without surrounding whitespace), rejects duplicate proof ids, resolves `previousProof` references, and treats `expires` at or before the entry's `versionTime` as expired. This accepts the DID, URN, and HTTPS forms used by the SDK, but it is not full WHATWG valid-URL-string conformance and can reject other standards-valid forms. Unknown proof members remain signature-bound and their proof-object JSON is preserved, but NetDid does not claim application semantics for every extension. In particular, array-valued `domain` is not supported by the pinned DataProofsDotnet model. A wire `proof` may be a single object or an array and `created` is optional; reserialization preserves each parsed proof object but normalizes a single-object container to an array. Duplicate JSON members, invalid UTF-8, and malformed content are rejected as `invalidDidLog`. + +Fetched entry hashing and controller/witness verification retain every JSON member under `parameters` and `state`, including nested extensions that the typed DID model does not surface. This prevents a post-sign extension injection from disappearing during model reconstruction. Update and Deactivate also preserve those members in prior fetched entries; deliberate public-model mutations fall back to modeled serialization instead of stale wire data. An update that preserves the document (`NewDocument == null`) likewise carries the previous state's signed nested members into the new signed entry rather than dropping them in a modeled rewrite. A supplied `NewDocument` is deep-copied once at the start of the update, so hashing, signing, the published log, and the returned document all reflect a single snapshot — a caller collection that changes contents between reads cannot publish bytes that differ from what was signed. + +Resolution enforces did:webvh's per-entry SCID identity: the SCID segment of every validated entry's `state.id` must match the DID's SCID (only host/path may differ under portability). A signed log whose genesis or an intermediate entry claims a foreign SCID, or omits `state.id`, is rejected as `invalidDidLog`; historical resolution validates only the prefix through the selected version. + +Verification work per entry is bounded by a controller-proof limit (default 8). Direct consumers can set it with `new DidWebVhMethod(client, logger: null, maxControllerProofsPerEntry: 16)` and DI consumers with `builder.AddDidWebVh(httpClientOptions: null, maxControllerProofsPerEntry: 16)`; raising it increases attacker-controlled canonicalization and signature work. The existing two-argument constructor and one-argument registration call remain source- and binary-compatible. With `DidResolutionOptions.IncludeLog`, latest resolution exposes the fully validated log, while historical resolution exposes only the validated prefix through the selected version. + ### Update (append to log) ```csharp diff --git a/src/NetDid.Core/Model/DidResolutionOptions.cs b/src/NetDid.Core/Model/DidResolutionOptions.cs index ac68933..c7fd458 100644 --- a/src/NetDid.Core/Model/DidResolutionOptions.cs +++ b/src/NetDid.Core/Model/DidResolutionOptions.cs @@ -14,8 +14,9 @@ public record DidResolutionOptions /// /// When true, methods that maintain a history (e.g. did:webvh) populate - /// with the parsed log. Methods without - /// history ignore this flag. Default: false. + /// with the validated history used for the + /// resolution. A historical query exposes history only through the selected version. Methods + /// without history ignore this flag. Default: false. /// public bool IncludeLog { get; init; } = false; diff --git a/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs b/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs index e894ce8..7dc5498 100644 --- a/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs +++ b/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs @@ -43,9 +43,30 @@ public NetDidBuilder AddDidPeer() return this; } - /// Register the did:webvh method. Uses IHttpClientFactory for HTTP requests. + /// + /// Register the did:webvh method with the default controller-proof verification budget. + /// Uses IHttpClientFactory for HTTP requests. + /// public NetDidBuilder AddDidWebVh(WebVhHttpClientOptions? httpClientOptions = null) + => AddDidWebVh( + httpClientOptions, + DidWebVhMethod.DefaultMaxControllerProofsPerEntry); + + /// + /// Register the did:webvh method with a caller-specified upper bound on controller proofs + /// verified per log entry. Uses IHttpClientFactory for HTTP requests. + /// + /// Resource limits for fetching did:webvh artifacts. + /// + /// Maximum controller proofs verified per entry. Must be at least one. Raising this limit + /// increases the canonicalization and signature-verification work an untrusted log can cause. + /// + public NetDidBuilder AddDidWebVh( + WebVhHttpClientOptions? httpClientOptions, + int maxControllerProofsPerEntry) { + ArgumentOutOfRangeException.ThrowIfLessThan(maxControllerProofsPerEntry, 1); + Services.AddSingleton(httpClientOptions ?? new WebVhHttpClientOptions()); // WebVhHttpClientOptions.Timeout is the sole time authority for this // library-owned client: neutralize HttpClient.Timeout (100s framework @@ -58,7 +79,9 @@ public NetDidBuilder AddDidWebVh(WebVhHttpClientOptions? httpClientOptions = nul sp.GetRequiredService()); Services.AddSingleton(sp => new DidWebVhMethod( - sp.GetRequiredService())); + sp.GetRequiredService(), + logger: null, + maxControllerProofsPerEntry: maxControllerProofsPerEntry)); return this; } diff --git a/src/NetDid.Method.WebVh/DidWebVhArtifacts.cs b/src/NetDid.Method.WebVh/DidWebVhArtifacts.cs index 2e6e42e..6155c42 100644 --- a/src/NetDid.Method.WebVh/DidWebVhArtifacts.cs +++ b/src/NetDid.Method.WebVh/DidWebVhArtifacts.cs @@ -10,7 +10,11 @@ namespace NetDid.Method.WebVh; /// public static class DidWebVhArtifacts { - /// UTF-8 contents of the did.jsonl log. + /// + /// UTF-8 contents of the did.jsonl log. Historical resolution with + /// returns the validated prefix through + /// the selected version; latest resolution and mutation operations return the complete log. + /// public const string DidJsonl = "did.jsonl"; /// UTF-8 contents of the did:web-compatible did.json document. @@ -19,6 +23,11 @@ public static class DidWebVhArtifacts /// UTF-8 contents of the did-witness.json file (present only when witnesses are configured). public const string DidWitnessJson = "did-witness.json"; - /// Parsed log chain as of , exposed by the resolver when is set. + /// + /// Parsed, validated log scope as an + /// of , exposed by the resolver when + /// is set. Historical resolution + /// returns only entries through the selected version. + /// public const string LogEntries = "log.entries"; } diff --git a/src/NetDid.Method.WebVh/DidWebVhMethod.cs b/src/NetDid.Method.WebVh/DidWebVhMethod.cs index 9ac272f..9484159 100644 --- a/src/NetDid.Method.WebVh/DidWebVhMethod.cs +++ b/src/NetDid.Method.WebVh/DidWebVhMethod.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Globalization; using System.Text; using System.Text.Json; @@ -7,6 +8,7 @@ using NetDid.Core; using NetCrypto; using NetDid.Core.Model; +using NetDid.Core.Serialization; using NetDid.Method.WebVh.Model; namespace NetDid.Method.WebVh; @@ -17,6 +19,13 @@ namespace NetDid.Method.WebVh; /// public sealed class DidWebVhMethod : DidMethodBase { + /// + /// Default maximum controller proofs verified per log entry. This is a resolver resource + /// policy, not a did:webvh conformance limit. + /// + public const int DefaultMaxControllerProofsPerEntry = + LogChainValidator.DefaultMaxControllerProofsPerEntry; + private readonly IWebVhHttpClient _httpClient; private readonly EddsaJcs2022Cryptosuite _suite; private readonly LogChainValidator _chainValidator; @@ -25,11 +34,29 @@ public sealed class DidWebVhMethod : DidMethodBase internal const string MethodVersion = "did:webvh:1.0"; + /// + /// Creates a did:webvh method using . + /// public DidWebVhMethod(IWebVhHttpClient httpClient, ILogger? logger = null) + : this(httpClient, logger, DefaultMaxControllerProofsPerEntry) + { + } + + /// Creates a did:webvh method with a caller-specified controller-proof budget. + /// Client used to fetch did:webvh artifacts. + /// Optional resolver logger. + /// + /// Maximum controller proofs verified per log entry. Must be at least one. Raising this value + /// increases the canonicalization and signature-verification work an untrusted log can cause. + /// + public DidWebVhMethod( + IWebVhHttpClient httpClient, + ILogger? logger, + int maxControllerProofsPerEntry) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _suite = new EddsaJcs2022Cryptosuite(); - _chainValidator = new LogChainValidator(_suite); + _chainValidator = new LogChainValidator(maxControllerProofsPerEntry); _witnessValidator = new WitnessValidator(_suite); _logger = logger ?? NullLogger.Instance; } @@ -219,14 +246,15 @@ protected override async Task ResolveCoreAsync( if (entries.Count == 0) return DidResolutionResult.NotFound(did); - // Determine the target entry index before validation - // This allows versioned queries to succeed even if later entries are invalid + // Determine the target entry index before validation. For an exact historical target, + // validation below is intentionally limited to the selected prefix. var targetIndex = FindTargetIndex(entries, options); if (targetIndex < 0) return DidResolutionResult.NotFound(did); // Validate the chain up to the target version - var perEntryParams = _chainValidator.ValidateChainWithPerEntryParams(entries, targetIndex + 1); + var perEntryParams = await _chainValidator.ValidateChainWithPerEntryParamsAsync( + entries, targetIndex + 1, ct); var effectiveParams = perEntryParams[^1]; var targetEntry = entries[targetIndex]; @@ -316,18 +344,19 @@ protected override async Task ResolveCoreAsync( // A directly requested deactivation entry suppresses the DID Document. For a prior // version of a DID that was validly deactivated later, retain that historical // document but mark its metadata deactivated as required by did:webvh v1.0. A parsed - // tail that fails chain, identity, or witness validation never contaminates a valid - // historical resolution. + // tail beyond the selected target that fails chain, identity, or witness validation + // does not contaminate that target's resolution artifacts. var isDeactivated = effectiveParams.Deactivated == true; var deactivatedForMetadata = isDeactivated; if (!isDeactivated && targetIndex < entries.Count - 1) { try { - var fullPerEntryParams = _chainValidator.ValidateChainWithPerEntryParams( - entries, entries.Count); - if (fullPerEntryParams[^1].Deactivated == true && - HasConsistentScid(entries, entries[0].Parameters.Scid)) + // Per-entry state.id SCID consistency is enforced inside chain validation, + // so a tail claiming a foreign identity fails here and is not asserted. + var fullPerEntryParams = await _chainValidator.ValidateChainWithPerEntryParamsAsync( + entries, entries.Count, ct); + if (fullPerEntryParams[^1].Deactivated == true) { var fullChainWitnessesValid = true; if (WitnessValidator.RequiresWitness(fullPerEntryParams, entries.Count - 1)) @@ -362,11 +391,20 @@ protected override async Task ResolveCoreAsync( IReadOnlyDictionary? artifacts = null; if (options?.IncludeLog == true) { - artifacts = new Dictionary - { - [DidWebVhArtifacts.DidJsonl] = Encoding.UTF8.GetString(logContent), - [DidWebVhArtifacts.LogEntries] = entries - }; + var resolvesLatest = targetIndex == entries.Count - 1; + IReadOnlyList exposedEntries = resolvesLatest + ? entries + : entries.Take(targetIndex + 1).ToList().AsReadOnly(); + var exposedJsonl = resolvesLatest + ? LogEntrySerializer.DecodeUtf8(logContent) + : GetRawJsonLinesPrefix(logContent, targetIndex + 1); + + artifacts = new ReadOnlyDictionary( + new Dictionary + { + [DidWebVhArtifacts.DidJsonl] = exposedJsonl, + [DidWebVhArtifacts.LogEntries] = exposedEntries + }); } return new DidResolutionResult @@ -424,7 +462,7 @@ protected override async Task UpdateCoreAsync( // Parse and validate existing log var entries = LogEntrySerializer.ParseJsonLines(updateOptions.CurrentLogContent); - var effectiveParams = _chainValidator.ValidateChain(entries); + var effectiveParams = await _chainValidator.ValidateChainAsync(entries, ct); // Bind the supplied log and new document to the target DID. Without these checks the // driver could emit a log the resolver rejects on identity grounds (resolution enforces @@ -433,7 +471,17 @@ protected override async Task UpdateCoreAsync( // witness thresholds, are orthogonal — a witness-policy update still needs matching proofs // published for the resulting log to resolve.) RequireAppendableLogForDid(entries, effectiveParams, did); - if (updateOptions.NewDocument is not null && updateOptions.NewDocument.Id.Value != did) + + // Snapshot the caller's document exactly once at the trust boundary. The document is + // read by validation, entry hashing, signing (across an await), the published log, and + // the compatibility artifact; a mutable or dynamic collection inside a live caller + // instance could otherwise present different contents to each stage, publishing bytes + // that diverge from the hashed and signed bytes. Every check below — including the Id + // binding — runs against this private copy. + var snapshotDocument = updateOptions.NewDocument is { } callerDocument + ? SnapshotDocument(callerDocument) + : null; + if (snapshotDocument is not null && snapshotDocument.Id.Value != did) throw new ArgumentException( $"NewDocument.Id must equal the DID being updated ('{did}').", nameof(options)); @@ -486,8 +534,9 @@ protected override async Task UpdateCoreAsync( var previousEntry = entries[^1]; - // Build updated document - var newDocument = updateOptions.NewDocument ?? previousEntry.State; + // Build updated document: the caller's snapshot, or the previous parsed document + // (whose wire provenance carries signed nested members into the new head verbatim). + var newDocument = snapshotDocument ?? previousEntry.State; RequireValidWitnessPolicy(newParams.Witness, nameof(options)); @@ -592,7 +641,7 @@ protected override async Task DeactivateCoreAsync( // Parse and validate existing log var entries = LogEntrySerializer.ParseJsonLines(deactivateOptions.CurrentLogContent); - var effectiveParams = _chainValidator.ValidateChain(entries); + var effectiveParams = await _chainValidator.ValidateChainAsync(entries, ct); // Bind the supplied log to the target DID (see issue #82 and UpdateCoreAsync). RequireAppendableLogForDid(entries, effectiveParams, did); @@ -815,6 +864,18 @@ private static LogEntryParameters BuildUpdateParameters(DidWebVhParameterUpdates }; } + /// + /// Deep-copies a caller-supplied DID Document through one JSON-LD serialization round-trip, + /// so hashing, signing, publication, and the reported result all observe a single read of + /// the caller's (possibly mutable or dynamic) collections. The round-trip is the same + /// projection publication applies — the log entry's state is always emitted as + /// JSON-LD — so it changes no published bytes; a document without an explicit + /// @context materializes the same default context publication would add. + /// + private static DidDocument SnapshotDocument(DidDocument document) + => DidDocumentSerializer.Deserialize( + DidDocumentSerializer.Serialize(document, DidContentTypes.JsonLd)); + /// /// Copies a caller-supplied witness policy so its entry list cannot change between /// validation and serialization. itself is sealed with init-only @@ -851,7 +912,7 @@ private static void RequireAppendableLogForDid( throw new ArgumentException( "The supplied DID log is already deactivated and cannot be updated."); - // entries is non-empty here: ValidateChain has already run and throws on an empty log. + // entries is non-empty here: ValidateChainAsync has already run and throws on an empty log. if (entries[^1].State.Id.Value != did) throw new ArgumentException( $"The supplied CurrentLogContent does not belong to the DID being operated on ('{did}')."); @@ -883,32 +944,6 @@ private static bool HasPolicyChange(LogEntryParameters before, LogEntryParameter return false; } - private static bool HasConsistentScid( - IReadOnlyList entries, - string? expectedScid) - { - if (string.IsNullOrEmpty(expectedScid)) - return false; - - foreach (var entry in entries) - { - try - { - if (!string.Equals( - DidUrlMapper.ExtractScid(entry.State.Id.Value), - expectedScid, - StringComparison.Ordinal)) - return false; - } - catch - { - return false; - } - } - - return true; - } - /// Order-insensitive, ordinal set equality; a null list is treated as empty. private static bool StringSetEquals(IReadOnlyList? a, IReadOnlyList? b) { @@ -938,6 +973,51 @@ private static bool WitnessConfigEquals(WitnessConfig? a, WitnessConfig? b) return idsA.SetEquals(idsB); } + /// + /// Returns the original JSON Lines content through the requested non-blank record. + /// The line separator after that record is excluded so an unchecked later record cannot + /// leak through the raw artifact. Parsing has already proven that the requested number of + /// records exists; failure here therefore indicates an internal parser/prefix mismatch. + /// + private static string GetRawJsonLinesPrefix(byte[] content, int entryCount) + { + var text = LogEntrySerializer.DecodeUtf8(content); + var lineStart = 0; + var entriesSeen = 0; + + while (lineStart < text.Length) + { + var newlineIndex = text.IndexOf('\n', lineStart); + var lineEnd = newlineIndex >= 0 ? newlineIndex : text.Length; + var hasContent = false; + for (var i = lineStart; i < lineEnd; i++) + { + if (!char.IsWhiteSpace(text[i])) + { + hasContent = true; + break; + } + } + + if (hasContent && ++entriesSeen == entryCount) + { + var prefixEnd = lineEnd; + if (prefixEnd > lineStart && text[prefixEnd - 1] == '\r') + prefixEnd--; + + return text[..prefixEnd]; + } + + if (newlineIndex < 0) + break; + + lineStart = newlineIndex + 1; + } + + throw new InvalidOperationException( + "The parsed did:webvh log does not contain the requested JSON Lines prefix."); + } + /// /// Find the target entry index based on resolution options. /// Returns -1 if a specific version was requested but not found. diff --git a/src/NetDid.Method.WebVh/DidWebVhUpdateOptions.cs b/src/NetDid.Method.WebVh/DidWebVhUpdateOptions.cs index e9e3e60..01292ee 100644 --- a/src/NetDid.Method.WebVh/DidWebVhUpdateOptions.cs +++ b/src/NetDid.Method.WebVh/DidWebVhUpdateOptions.cs @@ -20,7 +20,14 @@ public sealed record DidWebVhUpdateOptions : DidUpdateOptions /// public required ISigner SigningKey { get; init; } - /// The updated DID Document. If null, the previous document is preserved. + /// + /// The updated DID Document. If null, the previous document is preserved verbatim — the new + /// entry republishes the fetched state bytes, including signed nested members the typed + /// model does not surface. When supplied, the document is deep-copied exactly once at the + /// start of the update (one JSON-LD serialization round-trip): hashing, signing, the + /// published log, and all + /// reflect that private snapshot, never a later state of this instance. + /// public DidDocument? NewDocument { get; init; } /// Parameter updates to apply. If null, parameters are unchanged. diff --git a/src/NetDid.Method.WebVh/LogChainValidator.cs b/src/NetDid.Method.WebVh/LogChainValidator.cs index 8593daa..7bc9303 100644 --- a/src/NetDid.Method.WebVh/LogChainValidator.cs +++ b/src/NetDid.Method.WebVh/LogChainValidator.cs @@ -12,11 +12,26 @@ namespace NetDid.Method.WebVh; /// internal sealed class LogChainValidator { - private readonly EddsaJcs2022Cryptosuite _suite; + /// + /// Default upper bound on controller proofs verified per entry. Verifying each proof + /// re-canonicalizes the entry document, so this is an explicit resource policy (not a + /// conformance rule) that bounds per-entry verification work to + /// bound × entry-size over an already size-capped log. Real entries carry a single + /// controller proof; the default is far above any realistic co-signing arrangement. + /// Configurable via the constructor. An entry exceeding it is rejected as invalidDidLog. + /// + internal const int DefaultMaxControllerProofsPerEntry = 8; - public LogChainValidator(EddsaJcs2022Cryptosuite suite) + private readonly DataIntegrityProofPipeline _pipeline; + private readonly int _maxControllerProofsPerEntry; + + public LogChainValidator( + int maxControllerProofsPerEntry = DefaultMaxControllerProofsPerEntry) { - _suite = suite; + ArgumentOutOfRangeException.ThrowIfLessThan(maxControllerProofsPerEntry, 1); + + _pipeline = new DataIntegrityProofPipeline(); + _maxControllerProofsPerEntry = maxControllerProofsPerEntry; } /// @@ -24,9 +39,10 @@ public LogChainValidator(EddsaJcs2022Cryptosuite suite) /// Returns the effective parameters at the final entry on success. /// Throws LogChainValidationException on failure. /// - public LogEntryParameters ValidateChain(IReadOnlyList entries) + public Task ValidateChainAsync( + IReadOnlyList entries, CancellationToken ct = default) { - return ValidateChain(entries, entries.Count); + return ValidateChainAsync(entries, entries.Count, ct); } /// @@ -34,9 +50,10 @@ public LogEntryParameters ValidateChain(IReadOnlyList entries) /// Returns the effective parameters at the last validated entry. /// Throws LogChainValidationException on failure. /// - public LogEntryParameters ValidateChain(IReadOnlyList entries, int upToCount) + public async Task ValidateChainAsync( + IReadOnlyList entries, int upToCount, CancellationToken ct = default) { - var perEntry = ValidateChainWithPerEntryParams(entries, upToCount); + var perEntry = await ValidateChainWithPerEntryParamsAsync(entries, upToCount, ct); return perEntry[^1]; } @@ -45,8 +62,8 @@ public LogEntryParameters ValidateChain(IReadOnlyList entries, int upT /// Index 0 = genesis entry's effective params, etc. /// Throws LogChainValidationException on failure. /// - public IReadOnlyList ValidateChainWithPerEntryParams( - IReadOnlyList entries, int upToCount) + public async Task> ValidateChainWithPerEntryParamsAsync( + IReadOnlyList entries, int upToCount, CancellationToken ct = default) { if (entries.Count == 0) throw new LogChainValidationException(0, "DID log is empty."); @@ -56,7 +73,16 @@ public IReadOnlyList ValidateChainWithPerEntryParams( // Validate genesis entry var genesis = entries[0]; - ValidateGenesisEntry(genesis); + await ValidateGenesisEntryAsync(genesis, ct); + + // did:webvh v1.0: "The SCID segment of state.id MUST be byte-for-byte identical to the + // scid value in the DID and the first entry's parameters.scid. This check MUST apply to + // every entry's state.id, not just the first. A mismatch MUST terminate resolution." + // SCID-level (not full-DID) comparison keeps portable host/path renames valid. Enforced + // here so resolution, update, and deactivation share one identity gate: the driver can + // never build on a log its own resolver rejects. + var scid = genesis.Parameters.Scid!; + ValidateStateScidConsistency(genesis, scid, 1); var effectiveParams = genesis.Parameters; result.Add(effectiveParams); @@ -67,7 +93,8 @@ public IReadOnlyList ValidateChainWithPerEntryParams( var previous = entries[i - 1]; var current = entries[i]; - ValidateSubsequentEntry(current, previous, effectiveParams, i + 1); + ValidateStateScidConsistency(current, scid, i + 1); + await ValidateSubsequentEntryAsync(current, previous, effectiveParams, i + 1, ct); // Merge parameters for the next iteration effectiveParams = current.Parameters.MergeWith(effectiveParams); @@ -77,7 +104,7 @@ public IReadOnlyList ValidateChainWithPerEntryParams( return result; } - private void ValidateGenesisEntry(LogEntry genesis) + private async Task ValidateGenesisEntryAsync(LogEntry genesis, CancellationToken ct) { // Version number must be 1 if (genesis.VersionNumber != 1) @@ -104,8 +131,7 @@ private void ValidateGenesisEntry(LogEntry genesis) // post-SCID entry whose versionId is the SCID itself; it is the input to the genesis // entry-hash calculation and to reverse-substitution for SCID verification. var scid = genesis.Parameters.Scid; - var genesisForHashing = genesis with { VersionId = scid }; - var entryJsonForHashing = LogEntrySerializer.SerializeWithoutProof(genesisForHashing); + var entryJsonForHashing = LogEntrySerializer.SerializeWithoutProof(genesis, scid); var computedEntryHash = ScidGenerator.ComputeEntryHash(entryJsonForHashing); if (computedEntryHash != genesis.EntryHash) @@ -128,14 +154,15 @@ private void ValidateGenesisEntry(LogEntry genesis) ValidateUpdateKeys(genesisUpdateKeys, 1); - ValidateProof(genesis, genesisUpdateKeys, 1); + await ValidateProofAsync(genesis, genesisUpdateKeys, 1, ct); } - private void ValidateSubsequentEntry( + private async Task ValidateSubsequentEntryAsync( LogEntry current, LogEntry previous, LogEntryParameters effectiveParams, - int expectedVersion) + int expectedVersion, + CancellationToken ct) { // Check if previous entry was deactivated if (effectiveParams.Deactivated == true) @@ -161,8 +188,9 @@ private void ValidateSubsequentEntry( // Verify entry hash: the hash input's versionId is exactly the previous entry's // full published versionId. - var entryForHashing = current with { VersionId = previous.VersionId }; - var entryJsonWithoutProof = LogEntrySerializer.SerializeWithoutProof(entryForHashing); + var entryJsonWithoutProof = LogEntrySerializer.SerializeWithoutProof( + current, + previous.VersionId); var computedHash = ScidGenerator.ComputeEntryHash(entryJsonWithoutProof); if (computedHash != current.EntryHash) @@ -191,7 +219,39 @@ private void ValidateSubsequentEntry( $"No updateKeys authorize version {expectedVersion}."); } - ValidateProof(current, authorizedKeys, expectedVersion); + await ValidateProofAsync(current, authorizedKeys, expectedVersion, ct); + } + + /// + /// Enforces the per-entry identity rule: every validated entry's state.id must be a + /// did:webvh DID whose SCID segment equals the genesis parameters.scid byte-for-byte. + /// The comparison is deliberately SCID-level — under portability only the host/path portion + /// of state.id may change; the SCID is immutable for the life of the DID. + /// + private static void ValidateStateScidConsistency(LogEntry entry, string scid, int version) + { + var stateId = entry.State.Id.Value; + if (string.IsNullOrEmpty(stateId)) + throw new LogChainValidationException(version, + $"Entry {version} document has no id; every entry's state.id must carry the log's SCID."); + + string entryScid; + try + { + entryScid = DidUrlMapper.ExtractScid(stateId); + } + catch (ArgumentException) + { + throw new LogChainValidationException(version, + $"Entry {version} state.id is not a valid did:webvh DID."); + } + + if (!stateId.StartsWith("did:webvh:", StringComparison.Ordinal) + || !string.Equals(entryScid, scid, StringComparison.Ordinal)) + { + throw new LogChainValidationException(version, + $"Entry {version} state.id SCID does not match the log's SCID."); + } } /// @@ -213,37 +273,83 @@ private static void ValidateWitnessPolicy(WitnessConfig? config, int version) throw new LogChainValidationException(version, error); } - private void ValidateProof(LogEntry entry, IReadOnlyList? authorizedKeys, int version) + /// + /// Enforces the did:webvh v1.0 controller-proof rule: an entry requires at least one proof, + /// and every supplied proof must pass the checks implemented by DataProofsDotnet's + /// plus NetDid's application-boundary checks, and be + /// authorized by the active . "Resolvers MUST reject an + /// entry whose proof fails any check" — existential acceptance would admit logs that + /// stricter conforming resolvers reject (issue #101). One authorized signer suffices to + /// authorize the entry; there is no threshold semantics for controller proofs. + /// + /// + /// The pipeline verifies each proof's signature over the entry (with the proof + /// removed), enforces the required type/cryptosuite/purpose, resolves any + /// previousProof chain, and rejects a proof whose expires is at or before the + /// entry's versionTime. Authorization — a did:key verificationMethod (anti-spoof) + /// whose Ed25519 multibase is verbatim in the active updateKeys, used for + /// assertionMethod — is supplied by . Verifying + /// each proof re-canonicalizes the entry, so the proof count is bounded by + /// as an explicit resource policy (see + /// ). + /// + private async Task ValidateProofAsync( + LogEntry entry, IReadOnlyList authorizedKeys, int version, CancellationToken ct) { - if (entry.Proof is null || entry.Proof.Count == 0) + // Snapshot once: an IReadOnlyList implementation could present different contents + // per enumeration, letting a proof escape validation. + var proofs = entry.Proof?.ToArray(); + if (proofs is not { Length: > 0 }) throw new LogChainValidationException(version, $"No proof found at version {version}."); - var entryJsonWithoutProof = LogEntrySerializer.SerializeWithoutProof(entry); + // Bound verification work before doing any: the pipeline verifies every proof and each + // verification re-canonicalizes the entry document. `created` is attacker-chosen and + // part of the signed proof configuration, so one active key can mint arbitrarily many + // distinct valid proofs — the count, not key diversity, is what must be capped. + if (proofs.Length > _maxControllerProofsPerEntry) + throw new LogChainValidationException(version, + $"Entry at version {version} declares {proofs.Length} controller proofs, " + + $"exceeding the resolver's limit of {_maxControllerProofsPerEntry}."); + + // Verify against the entry as published (full-fidelity proofs via RawJson): the pipeline + // removes the proof member, JCS-canonicalizes the rest, and checks every proof. Its + // resolver authorizes only did:key methods whose Ed25519 multibase is an active update + // key used for assertionMethod; VerificationTime pins the expires policy to versionTime. + // Serialization is inside the try so any failure is reported as a chain-validation error + // (invalidDidLog), never as notFound. + var resolver = new WebVhUpdateKeyResolver(authorizedKeys); + var options = new ProofVerificationOptions + { + ExpectedProofPurpose = "assertionMethod", + VerificationTime = entry.VersionTime + }; - // At least one proof must be valid from an authorized update key - foreach (var proofValue in entry.Proof) + DocumentVerificationResult result; + try + { + var securedDocumentJson = LogEntrySerializer.Serialize(entry); + using var document = System.Text.Json.JsonDocument.Parse(securedDocumentJson); + result = await _pipeline.VerifyAsync(document.RootElement, resolver, options, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) { - // Verify the signature; the returned multibase is the signer's did:key id with - // the DID==fragment anti-spoof check already enforced. Null => invalid signature - // or malformed verificationMethod. - var signerKey = WebVhProofVerifier.VerifyAndExtractSigner(_suite, entryJsonWithoutProof, proofValue); - if (signerKey is null) - continue; - - // Verify the signer is an authorized update key. Exact equality between the - // signer's multibase key and an entry in authorizedKeys — substring matching would - // allow a proof with verificationMethod = "did:key:#" to be - // signed by the attacker yet authorized as the legitimate key. - if (authorizedKeys is null) - return; // Valid proof, no key restriction - - if (authorizedKeys.Contains(signerKey, StringComparer.Ordinal)) - return; // Valid proof from authorized key + // A verification path must never throw for hostile input. + throw new LogChainValidationException(version, + $"Proof verification failed at version {version} ({ex.GetType().Name})."); } - throw new LogChainValidationException(version, - $"No valid proof from an authorized update key at version {version}."); + if (!result.Verified) + { + var reason = result.ProofResults + .SelectMany(r => r.Problems) + .Select(p => p.Message) + .FirstOrDefault(m => m is not null) + ?? result.Problems.Select(p => p.Message).FirstOrDefault(m => m is not null) + ?? "a controller proof failed Data Integrity verification or is not from an active update key"; + throw new LogChainValidationException(version, + $"Proof validation failed at version {version}: {reason}"); + } } private static IReadOnlyList ValidatePreRotation( diff --git a/src/NetDid.Method.WebVh/LogEntrySerializer.cs b/src/NetDid.Method.WebVh/LogEntrySerializer.cs index bf48e54..726deba 100644 --- a/src/NetDid.Method.WebVh/LogEntrySerializer.cs +++ b/src/NetDid.Method.WebVh/LogEntrySerializer.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using NetDid.Core; @@ -12,6 +14,30 @@ namespace NetDid.Method.WebVh; /// public static class LogEntrySerializer { + private static readonly UTF8Encoding StrictUtf8 = new( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true); + + // Wire provenance is reference-identity metadata, not part of the public LogEntry value. + // A `with` clone deliberately has no provenance. The modeled fingerprint also detects + // in-place mutation through public nested collections before raw-backed serialization. + private static readonly ConditionalWeakTable WireEntries = new(); + + // State-level provenance, keyed by the parsed DidDocument reference. A freshly built entry + // that carries a parsed state — a preserve-mode update's new head — must republish that + // state verbatim: the typed model drops signed nested members it does not surface, and the + // new head is hashed/signed over whatever is emitted here, so a modeled rewrite would + // silently erase them. Guard rules: a `with`-cloned document is a new reference with no + // provenance (modeled fallback), and any model-visible change invalidates the fingerprint + // (modeled fallback). The fingerprint is over the lossy modeled serialization, so a + // model-invisible in-place change (a nested raw-only member, or replacing a list element + // with a model-equal one) is NOT detected and stale raw would be re-emitted — but that is + // unreachable through a signed path: the only WireStates-registered document that reaches a + // signed head is the internal previousEntry.State parsed from the fetched log (no caller + // reference), and a caller-supplied NewDocument is deep-copied by + // DidWebVhMethod.SnapshotDocument, which produces a fresh unregistered reference. + private static readonly ConditionalWeakTable WireStates = new(); + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -22,7 +48,7 @@ public static class LogEntrySerializer /// Parse a did.jsonl file (byte[]) into a list of LogEntry objects. public static IReadOnlyList ParseJsonLines(byte[] content) { - var text = Encoding.UTF8.GetString(content); + var text = DecodeUtf8(content); var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var entries = new List(lines.Length); @@ -37,13 +63,39 @@ public static IReadOnlyList ParseJsonLines(byte[] content) /// Serialize a single log entry to JSON string. public static string Serialize(LogEntry entry) { - return WriteEntry(entry, includeProof: true); + return WriteEntry(entry, includeProof: true, versionIdOverride: null); } /// Serialize a single log entry to JSON, excluding the proof field (for hashing/signing). public static string SerializeWithoutProof(LogEntry entry) { - return WriteEntry(entry, includeProof: false); + return WriteEntry(entry, includeProof: false, versionIdOverride: null); + } + + /// + /// Serialize an entry without its proof while replacing only the top-level version id. + /// Parsed-wire provenance, when still current, remains the source for every other member. + /// + internal static string SerializeWithoutProof(LogEntry entry, string versionIdOverride) + { + ArgumentNullException.ThrowIfNull(versionIdOverride); + return WriteEntry( + entry, + includeProof: false, + versionIdOverride: versionIdOverride); + } + + /// Decode fetched JSON bytes as strict UTF-8. + internal static string DecodeUtf8(byte[] content) + { + try + { + return StrictUtf8.GetString(content); + } + catch (DecoderFallbackException ex) + { + throw new FormatException("did:webvh content is not valid UTF-8.", ex); + } } /// Serialize a list of log entries to JSON Lines format (byte[]). @@ -58,7 +110,21 @@ public static byte[] ToJsonLines(IReadOnlyList entries) return Encoding.UTF8.GetBytes(sb.ToString()); } - private static string WriteEntry(LogEntry entry, bool includeProof) + private static string WriteEntry( + LogEntry entry, + bool includeProof, + string? versionIdOverride) + { + if (TryGetCurrentWireEntry(entry, out var wireEntry)) + return WriteWireEntry(entry, wireEntry.RawJson, includeProof, versionIdOverride); + + return WriteModeledEntry(entry, includeProof, versionIdOverride); + } + + private static string WriteModeledEntry( + LogEntry entry, + bool includeProof, + string? versionIdOverride) { using var stream = new MemoryStream(); using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false }); @@ -66,7 +132,7 @@ private static string WriteEntry(LogEntry entry, bool includeProof) writer.WriteStartObject(); // versionId - writer.WriteString("versionId", entry.VersionId); + writer.WriteString("versionId", versionIdOverride ?? entry.VersionId); // versionTime — ISO 8601 UTC writer.WritePropertyName("versionTime"); @@ -84,11 +150,17 @@ private static string WriteEntry(LogEntry entry, bool includeProof) writer.WritePropertyName("parameters"); WriteParameters(writer, entry.Parameters); - // state — the DID Document, serialized as JSON-LD + // state — a parsed document with current provenance re-emits its wire JSON verbatim; + // otherwise the DID Document is serialized as JSON-LD from the typed model. writer.WritePropertyName("state"); - var stateJson = DidDocumentSerializer.Serialize(entry.State, DidContentTypes.JsonLd); - using (var stateDoc = JsonDocument.Parse(stateJson)) + if (TryGetCurrentWireState(entry.State, out var stateWireJson)) + { + writer.WriteRawValue(stateWireJson); + } + else { + var stateJson = DidDocumentSerializer.Serialize(entry.State, DidContentTypes.JsonLd); + using var stateDoc = JsonDocument.Parse(stateJson); stateDoc.RootElement.WriteTo(writer); } @@ -105,6 +177,82 @@ private static string WriteEntry(LogEntry entry, bool includeProof) return Encoding.UTF8.GetString(stream.ToArray()); } + private static string WriteWireEntry( + LogEntry entry, + string rawJson, + bool includeProof, + string? versionIdOverride) + { + using var rawDocument = JsonDocument.Parse( + rawJson, + new JsonDocumentOptions { AllowDuplicateProperties = false }); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false }); + + writer.WriteStartObject(); + foreach (var property in rawDocument.RootElement.EnumerateObject()) + { + if (property.NameEquals("versionId")) + { + writer.WriteString("versionId", versionIdOverride ?? entry.VersionId); + continue; + } + + if (property.NameEquals("proof")) + { + if (includeProof && entry.Proof is { Count: > 0 }) + { + writer.WritePropertyName("proof"); + WriteProofArray(writer, entry.Proof); + } + + continue; + } + + writer.WritePropertyName(property.Name); + property.Value.WriteTo(writer); + } + + writer.WriteEndObject(); + writer.Flush(); + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static bool TryGetCurrentWireEntry( + LogEntry entry, + out WireEntry wireEntry) + { + if (!WireEntries.TryGetValue(entry, out wireEntry!)) + return false; + + var currentFingerprint = ComputeModeledFingerprint(entry); + return currentFingerprint.AsSpan().SequenceEqual(wireEntry.ModeledFingerprint); + } + + private static byte[] ComputeModeledFingerprint(LogEntry entry) + { + var modeled = WriteModeledEntry(entry, includeProof: true, versionIdOverride: null); + return SHA256.HashData(Encoding.UTF8.GetBytes(modeled)); + } + + private static bool TryGetCurrentWireState(DidDocument state, out string stateWireJson) + { + stateWireJson = null!; + if (!WireStates.TryGetValue(state, out var wireState)) + return false; + + var currentFingerprint = ComputeModeledStateFingerprint(state); + if (!currentFingerprint.AsSpan().SequenceEqual(wireState.ModeledFingerprint)) + return false; + + stateWireJson = wireState.RawJson; + return true; + } + + private static byte[] ComputeModeledStateFingerprint(DidDocument state) + => SHA256.HashData(Encoding.UTF8.GetBytes( + DidDocumentSerializer.Serialize(state, DidContentTypes.JsonLd))); + private static void WriteParameters(Utf8JsonWriter writer, LogEntryParameters parameters) { writer.WriteStartObject(); @@ -184,11 +332,23 @@ private static void WriteProofArray(Utf8JsonWriter writer, IReadOnlyList? proof = null; - if (root.TryGetProperty("proof", out var proofProp)) + using (doc) + try { - proof = ParseProofArray(proofProp); - } + var root = doc.RootElement; + + foreach (var property in root.EnumerateObject()) + { + if (property.Name is not ("versionId" or "versionTime" or "parameters" or "state" or "proof")) + throw new FormatException( + $"Unknown did:webvh log-entry property '{property.Name}'."); + } + + var versionId = root.GetProperty("versionId").GetString()!; + if (!root.TryGetProperty("versionTime", out var versionTimeElement) + || versionTimeElement.ValueKind != JsonValueKind.String + || versionTimeElement.GetString() is not { } versionTime) + { + throw new FormatException( + "did:webvh versionTime must be a non-null JSON string."); + } + var parameters = ParseParameters(root.GetProperty("parameters")); + + // Parse state as a DID Document, retaining its wire JSON as state-level provenance + // (registered before the whole-entry provenance below so the entry fingerprint is + // computed over the same raw-state-backed output it will be compared against). + var stateJson = root.GetProperty("state").GetRawText(); + var state = DidDocumentSerializer.Deserialize(stateJson); + WireStates.Add(state, new WireState( + stateJson, ComputeModeledStateFingerprint(state))); + + // Parse proof (optional here; chain validation requires one per entry). The schema + // permits a single proof object or an array of proof objects. + IReadOnlyList? proof = null; + if (root.TryGetProperty("proof", out var proofProp)) + { + proof = ParseProof(proofProp); + } + + var entry = new LogEntry + { + VersionId = versionId, + VersionTime = WebVhTimestamp.Parse(versionTime), + VersionTimeWireValue = versionTime, + VersionTimeRawJson = versionTimeElement.GetRawText(), + Parameters = parameters, + State = state, + Proof = proof + }; - return new LogEntry + WireEntries.Add( + entry, + new WireEntry(root.GetRawText(), ComputeModeledFingerprint(entry))); + return entry; + } + catch (Exception ex) when (ex is InvalidOperationException + or KeyNotFoundException + or OverflowException + or ArgumentException + or JsonException) { - VersionId = versionId, - VersionTime = WebVhTimestamp.Parse(versionTime), - VersionTimeWireValue = versionTime, - VersionTimeRawJson = versionTimeElement.GetRawText(), - Parameters = parameters, - State = state, - Proof = proof - }; + // Map JSON-access failures at this trust boundary to FormatException so a fetched + // log with malformed content (e.g. a string carrying an unpaired surrogate that + // parses as a token but throws on GetString) resolves as invalidDidLog, not notFound + // or an unhandled exception. Deliberate FormatExceptions above propagate unchanged. + throw new FormatException("did:webvh log entry contains malformed content.", ex); + } } private static LogEntryParameters ParseParameters(JsonElement element) @@ -354,16 +554,116 @@ or OverflowException } } - private static IReadOnlyList ParseProofArray(JsonElement element) + /// + /// Parses the log entry's proof member. The official did:webvh v1.0 log-entry + /// schema permits a single Data Integrity proof object or an array of them; both are + /// normalized to one list. Every structural violation throws + /// so callers at the trust boundary map it to invalidDidLog (issue #101). + /// + private static IReadOnlyList ParseProof(JsonElement element) { - return element.EnumerateArray().Select(e => new DataIntegrityProofValue + if (element.ValueKind == JsonValueKind.Object) + return new List { ParseProofObject(element) }.AsReadOnly(); + + if (element.ValueKind != JsonValueKind.Array) + throw new FormatException( + "did:webvh log-entry proof must be a proof object or an array of proof objects."); + + var proofs = new List(); + var proofIds = new HashSet(StringComparer.Ordinal); + foreach (var proofElement in element.EnumerateArray()) { - Type = e.GetProperty("type").GetString()!, - Cryptosuite = e.GetProperty("cryptosuite").GetString()!, - VerificationMethod = e.GetProperty("verificationMethod").GetString()!, - Created = e.GetProperty("created").GetString()!, - ProofPurpose = e.GetProperty("proofPurpose").GetString()!, - ProofValue = e.GetProperty("proofValue").GetString()! - }).ToList(); + var proof = ParseProofObject(proofElement); + if (proofElement.TryGetProperty("id", out var idElement)) + { + var id = idElement.GetString()!; + if (!proofIds.Add(id)) + { + throw new FormatException( + $"did:webvh log-entry proof id '{id}' is duplicated within the proof set."); + } + } + + proofs.Add(proof); + } + + if (proofs.Count == 0) + throw new FormatException( + "did:webvh log-entry proof array must contain at least one proof."); + + return proofs.AsReadOnly(); + } + + private static DataIntegrityProofValue ParseProofObject(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + throw new FormatException("Every did:webvh log-entry proof must be a JSON object."); + + ValidateOptionalProofId(element); + + // The did:webvh v1.0 log-entry schema requires these members "at minimum" and leaves + // additional properties open, so extra Data Integrity members (id, expires, previousProof, + // @context, extensions) are preserved verbatim via RawJson. NetDid's conservative + // absolute-URI policy is enforced at this application-schema boundary; the remaining + // supported semantics are delegated to the Data Integrity pipeline. The modeled fields + // below are convenience metadata; RawJson is the fidelity source for proof verification + // and re-emission. + return new DataIntegrityProofValue + { + Type = RequireProofString(element, "type"), + Cryptosuite = RequireProofString(element, "cryptosuite"), + VerificationMethod = RequireProofString(element, "verificationMethod"), + Created = OptionalProofString(element, "created"), + ProofPurpose = RequireProofString(element, "proofPurpose"), + ProofValue = RequireProofString(element, "proofValue"), + RawJson = element.GetRawText() + }; + } + + private static void ValidateOptionalProofId(JsonElement proof) + { + if (!proof.TryGetProperty("id", out _)) + return; + + var id = OptionalProofString(proof, "id")!; + if (id.Length == 0 + || !string.Equals(id, id.Trim(), StringComparison.Ordinal) + || !Uri.TryCreate(id, UriKind.Absolute, out var uri) + || !uri.IsAbsoluteUri + || !uri.IsWellFormedOriginalString()) + { + throw new FormatException( + "did:webvh log-entry proof member 'id' must be a non-empty, " + + "System.Uri-compatible absolute URI without surrounding whitespace when present."); + } + } + + private sealed record WireEntry(string RawJson, byte[] ModeledFingerprint); + + private sealed record WireState(string RawJson, byte[] ModeledFingerprint); + + private static string RequireProofString(JsonElement proof, string name) + { + if (!proof.TryGetProperty(name, out var value) + || value.ValueKind != JsonValueKind.String + || value.GetString() is not { } text) + { + throw new FormatException( + $"did:webvh log-entry proof member '{name}' must be a non-null JSON string."); + } + + return text; + } + + private static string? OptionalProofString(JsonElement proof, string name) + { + if (!proof.TryGetProperty(name, out var value)) + return null; + + if (value.ValueKind != JsonValueKind.String || value.GetString() is not { } text) + throw new FormatException( + $"did:webvh log-entry proof member '{name}' must be a JSON string when present."); + + return text; } } diff --git a/src/NetDid.Method.WebVh/Model/DataIntegrityProofValue.cs b/src/NetDid.Method.WebVh/Model/DataIntegrityProofValue.cs index d50ed08..7588feb 100644 --- a/src/NetDid.Method.WebVh/Model/DataIntegrityProofValue.cs +++ b/src/NetDid.Method.WebVh/Model/DataIntegrityProofValue.cs @@ -1,8 +1,26 @@ namespace NetDid.Method.WebVh.Model; /// -/// Serializable data model for a Data Integrity Proof inside a did:webvh log entry. +/// Serializable data model for a Data Integrity Proof inside a did:webvh log entry or +/// witness file. /// +/// +/// For log entries this models a controller proof. A did:webvh entry requires at +/// least one controller proof, and one active update key is sufficient to authorize the +/// entry. If multiple controller proofs are supplied, every supplied proof is processed by +/// DataProofsDotnet and must satisfy NetDid's controller policy: the applicable +/// type/cryptosuite/proofPurpose, a valid signature, a unique +/// System.Uri-compatible absolute-URI id without surrounding whitespace when present, +/// a non-dangling previousProof chain, an unexpired +/// expires relative to the entry's versionTime, and a signer in the active +/// updateKeys. Controller proofs do not use threshold semantics. The did:webvh v1.0 +/// log-entry schema requires the +/// members below "at minimum" and leaves additional properties open. Unknown members are +/// preserved and signature-bound, but NetDid does not claim application semantics for every +/// extension. The proof-id policy is conservative and is not full WHATWG valid-URL-string +/// conformance. Array-valued domain is not supported by the pinned DataProofsDotnet model. +/// See issue #101. +/// public sealed class DataIntegrityProofValue { /// Always "DataIntegrityProof". @@ -14,12 +32,29 @@ public sealed class DataIntegrityProofValue /// The DID URL of the verification method (e.g., "did:key:z6Mkf...#z6Mkf..."). public required string VerificationMethod { get; init; } - /// ISO 8601 timestamp when the proof was created. - public required string Created { get; init; } + /// + /// ISO 8601 timestamp when the proof was created, or null when absent. The + /// official did:webvh v1.0 log-entry schema marks created optional; proofs + /// produced by NetDid always carry it. + /// + public string? Created { get; init; } /// The purpose of this proof (e.g., "assertionMethod"). public required string ProofPurpose { get; init; } /// The multibase-encoded signature value. public required string ProofValue { get; init; } + + /// + /// Verbatim wire JSON of the proof object as parsed from a DID log; null for + /// programmatically constructed proofs. The eddsa-jcs-2022 signature covers the whole + /// proof configuration, including schema-permitted members this type does not surface + /// (id, expires, and any extensions). This verbatim JSON is the input to Data + /// Integrity verification, and re-emitting it preserves the proof object byte-for-byte when + /// Update/Deactivate republish a fetched log. A single-object enclosing proof value is + /// normalized to a one-element array. The setter is internal because it is + /// parser-populated fidelity data. When null, serialization falls back to the modeled + /// members above (the shape NetDid emits when it creates a proof). + /// + public string? RawJson { get; internal init; } } diff --git a/src/NetDid.Method.WebVh/Model/LogEntry.cs b/src/NetDid.Method.WebVh/Model/LogEntry.cs index ca608a6..d83f350 100644 --- a/src/NetDid.Method.WebVh/Model/LogEntry.cs +++ b/src/NetDid.Method.WebVh/Model/LogEntry.cs @@ -26,10 +26,24 @@ public sealed record LogEntry /// Entry parameters (method version, SCID, updateKeys, etc.). public required LogEntryParameters Parameters { get; init; } - /// The full DID Document at this version. + /// + /// The typed DID Document at this version. For a parsed log, hashing and proof verification + /// also retain signed nested wire members that this typed model does not surface. + /// public required DidDocument State { get; init; } - /// Data Integrity Proofs for this entry. + /// + /// The entry's Data Integrity controller proofs. A did:webvh entry requires at least one + /// controller proof, and one active update key is sufficient to authorize the entry. Every + /// supplied proof is processed by DataProofsDotnet and authorized against the active update + /// keys; the entry is rejected if any supplied proof fails NetDid's controller policy (issue + /// #101). Controller proofs do not use threshold semantics. Duplicate proof ids are rejected. + /// Proof extensions are preserved and signature-bound; NetDid enforces documented semantics + /// for id, expires, and + /// previousProof but does not claim semantics for every extension (see + /// ). The wire proof may be a single proof object or + /// an array; both parse into this list, and serialization uses the array form. + /// public IReadOnlyList? Proof { get; init; } /// The version number extracted from the versionId. diff --git a/src/NetDid.Method.WebVh/WebVhProofVerifier.cs b/src/NetDid.Method.WebVh/WebVhProofVerifier.cs index 6285565..2ce92f9 100644 --- a/src/NetDid.Method.WebVh/WebVhProofVerifier.cs +++ b/src/NetDid.Method.WebVh/WebVhProofVerifier.cs @@ -6,18 +6,22 @@ namespace NetDid.Method.WebVh; /// -/// Verifies eddsa-jcs-2022 Data Integrity proofs on did:webvh log / witness entries via +/// Verifies eddsa-jcs-2022 Data Integrity proofs on did:webvh witness entries via /// DataProofsDotnet, and parses a did:key verificationMethod into its authorized -/// multibase key (enforcing the DID==fragment anti-spoof rule). Relocated here from the -/// removed NetDid.Core.Crypto.DataIntegrity.DataIntegrityProofEngine; the DID-method-aware -/// parser has no home in DataProofsDotnet (whose dependency direction forbids DID parsing). +/// multibase key (enforcing the DID==fragment anti-spoof rule). Controller proofs on log entries +/// are verified by the full DataIntegrityProofPipeline (see +/// and ); is shared with +/// that path. Relocated here from the removed +/// NetDid.Core.Crypto.DataIntegrity.DataIntegrityProofEngine; the DID-method-aware parser +/// has no home in DataProofsDotnet (whose dependency direction forbids DID parsing). /// internal static class WebVhProofVerifier { /// - /// Verifies a single proof's signature over the entry JSON (with the proof removed). - /// Returns the signer's multibase key when the signature is valid AND the verificationMethod - /// is a well-formed did:key (anti-spoof enforced); otherwise null. + /// Verifies a single witness proof's signature over the entry JSON (with the proof + /// removed). Returns the signer's multibase key when the signature is valid AND the + /// verificationMethod is a well-formed did:key (anti-spoof enforced); otherwise + /// null. /// public static string? VerifyAndExtractSigner( EddsaJcs2022Cryptosuite suite, @@ -38,9 +42,12 @@ internal static class WebVhProofVerifier return null; } - // Reconstruct the proof exactly as written on the wire. Created is passed verbatim - // (no DateTimeOffset round-trip) so the hashed proof configuration is byte-identical - // to the one signed at creation time. + // Reconstruct the proof from the modeled fields. This path serves witness proofs (from + // did-witness.json); controller proofs in a log entry are verified by the full Data + // Integrity pipeline instead. A witness proof carrying any member outside this modeled + // set drops it here and therefore fails signature verification — fail closed, never + // forge. Created is passed verbatim (no DateTimeOffset round-trip) so the hashed proof + // configuration is byte-identical to the one signed at creation time. var proof = new DataIntegrityProof { Type = proofValue.Type, diff --git a/src/NetDid.Method.WebVh/WebVhUpdateKeyResolver.cs b/src/NetDid.Method.WebVh/WebVhUpdateKeyResolver.cs new file mode 100644 index 0000000..7f50421 --- /dev/null +++ b/src/NetDid.Method.WebVh/WebVhUpdateKeyResolver.cs @@ -0,0 +1,77 @@ +using DataProofsDotnet; +using DataProofsDotnet.DataIntegrity; +using NetCrypto; + +namespace NetDid.Method.WebVh; + +/// +/// The did:webvh authorization adapter for the Data Integrity verification pipeline. A +/// controller proof's verificationMethod is authorized only when it is a well-formed +/// did:key (DID==fragment anti-spoof enforced), its Ed25519 multibase appears verbatim in +/// the entry's active updateKeys, and the proof is used for assertionMethod. The +/// pipeline verifies the signature against the returned key; this resolver supplies only the +/// key and the authorization metadata. See issue #101. +/// +internal sealed class WebVhUpdateKeyResolver : IVerificationMethodResolver +{ + private static readonly IReadOnlySet AssertionMethodOnly = + new HashSet(StringComparer.Ordinal) { "assertionMethod" }; + + private readonly HashSet _authorizedKeys; + + /// + /// Snapshots the active update keys once. An implementation + /// could otherwise present different contents across the pipeline's per-proof resolutions. + /// + public WebVhUpdateKeyResolver(IReadOnlyList authorizedKeys) + { + _authorizedKeys = new HashSet(authorizedKeys, StringComparer.Ordinal); + } + + public Task ResolveAsync( + string verificationMethodUrl, + CancellationToken cancellationToken = default) + { + return Task.FromResult(Resolve(verificationMethodUrl)); + } + + private ResolvedVerificationMethod? Resolve(string verificationMethodUrl) + { + // Extract the signer's multibase from the did:key URL with the DID==fragment anti-spoof + // check. A "did:key:#" method returns null here (mismatch). + var multibaseKey = WebVhProofVerifier.ExtractDidKeyMultibase(verificationMethodUrl); + if (multibaseKey is null) + return null; + + // Authorize by exact-ordinal membership in the active updateKeys — no substring match. + if (!_authorizedKeys.Contains(multibaseKey)) + return null; + + PublicKeyMaterial publicKey; + try + { + publicKey = PublicKeyMaterial.FromMultikey(multibaseKey); + } + catch (ArgumentException) + { + // The key is in the active updateKeys but is not a decodable Multikey — a malformed + // (attacker-supplied) log parameter. ArgumentException is FromMultikey's documented + // and observed contract for every invalid input; fail closed so the proof is + // unauthorized and the entry rejected. Anything else is an unexpected bug and must + // propagate, not masquerade as an authorization decision. + return null; + } + + if (publicKey.KeyType != KeyType.Ed25519) + return null; + + return new ResolvedVerificationMethod + { + Id = verificationMethodUrl, + Controller = $"did:key:{multibaseKey}", + PublicKey = publicKey, + Relationships = AssertionMethodOnly, + ControllerControlsMethod = true + }; + } +} diff --git a/src/NetDid.Method.WebVh/WitnessValidator.cs b/src/NetDid.Method.WebVh/WitnessValidator.cs index 6958199..b7e1689 100644 --- a/src/NetDid.Method.WebVh/WitnessValidator.cs +++ b/src/NetDid.Method.WebVh/WitnessValidator.cs @@ -1,4 +1,3 @@ -using System.Text; using System.Text.Json; using DataProofsDotnet.DataIntegrity; using NetDid.Method.WebVh.Model; @@ -245,7 +244,7 @@ public static WitnessFile MergeWitnessProofs( { try { - var json = Encoding.UTF8.GetString(content); + var json = LogEntrySerializer.DecodeUtf8(content); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; diff --git a/tasks/lessons.md b/tasks/lessons.md index 5342055..c4d6e10 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -31,6 +31,57 @@ pre-rotation entry with the CURRENT entry's own updateKeys — the prior-keys rule only holds when pre-rotation is inactive. NetDid deviated and my #91 evidence contract ("keys authorized to sign the next entry") repeated the deviation as a promise (#93). +- `JsonDocument.Parse` (default options) ACCEPTS duplicate JSON members and keeps the LAST one. + For a spec-governed format where "every supplied X is validated" is the security contract, a + decoy duplicate (`"proof":[{bogus}],"proof":[valid]`) smuggles an unvalidated member past the + check. Parse untrusted document JSON at the trust boundary with + `new JsonDocumentOptions { AllowDuplicateProperties = false }` (recursive; catches nested dups) + and map the `JsonException` to the format's invalid-content error. (Issue #101 PR review.) +- Do NOT "preserve and accept" arbitrary members of a signed structure you do not evaluate in the + name of interop. Accepting a Data Integrity proof carrying `previousProof`/`expires`/`id`/`domain` + you never resolve/enforce is a FALSE validation claim (dangling chain refs, elapsed expiry pass + silently). For a narrow method profile (did:webvh controller proof = type/cryptosuite/ + verificationMethod/created?/proofPurpose/proofValue), REJECT any out-of-profile member. Then the + modeled fields are the whole proof, verification is byte-faithful, and no raw-JSON carry is + needed. "Fully interoperable" means interoperating with what the method actually emits, not + accepting every superset the base spec permits. (Issue #101 PR review.) +- A dedup/identity key that joins nullable strings is unsound: `Created ?? ""` folds absent + (`null`) and present-empty (`""`) — DISTINCT signed configs — to one key, and a shared separator + can be injected by field contents. Use a value TUPLE + (`HashSet<(string, string, string, string?, string, string)>`) so components compare + independently and `null != ""`. A string-join identity over attacker-controlled fields is a + collision waiting to skip a distinct invalid item behind a valid one. (Issue #101 PR review, F1.) +- An arbitrary count cap (e.g. "max 100 proofs") that rejects otherwise schema-valid input is a + baseless compatibility break, not a DoS control. Bound work by structure instead: stop at the + first failing item, and dedup byte-identical items (verify once). With deterministic Ed25519 + (one key ⇒ one valid signature over a fixed message), distinct passing proofs ≤ active keys, so + no cap is needed and no conforming log is rejected on count. (Issue #101 PR review, finding 3.) +- After a reworked fix that materially changes the design (removing a field, adding a trust check), + re-run the FULL adversarial review on the NEW surface — do not assume the prior clean verdict + carries over. The rework's own new dedup introduced a fresh soundness bug the second pass caught. +- Do NOT hand-roll a subset of a complex signed-format algorithm (W3C Data Integrity: type/suite/ + purpose/expires/previousProof-chains). Delegate verification to the library that owns it + (DataProofsDotnet's `DataIntegrityProofPipeline`) and contribute only the method-specific POLICY + via an `IVerificationMethodResolver` (did:key anti-spoof + updateKeys membership + assertionMethod), + plus `ProofVerificationOptions` (ExpectedProofPurpose, VerificationTime=versionTime for the + expires policy). Re-implementing DI semantics piecemeal produced three rounds of review findings + (issue #101 / PR #102). Feed the pipeline the entry serialized WITH full-fidelity proofs; an + entry-hash check already runs before proof validation, so the non-proof content is proven + byte-faithful to the signed bytes. +- A "deterministic Ed25519 ⇒ one valid proof per key" work bound is FALSE: eddsa-jcs-2022 signs the + whole proof configuration, and `created` is attacker-chosen, so one key mints unlimited distinct + valid proofs by varying `created`. Never derive a work bound from signature determinism over a + mutable message. Bound verification with an explicit, documented, configurable resource budget + (proofs per entry) over an already size-capped fetch. (Issue #101 PR review round 2.) +- A JSON-Schema field list that is "required at minimum" with additionalProperties OPEN means extra + members (`id`, `expires`) are CONFORMING; rejecting them is an interop regression, not hardening. + Preserve and VALIDATE them (or document a deliberate, labeled limitation) — do not silently reject + and call it "the profile." (Issue #101 PR review round 2, reversing my own round-1 narrowing.) +- Map JSON-ACCESS failures at the parse trust boundary, not just JsonException. `JsonDocument.Parse` + accepts a token like `"\uD800"` but `GetString()` throws `InvalidOperationException` on decode; a + `catch (JsonException)`-only boundary lets it escape to `notFound`. Catch the JSON-access set + (`InvalidOperationException`/`KeyNotFoundException`/`OverflowException`/`ArgumentException`/ + `JsonException`) → the format's invalid-content error. (Issue #101 PR review round 2, F3.) - Treat caller-supplied interface-typed collections (IReadOnlyList etc.) as adversarial code at trust boundaries: an implementation can return different contents per enumeration (TOCTOU). Snapshot ONCE at entry and use the private copy for validation, @@ -46,6 +97,49 @@ - Before implementing significant or breaking work from `main`, create a dedicated issue branch immediately after plan approval and before the first source edit; re-baselining `main` is not a substitute for establishing the implementation branch. +- Preserve-mode / "carry the previous value forward" is a fidelity trap for a signed format. + did:webvh Update with `NewDocument == null` took `previousEntry.State` (the TYPED model) into a + freshly built head entry, which re-serialized through the lossy `DidDocumentSerializer` and + silently DROPPED signed nested members the model doesn't surface (e.g. `verificationMethod[i].x-ext` + — `VerificationMethod` has no `AdditionalProperties`; only `DidDocument`/`Service` do). The head is + then hashed/signed over the reduced state, so the erasure is invisible. Round-3 fixed republished + PRIOR entries via whole-entry wire provenance but missed the NEW head. Fix: a second + `ConditionalWeakTable` keyed by the parsed document reference re-emits the + raw state verbatim while a modeled fingerprint still matches; a `with`-clone (new ref) or a + model-visible mutation falls back to modeled. A fingerprint over a LOSSY serialization does NOT bind + model-invisible members — sound only because the sole provenance-registered doc reaching a signed + head is the internal parsed `previousEntry.State` (no caller reference), and a supplied `NewDocument` + is deep-copied to a fresh unregistered reference. State the guard's precondition as "model-visible + change", not "any change". (Issue #101 PR #102 final adversarial round, F1.) +- Snapshot the caller's DID DOCUMENT once at the update trust boundary, not just the parameter + collections. `DidDocument` holds interface-typed collections (`IReadOnlyList<>`, + `IReadOnlyDictionary`); a hostile implementation returns different contents per + enumeration, and Update reads the document across an `await` (hash → sign → publish → did.json → + reported result), so the published bytes can diverge from the signed bytes. `SnapshotDocument` = + `Deserialize(Serialize(doc, JsonLd))` materializes concrete collections; every downstream stage, + including the `Id == did` binding check, uses the private copy. Even when an inner serializer + enumerates a field twice, the snapshot is discarded intermediate output — the frozen concrete copy + is what everything after reads, so hash == publish by construction. (Issue #101 PR #102 F2.) +- Enforce a spec's "every entry" identity invariant at the ONE chain-validation choke point, not in a + branch. did:webvh v1.0: the SCID segment of `state.id` MUST equal `parameters.scid` "for every + entry's state.id, not just the first ... independently of portability" (only host/path may change). + NetDid checked `State.Id == did` on the TARGET only and ran a per-entry `HasConsistentScid` helper + solely in the deactivated-tail metadata branch, so a genuinely signed middle/genesis entry with a + foreign SCID resolved. Fix: one `ValidateStateScidConsistency` call for genesis + every subsequent + validated entry inside `ValidateChainWithPerEntryParams` — resolution maps it to `invalidDidLog`, + and Update/Deactivate inherit writer parity through the same `ValidateChain`. Compare SCID-level + (keeps portable renames valid), pin the method with `StartsWith("did:webvh:")` (ExtractScid alone + returns a segment for sibling methods like `did:webvhevil:`), and reject empty/missing/malformed + ids. Moving the check ahead of a later `ArgumentException` identity binding changes the thrown + exception TYPE for forged logs (now `LogChainValidationException`) — update the pinning tests and + note it in the CHANGELOG. (Issue #101 PR #102 F3.) +- Adversarial subagents that run `git stash`/`git checkout` to test pre-fix behavior can CLOBBER the + working tree they were told to review: a `git checkout -- ` reverted my uncommitted + test edits (an added test + two assertion changes) while leaving untracked new files alone, and the + stash list came back empty. After any adversarial pass that reports "tree restored to WIP" or + "reverted my changes", re-run `git diff --stat` and the FULL suite before trusting green — a + dropped edit reads as a passing baseline. Prefer giving review agents a read-only worktree, or + re-verify the diff is intact afterward. (Issue #101 PR #102 final adversarial round.) - The ≥3-steps non-triviality test counts the WHOLE task workflow (tests, verification, adversarial review, PR), not the size of the source diff. A two-line fix driven through the full issue-fix cycle is non-trivial and requires plan approval BEFORE the first edit. Neither diff --git a/tasks/todo20260712-231228.md b/tasks/todo20260712-231228.md new file mode 100644 index 0000000..f14abeb --- /dev/null +++ b/tasks/todo20260712-231228.md @@ -0,0 +1,524 @@ +# Fix issue #101 — did:webvh multi-proof validation accepts invalid or unauthorized extra proofs + +## Context + +`LogChainValidator.ValidateProof` implements an **existential** rule: an entry is accepted as soon as one proof verifies from an active update key; every other supplied proof (bad signature, unauthorized signer, wrong purpose) is silently skipped. did:webvh v1.0 §Authorized Keys states — verified against the spec source (`spec/specification.md`, decentralized-identity/didwebvh): + +> "Resolvers **MUST** reject an entry whose proof fails *any* check." + +with per-proof requirements: `type == DataIntegrityProof`, cryptosuite from the active method version (`eddsa-jcs-2022` for `did:webvh:1.0`), `proofPurpose == assertionMethod`, verificationMethod resolving to a multikey **verbatim** in the active `updateKeys`. NetDid accepts logs stricter conforming resolvers reject → interop/conformance defect (Medium; not an authorization bypass since ≥1 valid authorized proof is still required). + +**Assessment — confirmed root causes (all verified against source):** + +1. `src/NetDid.Method.WebVh/LogChainValidator.cs:225-243` — existential loop: `continue` past `signerKey is null` (invalid signature/VM) and past valid-but-unauthorized signers; `return` on first valid+authorized proof. +2. **`proofPurpose` is never checked anywhere.** DataProofsDotnet's `JcsCryptosuite.VerifyProof` (dataproofs-dotnet `JcsCryptosuite.cs:137-245`) enforces `type`+`cryptosuite` but *not* purpose; NetDid passes it through unchecked. A single-proof entry with `proofPurpose: "authentication"`, validly signed by an authorized key, is accepted today — direct spec violation, even without multi-proof. +3. `src/NetDid.Method.WebVh/LogEntrySerializer.cs:357-368` — `ParseProofArray` only accepts a JSON array, but the official log-entry schema (`schemas/v1.0/log_entry.json`) permits `proof` as `oneOf` [single proof object | array]. Structural failures escape as `KeyNotFoundException` (missing member), `InvalidOperationException` (non-string member); JSON-null members are hidden by `!`. It also *requires* `created`, which the schema marks **optional** (required = type, cryptosuite, verificationMethod, proofPurpose, proofValue). +4. **Proof fidelity**: the six-field `DataIntegrityProofValue` DTO drops schema-permitted `id`/`expires` and extension members. Since eddsa-jcs-2022 signs the whole proof config, (a) a conforming foreign proof carrying `expires` fails verification in NetDid (reconstructed config ≠ signed config), and (b) update/deactivate re-serialize the fetched log via `ToJsonLines`, **stripping those members and corrupting foreign proofs in the republished log**. DataProofsDotnet's `DataIntegrityProof` already round-trips all fields + `[JsonExtensionData]`, so full fidelity is achievable by carrying the wire JSON. +5. `src/NetDid.Method.WebVh/DidWebVhMethod.cs:202-217, 409-413` — proof structural exceptions bypass the `FormatException → invalidDidLog` mapping and hit `catch (Exception) → NotFound`; Update/Deactivate (`:426, :594`) leak raw `KeyNotFoundException`/`InvalidOperationException`. +6. `DidWebVhMethod.cs:363-369` — `IncludeLog` exposes the parsed proof collection after existential validation (fixed structurally once validation is universal). + +**Out of scope (each justified):** +- **Witness proofs** (`WitnessValidator.cs`) — the spec *explicitly permits* ignoring failed witness proofs when the threshold is met; different normative rule; issue explicitly excludes. The shared `WebVhProofVerifier`/DTO changes must not alter witness behavior. +- **`expires` enforcement** — neither did:webvh v1.0 prose nor the schema requires expiry evaluation for controller proofs on append-only history; the suite still validates its dateTimeStamp format when present. +- **Non-proof structural errors → notFound** (e.g. non-string `versionId`) — pre-existing, outside the proof surface this issue covers. +- **Non-did:key verificationMethod forms** — spec + implementation guide use `did:key`; existing behavior retained. +- **Threshold/all-keys semantics** — explicitly rejected by the issue; one authorized signer still suffices, every *supplied* proof must merely be valid. +- **Pre-rotation active-key selection** — unchanged (issue #98 territory). + +## Plan + +Branch: `fix/issue-101-multi-proof-validation` off `main` (PR #100 merged; tree clean). Copy this plan to `tasks/todo{timestamp}.md` per repo convention. + +### 1. `Model/DataIntegrityProofValue.cs` — schema-faithful DTO +- `Created`: `required string` → `string?` (schema-optional; suite already handles null). +- Add `string? RawJson { get; init; }` — verbatim wire JSON of the proof object, populated by the parser only; null for programmatically built proofs. Mirrors the existing `LogEntry.VersionTimeRawJson` fidelity pattern. XML-doc the contract (fidelity source for re-serialization + verification; consumers can read unmodeled members like `id`/`expires` from it). +- XML-doc the controller-proof contract from the issue's "Documentation contract" paragraph. + +### 2. `LogEntrySerializer.cs` — schema-shape parsing at the trust boundary +- Replace `ParseProofArray` with a `ParseProof(JsonElement)` that: + - accepts a single proof **object** or an **array** of objects (schema `oneOf`), normalizing to one list; rejects empty arrays and any other ValueKind; + - per element: must be an object; `type`, `cryptosuite`, `verificationMethod`, `proofPurpose`, `proofValue` present and JSON strings; `created` optional but a string when present; unknown members permitted (schema has no `additionalProperties: false`) and preserved via `RawJson = element.GetRawText()`; + - every structural failure → `FormatException` (never `KeyNotFoundException`/`InvalidOperationException`), so resolution maps to `invalidDidLog` and Update/Deactivate surface the same exception type as other malformed-log content (lessons.md trust-boundary rule); + - returns a read-only list. +- `WriteProofArray`: `writer.WriteRawValue(proof.RawJson)` when present (byte-faithful round-trip of foreign proofs through update/deactivate); else write modeled fields, skipping `created` when null. (A wire single-object `proof` re-emits as a one-element array — both shapes are schema-conformant, and proof bytes are outside entryHash/SCID/witness signatures, so this is safe; documented in CHANGELOG.) + +### 3. `WebVhProofVerifier.cs` — verify the proof that was actually signed +- In `VerifyAndExtractSigner`: when `RawJson` is present, build the `DataIntegrityProof` via `JsonSerializer.Deserialize(RawJson)` (DataProofsDotnet model round-trips all members incl. `[JsonExtensionData]`), so the recomputed proof config is byte-faithful for conforming foreign proofs carrying `id`/`expires`/extensions. Deserialization failure → `null` (fail closed). Otherwise keep the current six-field reconstruction (NetDid-authored proofs, witness path — behavior unchanged). + +### 4. `LogChainValidator.cs` — universal controller-proof validation +- `ValidateProof(LogEntry, IReadOnlyList authorizedKeys, int version)`: + - snapshot `entry.Proof` once (`ToArray()`) per the TOCTOU lesson; + - empty/missing → reject (unchanged; spec prose: every entry MUST include a proof); + - **for every proof**: explicit policy checks with specific failure messages — `Type == "DataIntegrityProof"`, `Cryptosuite == EddsaJcs2022Cryptosuite.CryptosuiteName`, `ProofPurpose == "assertionMethod"` (closes gap #2; checked before signature work so wrong-type/suite/purpose get precise errors regardless of signature validity) — then `VerifyAndExtractSigner` must return a signer (else reject: invalid signature or malformed VM), and the signer must be in `authorizedKeys` (existing exact-ordinal match); + - success only after the whole collection passes. One authorized signer among several *valid* proofs still suffices — no threshold introduced… every proof must be valid and authorized per the spec's active-updateKeys rule ("verificationMethod resolves to a multikey that appears verbatim in the active updateKeys" is one of the checks the entry "MUST" not fail). + - make `authorizedKeys` non-nullable and drop the dead `authorizedKeys is null → return` branch (both callers pass non-null; under universal semantics that branch would be a policy hole). + +### 5. No changes needed in `DidWebVhMethod.cs` +Resolution already maps `FormatException → invalidDidLog` (line 206) and `LogChainValidationException → invalidDidLog` (line 397); step 2 makes proof malformation take those paths. Update/Deactivate inherit consistent `FormatException`. `IncludeLog` becomes safe structurally (only fully-validated collections survive to artifact exposure). + +### 6. Regression tests — `Issue101_*` (fail-first), new file `tests/NetDid.Method.WebVh.Tests/LogChainValidatorMultiProofTests.cs` + additions to `LogEntrySerializerTests.cs` +Reuse the issue-#50 helpers pattern (`CreateLogAsync`, `TamperGenesisProof`-style re-signing via `EddsaJcs2022Cryptosuite.CreateProofAsync`, `MockWebVhHttpClient`). + +Validation matrix (issue's required coverage): +- one valid authorized proof → accept; multiple valid authorized proofs (genesis with 2 updateKeys, both sign) → accept +- valid + invalid-signature extra, **both orders** → reject +- valid + valid-but-unauthorized extra, **both orders** → reject (the issue's exact repro) +- only unauthorized proofs → reject; missing proof → reject; empty `proof: []` on the wire → `invalidDidLog` +- wrong `proofPurpose` (validly signed by authorized key over `authentication`) → reject — *the new conformance check; passes today, must fail post-fix* +- wrong `type` / wrong `cryptosuite` → reject with policy error +- structurally malformed extra proof (missing member / non-string member / JSON-null member) → resolution returns **`invalidDidLog`**, not `notFound`; `ParseJsonLines` throws `FormatException` +- single proof **object** (non-array) on the wire → parses, validates, resolves +- `created`-less proof (schema-optional) signed via the suite → accepted +- foreign proof with signed extra member (e.g. `expires`) → **accepted** (fidelity), and an Update on that log **preserves the member byte-for-byte** in the emitted jsonl (round-trip) +- `IncludeLog`: mixed valid+unauthorized log → `invalidDidLog` with no artifacts exposed +- Fail-first: run the suite against pre-fix code (stash), record which tests fail, restore. + +### 7. Docs + CHANGELOG (no version bump — release skill owns that) +- `NetDidPRD.md`: add the controller-proof contract to the did:webvh section: *"A did:webvh entry requires at least one controller proof. One active update key is sufficient to authorize the entry. If multiple controller proofs are supplied, every supplied proof must be structurally valid, cryptographically valid (type `DataIntegrityProof`, cryptosuite `eddsa-jcs-2022`, purpose `assertionMethod`), and signed by an active update key. Controller proofs do not use threshold semantics."* +- XML docs: `LogEntry.Proof`, `DataIntegrityProofValue`. +- `README.md`: concise note under did:webvh (LogEntry.Proof is public via `IncludeLog`). +- `CHANGELOG.md` `[Unreleased]`: Fixed (universal proof validation, purpose check, invalidDidLog mapping) + Changed (stricter conformance: multi-proof entries with invalid extras now rejected — called out as intentional compatibility change) + Fixed (interop: single-object proof shape, optional created, foreign-proof member fidelity). +- `w3c-conformance-report.md` audit table: add row `did:webvh multi-proof existential validation (#101) | Issue101_* in tests/NetDid.Method.WebVh.Tests/...`. + +### 8. Gates +- `net-did-verify` skill: 0-warning Release build, all test projects with per-project counts (baseline 979 green), W3C conformance green, samples end-to-end. +- `adversarial-review` skill on the completed diff (security-sensitive: proof validation, parsing trust boundary); fix CONFIRMED findings, re-verify. +- Commit, push, open PR with `Fixes #101`; **stop at the open PR** (user merges). Append `## Review` to the plan file: files, counts, acceptance-criteria re-check, fail-first evidence, PR URL. + +## Verification +- Fail-first evidence for the new tests (stash/run/restore). +- Full suite + W3C conformance + samples via `net-did-verify`. +- Behavior diff vs `main`: the issue's repro (valid+unauthorized mixed proofs, both orders) resolves successfully on `main`, must return `invalidDidLog` on the branch; single-proof logs produced by `CreateAsync` resolve identically on both. + +--- + +## Review (completed 2026-07-13) + +**PR:** https://github.com/moisesja/net-did/pull/102 (`fix/issue-101-multi-proof-validation` → `main`) — Fixes #101. **Stopped at open PR; not merged.** + +### Files changed +| File | Change | +|---|---| +| `src/NetDid.Method.WebVh/LogChainValidator.cs` | `ValidateProof` now universal: per-proof type/cryptosuite/proofPurpose policy + signature + authorized-signer; snapshot-once; `authorizedKeys` non-nullable (dead bypass removed); `MaxProofsPerEntry = 100` resource cap | +| `src/NetDid.Method.WebVh/LogEntrySerializer.cs` | `ParseProof` accepts single object or array (schema `oneOf`); required/optional member checks → `FormatException`; `WriteProofArray` re-emits `RawJson` verbatim, skips null `created` | +| `src/NetDid.Method.WebVh/WebVhProofVerifier.cs` | Verifies from `RawJson` when present (byte-faithful config incl. unmodeled members) with a 6-field model/JSON consistency guard; else modeled-field path (unchanged for witness/programmatic proofs) | +| `src/NetDid.Method.WebVh/Model/DataIntegrityProofValue.cs` | `Created` → `string?`; added `RawJson` (`internal init`); controller-proof contract XML doc | +| `src/NetDid.Method.WebVh/Model/LogEntry.cs` | `Proof` XML doc: universal contract | +| `NetDidPRD.md`, `README.md`, `CHANGELOG.md` | Controller-proof contract, schema shapes, resource cap, compatibility note | +| `tests/.../Infrastructure/ConformanceReport.cs` + `w3c-conformance-report.md` | #101 audit-table row | +| `tests/NetDid.Method.WebVh.Tests/LogChainValidatorMultiProofTests.cs` | New — 25 `Issue101_*` tests | + +### Test counts (Release, `--no-build`) +Core 375 · Key 52 · Peer 48 · **WebVh 356** (+25) · DI 14 · W3C 175 → **1020 total, 0 failed**. 0-warning Release build; W3C suite green; all four samples exit 0; `git diff --check` clean. + +### Fail-first evidence +`Issue101_*` run against pre-fix `src/` (stashed): **19/23 failed** (the 4 that passed are baseline-independent, e.g. single/multiple valid proofs resolve). Restored fix → all pass. Cap tests added after this pass. + +### Behavior diff vs `main` +- Issue's repro (valid + unauthorized proof, both orders) resolves **successfully on `main`**, returns **`invalidDidLog`** on the branch. +- Single-proof `CreateAsync` logs resolve identically on both. +- Single proof **object** and missing-`created` foreign proofs: `notFound` on `main` → resolve on the branch (strict superset of accepted logs; no historical resolution regressed). + +### Acceptance criteria (issue #101) — all met +Every supplied proof checked ✓ · any invalid/unauthorized/wrong-suite/purpose/type invalidates ✓ · multiple valid proofs supported ✓ · no all-keys/threshold requirement ✓ · both proof shapes handled ✓ · malformed → `invalidDidLog` (not `notFound`/unhandled) ✓ · mixed sets both orders covered ✓ · PRD + XML + CHANGELOG state the contract ✓ · tests linked from conformance audit table ✓. + +### Adversarial review +Two independent lenses (crypto/trust-boundary + spec/parsing): **no CONFIRMED bypass**. Hardened two lower-severity findings — proof-count cap (per-proof canonicalization amplification) and `RawJson` `internal init` (JSON Lines corruption via consumer-constructed value). Verdict: clean. + +--- + +## Review round 1 (maintainer review of PR #102, addressed 2026-07-13) + +Maintainer (moisesja) found the "every supplied proof is validated" claim was still false and gave four findings; a fresh adversarial re-review of the rework found one more. All fixed in commit `34a88a5`. + +| # | Sev | Finding | Resolution | Test | +|---|-----|---------|-----------|------| +| 1 | Med | Duplicate JSON members: default parse keeps the last, so a decoy `proof` beside a valid one smuggles an unvalidated proof | `DeserializeEntry` parses with `AllowDuplicateProperties = false` (recursive) → `FormatException` → `invalidDidLog` | `Issue101_DuplicateTopLevelProofMember_*`, `Issue101_DuplicateMemberInsideProofObject_*` | +| 2 | Med | `RawJson` was signature-checked, not Data-Integrity-validated: `previousProof`/`expires`/`id`/`domain` accepted without evaluation | Narrowed to the did:webvh controller-proof profile; out-of-profile members rejected as unsupported; `RawJson` removed (modeled fields are the whole proof) | `Issue101_ProofWithUnsupportedMember_*` (theory ×6) | +| 3 | Med | Arbitrary 100-proof cap = baseless compat break + weak DoS control | Removed cap; bound work by stop-at-first-failure + dedup of identical proofs; deterministic Ed25519 ⇒ distinct passing proofs ≤ active keys | `Issue101_ManyIdenticalValidProofs_Resolves`, `Issue101_LargeEntryWithRepeatedProofs_Resolves` | +| 4 | Low | Update/Deactivate exception contract untested | Added coverage | `Issue101_Update_OnMalformedProofLog_ThrowsFormatException`, `Issue101_Deactivate_*` | +| F1 | Low | (found by re-review) dedup identity folded absent `created` and `created:""` → could skip a distinct invalid proof | Value-tuple identity (`null != ""`, injection-free) | `Issue101_DedupDoesNotSkipDistinctCreatedVariant_ResolvesInvalidDidLog` (fail-first verified) | + +Behavior change vs first-round PR: removed `DataIntegrityProofValue.RawJson` and the `MaxProofsPerEntry` cap; proofs outside the profile and duplicate-member entries are now `invalidDidLog`. Docs (PRD §7.4, README, CHANGELOG) rewritten to the narrowed, honest claim. Full suite 1029 green; 0-warning Release; W3C conformance green; samples exit 0. Lessons captured in `tasks/lessons.md` (duplicate-member smuggling, false-validation-via-preservation, tuple-vs-join identity, cap-vs-structural-bound, re-review-after-rework). + +--- + +## Review round 2 (maintainer review of PR #102, addressed 2026-07-13) + +Round-2 review found the round-1 replacement design introduced a false security invariant and a conformance regression. Root cause: still hand-rolling a subset of Data Integrity. Reworked to **delegate controller-proof verification to DataProofsDotnet's `DataIntegrityProofPipeline`**; NetDid contributes only did:webvh policy. Commit `909b353`. + +| # | Sev | Finding | Resolution | Test | +|---|-----|---------|-----------|------| +| 1 | Med/High | "distinct proofs ≤ active keys" bound is false — `created` is attacker-chosen and signed, so one key mints unlimited distinct valid proofs; dedup only skipped byte-identical | Dropped the claim + dedup; explicit configurable per-entry proof budget (default 8) over the size-capped fetch | `Issue101_DistinctValidProofsBeyondBudget_*`, `Issue101_ProofCountAtBudget_Resolves` | +| 2 | Med | Closed 6-member profile rejects conforming proofs — schema requires members "at minimum", additionalProperties open, so `id`/`expires` are conforming | Verify via full DI pipeline; `id`/`expires`/`previousProof` preserved (RawJson) + validated (expires vs versionTime; dangling chain rejected) | `Issue101_SignedProofWith{FutureExpires,PastExpires,Id,DanglingPreviousProof}_*` | +| 3 | Low | `"\uD800"` parses but GetString throws InvalidOperationException → escapes to notFound | Parse boundary maps JSON-access failures to FormatException → invalidDidLog | `Issue101_ProofWithUnpairedSurrogate_ResolvesInvalidDidLog` | +| 4 | Low | Stale PR/description/comments (RawJson, cap, 1020, "byte-faithful") | PR body + PRD + README + CHANGELOG + XML rewritten to pipeline design | — | + +New/changed source: `WebVhUpdateKeyResolver.cs` (new — did:key anti-spoof + updateKeys membership + assertionMethod), `LogChainValidator.ValidateProof` (pipeline delegation + budget), `LogEntrySerializer` (open-profile parse + RawJson + JSON-access error mapping), `DataIntegrityProofValue.RawJson` restored, `WebVhProofVerifier` now witness-only. `LogChainValidator` ctor no longer takes the suite (call sites updated). + +Adversarial re-review (3rd pass, pipeline design): no confirmed bypass — authorization bound to the verified signature; RawJson provenance GetRawText-only + internal init + validated WriteRawValue; every verification exit fails closed; every resolution-influencing entry validated under the budget. Fixed one fail-closed nit it raised (moved `Serialize` inside the try so a serialize failure reports invalidDidLog, not notFound). Full suite 1028 green; 0-warning Release; W3C conformance green; samples exit 0. Lessons added: delegate-don't-reimplement, false-determinism-bound, "required at minimum"-means-open, map-JSON-access-errors. + +--- + +## Review round 3 fix plan (approved 2026-07-13) + +### Re-baseline and scope + +- Target remains PR #102, branch `fix/issue-101-multi-proof-validation`, head + `948a1051cd87256c2e8506f5776da58d93a24f2a`; GitHub CI is green and there are no + inline review threads. The four actionable clusters are in the third top-level review. +- The real checkout has an unrelated user modification in + `samples/NetDid.Samples.DidWebVh/Program.cs`. It will remain untouched. Implementation will + run in an isolated clone at the reviewed head and push only intentional commits to the existing + PR branch. +- Normative basis rechecked: Data Integrity requires a present proof `id` to be a URL and permits + `domain` as a string or unordered set of strings + ([W3C Data Integrity §2.1](https://www.w3.org/TR/vc-data-integrity/#proofs)); proof chains use + `previousProof` and all referenced proofs must verify + ([§4.5](https://www.w3.org/TR/vc-data-integrity/#verify-proof-sets-and-chains)); did:webvh requires + every controller proof to pass its checks + ([Authorized Keys](https://identity.foundation/didwebvh/v1.0/#authorized-keys)). + +### Confirmed root causes + +1. **Historical `IncludeLog` exposes an unchecked tail (Medium).** + `DidWebVhMethod.ResolveCoreAsync` validates only through `targetIndex` (`:222-230`) so a valid + historical version remains resolvable despite a corrupt later tail, but the artifact block + (`:362-369`) returns the entire fetched JSONL and parsed entry list. The raw and parsed artifacts + therefore include entries/proofs that did not establish the returned result. +2. **Invalid proof `id` is accepted (Medium).** `LogEntrySerializer.ParseProofObject` + (`:426-445`) preserves `id` only in `RawJson`; DataProofsDotnet preview.1 models it as an + unconstrained string and the pipeline does not enforce the W3C URL requirement. A genuinely + signed authorized proof with `id: "not a URL"` resolves. +3. **The proof budget has no consumer configuration surface (Medium).** + `LogChainValidator` and its constructor are internal, while the existing public + `DidWebVhMethod` constructor and `NetDidBuilder.AddDidWebVh` always select the default eight. +4. **Interop/documentation claims outrun dependency behavior and test evidence (Low/Medium).** + DataProofsDotnet preview.1 models `domain` as `string?`, so a standards-valid array-valued domain + is rejected. Existing XML/README/PRD wording still implies arbitrary Data Integrity members are + fully supported. The dangling-chain test mutates a proof after signing and does not isolate + chain resolution; no committed positive genuine-chain test exists. The writer also normalizes a + single-object `proof` container to an array, so whole-container byte-fidelity must not be claimed + unless explicitly preserved. + +### Out of scope + +- **DataProofsDotnet's public `domain` model redesign/release** belongs to the sibling dependency, + not this repository. This PR will characterize and clearly document that upstream limitation; + it will not fabricate local JCS/domain handling or edit/publish the dependency. +- **Witness proofs** retain their separate threshold semantics and verifier path; the controller + proof budget and URL guard do not change witness behavior. +- **The macOS DidKey/P-521 AppleCrypto sample abort** reproduces outside this diff and is unrelated + to did:webvh proof validation. +- No merge, upstream issue creation, or release/version bump is included. + +### Implementation plan + +- [ ] **Add fail-first regressions before source changes.** + - Historical v1 resolution with `IncludeLog` and a validly chained v2 carrying a genuinely signed + unauthorized extra proof: resolution succeeds, but both artifacts must stop at v1. Assert the + raw prefix contains neither v2's version id nor the attacker proof. + - Historical `IncludeLog` with an entirely valid tail still returns only the requested prefix; + latest resolution continues returning the exact complete fetched JSONL and all entries. + - A genuinely signed authorized proof with `id: "not a URL"` returns `invalidDidLog`; valid + absolute URLs including `urn:proof:1` remain accepted. Null, empty, and non-string `id` values + fail at the parse boundary. + - Direct construction with a raised limit accepts nine genuinely distinct valid proofs; a lowered + limit rejects above it; zero/negative values throw `ArgumentOutOfRangeException`. + - DI configuration reaches the resolver behavior (nine proofs accepted through the registered + `IDidMethod`), not merely an options registration. + - Replace/strengthen the dangling-reference case with a genuinely signed dangling proof and add a + genuine two-proof chain that resolves. Add a stock-suite array-valued-domain characterization + test so the documented dependency limitation cannot silently turn back into a broad claim. + - Record current-head failures. For the additive budget API, absence/compile failure is the + pre-fix evidence; run behavioral fail-first immediately after the API shell exists but before + wiring it into `LogChainValidator`. +- [ ] **Make `IncludeLog` resolution-relative without weakening historical availability.** + - Return a read-only `entries[0..targetIndex]` artifact, never the unchecked tail. + - Add a small internal JSONL-prefix helper that scans the original UTF-8 text using the same + non-empty-line rules as `ParseJsonLines` and returns the original bytes/text through the Nth + record. Do not reserialize parsed entries: that would normalize property order and proof shape. + - Keep latest-resolution artifacts byte-for-byte identical to the fetched log. Pin LF/CRLF and + blank-line behavior so raw and parsed artifact views cover the identical prefix. +- [ ] **Enforce the W3C proof-`id` URL rule at NetDid's parse trust boundary.** + - When `id` is present, require a non-null string and a well-formed absolute URI/URL; malformed + values throw `FormatException`, mapping Resolve to `invalidDidLog` and preserving the existing + Update/Deactivate malformed-log contract. + - Keep `RawJson` as the signed/fidelity source and continue delegating cryptographic/chain/expiry + verification to DataProofsDotnet; do not duplicate the full pipeline. +- [ ] **Expose the controller-proof budget through binary-compatible public overloads.** + - Preserve the exact existing `DidWebVhMethod(IWebVhHttpClient, ILogger? = null)` signature and + delegate it to a required higher-arity overload taking the existing logger position plus + `int maxControllerProofsPerEntry`, avoiding ambiguity for existing untyped `default` calls. + - Preserve the exact existing `AddDidWebVh(WebVhHttpClientOptions? = null)` signature and delegate + it to a required higher-arity overload taking the existing HTTP-options position plus + `int maxControllerProofsPerEntry`, avoiding ambiguity for existing untyped `default` calls. + - Keep default eight, rename internal fields/parameters to say **controller** proofs, validate + values `< 1` immediately, and warn that raising the value increases attacker-controlled + canonicalization/signature work. Do not mix this resolver policy into HTTP fetch options. +- [ ] **Correct public contracts and fidelity wording.** + - Update `README.md`, `NetDidPRD.md`, `CHANGELOG.md`, `DataIntegrityProofValue`/`LogEntry` XML, + `DidWebVhArtifacts` XML, and constructor/DI XML with: validated historical prefix semantics; + exact budget APIs; enforced URL `id`; supported `id`/`expires`/proof chains; explicit + array-valued-`domain` dependency limitation; and accurate proof-object/container fidelity. + - Preserve parsed proof-object JSON verbatim, but intentionally keep the existing safe + single-object-to-array container normalization and state/test it explicitly. Do not add shape + metadata: both containers are schema-valid, normalization does not invalidate the proof, and a + marker would add stale raw/model dual-view risk merely to support an unnecessary fidelity claim. + - Update the PR body after verification so test counts and compatibility claims match reality. +- [ ] **Verify and red-team the completed diff.** + - Run focused Issue101, serializer, historical-resolution, and DI tests; prove the new tests fail + before and pass after the fixes. + - Run the `net-did-verify` gate: locked restore, zero-warning Release build, full suite with + per-project counts, W3C conformance, all samples with honest exit codes, `git diff --check`, and + restore generated report timestamp-only noise. + - Run independent adversarial agents over trust-boundary parsing, raw/parsed artifact parity, + constructor/DI compatibility, hostile proof collections, URL edge cases, chain semantics, and + the documented upstream limitation. Reproduce or refute every finding; fix all confirmed ones + and re-run affected gates. +- [ ] **Finish the existing PR, but do not merge.** + - Append the completed round-3 matrix, fail-first evidence, verification ledger, behavior diff, + and adversarial verdict here; update `CHANGELOG.md`; commit intentionally; push the existing + branch; refresh PR checks and body. Do not resolve/reply to review feedback or submit another + review unless separately authorized; do not merge. + +### Expected files + +| Area | Files | +|---|---| +| Resolver/artifacts | `src/NetDid.Method.WebVh/DidWebVhMethod.cs`, `LogEntrySerializer.cs`, `DidWebVhArtifacts.cs` | +| Proof policy/budget | `src/NetDid.Method.WebVh/LogChainValidator.cs`, `Model/DataIntegrityProofValue.cs`, `Model/LogEntry.cs` | +| DI | `src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs` | +| Tests | `tests/NetDid.Method.WebVh.Tests/LogChainValidatorMultiProofTests.cs`, `LogEntrySerializerTests.cs`, `DidWebVhMethodTests.cs`; `tests/NetDid.Extensions.DependencyInjection.Tests/ServiceRegistrationTests.cs` | +| Contracts | `README.md`, `NetDidPRD.md`, `CHANGELOG.md`, this task file; PR body after successful validation | + +### Approval gate + +Approved by the user on 2026-07-13. Implementation began in the isolated clone. + +--- + +## Round 3 adversarial amendment (approved 2026-07-13) + +The mandatory completed-diff adversarial review reproduced additional trust-boundary defects that +were not in the four approved review findings. Project instructions require a revised plan and +fresh approval before expanding the implementation into raw-entry integrity handling. + +### Reproduced findings + +1. **Fetched entry content is not fully integrity-protected (Medium/High).** A valid generated log + was changed *after signing* by adding + `state.verificationMethod[0].attackerInjected = "not-covered-by-signature"`. Resolution returned + `error = null`, a document, and an `IncludeLog` artifact containing the injection. The DID + Document converter drops that unknown nested member; entry hashing and proof verification then + use the reduced reconstructed model instead of the fetched entry. This is a real integrity + bypass, not a fidelity-only issue. +2. **Proof-id validation is a conservative .NET URI check, not WHATWG URL conformance (Medium).** + `System.Uri` accepts whitespace-normalized and other WHATWG-validation-error inputs, while it + rejects the valid one-letter-scheme URL `a:b`. No maintained .NET package found in the research + exposes WHATWG non-fatal validation errors: the closest candidate normalizes and accepts them. + The current generic “well-formed absolute URL” wording is therefore broader than the actual API. +3. **Duplicate proof ids make an unordered proof set order-dependent (Medium).** A genuine + three-proof chain with two distinct valid predecessors sharing `urn:proof:same` resolved in one + array order and failed after swapping those predecessors. The pinned pipeline selects the first + matching id; NetDid currently admits the ambiguous set. +4. **Malformed UTF-8 is silently normalized before verification (Medium).** Replacing signed + `\uFFFD` bytes with raw `0xFF` still resolved because `Encoding.UTF8.GetString` replaced the + invalid byte with U+FFFD before JSON, hash, and proof processing. A strict resolver rejects the + same byte sequence. +5. **Two low-severity defects in the approved implementation were already corrected during the + adversarial pass.** Required higher-arity budget overloads now preserve existing untyped + `default` call sites, and resolution artifacts are read-only so cached results cannot be poisoned + by casting `IReadOnlyDictionary` back to a mutable dictionary. Both have regression coverage. + +### Scope decisions + +- Fix the four reproduced proof/log trust-boundary findings above in this PR because they directly + invalidate its universal-validation and validated-artifact claims. +- Do **not** add a low-adoption URL-parser dependency that reports only fatal parse failures. Keep a + conservative .NET absolute-URI policy, reject leading/trailing whitespace explicitly, and + document the WHATWG compatibility limitation precisely. Exact WHATWG valid-URL-string support + requires validation-error reporting plus the Web Platform Test corpus and belongs in a dedicated + implementation/dependency effort. +- The IncludeLog adversary also identified pre-existing historical-resolution behavior involving + malformed tails, unvalidated `versionTime` selection, and deactivation metadata followed by + garbage. Those are separate resolution-availability/metadata semantics, not the reported + unchecked-artifact-tail bug. This amendment narrows the new contracts so they do not claim those + cases; it does not redesign historical resolution selection in issue #101. + +### Amended implementation plan + +- [ ] **Add fail-first raw-integrity and encoding regressions.** Commit a post-sign nested + verification-method-member mutation that must return `invalidDidLog` with no artifacts; a + genuinely signed entry carrying an unknown nested member that must still verify; a strict UTF-8 + case replacing signed `\uFFFD` JSON with raw `0xFF`; and Update/Deactivate preservation coverage + for valid fetched entries. Re-run existing self-consistent forged-identity/SCID-binding tests as + well as the post-sign tampering cases required by project policy. +- [ ] **Retain private parser provenance and verify the fetched semantics.** Keep wire provenance + private to `LogEntrySerializer` in a `ConditionalWeakTable` rather than adding + a raw property to the public record. Store the duplicate-checked raw root plus a SHA-256 + fingerprint of the modeled serialization at parse time. Raw-backed serialization is allowed only + while the current modeled fingerprint still matches; a `with` clone has no provenance and any + in-place public-model mutation invalidates the fingerprint, so stale wire data cannot override a + deliberate consumer change. Programmatically constructed entries retain the current modeled path. +- [ ] **Transform only the protocol shell and preserve every fetched nested member.** Refactor the + serializer so an unchanged parsed entry copies raw `versionTime`/`parameters`/`state` values, + rewrites `proof` through the existing proof-object-preserving array writer, and can omit `proof` + or override only top-level `versionId` for hash/SCID/witness input. Route controller hashes, + controller proofs, and witness proofs through that common raw-aware representation. Update and + Deactivate already republish parsed prior entries through `ToJsonLines`; unchanged provenance will + preserve their nested members, while newly appended entries remain modeled. Keep the documented + single-proof-object-to-array normalization. +- [ ] **Make byte decoding fail closed.** Decode did.jsonl with strict UTF-8 + (`throwOnInvalidBytes: true`) and map `DecoderFallbackException` to `FormatException` then + `invalidDidLog`. Use the same strict rule at the witness-file boundary and in the historical raw + prefix helper; add focused invalid-byte tests for both reachable paths. +- [ ] **Remove proof-id ambiguity and state the real URL policy.** Reject duplicate present proof + ids under ordinal equality before pipeline verification. Strengthen the current guard to reject + leading/trailing whitespace, retain valid DID/URN/HTTPS coverage and the signed `not a URL` + negative case, and add signed characterization cases for the documented .NET-vs-WHATWG + limitation. Describe this as NetDid's conservative `System.Uri`-compatible absolute-URI policy, + not generic/full WHATWG conformance. +- [ ] **Update contracts and the round-3 record.** Correct README, PRD, CHANGELOG, XML comments, + and the eventual PR body to distinguish raw-entry semantic integrity, proof-object fidelity, + strict UTF-8, duplicate-id rejection, conservative proof-id URI policy, array-domain dependency + limitation, and the exact bounds of historical-tail availability. +- [ ] **Re-run the full gate and a fresh independent attack.** Run focused fail-first cases, locked + restore, zero-warning Release build, all projects, W3C conformance, sample ledger with the known + main-branch AppleCrypto abort, `git diff --check`, behavior comparison, and new adversarial agents + over raw/model parity, SCID/hash/proof/witness inputs, invalid bytes, duplicate ids, URL edge cases, + public API compatibility, and artifact immutability. Fix every confirmed in-scope regression, + then commit, push the existing PR branch, refresh the accurate PR body/checks, and stop before + merge or review-thread replies. + +### Additional expected files + +`src/NetDid.Method.WebVh/LogEntrySerializer.cs`, `LogChainValidator.cs`, `WitnessValidator.cs`, and +mutation call sites only if the common serializer does not cover them; focused WebVh serializer, +chain, witness, and method tests; existing contract/task files. No dependency, version, public-model, +sibling-repo, or release-publishing changes are authorized. + +### Amendment approval gate + +Approved by the user on 2026-07-13. Implementation resumed in the isolated clone. + +--- + +## Round 3 final adversarial findings — fix plan (approved 2026-07-13) + +The final adversarial review of the round-3 draft (`4287816`) reproduced three unresolved +integrity bugs. All three confirmed against source; plan approved via plan mode. + +### Confirmed root causes + +1. **F1 (Medium) — preserve-mode updates erase signed document extensions.** + `UpdateCoreAsync` with `NewDocument == null` takes `previousEntry.State` (the typed model, + `DidWebVhMethod.cs:525`); the new head entry has no wire provenance, so + `LogEntrySerializer.WriteModeledEntry` reserializes state through `DidDocumentSerializer`, + dropping nested unknown members (e.g. `verificationMethod[0].x-ext` — `VerificationMethod` + has no `AdditionalProperties`). The head is hashed/signed over the reduced state, so the + erasure is silent. Round 3 fixed republished *prior* entries only. +2. **F2 (Medium) — mutable `NewDocument` can diverge from the signed bytes during async + signing.** The caller's document object is enumerated at four points (hash `:576`, sign + `:581` across an `await`, publish `:591`, did.json `:596`); interface-typed collections can + present different contents per enumeration (TOCTOU, `tasks/lessons.md` snapshot-once rule). + Parameters are snapshot-once; the document is the gap. Create is single-read by + construction; Deactivate builds its own minimal doc. +3. **F3 (Medium) — resolution does not enforce SCID identity consistency on every historical + entry.** Only the target entry's `State.Id` is checked (`:261`) plus the genesis + `parameters.scid` binding (`:279`); `HasConsistentScid` runs only in the deactivated-tail + metadata branch. Spec (didwebvh `spec/specification.md`): "The SCID segment of `state.id` + MUST be byte-for-byte identical to the `scid` value in the DID and the first entry's + `parameters.scid`. This check MUST apply to every entry's `state.id`, not just the first. + A mismatch MUST terminate resolution." Applies independently of portability (only + host/path may change). + +### Out of scope + +`did.json` compatibility projection (inherently rewriting, unsigned); full portability +enforcement (separate conformance surface — follow-up audit candidate); Create-path TOCTOU +(single-read by construction); `DidResolutionResult.DidDocument` typed-model reduction +(documented round-3 limitation); macOS AppleCrypto P-521 sample abort (reproduces on main). + +### Plan + +- [ ] Fail-first regressions: F1 preserve-mode head fidelity (`LogEntryWireIntegrityTests`), + F2 hostile `NewDocument` collection (`DidWebVhMethodTests`, reuse `FlippingList`), + F3 crafted signed logs with SCID-inconsistent middle/genesis entries, missing id, + historical-prefix boundary, Update exception (`ScidConsistencyTests`, new). +- [ ] F1: state-provenance channel in `LogEntrySerializer` — `ConditionalWeakTable` keyed by the parsed `DidDocument` reference (raw state JSON + SHA-256 modeled + fingerprint), registered in `DeserializeEntry` before the whole-entry provenance; + `WriteModeledEntry` emits the raw state while the fingerprint is current. `with`-cloned + or mutated states fall back to modeled serialization (pins existing provenance tests). +- [ ] F2: `SnapshotDocument` (serialize+deserialize round-trip, JSON-LD) in `UpdateCoreAsync`; + Id check and all downstream stages use the snapshot. +- [ ] F3: per-entry `state.id` SCID-consistency check in + `LogChainValidator.ValidateChainWithPerEntryParams` (one enforcement point → Resolve + maps to `invalidDidLog`; Update/Deactivate get writer parity); remove the now-redundant + `HasConsistentScid` tail-branch call + helper; keep the target's exact `State.Id == did` + check. +- [ ] Docs: CHANGELOG `[Unreleased]`, PRD did:webvh contract, XML docs; this record. +- [ ] Gates: `net-did-verify`, then `adversarial-review` on the completed diff; fix CONFIRMED + findings; commit, push, refresh PR #102 body; stop before merge. + +### Review (completed 2026-07-13) + +**Findings matrix** + +| # | Sev | Finding | Fix | Enforcement point | +|---|-----|---------|-----|-------------------| +| F1 | Med | Preserve-mode update (`NewDocument == null`) rebuilt the new head from the typed model, silently dropping signed nested document members before hashing/signing | State-level wire provenance (`ConditionalWeakTable`) re-emits the previous state verbatim; modeled fingerprint gates raw-vs-modeled | `LogEntrySerializer.WriteModeledEntry` / `DeserializeEntry` | +| F2 | Med | Mutable/dynamic `NewDocument` collections could publish bytes diverging from the hashed+signed bytes (TOCTOU across the async sign) | `SnapshotDocument` deep-copies once (JSON-LD round-trip); every stage + the Id check use the snapshot | `DidWebVhMethod.UpdateCoreAsync` | +| F3 | Med | Resolution enforced `state.id` SCID identity only on the target entry; a signed middle/genesis entry with a foreign SCID resolved | Per-entry `ValidateStateScidConsistency` for genesis + every validated entry; redundant `HasConsistentScid` tail helper removed | `LogChainValidator.ValidateChainWithPerEntryParams` | + +**Fail-first evidence** — new tests run against pre-fix source: 6 of 9 FAILED +(`ScidConsistency` middle/genesis/missing-id/Update ×4, `Issue101_PreserveModeUpdate…`, +`Issue101_Update_DynamicNewDocumentCollection…`); 3 passed (baseline-independent positives: +`GenuinelySignedNestedVerificationMethodExtension`, `Deactivate_HeadRemainsMinimal`, +`HistoricalTargetBeforeForeignScidTail`). Post-fix: all 9 pass. + +**Test counts (Release, `--no-build`)** — Core 375 · Key 52 · Peer 48 · **WebVh 409** (+9 new, ++2 Issue82 assertion updates) · DI 18 · W3C 175 → **1077 total, 0 failed**. 0-warning Release +build; W3C conformance green (report timestamp churn restored); all four samples exit 0; +`git diff --check` clean. + +**New/changed source** — `LogEntrySerializer.cs` (WireStates channel), `DidWebVhMethod.cs` +(`SnapshotDocument`, snapshot wiring, removed `HasConsistentScid`), `LogChainValidator.cs` +(`ValidateStateScidConsistency`), `DidWebVhUpdateOptions.cs` (XML). Tests: +`ScidConsistencyTests.cs` (new, 5), `LogEntryWireIntegrityTests.cs` (+2), `DidWebVhMethodTests.cs` +(+1 F2, 2 Issue82 assertions). + +**Adversarial review** — three independent lenses (F1 serializer-provenance/aliasing, F2 +trust-boundary/TOCTOU, F3 SCID/spec-conformance), each prompted to refute. **No CONFIRMED +security defect.** Non-security cleanup addressed: tightened the F1 provenance doc comment to state +the guard's precondition as "model-visible change" (the fingerprint is over a lossy serialization); +CHANGELOG notes the intentional Update/Deactivate exception-type change (`ArgumentException` → +`LogChainValidationException`). The reviewers' `git` operations reverted uncommitted test edits mid-run; +detected via `git diff --stat`, re-applied, and re-verified green (lesson captured). + +**Behavior diff vs `main`** — a preserve-mode update now republishes signed nested state members +(previously erased); a hostile `NewDocument` collection can no longer desync publish from sign; a +signed log with an SCID-inconsistent non-target entry now returns `invalidDidLog` (previously +resolved). Conforming single-controller logs and normal updates are unchanged. + +**Stopped at the open PR; not merged.** + +### Review follow-up (2026-07-13): narrow the resolver's bare catch + +| Sev | Finding | Resolution | Test | +|-----|---------|-----------|------| +| Low | `WebVhUpdateKeyResolver.Resolve` used a bare `catch { return null; }` around `PublicKeyMaterial.FromMultikey` — fails closed correctly but silently swallows unexpected exception types | Narrowed to `catch (ArgumentException)`: FromMultikey's documented contract, empirically confirmed against the pinned DataProofsDotnet.Core 0.1.0-preview.1 (every hostile multikey input — bad multibase chars, empty payload, garbage codec, truncated key — throws exactly `ArgumentException`). The documented malformed-log-parameter path still resolves to null (proof unauthorized → `invalidDidLog`); anything else now propagates as the bug it is | `Issue101_MalformedAuthorizedKey_FailsClosed`, `Issue101_WellFormedAuthorizedEd25519Key_Resolves` (new `WebVhUpdateKeyResolverTests.cs`) | + +No observable behavior change (no CHANGELOG entry). WebVh suite 411 green (+2); 0-warning Release build. + +### Review follow-up (2026-07-13): remove sync-over-async in ValidateProof + +| Sev | Finding | Resolution | +|-----|---------|-----------| +| Low | `LogChainValidator.ValidateProof` blocked on `_pipeline.VerifyAsync(...).GetAwaiter().GetResult()`, justified only by a comment that the resolver "completes synchronously" — an invariant not enforced by the type system; a future async resolver or pipeline change would reintroduce thread-pool-starvation/deadlock risk | Made the validation chain honestly async end-to-end: `ValidateChainAsync` / `ValidateChainWithPerEntryParamsAsync` / private `ValidateGenesisEntryAsync` / `ValidateSubsequentEntryAsync` / `ValidateProofAsync`, awaiting `VerifyAsync` with a real `CancellationToken` threaded from the (already async) `DidWebVhMethod` call sites. `OperationCanceledException` still propagates (unchanged filter); static helpers stay sync. `LogChainValidator` is internal, so no public API change | + +Call sites updated: 4 in `DidWebVhMethod` (Resolve ×2, Update, Deactivate — now pass `ct`) and +~21 test call sites across 5 files (`.Invoking` → `.Awaiting`/`ThrowAsync`). Pure refactor, no +behavior change (no CHANGELOG entry). Full suite **1079 green**; 0-warning Release build; W3C +green; `git diff --check` clean. diff --git a/tasks/todo20260713-pr102-review.md b/tasks/todo20260713-pr102-review.md new file mode 100644 index 0000000..2b5f57d --- /dev/null +++ b/tasks/todo20260713-pr102-review.md @@ -0,0 +1,12 @@ +# PR #102 Continued Review + +- [ ] Re-baseline the PR title, description, commits, checks, and complete diff. +- [ ] Review scope, commit quality, tests, critical paths, and compatibility. +- [ ] Run independent correctness/spec and adversarial security passes. +- [ ] Validate every proposed finding against the current PR head. +- [ ] Post a specific GitHub review (approval or requested changes). +- [ ] Record the review outcome and evidence below. + +## Review + +Pending. diff --git a/tests/NetDid.Extensions.DependencyInjection.Tests/ServiceRegistrationTests.cs b/tests/NetDid.Extensions.DependencyInjection.Tests/ServiceRegistrationTests.cs index a3ce309..b0b831a 100644 --- a/tests/NetDid.Extensions.DependencyInjection.Tests/ServiceRegistrationTests.cs +++ b/tests/NetDid.Extensions.DependencyInjection.Tests/ServiceRegistrationTests.cs @@ -1,4 +1,6 @@ using System.Net; +using System.Text; +using System.Text.Json.Nodes; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using NetDid.Core; @@ -93,6 +95,76 @@ public void AddDidWebVh_NeutralizesHttpClientTimeout() http.Timeout.Should().Be(Timeout.InfiniteTimeSpan); } + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Issue101_AddDidWebVh_RejectsControllerProofBudgetBelowOne(int invalidBudget) + { + var services = new ServiceCollection(); + + var act = () => services.AddNetDid(builder => + builder.AddDidWebVh( + httpClientOptions: null, + maxControllerProofsPerEntry: invalidBudget)); + + act.Should().Throw() + .Which.ParamName.Should().Be("maxControllerProofsPerEntry"); + } + + [Fact] + public async Task Issue101_AddDidWebVh_FlowsRaisedControllerProofBudgetIntoMethod() + { + const int raisedBudget = 9; + var logClient = new FixedLogWebVhHttpClient(); + var services = new ServiceCollection(); + services.AddNetDid(builder => + builder.AddDidWebVh( + httpClientOptions: null, + maxControllerProofsPerEntry: raisedBudget)); + + // The last registration is the client consumed by the deferred IDidMethod factory. + services.AddSingleton(logClient); + using var provider = services.BuildServiceProvider(); + var method = provider.GetServices() + .Single(candidate => candidate.MethodName == "webvh"); + + var key = provider.GetRequiredService().Generate(KeyType.Ed25519); + var signer = new KeyPairSigner( + key, + provider.GetRequiredService()); + var created = await method.CreateAsync(new DidWebVhCreateOptions + { + Domain = "example.com", + UpdateKey = signer + }); + + var log = (string)created.Artifacts![DidWebVhArtifacts.DidJsonl]; + var genesis = JsonNode.Parse(log)!.AsObject(); + var originalProof = genesis["proof"]!.AsArray().Single()!; + var proofs = new JsonArray(); + for (var i = 0; i < raisedBudget; i++) + proofs.Add(originalProof.DeepClone()); + genesis["proof"] = proofs; + logClient.DidLog = Encoding.UTF8.GetBytes(genesis.ToJsonString()); + + var resolved = await method.ResolveAsync(created.Did.Value); + + resolved.ResolutionMetadata.Error.Should().BeNull(); + resolved.DidDocument.Should().NotBeNull(); + } + + [Fact] + public void Issue101_ExistingUntypedDefaultWebVhOptionsCall_RemainsSourceCompatible() + { + var services = new ServiceCollection(); + + services.AddNetDid(builder => builder.AddDidWebVh(default)); + + using var provider = services.BuildServiceProvider(); + provider.GetServices() + .Should().ContainSingle(method => method.MethodName == "webvh"); + } + private sealed class FixedBodyHandler(byte[] body) : HttpMessageHandler { protected override Task SendAsync( @@ -113,6 +185,17 @@ protected override async Task SendAsync( } } + private sealed class FixedLogWebVhHttpClient : IWebVhHttpClient + { + public byte[]? DidLog { get; set; } + + public Task FetchDidLogAsync(Uri logUrl, CancellationToken ct = default) + => Task.FromResult(DidLog); + + public Task FetchWitnessFileAsync(Uri witnessUrl, CancellationToken ct = default) + => Task.FromResult(null); + } + [Fact] public void AddNetDid_RegistersSharedInfrastructure() { diff --git a/tests/NetDid.Method.WebVh.Tests/ControllerProofBudgetTests.cs b/tests/NetDid.Method.WebVh.Tests/ControllerProofBudgetTests.cs new file mode 100644 index 0000000..7e9351c --- /dev/null +++ b/tests/NetDid.Method.WebVh.Tests/ControllerProofBudgetTests.cs @@ -0,0 +1,103 @@ +using System.Text; +using FluentAssertions; +using NetCrypto; +using NetDid.Core.Exceptions; +using NetDid.Method.WebVh.Model; + +namespace NetDid.Method.WebVh.Tests; + +public class ControllerProofBudgetTests +{ + private readonly DefaultKeyGenerator _keyGenerator = new(); + private readonly DefaultCryptoProvider _cryptoProvider = new(); + + [Fact] + public async Task Issue101_DefaultBudget_RejectsEntryAboveEightProofs() + { + var entry = await CreateGenesisWithProofCountAsync( + LogChainValidator.DefaultMaxControllerProofsPerEntry + 1); + + var act = () => new LogChainValidator().ValidateChainAsync([entry]); + + await act.Should().ThrowAsync() + .WithMessage("*exceeding the resolver's limit of 8*"); + } + + [Fact] + public async Task Issue101_RaisedValidatorBudget_AcceptsEntryAboveDefault() + { + var raisedBudget = LogChainValidator.DefaultMaxControllerProofsPerEntry + 1; + var entry = await CreateGenesisWithProofCountAsync(raisedBudget); + + var act = () => new LogChainValidator(raisedBudget).ValidateChainAsync([entry]); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Issue101_LoweredValidatorBudget_RejectsEntryAboveConfiguredLimit() + { + var entry = await CreateGenesisWithProofCountAsync(2); + + var act = () => new LogChainValidator(1).ValidateChainAsync([entry]); + + await act.Should().ThrowAsync() + .WithMessage("*exceeding the resolver's limit of 1*"); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Issue101_ValidatorBudgetBelowOne_ThrowsArgumentOutOfRangeException( + int invalidBudget) + { + var act = () => new LogChainValidator(invalidBudget); + + act.Should().Throw() + .Which.ParamName.Should().Be("maxControllerProofsPerEntry"); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Issue101_PublicMethodBudgetBelowOne_ThrowsArgumentOutOfRangeException( + int invalidBudget) + { + var act = () => new DidWebVhMethod( + new MockWebVhHttpClient(), + logger: null, + maxControllerProofsPerEntry: invalidBudget); + + act.Should().Throw() + .Which.ParamName.Should().Be("maxControllerProofsPerEntry"); + } + + [Fact] + public void Issue101_ExistingUntypedDefaultLoggerCall_RemainsSourceCompatible() + { + var method = new DidWebVhMethod(new MockWebVhHttpClient(), default); + + method.Should().NotBeNull(); + } + + private async Task CreateGenesisWithProofCountAsync(int proofCount) + { + var updateKey = _keyGenerator.Generate(KeyType.Ed25519); + var signer = new KeyPairSigner(updateKey, _cryptoProvider); + var method = new DidWebVhMethod(new MockWebVhHttpClient()); + var created = await method.CreateAsync(new DidWebVhCreateOptions + { + Domain = "example.com", + UpdateKey = signer + }); + + var log = (string)created.Artifacts![DidWebVhArtifacts.DidJsonl]; + var genesis = LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(log)).Single(); + var proof = genesis.Proof!.Single(); + + return genesis with + { + Proof = Enumerable.Repeat(proof, proofCount).ToArray() + }; + } +} diff --git a/tests/NetDid.Method.WebVh.Tests/DidWebVhMethodTests.cs b/tests/NetDid.Method.WebVh.Tests/DidWebVhMethodTests.cs index 7a51eac..6f82a81 100644 --- a/tests/NetDid.Method.WebVh.Tests/DidWebVhMethodTests.cs +++ b/tests/NetDid.Method.WebVh.Tests/DidWebVhMethodTests.cs @@ -153,10 +153,10 @@ public async Task Create_LegacyGenesisUsingScidAsEntryHash_IsRejected() VersionId = $"1-{genesis.Parameters.Scid}" }; - var act = () => new LogChainValidator(new EddsaJcs2022Cryptosuite()) - .ValidateChain([legacyGenesis]); + var act = () => new LogChainValidator() + .ValidateChainAsync([legacyGenesis]); - act.Should().Throw() + await act.Should().ThrowAsync() .WithMessage("*Genesis entry hash*"); } @@ -194,10 +194,10 @@ public async Task Create_LegacyCodecTaggedMultibaseEntryHash_IsRejected() Proof = [await SignEntryAsync(legacyGenesis, signer)] }; - var act = () => new LogChainValidator(new EddsaJcs2022Cryptosuite()) - .ValidateChain([legacyGenesis]); + var act = () => new LogChainValidator() + .ValidateChainAsync([legacyGenesis]); - act.Should().Throw() + await act.Should().ThrowAsync() .WithMessage("*Genesis entry hash*"); } @@ -882,10 +882,10 @@ public async Task Issue14_CurrentVersionPrefixedToHashInput_IsRejected() Proof = [await SignEntryAsync(nonConformantEntry, signer)] }; - var act = () => new LogChainValidator(new EddsaJcs2022Cryptosuite()) - .ValidateChain([genesis, nonConformantEntry]); + var act = () => new LogChainValidator() + .ValidateChainAsync([genesis, nonConformantEntry]); - act.Should().Throw() + await act.Should().ThrowAsync() .WithMessage("*Entry hash mismatch at version 2*"); } @@ -1981,6 +1981,97 @@ public async Task Issue37_Resolve_AfterUpdate_LogContainsAllEntries() entries.Should().HaveCount(2); entries[0].VersionNumber.Should().Be(1); entries[1].VersionNumber.Should().Be(2); + ((string)resolveResult.Artifacts[DidWebVhArtifacts.DidJsonl]) + .Should().Be(updatedLog, "latest resolution validates and exposes the complete fetched log"); + } + + [Fact] + public async Task Issue37_Resolve_HistoricalIncludeLog_ExposesOnlyValidatedPrefix() + { + var (method, httpClient) = CreateMethod(); + var signer = CreateEd25519Signer(); + + var createResult = await method.CreateAsync(new DidWebVhCreateOptions + { + Domain = "example.com", + UpdateKey = signer + }); + var did = createResult.Did.Value; + var updateResult = await method.UpdateAsync(did, new DidWebVhUpdateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes( + (string)createResult.Artifacts![DidWebVhArtifacts.DidJsonl]), + SigningKey = signer + }); + + var updatedLog = (string)updateResult.Artifacts![DidWebVhArtifacts.DidJsonl]; + var lines = updatedLog.Split('\n'); + var crlfLog = string.Join("\r\n", lines); + var entries = LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(crlfLog)); + httpClient.SetLogResponse( + DidUrlMapper.MapToLogUrl(did), Encoding.UTF8.GetBytes(crlfLog)); + + var result = await method.ResolveAsync(did, new DidResolutionOptions + { + VersionId = entries[0].VersionId, + IncludeLog = true + }); + + result.ResolutionMetadata.Error.Should().BeNull(); + var exposedEntries = (IReadOnlyList)result.Artifacts![DidWebVhArtifacts.LogEntries]; + exposedEntries.Should().ContainSingle() + .Which.VersionId.Should().Be(entries[0].VersionId); + ((string)result.Artifacts[DidWebVhArtifacts.DidJsonl]).Should().Be(lines[0], + "the raw artifact must end exactly at the validated entry, without the CRLF separator"); + } + + [Fact] + public async Task Issue101_Resolve_HistoricalIncludeLog_DoesNotExposeInvalidTail() + { + var (method, httpClient) = CreateMethod(); + var signer = CreateEd25519Signer(); + + var createResult = await method.CreateAsync(new DidWebVhCreateOptions + { + Domain = "example.com", + UpdateKey = signer + }); + var did = createResult.Did.Value; + var updateResult = await method.UpdateAsync(did, new DidWebVhUpdateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes( + (string)createResult.Artifacts![DidWebVhArtifacts.DidJsonl]), + SigningKey = signer + }); + + var updatedLog = (string)updateResult.Artifacts![DidWebVhArtifacts.DidJsonl]; + var entries = LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(updatedLog)); + var lines = updatedLog.Split('\n'); + lines[1] = lines[1].Replace( + entries[1].Proof![0].ProofValue, "zInvalidUncheckedTailProof"); + var corruptedLog = string.Join('\n', lines); + httpClient.SetLogResponse( + DidUrlMapper.MapToLogUrl(did), Encoding.UTF8.GetBytes(corruptedLog)); + + var result = await method.ResolveAsync(did, new DidResolutionOptions + { + VersionId = entries[0].VersionId, + IncludeLog = true + }); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + var exposedEntries = (IReadOnlyList)result.Artifacts![DidWebVhArtifacts.LogEntries]; + exposedEntries.Should().ContainSingle() + .Which.VersionId.Should().Be(entries[0].VersionId); + var exposedJsonl = (string)result.Artifacts[DidWebVhArtifacts.DidJsonl]; + exposedJsonl.Should().Be(lines[0]); + exposedJsonl.Should().NotContain("zInvalidUncheckedTailProof"); + + var dictionaryView = (IDictionary)result.Artifacts; + var mutate = () => dictionaryView[DidWebVhArtifacts.DidJsonl] = corruptedLog; + mutate.Should().Throw( + "cached resolution artifacts must not be caller-mutable"); } [Fact] @@ -2640,13 +2731,65 @@ public async Task Issue91_Update_DynamicKeyList_CannotDesyncEvidenceFromArtifact var entries = LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(updatedLog)); entries[^1].Parameters.UpdateKeys.Should().BeEquivalentTo([newKey.MultibasePublicKey]); - var effective = new LogChainValidator(new EddsaJcs2022Cryptosuite()).ValidateChain(entries); + var effective = await new LogChainValidator().ValidateChainAsync(entries); effective.UpdateKeys.Should().BeEquivalentTo([newKey.MultibasePublicKey]); updateResult.UpdateKeyChange.Should().Be(AuthorizationChangeStatus.Changed); updateResult.EffectiveUpdateKeys.Should().BeEquivalentTo([newKey.MultibasePublicKey]); } + [Fact] + public async Task Issue101_Update_DynamicNewDocumentCollection_PublishedLogMatchesSignedSnapshot() + { + // A NewDocument whose collections yield different contents per enumeration must not be + // able to desynchronize the entry hash, the signed bytes, and the published log — + // hashing, signing, publication, and the reported document must all reflect a single + // snapshot taken at the trust boundary. + var (method, httpClient) = CreateMethod(); + var (did, log, signer) = await CreateWebVhDidAsync(method); + + var firstService = new Service + { + Id = "#service-first", + Type = "ExampleService", + ServiceEndpoint = ServiceEndpointValue.FromUri("https://example.com/first") + }; + var laterService = new Service + { + Id = "#service-later", + Type = "ExampleService", + ServiceEndpoint = ServiceEndpointValue.FromUri("https://example.com/later") + }; + var newDocument = new DidDocument + { + Id = new Did(did), + Service = new FlippingList([firstService], [laterService]) + }; + + var updateResult = await method.UpdateAsync(did, new DidWebVhUpdateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes(log), + SigningKey = signer, + NewDocument = newDocument + }); + + // The published log verifies end-to-end and carries the first (snapshot) read. + var updatedLog = (string)updateResult.Artifacts![DidWebVhArtifacts.DidJsonl]; + httpClient.SetLogResponse( + DidUrlMapper.MapToLogUrl(did), Encoding.UTF8.GetBytes(updatedLog)); + var resolved = await method.ResolveAsync(did); + + resolved.ResolutionMetadata.Error.Should().BeNull( + "the published bytes must be the same single snapshot that was hashed and signed"); + resolved.DidDocument!.Service.Should().ContainSingle() + .Which.Id.Should().Be("#service-first"); + + // The reported document is the private snapshot, not the caller's live instance. + updateResult.DidDocument.Should().NotBeSameAs(newDocument); + updateResult.DidDocument!.Service.Should().ContainSingle() + .Which.Id.Should().Be("#service-first"); + } + [Fact] public async Task Issue91_Update_EffectiveUpdateKeys_MatchesValidatedChain() { @@ -2668,7 +2811,7 @@ public async Task Issue91_Update_EffectiveUpdateKeys_MatchesValidatedChain() var updatedLog = (string)updateResult.Artifacts![DidWebVhArtifacts.DidJsonl]; var entries = LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(updatedLog)); - var effective = new LogChainValidator(new EddsaJcs2022Cryptosuite()).ValidateChain(entries); + var effective = await new LogChainValidator().ValidateChainAsync(entries); updateResult.EffectiveUpdateKeys.Should().BeEquivalentTo(effective.UpdateKeys); } @@ -2737,7 +2880,7 @@ public async Task Issue91_Update_DynamicNextKeyHashes_CannotDesyncArtifact() var entries = LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(updatedLog)); entries[^1].Parameters.NextKeyHashes.Should().BeEquivalentTo([honestCommitment]); - var effective = new LogChainValidator(new EddsaJcs2022Cryptosuite()).ValidateChain(entries); + var effective = await new LogChainValidator().ValidateChainAsync(entries); effective.NextKeyHashes.Should().BeEquivalentTo([honestCommitment]); // Pre-rotation hides only the following entry's keys. This activation entry was still @@ -2955,7 +3098,7 @@ public async Task Issue98_Update_DynamicRevealedKeySet_CannotDesyncFullEvidenceF var entries = LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(updatedLog)); entries[^1].Parameters.UpdateKeys.Should().BeEquivalentTo( [key2.MultibasePublicKey, key2B.MultibasePublicKey]); - new LogChainValidator(new EddsaJcs2022Cryptosuite()).ValidateChain(entries) + (await new LogChainValidator().ValidateChainAsync(entries)) .UpdateKeys.Should().BeEquivalentTo( [key2.MultibasePublicKey, key2B.MultibasePublicKey]); @@ -3134,8 +3277,10 @@ public async Task Issue82_Update_ForgedLogClaimingDid_Throws() var victimDid = victim.Did.Value; // Attacker crafts a validly-signed log whose latest entry claims the victim DID and whose - // authority is the attacker's own key. RequireAppendableLogForDid's State.Id check passes; - // the genesis-SCID binding must reject it. + // authority is the attacker's own key. Such a log is internally SCID-inconsistent + // (state.id carries the victim's SCID, parameters.scid the forge's own hash), so the + // per-entry identity rule (#101) rejects it during chain validation — before the + // genesis-SCID binding that used to catch it (issue #82) is even reached. var attackerKey = CreateEd25519Signer(); var forgedLog = await ForgeGenesisClaimingDidAsync(victimDid, attackerKey); @@ -3146,7 +3291,7 @@ public async Task Issue82_Update_ForgedLogClaimingDid_Throws() NewDocument = new DidDocument { Id = new Did(victimDid) } }); - await act.Should().ThrowAsync().WithMessage("*genesis SCID*"); + await act.Should().ThrowAsync().WithMessage("*SCID*"); } [Fact] @@ -3172,6 +3317,8 @@ public async Task Issue82_Deactivate_ForgedLogClaimingDid_Throws() SigningKey = attackerKey }); - await act.Should().ThrowAsync().WithMessage("*genesis SCID*"); + // The forged log is internally SCID-inconsistent, so the per-entry identity rule + // (#101) rejects it during chain validation (see the Update variant above). + await act.Should().ThrowAsync().WithMessage("*SCID*"); } } diff --git a/tests/NetDid.Method.WebVh.Tests/LogChainValidatorMultiProofTests.cs b/tests/NetDid.Method.WebVh.Tests/LogChainValidatorMultiProofTests.cs new file mode 100644 index 0000000..23e2083 --- /dev/null +++ b/tests/NetDid.Method.WebVh.Tests/LogChainValidatorMultiProofTests.cs @@ -0,0 +1,1019 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using DataProofsDotnet; +using DataProofsDotnet.DataIntegrity; +using FluentAssertions; +using NetCrypto; +using NetDid.Core.Exceptions; +using NetDid.Core.Model; +using NetDid.Method.WebVh.Model; + +namespace NetDid.Method.WebVh.Tests; + +/// +/// Regression tests for issue #101: did:webvh multi-proof validation accepted invalid or +/// unauthorized extra proofs. +/// +/// Before the fix, LogChainValidator.ValidateProof implemented an existential rule — +/// the entry was accepted as soon as one proof verified from an active update key, silently +/// skipping every other supplied proof. did:webvh v1.0 §Authorized Keys requires the +/// opposite: "Resolvers MUST reject an entry whose proof fails any check." Verification now +/// delegates every proof to DataProofsDotnet's Data Integrity pipeline with a did:webvh +/// authorization resolver, an assertionMethod purpose expectation, and an +/// expires policy pinned to the entry's versionTime. These tests pin the +/// universal rule (every supplied controller proof must verify and be authorized; one +/// authorized signer suffices; no threshold semantics), support for schema-defined members +/// (id, expires) and their semantics, the single-proof-object shape, optional +/// created, rejection of duplicate JSON members and malformed content +/// (invalidDidLog), and the explicit per-entry proof budget. +/// +public class LogChainValidatorMultiProofTests +{ + private readonly DefaultKeyGenerator _keyGen = new(); + private readonly DefaultCryptoProvider _crypto = new(); + + // ================================================================ + // Helpers + // ================================================================ + + private (DidWebVhMethod Method, MockWebVhHttpClient HttpClient) CreateMethod() + { + var httpClient = new MockWebVhHttpClient(); + var method = new DidWebVhMethod(httpClient); + return (method, httpClient); + } + + private (KeyPair KeyPair, ISigner Signer) CreateEd25519() + { + var keyPair = _keyGen.Generate(KeyType.Ed25519); + return (keyPair, new KeyPairSigner(keyPair, _crypto)); + } + + private async Task<(string Did, List Entries, string Jsonl)> CreateLogAsync( + ISigner authorizedSigner) + { + var (method, _) = CreateMethod(); + var result = await method.CreateAsync(new DidWebVhCreateOptions + { + Domain = "example.com", + UpdateKey = authorizedSigner + }); + + var jsonl = (string)result.Artifacts![DidWebVhArtifacts.DidJsonl]; + var entries = LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(jsonl)).ToList(); + return (result.Did.Value, entries, jsonl); + } + + /// + /// Signs (serialized without proof) with the conformant + /// eddsa-jcs-2022 suite. The signature genuinely covers the produced proof + /// configuration, so wrong-purpose or extra-member proofs built here are exactly what + /// a conforming (or hostile) foreign implementation would emit. + /// + private async Task SignEntryAsync( + LogEntry entry, + KeyPair keyPair, + string proofPurpose = "assertionMethod", + string? created = null, + string? expires = null, + string? id = null, + PreviousProofReference? previousProof = null, + IReadOnlyList? arrayDomain = null) + { + var vm = $"did:key:{keyPair.MultibasePublicKey}#{keyPair.MultibasePublicKey}"; + var proofOptions = new DataIntegrityProof + { + Cryptosuite = EddsaJcs2022Cryptosuite.CryptosuiteName, + VerificationMethod = vm, + Created = created, + Expires = expires, + Id = id, + ProofPurpose = proofPurpose, + PreviousProof = previousProof, + // The pinned DataProofsDotnet model exposes domain as string?, but its low-level + // suite still signs an array-valued domain when supplied as an extension. This + // lets the integration test characterize the high-level pipeline limitation with + // a cryptographically self-consistent proof. + AdditionalProperties = arrayDomain is null + ? null + : new Dictionary + { + ["domain"] = JsonSerializer.SerializeToElement(arrayDomain) + } + }; + + var entryJsonWithoutProof = LogEntrySerializer.SerializeWithoutProof(entry); + using var document = JsonDocument.Parse(entryJsonWithoutProof); + var suite = new EddsaJcs2022Cryptosuite(); + var proof = await suite.CreateProofAsync( + document.RootElement, proofOptions, new KeyPairSigner(keyPair, _crypto)); + suite.VerifyProof( + document.RootElement, + proof, + PublicKeyMaterial.FromMultikey(keyPair.MultibasePublicKey)) + .Verified.Should().BeTrue( + "the helper must produce a self-consistent signature before policy validation"); + + return new DataIntegrityProofValue + { + Type = proof.Type, + Cryptosuite = proof.Cryptosuite!, + VerificationMethod = proof.VerificationMethod!, + Created = proof.Created, + ProofPurpose = proof.ProofPurpose!, + ProofValue = proof.ProofValue!, + // Verbatim signed proof JSON: carries any id/expires so the signature covers them + // and they round-trip, exactly as a foreign implementation's proof would. + RawJson = JsonSerializer.Serialize(proof, DataProofsJsonOptions.Default) + }; + } + + /// Copies a proof with a different created value, invalidating its signature. + private static DataIntegrityProofValue WithBrokenSignature(DataIntegrityProofValue proof) + => new() + { + Type = proof.Type, + Cryptosuite = proof.Cryptosuite, + VerificationMethod = proof.VerificationMethod, + Created = "2001-01-01T00:00:00Z", + ProofPurpose = proof.ProofPurpose, + ProofValue = proof.ProofValue + }; + + private static byte[] SerializeLog(IEnumerable entries) + => LogEntrySerializer.ToJsonLines(entries.ToList()); + + private static string MutateGenesisLine(string jsonl, Action mutate) + { + var lines = jsonl.Split('\n'); + var node = JsonNode.Parse(lines[0])!.AsObject(); + mutate(node); + lines[0] = node.ToJsonString(); + return string.Join('\n', lines); + } + + private async Task ResolveLogAsync(string did, byte[] log) + { + var (method, httpClient) = CreateMethod(); + httpClient.SetLogResponse(DidUrlMapper.MapToLogUrl(did), log); + return await method.ResolveAsync(did); + } + + // ================================================================ + // Universal validation: extra invalid/unauthorized proofs reject + // ================================================================ + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Issue101_ValidPlusUnauthorizedProof_EitherOrder_ResolvesInvalidDidLog( + bool unauthorizedFirst) + { + var (_, authorizedSigner) = CreateEd25519(); + var (attackerKp, _) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(authorizedSigner); + + var authorizedProof = entries[0].Proof![0]; + var unauthorizedProof = await SignEntryAsync( + entries[0], attackerKp, created: authorizedProof.Created); + + entries[0] = entries[0] with + { + Proof = unauthorizedFirst + ? [unauthorizedProof, authorizedProof] + : [authorizedProof, unauthorizedProof] + }; + + var result = await ResolveLogAsync(did, SerializeLog(entries)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Issue101_ValidPlusInvalidSignatureProof_EitherOrder_ResolvesInvalidDidLog( + bool invalidFirst) + { + var (_, authorizedSigner) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(authorizedSigner); + + var authorizedProof = entries[0].Proof![0]; + // Same authorized signer/VM, but the created value no longer matches the signed + // proof configuration — a syntactically valid proof whose signature fails. + var brokenProof = WithBrokenSignature(authorizedProof); + + entries[0] = entries[0] with + { + Proof = invalidFirst + ? [brokenProof, authorizedProof] + : [authorizedProof, brokenProof] + }; + + var result = await ResolveLogAsync(did, SerializeLog(entries)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + [Fact] + public async Task Issue101_OnlyUnauthorizedProofs_ResolvesInvalidDidLog() + { + var (_, authorizedSigner) = CreateEd25519(); + var (attacker1, _) = CreateEd25519(); + var (attacker2, _) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(authorizedSigner); + + var created = entries[0].Proof![0].Created; + entries[0] = entries[0] with + { + Proof = + [ + await SignEntryAsync(entries[0], attacker1, created: created), + await SignEntryAsync(entries[0], attacker2, created: created) + ] + }; + + var result = await ResolveLogAsync(did, SerializeLog(entries)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + [Fact] + public async Task Issue101_ValidateChain_MixedProofs_Throws() + { + // The issue's direct reproduction: genesis with an unauthorized-but-valid proof + // ahead of the authorized one previously validated successfully. + var (_, authorizedSigner) = CreateEd25519(); + var (attackerKp, _) = CreateEd25519(); + var (_, entries, _) = await CreateLogAsync(authorizedSigner); + + var authorizedProof = entries[0].Proof![0]; + var unauthorizedProof = await SignEntryAsync( + entries[0], attackerKp, created: authorizedProof.Created); + var mixed = entries[0] with { Proof = [unauthorizedProof, authorizedProof] }; + + var act = () => new LogChainValidator() + .ValidateChainAsync([mixed]); + + await act.Should().ThrowAsync() + .WithMessage("*Proof validation failed*"); + } + + // ================================================================ + // Multiple valid authorized proofs stay supported (no threshold) + // ================================================================ + + [Fact] + public async Task Issue101_MultipleValidAuthorizedProofs_Resolves() + { + var (kpA, signerA) = CreateEd25519(); + var (kpB, _) = CreateEd25519(); + var (method, httpClient) = CreateMethod(); + + var createResult = await method.CreateAsync(new DidWebVhCreateOptions + { + Domain = "example.com", + UpdateKey = signerA + }); + var did = createResult.Did.Value; + + // v2 declares updateKeys [A, B]; v3 is then authorized by both. + var update1 = await method.UpdateAsync(did, new DidWebVhUpdateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes( + (string)createResult.Artifacts![DidWebVhArtifacts.DidJsonl]), + SigningKey = signerA, + ParameterUpdates = new DidWebVhParameterUpdates + { + UpdateKeys = [kpA.MultibasePublicKey, kpB.MultibasePublicKey] + } + }); + var update2 = await method.UpdateAsync(did, new DidWebVhUpdateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes( + (string)update1.Artifacts![DidWebVhArtifacts.DidJsonl]), + SigningKey = signerA + }); + + var entries = LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes( + (string)update2.Artifacts![DidWebVhArtifacts.DidJsonl])).ToList(); + + // Replace v3's proof with two valid proofs from the two active update keys. + var created = entries[2].Proof![0].Created; + entries[2] = entries[2] with + { + Proof = + [ + await SignEntryAsync(entries[2], kpA, created: created), + await SignEntryAsync(entries[2], kpB, created: created) + ] + }; + + httpClient.SetLogResponse(DidUrlMapper.MapToLogUrl(did), SerializeLog(entries)); + var result = await method.ResolveAsync(did); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + } + + // ================================================================ + // Per-proof policy: proofPurpose / type / cryptosuite + // ================================================================ + + [Fact] + public async Task Issue101_WrongProofPurpose_ResolvesInvalidDidLog() + { + // Validly signed by the authorized key, but over proofPurpose "authentication". + // did:webvh requires assertionMethod; before the fix this resolved successfully. + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + + var created = entries[0].Proof![0].Created; + entries[0] = entries[0] with + { + Proof = [await SignEntryAsync(entries[0], kpA, proofPurpose: "authentication", created: created)] + }; + + var result = await ResolveLogAsync(did, SerializeLog(entries)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + [Fact] + public async Task Issue101_ValidateChain_WrongType_ThrowsPolicyError() + { + var (_, signerA) = CreateEd25519(); + var (_, entries, _) = await CreateLogAsync(signerA); + + var original = entries[0].Proof![0]; + var wrongType = new DataIntegrityProofValue + { + Type = "Ed25519Signature2020", + Cryptosuite = original.Cryptosuite, + VerificationMethod = original.VerificationMethod, + Created = original.Created, + ProofPurpose = original.ProofPurpose, + ProofValue = original.ProofValue + }; + var tampered = entries[0] with { Proof = [original, wrongType] }; + + var act = () => new LogChainValidator() + .ValidateChainAsync([tampered]); + + await act.Should().ThrowAsync() + .WithMessage("*version 1*"); + } + + [Fact] + public async Task Issue101_ValidateChain_WrongCryptosuite_ThrowsPolicyError() + { + var (_, signerA) = CreateEd25519(); + var (_, entries, _) = await CreateLogAsync(signerA); + + var original = entries[0].Proof![0]; + var wrongSuite = new DataIntegrityProofValue + { + Type = original.Type, + Cryptosuite = "eddsa-rdfc-2022", + VerificationMethod = original.VerificationMethod, + Created = original.Created, + ProofPurpose = original.ProofPurpose, + ProofValue = original.ProofValue + }; + var tampered = entries[0] with { Proof = [original, wrongSuite] }; + + var act = () => new LogChainValidator() + .ValidateChainAsync([tampered]); + + await act.Should().ThrowAsync() + .WithMessage("*version 1*"); + } + + // ================================================================ + // Schema shapes: single proof object, empty/missing proof + // ================================================================ + + [Fact] + public async Task Issue101_SingleProofObject_ParsesAndResolves() + { + // The official log-entry schema permits proof as one object or an array. + var (_, signerA) = CreateEd25519(); + var (did, _, jsonl) = await CreateLogAsync(signerA); + + var singleObjectLog = MutateGenesisLine(jsonl, node => + node["proof"] = node["proof"]![0]!.DeepClone()); + + var parsed = LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(singleObjectLog)); + parsed[0].Proof.Should().HaveCount(1); + + var result = await ResolveLogAsync(did, Encoding.UTF8.GetBytes(singleObjectLog)); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + } + + [Fact] + public async Task Issue101_EmptyProofArray_ResolvesInvalidDidLog() + { + var (_, signerA) = CreateEd25519(); + var (did, _, jsonl) = await CreateLogAsync(signerA); + + var emptyProofLog = MutateGenesisLine(jsonl, node => node["proof"] = new JsonArray()); + + var parse = () => LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(emptyProofLog)); + parse.Should().Throw().WithMessage("*at least one proof*"); + + var result = await ResolveLogAsync(did, Encoding.UTF8.GetBytes(emptyProofLog)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + [Fact] + public async Task Issue101_MissingProof_ResolvesInvalidDidLog() + { + var (_, signerA) = CreateEd25519(); + var (did, _, jsonl) = await CreateLogAsync(signerA); + + var noProofLog = MutateGenesisLine(jsonl, node => node.Remove("proof")); + + var result = await ResolveLogAsync(did, Encoding.UTF8.GetBytes(noProofLog)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + // ================================================================ + // Structural malformations: FormatException → invalidDidLog + // ================================================================ + + [Theory] + [InlineData("missing-member")] + [InlineData("non-string-member")] + [InlineData("null-member")] + [InlineData("non-object-element")] + [InlineData("non-object-non-array-proof")] + [InlineData("null-id")] + [InlineData("empty-id")] + [InlineData("non-string-id")] + public async Task Issue101_MalformedProof_ThrowsFormatException_AndResolvesInvalidDidLog( + string malformation) + { + var (_, signerA) = CreateEd25519(); + var (did, _, jsonl) = await CreateLogAsync(signerA); + + var malformedLog = MutateGenesisLine(jsonl, node => + { + switch (malformation) + { + case "missing-member": + node["proof"]![0]!.AsObject().Remove("proofValue"); + break; + case "non-string-member": + node["proof"]![0]!["proofValue"] = 42; + break; + case "null-member": + node["proof"]![0]!["type"] = null; + break; + case "non-object-element": + node["proof"] = new JsonArray(JsonValue.Create("bogus")); + break; + case "non-object-non-array-proof": + node["proof"] = JsonValue.Create("bogus"); + break; + case "null-id": + node["proof"]![0]!["id"] = null; + break; + case "empty-id": + node["proof"]![0]!["id"] = ""; + break; + case "non-string-id": + node["proof"]![0]!["id"] = 42; + break; + } + }); + + var parse = () => LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(malformedLog)); + parse.Should().Throw(); + + // Malformed log content is invalidDidLog, never notFound (and never an unhandled + // KeyNotFoundException / InvalidOperationException). + var result = await ResolveLogAsync(did, Encoding.UTF8.GetBytes(malformedLog)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + [Fact] + public async Task Issue101_MalformedExtraProofBesideValidOne_ResolvesInvalidDidLog() + { + // A structurally broken EXTRA proof next to the valid one must not be skippable. + var (_, signerA) = CreateEd25519(); + var (did, _, jsonl) = await CreateLogAsync(signerA); + + var malformedLog = MutateGenesisLine(jsonl, node => + { + var broken = node["proof"]![0]!.DeepClone().AsObject(); + broken.Remove("proofValue"); + node["proof"]!.AsArray().Add(broken); + }); + + var result = await ResolveLogAsync(did, Encoding.UTF8.GetBytes(malformedLog)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + // ================================================================ + // Interop: optional created (schema-permitted) + // ================================================================ + + [Fact] + public async Task Issue101_ProofWithoutCreated_Resolves() + { + // created is optional in the official log-entry schema's proof definition. + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + + entries[0] = entries[0] with + { + Proof = [await SignEntryAsync(entries[0], kpA, created: null)] + }; + + var result = await ResolveLogAsync(did, SerializeLog(entries)); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + } + + // ================================================================ + // Schema-defined proof members (id, expires) are supported and their + // semantics enforced via the Data Integrity pipeline (PR #102 review, finding 2) + // ================================================================ + + [Fact] + public async Task Issue101_SignedProofWithFutureExpires_Resolves() + { + // expires is schema-defined and additionalProperties is open, so a genuinely signed + // proof with a future expires must verify (not be rejected as unsupported). + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + + entries[0] = entries[0] with + { + Proof = [await SignEntryAsync(entries[0], kpA, + created: entries[0].Proof![0].Created, expires: "2099-12-31T23:59:59Z")] + }; + var log = SerializeLog(entries); + Encoding.UTF8.GetString(log).Should().Contain("\"expires\""); + + var result = await ResolveLogAsync(did, log); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + } + + [Fact] + public async Task Issue101_SignedProofWithPastExpires_ResolvesInvalidDidLog() + { + // A proof whose expires precedes the entry's versionTime is expired: the resolver + // enforces the expires policy against versionTime and rejects it. + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + + entries[0] = entries[0] with + { + Proof = [await SignEntryAsync(entries[0], kpA, + created: entries[0].Proof![0].Created, expires: "2000-01-01T00:00:00Z")] + }; + + var result = await ResolveLogAsync(did, SerializeLog(entries)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + [Theory] + [InlineData("urn:proof:1")] + [InlineData("did:example:proof-1")] + [InlineData("https://proof.example/1")] + public async Task Issue101_SignedProofWithId_Resolves(string proofId) + { + // id is schema-defined; a genuinely signed proof carrying it must verify and the id + // must round-trip on re-serialization. + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + + entries[0] = entries[0] with + { + Proof = [await SignEntryAsync(entries[0], kpA, + created: entries[0].Proof![0].Created, id: proofId)] + }; + var log = SerializeLog(entries); + Encoding.UTF8.GetString(log).Should().Contain(proofId); + + var result = await ResolveLogAsync(did, log); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + } + + [Fact] + public async Task Issue101_SignedProofWithInvalidId_ResolvesInvalidDidLog() + { + // Data Integrity requires a present proof id to be a URL. This proof is genuinely + // signed over the invalid id, so rejection cannot be attributed to signature failure. + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + + entries[0] = entries[0] with + { + Proof = [await SignEntryAsync(entries[0], kpA, + created: entries[0].Proof![0].Created, id: "not a URL")] + }; + + var result = await ResolveLogAsync(did, SerializeLog(entries)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + [Fact] + public async Task Issue101_SignedProofWithDanglingPreviousProof_ResolvesInvalidDidLog() + { + // A previousProof reference that matches no proof id in the entry is a dangling chain + // reference. The proof is signed with previousProof already in its configuration, so + // this test cannot pass merely because the test mutated a valid proof after signing. + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + + entries[0] = entries[0] with + { + Proof = [await SignEntryAsync( + entries[0], kpA, + created: entries[0].Proof![0].Created, + previousProof: PreviousProofReference.FromSingle("urn:missing"))] + }; + + var result = await ResolveLogAsync(did, SerializeLog(entries)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + [Fact] + public async Task Issue101_GenuineTwoProofChain_Resolves() + { + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + const string firstProofId = "urn:proof:chain-root"; + var verificationMethod = + $"did:key:{kpA.MultibasePublicKey}#{kpA.MultibasePublicKey}"; + var created = entries[0].Proof![0].Created; + var pipeline = new DataIntegrityProofPipeline(); + var signer = new KeyPairSigner(kpA, _crypto); + + using var unsecured = JsonDocument.Parse( + LogEntrySerializer.SerializeWithoutProof(entries[0])); + var withFirst = await pipeline.AddProofAsync(unsecured.RootElement, new DataIntegrityProof + { + Id = firstProofId, + Cryptosuite = EddsaJcs2022Cryptosuite.CryptosuiteName, + VerificationMethod = verificationMethod, + Created = created, + ProofPurpose = "assertionMethod" + }, signer); + var withChain = await pipeline.AddProofAsync(withFirst, new DataIntegrityProof + { + Id = "urn:proof:chain-child", + Cryptosuite = EddsaJcs2022Cryptosuite.CryptosuiteName, + VerificationMethod = verificationMethod, + Created = created, + ProofPurpose = "assertionMethod", + PreviousProof = PreviousProofReference.FromSingle(firstProofId) + }, signer); + + var result = await ResolveLogAsync( + did, Encoding.UTF8.GetBytes(withChain.GetRawText())); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + } + + [Fact] + public async Task Issue101_ArrayValuedDomain_PinnedPipelineRejectsUpstreamLimitation() + { + // W3C Data Integrity permits domain as an unordered set of strings. The low-level stock + // suite signs this representation, but the pinned pipeline models domain as string? and + // therefore rejects it during proof deserialization. Keep this characterization explicit + // until DataProofsDotnet publishes a string-or-set model. + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + + entries[0] = entries[0] with + { + Proof = [await SignEntryAsync( + entries[0], kpA, + created: entries[0].Proof![0].Created, + arrayDomain: ["domain.example", "https://domain.example:8443"])] + }; + var log = SerializeLog(entries); + Encoding.UTF8.GetString(log).Should().Contain( + "\"domain\":[\"domain.example\",\"https://domain.example:8443\"]"); + + var result = await ResolveLogAsync(did, log); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + // ================================================================ + // Malformed Unicode escapes map to invalidDidLog (PR #102 review, finding 3) + // ================================================================ + + [Fact] + public async Task Issue101_ProofWithUnpairedSurrogate_ResolvesInvalidDidLog() + { + // "\uD800" is a token JsonDocument.Parse accepts but GetString throws on. The parse + // boundary must map that to FormatException -> invalidDidLog, never notFound. + var (_, signerA) = CreateEd25519(); + var (did, _, jsonl) = await CreateLogAsync(signerA); + + var line = jsonl.Split('\n')[0]; + var doctored = line.Replace( + "\"proofPurpose\":\"assertionMethod\"", "\"proofPurpose\":\"\\uD800\""); + + var parse = () => LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(doctored)); + parse.Should().Throw(); + + var result = await ResolveLogAsync(did, Encoding.UTF8.GetBytes(doctored)); + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + // ================================================================ + // Duplicate JSON members cannot smuggle an unvalidated proof (PR #102 review, finding 1) + // ================================================================ + + [Fact] + public async Task Issue101_DuplicateTopLevelProofMember_ResolvesInvalidDidLog() + { + // .NET keeps the last of a duplicate pair. A leading decoy "proof" beside the valid + // trailing one is an extra supplied proof that was never validated — the exact + // invariant this fix establishes. Duplicate members must reject the whole entry. + var (_, signerA) = CreateEd25519(); + var (did, _, jsonl) = await CreateLogAsync(signerA); + + var line = jsonl.Split('\n')[0]; + // Inject a decoy proof member ahead of the existing one via raw text. "proof": is the + // top-level member (distinct from "proofPurpose"/"proofValue"), so it occurs once. + var doctored = line.Replace( + "\"proof\":", "\"proof\":[{\"type\":\"bogus\"}],\"proof\":"); + + var parse = () => LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(doctored)); + parse.Should().Throw(); + + var result = await ResolveLogAsync(did, Encoding.UTF8.GetBytes(doctored)); + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + [Fact] + public async Task Issue101_DuplicateMemberInsideProofObject_ResolvesInvalidDidLog() + { + var (_, signerA) = CreateEd25519(); + var (did, _, jsonl) = await CreateLogAsync(signerA); + + var line = jsonl.Split('\n')[0]; + // Duplicate proofPurpose inside the proof object with a non-conforming last value. + var doctored = line.Replace( + "\"proofPurpose\":\"assertionMethod\"", + "\"proofPurpose\":\"assertionMethod\",\"proofPurpose\":\"authentication\""); + + var parse = () => LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes(doctored)); + parse.Should().Throw(); + + var result = await ResolveLogAsync(did, Encoding.UTF8.GetBytes(doctored)); + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + // ================================================================ + // Update/Deactivate surface malformed proof content as FormatException (finding 4) + // ================================================================ + + [Fact] + public async Task Issue101_Update_OnMalformedProofLog_ThrowsFormatException() + { + var (_, signerA) = CreateEd25519(); + var (did, _, jsonl) = await CreateLogAsync(signerA); + + var malformed = MutateGenesisLine(jsonl, node => node["proof"]![0]!.AsObject().Remove("proofValue")); + var (method, _) = CreateMethod(); + + var act = () => method.UpdateAsync(did, new DidWebVhUpdateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes(malformed), + SigningKey = signerA + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Issue101_Deactivate_OnMalformedProofLog_ThrowsFormatException() + { + var (_, signerA) = CreateEd25519(); + var (did, _, jsonl) = await CreateLogAsync(signerA); + + var malformed = MutateGenesisLine(jsonl, node => node["proof"]![0]!["proofValue"] = 42); + var (method, _) = CreateMethod(); + + var act = () => method.DeactivateAsync(did, new DidWebVhDeactivateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes(malformed), + SigningKey = signerA + }); + + await act.Should().ThrowAsync(); + } + + // ================================================================ + // Verification work is bounded by an explicit proof budget (PR #102 review, finding 1) + // ================================================================ + + [Fact] + public async Task Issue101_ProofCountAtBudget_Resolves() + { + // A count at the budget resolves (the budget bounds work; it does not reject small + // multi-proof entries). These are distinct valid proofs from one key (varying created), + // which the false "distinct <= active keys" claim wrongly assumed impossible. + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + + var baseInstant = DateTimeOffset.Parse(entries[0].Proof![0].Created!).UtcDateTime; + var proofs = new List(); + for (int i = 0; i < LogChainValidator.DefaultMaxControllerProofsPerEntry; i++) + { + var created = baseInstant.AddSeconds(-1 - i).ToString("yyyy-MM-ddTHH:mm:ssZ"); + proofs.Add(await SignEntryAsync(entries[0], kpA, created: created)); + } + entries[0] = entries[0] with { Proof = proofs }; + + var result = await ResolveLogAsync(did, SerializeLog(entries)); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + } + + [Fact] + public async Task Issue101_DistinctValidProofsBeyondBudget_ResolvesInvalidDidLog() + { + // The reviewer's amplification repro: one key mints many DISTINCT valid proofs by + // varying created. Beyond the budget the entry is rejected, bounding verification work. + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + + var baseInstant = DateTimeOffset.Parse(entries[0].Proof![0].Created!).UtcDateTime; + var proofs = new List(); + for (int i = 0; i < LogChainValidator.DefaultMaxControllerProofsPerEntry + 1; i++) + { + var created = baseInstant.AddSeconds(-1 - i).ToString("yyyy-MM-ddTHH:mm:ssZ"); + proofs.Add(await SignEntryAsync(entries[0], kpA, created: created)); + } + entries[0] = entries[0] with { Proof = proofs }; + + var result = await ResolveLogAsync(did, SerializeLog(entries)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + [Fact] + public async Task Issue101_RaisedPublicBudget_AllowsDistinctProofsAboveDefault() + { + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + var raisedBudget = LogChainValidator.DefaultMaxControllerProofsPerEntry + 1; + var baseInstant = DateTimeOffset.Parse(entries[0].Proof![0].Created!).UtcDateTime; + var proofs = new List(); + for (var i = 0; i < raisedBudget; i++) + { + var created = baseInstant.AddSeconds(-1 - i).ToString("yyyy-MM-ddTHH:mm:ssZ"); + proofs.Add(await SignEntryAsync(entries[0], kpA, created: created)); + } + entries[0] = entries[0] with { Proof = proofs }; + + var httpClient = new MockWebVhHttpClient(); + httpClient.SetLogResponse(DidUrlMapper.MapToLogUrl(did), SerializeLog(entries)); + var method = new DidWebVhMethod( + httpClient, logger: null, maxControllerProofsPerEntry: raisedBudget); + + var result = await method.ResolveAsync(did); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + } + + [Fact] + public async Task Issue101_ProofWithEmptyCreated_ResolvesInvalidDidLog() + { + // created:"" is present but not a valid dateTimeStamp; every supplied proof must verify, + // so an entry pairing a valid proof with this one is rejected. + var (kpA, signerA) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(signerA); + + var valid = await SignEntryAsync(entries[0], kpA, created: null); + var invalidEmptyCreated = new DataIntegrityProofValue + { + Type = valid.Type, + Cryptosuite = valid.Cryptosuite, + VerificationMethod = valid.VerificationMethod, + Created = "", // present-but-empty: not a valid dateTimeStamp + ProofPurpose = valid.ProofPurpose, + ProofValue = valid.ProofValue + }; + entries[0] = entries[0] with { Proof = [valid, invalidEmptyCreated] }; + + var result = await ResolveLogAsync(did, SerializeLog(entries)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + // ================================================================ + // IncludeLog never exposes an unchecked proof collection + // ================================================================ + + [Fact] + public async Task Issue101_IncludeLog_MixedProofs_ExposesNoArtifacts() + { + var (_, authorizedSigner) = CreateEd25519(); + var (attackerKp, _) = CreateEd25519(); + var (did, entries, _) = await CreateLogAsync(authorizedSigner); + + var authorizedProof = entries[0].Proof![0]; + var unauthorizedProof = await SignEntryAsync( + entries[0], attackerKp, created: authorizedProof.Created); + entries[0] = entries[0] with { Proof = [unauthorizedProof, authorizedProof] }; + + var (method, httpClient) = CreateMethod(); + httpClient.SetLogResponse(DidUrlMapper.MapToLogUrl(did), SerializeLog(entries)); + + var result = await method.ResolveAsync( + did, new DidResolutionOptions { IncludeLog = true }); + + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + result.Artifacts.Should().BeNull(); + } + + [Fact] + public async Task Issue101_HistoricalIncludeLog_ExcludesGenuinelyUnauthorizedTailProof() + { + var (_, authorizedSigner) = CreateEd25519(); + var (attackerKey, _) = CreateEd25519(); + var (method, httpClient) = CreateMethod(); + var created = await method.CreateAsync(new DidWebVhCreateOptions + { + Domain = "example.com", + UpdateKey = authorizedSigner + }); + var did = created.Did.Value; + var updated = await method.UpdateAsync(did, new DidWebVhUpdateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes( + (string)created.Artifacts![DidWebVhArtifacts.DidJsonl]), + SigningKey = authorizedSigner + }); + var entries = LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes( + (string)updated.Artifacts![DidWebVhArtifacts.DidJsonl])).ToList(); + + var authorizedProof = entries[1].Proof![0]; + var unauthorizedProof = await SignEntryAsync( + entries[1], attackerKey, created: authorizedProof.Created); + entries[1] = entries[1] with { Proof = [authorizedProof, unauthorizedProof] }; + var hostileLog = Encoding.UTF8.GetString(SerializeLog(entries)); + httpClient.SetLogResponse( + DidUrlMapper.MapToLogUrl(did), Encoding.UTF8.GetBytes(hostileLog)); + + var result = await method.ResolveAsync(did, new DidResolutionOptions + { + VersionId = entries[0].VersionId, + IncludeLog = true + }); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + var exposed = (IReadOnlyList)result.Artifacts![DidWebVhArtifacts.LogEntries]; + exposed.Should().ContainSingle() + .Which.VersionId.Should().Be(entries[0].VersionId); + var exposedJsonl = (string)result.Artifacts[DidWebVhArtifacts.DidJsonl]; + exposedJsonl.Should().Be(hostileLog.Split('\n')[0]); + exposedJsonl.Should().NotContain(unauthorizedProof.ProofValue); + } +} diff --git a/tests/NetDid.Method.WebVh.Tests/LogChainValidatorTimestampTests.cs b/tests/NetDid.Method.WebVh.Tests/LogChainValidatorTimestampTests.cs index 45ebe83..98a1ad4 100644 --- a/tests/NetDid.Method.WebVh.Tests/LogChainValidatorTimestampTests.cs +++ b/tests/NetDid.Method.WebVh.Tests/LogChainValidatorTimestampTests.cs @@ -16,11 +16,11 @@ public sealed class LogChainValidatorTimestampTests public async Task ValidateChain_RejectsEqualAdjacentVersionTimes() { var (_, _, entries) = await CreateAuthenticatedChainAsync(time => time); - var validator = new LogChainValidator(_suite); + var validator = new LogChainValidator(); - var act = () => validator.ValidateChain(entries); + var act = () => validator.ValidateChainAsync(entries); - act.Should().Throw() + await act.Should().ThrowAsync() .WithMessage("*strictly later*"); } @@ -28,11 +28,11 @@ public async Task ValidateChain_RejectsEqualAdjacentVersionTimes() public async Task ValidateChain_RejectsDecreasingAdjacentVersionTimes() { var (_, _, entries) = await CreateAuthenticatedChainAsync(time => time.AddTicks(-1)); - var validator = new LogChainValidator(_suite); + var validator = new LogChainValidator(); - var act = () => validator.ValidateChain(entries); + var act = () => validator.ValidateChainAsync(entries); - act.Should().Throw() + await act.Should().ThrowAsync() .WithMessage("*strictly later*"); } @@ -40,11 +40,11 @@ public async Task ValidateChain_RejectsDecreasingAdjacentVersionTimes() public async Task ValidateChain_AcceptsFractionalSecondIncrease() { var (_, _, entries) = await CreateAuthenticatedChainAsync(time => time.AddTicks(1)); - var validator = new LogChainValidator(_suite); + var validator = new LogChainValidator(); - var act = () => validator.ValidateChain(entries); + var act = () => validator.ValidateChainAsync(entries); - act.Should().NotThrow(); + await act.Should().NotThrowAsync(); } [Fact] @@ -108,11 +108,11 @@ public async Task ValidateChain_RejectsMalformedLaterWitnessPolicy() var (_, _, entries) = await CreateAuthenticatedChainAsync( time => time.AddTicks(1), new LogEntryParameters { Witness = malformedPolicy }); - var validator = new LogChainValidator(_suite); + var validator = new LogChainValidator(); - var act = () => validator.ValidateChain(entries); + var act = () => validator.ValidateChainAsync(entries); - act.Should().Throw() + await act.Should().ThrowAsync() .WithMessage("*duplicated*"); } @@ -144,11 +144,11 @@ public async Task ValidateChain_AcceptsEmptyWitnessDisableTransition() var (_, _, entries) = await CreateAuthenticatedChainAsync( time => time.AddTicks(1), new LogEntryParameters { Witness = new WitnessConfig() }); - var validator = new LogChainValidator(_suite); + var validator = new LogChainValidator(); - var act = () => validator.ValidateChain(entries); + var act = () => validator.ValidateChainAsync(entries); - act.Should().NotThrow(); + await act.Should().NotThrowAsync(); } [Theory] @@ -192,7 +192,7 @@ public async Task Update_FutureDatedCurrentLog_StillEmitsStrictlyIncreasingVersi Encoding.UTF8.GetBytes((string)result.Artifacts![DidWebVhArtifacts.DidJsonl])); updatedEntries[2].VersionTime.Should().BeAfter(updatedEntries[1].VersionTime); - new LogChainValidator(_suite).ValidateChain(updatedEntries); + await new LogChainValidator().ValidateChainAsync(updatedEntries); } private async Task<(string Did, ISigner Signer, IReadOnlyList Entries)> diff --git a/tests/NetDid.Method.WebVh.Tests/LogEntryWireIntegrityTests.cs b/tests/NetDid.Method.WebVh.Tests/LogEntryWireIntegrityTests.cs new file mode 100644 index 0000000..fdd2304 --- /dev/null +++ b/tests/NetDid.Method.WebVh.Tests/LogEntryWireIntegrityTests.cs @@ -0,0 +1,265 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using DataProofsDotnet.DataIntegrity; +using FluentAssertions; +using NetCrypto; +using NetDid.Core.Model; + +namespace NetDid.Method.WebVh.Tests; + +/// +/// Security regressions for preserving the fetched log entry as the integrity input. A resolver +/// must verify the JSON that arrived on the wire, not a reduced object-model reconstruction. +/// +public sealed class LogEntryWireIntegrityTests +{ + private readonly DefaultKeyGenerator _keyGenerator = new(); + private readonly DefaultCryptoProvider _crypto = new(); + + [Fact] + public async Task Issue101_PostSignNestedVerificationMethodMutation_ResolvesInvalidDidLogWithoutArtifacts() + { + var (method, httpClient, signer) = CreateMethodAndSigner(); + var created = await CreateAsync(method, signer); + var did = created.Did.Value; + var log = (string)created.Artifacts![DidWebVhArtifacts.DidJsonl]; + var entry = JsonNode.Parse(log)!.AsObject(); + + entry["state"]!["verificationMethod"]![0]!["attackerInjected"] = + "not-covered-by-signature"; + httpClient.SetLogResponse( + DidUrlMapper.MapToLogUrl(did), Encoding.UTF8.GetBytes(entry.ToJsonString())); + + var result = await method.ResolveAsync( + did, new DidResolutionOptions { IncludeLog = true }); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + result.Artifacts.Should().BeNull(); + } + + [Fact] + public async Task Issue101_InvalidUtf8ReplacingSignedReplacementCharacter_ResolvesInvalidDidLog() + { + var (method, httpClient, signer) = CreateMethodAndSigner(); + var created = await method.CreateAsync(new DidWebVhCreateOptions + { + Domain = "example.com", + UpdateKey = signer, + Services = + [ + new Service + { + Id = "#service", + Type = "ExampleService", + ServiceEndpoint = ServiceEndpointValue.FromUri("https://example.com/service"), + AdditionalProperties = new Dictionary + { + ["marker"] = JsonSerializer.SerializeToElement("\uFFFD") + } + } + ] + }); + var did = created.Did.Value; + var signedBytes = Encoding.UTF8.GetBytes( + (string)created.Artifacts![DidWebVhArtifacts.DidJsonl]); + var invalidUtf8 = ReplaceOnce( + signedBytes, Encoding.ASCII.GetBytes("\\uFFFD"), [0xFF]); + httpClient.SetLogResponse(DidUrlMapper.MapToLogUrl(did), invalidUtf8); + + var result = await method.ResolveAsync( + did, new DidResolutionOptions { IncludeLog = true }); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + result.Artifacts.Should().BeNull(); + } + + [Fact] + public async Task Issue101_GenuinelySignedNestedVerificationMethodExtension_Resolves() + { + var (method, httpClient, signer) = CreateMethodAndSigner(); + var created = await CreateAsync(method, signer); + var did = created.Did.Value; + var foreignLog = await BuildLogWithSignedNestedStateExtensionAsync( + method, did, created, signer); + httpClient.SetLogResponse( + DidUrlMapper.MapToLogUrl(did), Encoding.UTF8.GetBytes(foreignLog)); + + var result = await method.ResolveAsync( + did, new DidResolutionOptions { IncludeLog = true }); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + ((string)result.Artifacts![DidWebVhArtifacts.DidJsonl]) + .Should().Contain("foreignExtension"); + + var republished = await method.UpdateAsync(did, new DidWebVhUpdateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes(foreignLog), + SigningKey = signer + }); + ((string)republished.Artifacts![DidWebVhArtifacts.DidJsonl]) + .Split('\n')[1] + .Should().Contain("foreignExtension", + "Update must preserve fetched prior entries instead of reducing their state model"); + + var deactivated = await method.DeactivateAsync(did, new DidWebVhDeactivateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes(foreignLog), + SigningKey = signer + }); + ((string)deactivated.Artifacts![DidWebVhArtifacts.DidJsonl]) + .Split('\n')[1] + .Should().Contain("foreignExtension", + "Deactivate must preserve fetched prior entries instead of reducing their state model"); + } + + [Fact] + public async Task Issue101_PreserveModeUpdate_CarriesSignedNestedStateExtensionIntoNewHead() + { + var (method, httpClient, signer) = CreateMethodAndSigner(); + var created = await CreateAsync(method, signer); + var did = created.Did.Value; + var foreignLog = await BuildLogWithSignedNestedStateExtensionAsync( + method, did, created, signer); + + // NewDocument == null promises "the previous document is preserved" — the new signed + // head must republish the previous state verbatim, not a reduced typed-model + // reconstruction that silently erases signed extension members. + var updated = await method.UpdateAsync(did, new DidWebVhUpdateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes(foreignLog), + SigningKey = signer + }); + + var updatedLog = (string)updated.Artifacts![DidWebVhArtifacts.DidJsonl]; + var lines = updatedLog.Split('\n'); + lines.Should().HaveCount(3); + var previousState = JsonNode.Parse(lines[1])!["state"]!.ToJsonString(); + var headState = JsonNode.Parse(lines[2])!["state"]!.ToJsonString(); + headState.Should().Be(previousState, + "a preserve-mode update must carry the previous signed document into the new head"); + + httpClient.SetLogResponse( + DidUrlMapper.MapToLogUrl(did), Encoding.UTF8.GetBytes(updatedLog)); + var resolved = await method.ResolveAsync(did); + resolved.ResolutionMetadata.Error.Should().BeNull(); + resolved.DidDocument.Should().NotBeNull(); + } + + [Fact] + public async Task Issue101_Deactivate_HeadRemainsMinimalDocumentByDesign() + { + // Deactivation intentionally publishes a minimal final document; it is not a + // preserve-mode update and must not start carrying prior-state extensions. + var (method, _, signer) = CreateMethodAndSigner(); + var created = await CreateAsync(method, signer); + var did = created.Did.Value; + var foreignLog = await BuildLogWithSignedNestedStateExtensionAsync( + method, did, created, signer); + + var deactivated = await method.DeactivateAsync(did, new DidWebVhDeactivateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes(foreignLog), + SigningKey = signer + }); + + var head = JsonNode.Parse( + ((string)deactivated.Artifacts![DidWebVhArtifacts.DidJsonl]).Split('\n')[^1])!; + head["state"]!["id"]!.GetValue().Should().Be(did); + head["state"]!.ToJsonString().Should().NotContain("foreignExtension"); + } + + [Fact] + public void Issue101_WitnessFileWithInvalidUtf8_IsRejected() + { + var validJson = """ + [{"versionId":"1-test","proofs":[{"type":"DataIntegrityProof","cryptosuite":"eddsa-jcs-2022","verificationMethod":"did:key:zTest#zTest","created":"2026-07-13T12:00:00Z","proofPurpose":"assertionMethod","proofValue":"\uFFFD"}]}] + """; + var invalidUtf8 = ReplaceOnce( + Encoding.UTF8.GetBytes(validJson), + Encoding.ASCII.GetBytes("\\uFFFD"), + [0xFF]); + + var parsed = WitnessValidator.ParseWitnessFile(invalidUtf8); + + parsed.Should().BeNull("witness JSON is also a strict UTF-8 trust boundary"); + } + + private (DidWebVhMethod Method, MockWebVhHttpClient HttpClient, KeyPairSigner Signer) + CreateMethodAndSigner() + { + var httpClient = new MockWebVhHttpClient(); + var method = new DidWebVhMethod(httpClient); + var keyPair = _keyGenerator.Generate(KeyType.Ed25519); + var signer = new KeyPairSigner(keyPair, _crypto); + return (method, httpClient, signer); + } + + private static Task CreateAsync(DidWebVhMethod method, KeyPairSigner signer) + => method.CreateAsync(new DidWebVhCreateOptions + { + Domain = "example.com", + UpdateKey = signer + }); + + /// + /// Builds a two-entry log whose second entry is genuinely signed and carries a nested + /// verification-method extension member that the typed model does not surface + /// (foreignExtension), simulating a conforming foreign controller's document. + /// + private static async Task BuildLogWithSignedNestedStateExtensionAsync( + DidWebVhMethod method, string did, DidCreateResult created, KeyPairSigner signer) + { + var updated = await method.UpdateAsync(did, new DidWebVhUpdateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes( + (string)created.Artifacts![DidWebVhArtifacts.DidJsonl]), + SigningKey = signer + }); + var lines = ((string)updated.Artifacts![DidWebVhArtifacts.DidJsonl]).Split('\n'); + lines.Should().HaveCount(2); + + var previousVersionId = JsonNode.Parse(lines[0])!["versionId"]!.GetValue(); + var unsigned = JsonNode.Parse(lines[1])!.AsObject(); + unsigned.Remove("proof"); + unsigned["state"]!["verificationMethod"]![0]!["foreignExtension"] = + new JsonObject { ["policy"] = "preserved" }; + + // did:webvh hashes an update with its versionId temporarily set to the previous full + // versionId, then publishes the resulting hash in the update's actual versionId. + unsigned["versionId"] = previousVersionId; + var entryHash = ScidGenerator.ComputeEntryHash(unsigned.ToJsonString()); + unsigned["versionId"] = $"2-{entryHash}"; + + using var unsignedDocument = JsonDocument.Parse(unsigned.ToJsonString()); + var verificationMethod = + $"did:key:{signer.MultibasePublicKey}#{signer.MultibasePublicKey}"; + var secured = await new DataIntegrityProofPipeline().AddProofAsync( + unsignedDocument.RootElement, + new DataIntegrityProof + { + Cryptosuite = EddsaJcs2022Cryptosuite.CryptosuiteName, + VerificationMethod = verificationMethod, + ProofPurpose = "assertionMethod" + }, + signer); + return $"{lines[0]}\n{secured.GetRawText()}"; + } + + private static byte[] ReplaceOnce(byte[] source, byte[] oldValue, byte[] newValue) + { + var index = source.AsSpan().IndexOf(oldValue); + index.Should().BeGreaterThanOrEqualTo(0, + "the signed JSON must contain the escaped replacement character"); + + var result = new byte[source.Length - oldValue.Length + newValue.Length]; + source.AsSpan(0, index).CopyTo(result); + newValue.CopyTo(result.AsSpan(index)); + source.AsSpan(index + oldValue.Length) + .CopyTo(result.AsSpan(index + newValue.Length)); + return result; + } +} diff --git a/tests/NetDid.Method.WebVh.Tests/LogEntryWireProvenanceTests.cs b/tests/NetDid.Method.WebVh.Tests/LogEntryWireProvenanceTests.cs new file mode 100644 index 0000000..30fb4f0 --- /dev/null +++ b/tests/NetDid.Method.WebVh.Tests/LogEntryWireProvenanceTests.cs @@ -0,0 +1,137 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using NetDid.Core.Model; +using NetDid.Method.WebVh.Model; + +namespace NetDid.Method.WebVh.Tests; + +public sealed class LogEntryWireProvenanceTests +{ + private const string EntryJson = """ + { + "versionId": "1-zWireProvenanceTest", + "versionTime": "2026-07-13T12:00:00Z", + "parameters": {}, + "state": { + "@context": "https://www.w3.org/ns/did/v1", + "id": "did:example:wire-provenance", + "verificationMethod": [{ + "id": "did:example:wire-provenance#key-1", + "type": "Multikey", + "controller": "did:example:wire-provenance", + "publicKeyMultibase": "z6MkhWireProvenanceTest", + "x-wire-extension": { + "nested": true + } + }], + "x-mutable-extension": { + "value": "original" + } + }, + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-jcs-2022", + "verificationMethod": "did:key:z6MkhWireProvenanceTest#z6MkhWireProvenanceTest", + "created": "2026-07-13T12:00:00Z", + "proofPurpose": "assertionMethod", + "proofValue": "zWireProvenanceProof", + "x-proof-extension": { + "retained": true + } + } + } + """; + + [Fact] + public void ParsedUnknownNestedVerificationMethodMember_SerializePreservesMember() + { + var entry = ParseEntry(); + + var serialized = JsonNode.Parse(LogEntrySerializer.Serialize(entry))!; + var extension = serialized["state"]!["verificationMethod"]![0]!["x-wire-extension"]; + + extension.Should().NotBeNull( + "every fetched nested member must remain in controller-proof verification and republishing input"); + extension!["nested"]!.GetValue().Should().BeTrue(); + } + + [Fact] + public void ParsedUnknownNestedVerificationMethodMember_SerializeWithoutProofPreservesMember() + { + var entry = ParseEntry(); + + var serialized = JsonNode.Parse(LogEntrySerializer.SerializeWithoutProof(entry))!; + + serialized["proof"].Should().BeNull(); + var extension = serialized["state"]!["verificationMethod"]![0]!["x-wire-extension"]; + extension.Should().NotBeNull( + "entry hashes and witness proofs must cover every fetched nested member"); + extension!["nested"]!.GetValue().Should().BeTrue(); + } + + [Fact] + public void ParsedUnknownNestedVerificationMethodMember_VersionIdOverridePreservesMember() + { + var entry = ParseEntry(); + + var serialized = JsonNode.Parse(LogEntrySerializer.SerializeWithoutProof( + entry, + "previous-version-id"))!; + + serialized["versionId"]!.GetValue().Should().Be("previous-version-id"); + serialized["proof"].Should().BeNull(); + serialized["state"]!["verificationMethod"]![0]!["x-wire-extension"]!["nested"]! + .GetValue().Should().BeTrue( + "entry-hash overrides must change only versionId, not reduce fetched state"); + } + + [Fact] + public void ParsedSingleProofObject_SerializeStillNormalizesContainerToArray() + { + var entry = ParseEntry(); + + var serialized = JsonNode.Parse(LogEntrySerializer.Serialize(entry))!; + + var proofs = serialized["proof"].Should().BeOfType().Subject; + proofs.Should().ContainSingle(); + proofs[0]!["x-proof-extension"]!["retained"]!.GetValue().Should().BeTrue(); + } + + [Fact] + public void ParsedEntry_WithChangedState_SerializesModeledChangeInsteadOfStaleWireJson() + { + var entry = ParseEntry(); + var changed = entry with + { + State = entry.State with { Id = new Did("did:example:changed") } + }; + + var serialized = JsonNode.Parse(LogEntrySerializer.Serialize(changed))!; + + serialized["state"]!["id"]!.GetValue().Should().Be("did:example:changed"); + serialized["state"]!["verificationMethod"]![0]!["x-wire-extension"].Should().BeNull( + "a clone with deliberate public-model changes must fall back to modeled serialization, not stale provenance"); + } + + [Fact] + public void ParsedEntry_InPlaceNestedModelMutation_SerializesMutationInsteadOfStaleWireJson() + { + var entry = ParseEntry(); + var additionalProperties = entry.State.AdditionalProperties + .Should().BeOfType>().Subject; + additionalProperties["x-mutable-extension"] = JsonSerializer.SerializeToElement( + new Dictionary { ["value"] = "changed" }); + + var serialized = JsonNode.Parse(LogEntrySerializer.Serialize(entry))!; + + serialized["state"]!["x-mutable-extension"]!["value"]!.GetValue() + .Should().Be("changed", + "an in-place mutation must invalidate parsed-wire provenance before serialization"); + } + + private static LogEntry ParseEntry() + => LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes( + JsonNode.Parse(EntryJson)!.ToJsonString())).Single(); +} diff --git a/tests/NetDid.Method.WebVh.Tests/PreRotationConformanceTests.cs b/tests/NetDid.Method.WebVh.Tests/PreRotationConformanceTests.cs index 86a3416..a567f00 100644 --- a/tests/NetDid.Method.WebVh.Tests/PreRotationConformanceTests.cs +++ b/tests/NetDid.Method.WebVh.Tests/PreRotationConformanceTests.cs @@ -195,11 +195,11 @@ public async Task Resolve_ActivationEntry_RejectsFutureCommittedKeySigner() NextKeyHashes = [PreRotationManager.ComputeKeyCommitment(futureKey.MultibasePublicKey)] }, futureKey); - var validator = new LogChainValidator(new EddsaJcs2022Cryptosuite()); + var validator = new LogChainValidator(); - validator.Invoking(v => v.ValidateChain([genesis, activation])) - .Should().Throw() - .WithMessage("*authorized update key*"); + await validator.Awaiting(v => v.ValidateChainAsync([genesis, activation])) + .Should().ThrowAsync() + .WithMessage("*Proof validation failed*"); } [Fact] @@ -275,12 +275,12 @@ public async Task Resolve_ActivePreRotation_AcceptsCommittedMultiKeySetAndReject [key2.MultibasePublicKey, key3.MultibasePublicKey, extraKey.MultibasePublicKey], NextKeyHashes = [] }, key3); - var validator = new LogChainValidator(new EddsaJcs2022Cryptosuite()); + var validator = new LogChainValidator(); - validator.Invoking(v => v.ValidateChain([genesis, valid])) - .Should().NotThrow(); - validator.Invoking(v => v.ValidateChain([genesis, invalid])) - .Should().Throw() + await validator.Awaiting(v => v.ValidateChainAsync([genesis, valid])) + .Should().NotThrowAsync(); + await validator.Awaiting(v => v.ValidateChainAsync([genesis, invalid])) + .Should().ThrowAsync() .WithMessage("*commitment does not match*"); } @@ -319,13 +319,13 @@ public async Task Resolve_ActivePreRotation_AcceptsCurrentKeyAndRejectsPreviousK }; var validEntry = await AppendEntryAsync(genesis, parameters, committedKey); var invalidEntry = await AppendEntryAsync(genesis, parameters, previousKey); - var validator = new LogChainValidator(new EddsaJcs2022Cryptosuite()); + var validator = new LogChainValidator(); - validator.Invoking(v => v.ValidateChain([genesis, validEntry])) - .Should().NotThrow(); - validator.Invoking(v => v.ValidateChain([genesis, invalidEntry])) - .Should().Throw() - .WithMessage("*authorized update key*"); + await validator.Awaiting(v => v.ValidateChainAsync([genesis, validEntry])) + .Should().NotThrowAsync(); + await validator.Awaiting(v => v.ValidateChainAsync([genesis, invalidEntry])) + .Should().ThrowAsync() + .WithMessage("*Proof validation failed*"); } [Fact] @@ -378,7 +378,7 @@ public async Task Deactivate_ActivePreRotation_RevealsCommittedSignerAndEndsPreR finalParameters.Deactivated.Should().BeTrue(); finalParameters.UpdateKeys.Should().Equal(committedKey.MultibasePublicKey); finalParameters.NextKeyHashes.Should().NotBeNull().And.BeEmpty(); - new LogChainValidator(new EddsaJcs2022Cryptosuite()).ValidateChain(entries); + await new LogChainValidator().ValidateChainAsync(entries); httpClient.SetLogResponse(DidUrlMapper.MapToLogUrl(did), deactivatedLog); var resolved = await method.ResolveAsync(did); @@ -585,10 +585,10 @@ public async Task ValidateChain_UnsupportedMethodVersion_IsRejected() Witness = genesis.Parameters.Witness } }; - var validator = new LogChainValidator(new EddsaJcs2022Cryptosuite()); + var validator = new LogChainValidator(); - validator.Invoking(v => v.ValidateChain([invalidGenesis])) - .Should().Throw() + await validator.Awaiting(v => v.ValidateChainAsync([invalidGenesis])) + .Should().ThrowAsync() .WithMessage("*Unsupported did:webvh method version*"); } diff --git a/tests/NetDid.Method.WebVh.Tests/ProofIdBoundaryTests.cs b/tests/NetDid.Method.WebVh.Tests/ProofIdBoundaryTests.cs new file mode 100644 index 0000000..9d5d7a0 --- /dev/null +++ b/tests/NetDid.Method.WebVh.Tests/ProofIdBoundaryTests.cs @@ -0,0 +1,193 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using DataProofsDotnet; +using DataProofsDotnet.DataIntegrity; +using FluentAssertions; +using NetCrypto; +using NetDid.Core.Model; +using NetDid.Method.WebVh.Model; + +namespace NetDid.Method.WebVh.Tests; + +/// +/// Public-boundary regression tests for proof identifiers. Every proof constructed here is +/// genuinely signed over its final id/previousProof configuration so rejection +/// cannot be explained by post-signature mutation. +/// +public class ProofIdBoundaryTests +{ + private readonly DefaultKeyGenerator _keyGenerator = new(); + private readonly DefaultCryptoProvider _cryptoProvider = new(); + + [Fact] + public async Task Issue101_WhitespaceWrappedProofId_ResolvesInvalidDidLog() + { + var fixture = await CreateGenesisAsync(); + fixture.Entries[0] = fixture.Entries[0] with + { + Proof = + [ + await SignEntryAsync( + fixture.Entries[0], + fixture.KeyPair, + fixture.Created, + id: " urn:proof:wrapped ") + ] + }; + + var result = await ResolveAsync( + fixture.Did, LogEntrySerializer.ToJsonLines(fixture.Entries)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + [Theory] + [InlineData("urn:proof:1", true)] + [InlineData("did:example:proof-1", true)] + [InlineData("https://proof.example/1", true)] + [InlineData("a:b", false)] + public async Task Issue101_ConservativeProofIdPolicy_AcceptsExpectedSchemesOnly( + string proofId, + bool shouldResolve) + { + var fixture = await CreateGenesisAsync(); + fixture.Entries[0] = fixture.Entries[0] with + { + Proof = + [ + await SignEntryAsync( + fixture.Entries[0], + fixture.KeyPair, + fixture.Created, + id: proofId) + ] + }; + + var result = await ResolveAsync( + fixture.Did, LogEntrySerializer.ToJsonLines(fixture.Entries)); + + if (shouldResolve) + { + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + } + else + { + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Issue101_DuplicateSignedProofIdsInProofSet_EitherOrder_ResolveInvalidDidLog( + bool reverseDuplicateRoots) + { + const string duplicateId = "urn:proof:duplicate"; + var fixture = await CreateGenesisAsync(); + var baseInstant = DateTimeOffset.Parse( + fixture.Created, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal); + + var first = await SignEntryAsync( + fixture.Entries[0], fixture.KeyPair, fixture.Created, id: duplicateId); + var second = await SignEntryAsync( + fixture.Entries[0], + fixture.KeyPair, + baseInstant.AddSeconds(-1).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture), + id: duplicateId); + fixture.Entries[0] = fixture.Entries[0] with + { + Proof = reverseDuplicateRoots + ? [second, first] + : [first, second] + }; + + var result = await ResolveAsync( + fixture.Did, LogEntrySerializer.ToJsonLines(fixture.Entries)); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog"); + } + + private async Task CreateGenesisAsync() + { + var keyPair = _keyGenerator.Generate(KeyType.Ed25519); + var signer = new KeyPairSigner(keyPair, _cryptoProvider); + var method = new DidWebVhMethod(new MockWebVhHttpClient()); + var created = await method.CreateAsync(new DidWebVhCreateOptions + { + Domain = "example.com", + UpdateKey = signer + }); + var entries = LogEntrySerializer.ParseJsonLines(Encoding.UTF8.GetBytes( + (string)created.Artifacts![DidWebVhArtifacts.DidJsonl])).ToList(); + + return new GenesisFixture( + created.Did.Value, + entries, + keyPair, + entries[0].Proof![0].Created!); + } + + private async Task SignEntryAsync( + LogEntry entry, + KeyPair keyPair, + string created, + string id, + PreviousProofReference? previousProof = null) + { + var proofOptions = new DataIntegrityProof + { + Id = id, + Cryptosuite = EddsaJcs2022Cryptosuite.CryptosuiteName, + VerificationMethod = + $"did:key:{keyPair.MultibasePublicKey}#{keyPair.MultibasePublicKey}", + Created = created, + ProofPurpose = "assertionMethod", + PreviousProof = previousProof + }; + using var unsecured = JsonDocument.Parse( + LogEntrySerializer.SerializeWithoutProof(entry)); + var suite = new EddsaJcs2022Cryptosuite(); + var proof = await suite.CreateProofAsync( + unsecured.RootElement, + proofOptions, + new KeyPairSigner(keyPair, _cryptoProvider)); + + suite.VerifyProof( + unsecured.RootElement, + proof, + PublicKeyMaterial.FromMultikey(keyPair.MultibasePublicKey)) + .Verified.Should().BeTrue( + "the fixture must prove its final proof configuration is self-consistently signed"); + + return new DataIntegrityProofValue + { + Type = proof.Type, + Cryptosuite = proof.Cryptosuite!, + VerificationMethod = proof.VerificationMethod!, + Created = proof.Created, + ProofPurpose = proof.ProofPurpose!, + ProofValue = proof.ProofValue!, + RawJson = JsonSerializer.Serialize(proof, DataProofsJsonOptions.Default) + }; + } + + private static async Task ResolveAsync(string did, byte[] log) + { + var httpClient = new MockWebVhHttpClient(); + httpClient.SetLogResponse(DidUrlMapper.MapToLogUrl(did), log); + return await new DidWebVhMethod(httpClient).ResolveAsync(did); + } + + private sealed record GenesisFixture( + string Did, + List Entries, + KeyPair KeyPair, + string Created); +} diff --git a/tests/NetDid.Method.WebVh.Tests/ScidConsistencyTests.cs b/tests/NetDid.Method.WebVh.Tests/ScidConsistencyTests.cs new file mode 100644 index 0000000..17b16b4 --- /dev/null +++ b/tests/NetDid.Method.WebVh.Tests/ScidConsistencyTests.cs @@ -0,0 +1,242 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using DataProofsDotnet.DataIntegrity; +using FluentAssertions; +using NetCrypto; +using NetDid.Core.Exceptions; +using NetDid.Core.Model; + +namespace NetDid.Method.WebVh.Tests; + +/// +/// Regressions for the did:webvh v1.0 per-entry SCID identity rule: "The SCID segment of +/// state.id MUST be byte-for-byte identical to the scid value in the DID and the first entry's +/// parameters.scid. This check MUST apply to every entry's state.id, not just the first. A +/// mismatch MUST terminate resolution." Only the host/path portion may change under +/// portability; the SCID segment is immutable for the life of the DID. +/// +public sealed class ScidConsistencyTests +{ + private readonly DefaultKeyGenerator _keyGenerator = new(); + private readonly DefaultCryptoProvider _crypto = new(); + + [Fact] + public async Task Issue101_MiddleEntryStateIdWithForeignScid_ResolvesInvalidDidLog() + { + var (method, httpClient, signer) = CreateMethodAndSigner(); + var created = await CreateAsync(method, signer); + var did = created.Did.Value; + var genesisLog = (string)created.Artifacts![DidWebVhArtifacts.DidJsonl]; + var genesisState = JsonNode.Parse(genesisLog)!["state"]!.DeepClone(); + + // A genuinely signed middle entry whose document claims a different SCID identity, + // followed by a genuinely signed head that restores the requested DID. Every hash and + // proof verifies; only the identity rule can reject this log. + var log = await AppendCraftedEntryAsync(genesisLog, signer, entry => + entry["state"]!["id"] = "did:webvh:zQmForgedForeignScidValue:example.com"); + log = await AppendCraftedEntryAsync(log, signer, entry => + entry["state"] = genesisState.DeepClone()); + httpClient.SetLogResponse(DidUrlMapper.MapToLogUrl(did), Encoding.UTF8.GetBytes(log)); + + var result = await method.ResolveAsync(did, new DidResolutionOptions { IncludeLog = true }); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog", + "every validated entry's state.id must carry the log's SCID, not just the target's"); + result.Artifacts.Should().BeNull(); + } + + [Fact] + public async Task Issue101_GenesisStateIdWithForeignScid_TargetV2_ResolvesInvalidDidLog() + { + var (method, httpClient, signer) = CreateMethodAndSigner(); + + // A fully self-consistent genesis (SCID and entry hash verify) whose state.id carries + // a foreign SCID. The foreign value is not the SCID, so it survives the placeholder + // reverse-substitution untouched and the log remains self-certifying. + var genesisLine = await CraftGenesisWithStateIdAsync( + signer, "did:webvh:zQmForgedForeignScidValue:example.com"); + var scid = JsonNode.Parse(genesisLine)!["parameters"]!["scid"]!.GetValue(); + var did = $"did:webvh:{scid}:example.com"; + + // A genuinely signed v2 whose document claims the requested DID, so the target-entry + // identity check passes and only the genesis state.id is inconsistent. + var log = await AppendCraftedEntryAsync(genesisLine, signer, entry => + entry["state"]!["id"] = did); + httpClient.SetLogResponse(DidUrlMapper.MapToLogUrl(did), Encoding.UTF8.GetBytes(log)); + + var result = await method.ResolveAsync(did); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog", + "the genesis state.id SCID must match parameters.scid even when it is not the target"); + } + + [Fact] + public async Task Issue101_MiddleEntryStateMissingId_ResolvesInvalidDidLog() + { + var (method, httpClient, signer) = CreateMethodAndSigner(); + var created = await CreateAsync(method, signer); + var did = created.Did.Value; + var genesisLog = (string)created.Artifacts![DidWebVhArtifacts.DidJsonl]; + var genesisState = JsonNode.Parse(genesisLog)!["state"]!.DeepClone(); + + var log = await AppendCraftedEntryAsync(genesisLog, signer, entry => + entry["state"]!.AsObject().Remove("id")); + log = await AppendCraftedEntryAsync(log, signer, entry => + entry["state"] = genesisState.DeepClone()); + httpClient.SetLogResponse(DidUrlMapper.MapToLogUrl(did), Encoding.UTF8.GetBytes(log)); + + var result = await method.ResolveAsync(did); + + result.DidDocument.Should().BeNull(); + result.ResolutionMetadata.Error.Should().Be("invalidDidLog", + "an entry whose document has no id cannot satisfy the per-entry SCID identity rule"); + } + + [Fact] + public async Task Issue101_HistoricalTargetBeforeForeignScidTail_StillResolves() + { + // Historical resolution intentionally validates only the selected prefix; a corrupt + // tail must not revoke an already-established version. This pins the boundary of the + // per-entry rule: it covers every entry that establishes the returned result. + var (method, httpClient, signer) = CreateMethodAndSigner(); + var created = await CreateAsync(method, signer); + var did = created.Did.Value; + var genesisLog = (string)created.Artifacts![DidWebVhArtifacts.DidJsonl]; + var genesisVersionId = JsonNode.Parse(genesisLog)!["versionId"]!.GetValue(); + + var log = await AppendCraftedEntryAsync(genesisLog, signer, entry => + entry["state"]!["id"] = "did:webvh:zQmForgedForeignScidValue:example.com"); + httpClient.SetLogResponse(DidUrlMapper.MapToLogUrl(did), Encoding.UTF8.GetBytes(log)); + + var result = await method.ResolveAsync( + did, new DidResolutionOptions { VersionId = genesisVersionId }); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + result.DidDocument!.Id.Value.Should().Be(did); + } + + [Fact] + public async Task Issue101_Update_MiddleEntryForeignScid_ThrowsLogChainValidationException() + { + // Writer parity: the driver must not append to a log its own resolver rejects on + // identity grounds. + var (method, _, signer) = CreateMethodAndSigner(); + var created = await CreateAsync(method, signer); + var did = created.Did.Value; + var genesisLog = (string)created.Artifacts![DidWebVhArtifacts.DidJsonl]; + var genesisState = JsonNode.Parse(genesisLog)!["state"]!.DeepClone(); + + var log = await AppendCraftedEntryAsync(genesisLog, signer, entry => + entry["state"]!["id"] = "did:webvh:zQmForgedForeignScidValue:example.com"); + log = await AppendCraftedEntryAsync(log, signer, entry => + entry["state"] = genesisState.DeepClone()); + + var act = () => method.UpdateAsync(did, new DidWebVhUpdateOptions + { + CurrentLogContent = Encoding.UTF8.GetBytes(log), + SigningKey = signer + }); + + await act.Should().ThrowAsync(); + } + + private (DidWebVhMethod Method, MockWebVhHttpClient HttpClient, KeyPairSigner Signer) + CreateMethodAndSigner() + { + var httpClient = new MockWebVhHttpClient(); + var method = new DidWebVhMethod(httpClient); + var keyPair = _keyGenerator.Generate(KeyType.Ed25519); + var signer = new KeyPairSigner(keyPair, _crypto); + return (method, httpClient, signer); + } + + private static Task CreateAsync(DidWebVhMethod method, KeyPairSigner signer) + => method.CreateAsync(new DidWebVhCreateOptions + { + Domain = "example.com", + UpdateKey = signer + }); + + /// + /// Appends a genuinely signed entry to a JSON Lines log: clones the previous entry's state, + /// applies , computes the entry hash with the spec's + /// previous-versionId substitution, and signs the result with the authorized key. + /// + private static async Task AppendCraftedEntryAsync( + string log, KeyPairSigner signer, Action mutate) + { + var lines = log.Split('\n'); + var previous = JsonNode.Parse(lines[^1])!.AsObject(); + var previousVersionId = previous["versionId"]!.GetValue(); + var previousTime = WebVhTimestamp.Parse(previous["versionTime"]!.GetValue()); + + var entry = new JsonObject + { + ["versionId"] = previousVersionId, + ["versionTime"] = WebVhTimestamp.Format(previousTime.AddSeconds(1)), + ["parameters"] = new JsonObject(), + ["state"] = previous["state"]!.DeepClone() + }; + mutate(entry); + + var entryHash = ScidGenerator.ComputeEntryHash(entry.ToJsonString()); + entry["versionId"] = $"{lines.Length + 1}-{entryHash}"; + + return $"{log}\n{await SignEntryAsync(entry, signer)}"; + } + + /// + /// Crafts a fully self-consistent signed genesis entry (SCID and entry hash verify) whose + /// state.id is the supplied literal instead of the DID derived from the computed SCID. + /// + private static async Task CraftGenesisWithStateIdAsync( + KeyPairSigner signer, string stateId) + { + var template = new JsonObject + { + ["versionId"] = ScidGenerator.Placeholder, + ["versionTime"] = WebVhTimestamp.Format(DateTimeOffset.UtcNow), + ["parameters"] = new JsonObject + { + ["method"] = DidWebVhMethod.MethodVersion, + ["scid"] = ScidGenerator.Placeholder, + ["updateKeys"] = new JsonArray(signer.MultibasePublicKey) + }, + ["state"] = new JsonObject + { + ["@context"] = new JsonArray("https://www.w3.org/ns/did/v1"), + ["id"] = stateId + } + }; + + var scid = ScidGenerator.ComputeScid(template.ToJsonString()); + var preliminaryJson = ScidGenerator.ReplacePlaceholders(template.ToJsonString(), scid); + var entryHash = ScidGenerator.ComputeEntryHash(preliminaryJson); + + var entry = JsonNode.Parse(preliminaryJson)!.AsObject(); + entry["versionId"] = $"1-{entryHash}"; + + return await SignEntryAsync(entry, signer); + } + + private static async Task SignEntryAsync(JsonObject entry, KeyPairSigner signer) + { + using var unsignedDocument = JsonDocument.Parse(entry.ToJsonString()); + var verificationMethod = + $"did:key:{signer.MultibasePublicKey}#{signer.MultibasePublicKey}"; + var secured = await new DataIntegrityProofPipeline().AddProofAsync( + unsignedDocument.RootElement, + new DataIntegrityProof + { + Cryptosuite = EddsaJcs2022Cryptosuite.CryptosuiteName, + VerificationMethod = verificationMethod, + ProofPurpose = "assertionMethod" + }, + signer); + return secured.GetRawText(); + } +} diff --git a/tests/NetDid.Method.WebVh.Tests/WebVhUpdateKeyResolverTests.cs b/tests/NetDid.Method.WebVh.Tests/WebVhUpdateKeyResolverTests.cs new file mode 100644 index 0000000..465e6cf --- /dev/null +++ b/tests/NetDid.Method.WebVh.Tests/WebVhUpdateKeyResolverTests.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using NetCrypto; + +namespace NetDid.Method.WebVh.Tests; + +/// +/// Contract tests for the did:webvh authorization adapter. The resolver supplies the pipeline +/// only keys that are well-formed, Ed25519, and verbatim members of the active updateKeys; +/// every rejection returns null so the proof fails closed. +/// +public sealed class WebVhUpdateKeyResolverTests +{ + [Fact] + public async Task Issue101_MalformedAuthorizedKey_FailsClosed() + { + // A log can declare a syntactically arbitrary string in updateKeys. A proof whose + // did:key verificationMethod references it verbatim passes the membership check, but + // the value is not a decodable Multikey — FromMultikey's documented ArgumentException + // must resolve to null (unauthorized), not escape or authorize. + const string malformed = "not-a-valid-multikey"; + var resolver = new WebVhUpdateKeyResolver([malformed]); + + var resolved = await resolver.ResolveAsync($"did:key:{malformed}#{malformed}"); + + resolved.Should().BeNull(); + } + + [Fact] + public async Task Issue101_WellFormedAuthorizedEd25519Key_Resolves() + { + var keyPair = new DefaultKeyGenerator().Generate(KeyType.Ed25519); + var multibase = new KeyPairSigner(keyPair, new DefaultCryptoProvider()).MultibasePublicKey; + var resolver = new WebVhUpdateKeyResolver([multibase]); + + var resolved = await resolver.ResolveAsync($"did:key:{multibase}#{multibase}"); + + resolved.Should().NotBeNull(); + resolved!.PublicKey.KeyType.Should().Be(KeyType.Ed25519); + resolved.Relationships.Should().BeEquivalentTo(["assertionMethod"]); + } +} diff --git a/tests/NetDid.Tests.W3CConformance/Infrastructure/ConformanceReport.cs b/tests/NetDid.Tests.W3CConformance/Infrastructure/ConformanceReport.cs index 297ee55..30c3b68 100644 --- a/tests/NetDid.Tests.W3CConformance/Infrastructure/ConformanceReport.cs +++ b/tests/NetDid.Tests.W3CConformance/Infrastructure/ConformanceReport.cs @@ -164,6 +164,7 @@ private static void AppendScope(StringBuilder sb) sb.AppendLine("| did:webvh URL mapping unsafe encodings (#49) | `Issue49_*` in `tests/NetDid.Method.WebVh.Tests/DidUrlMapperTests.cs` + `DidWebVhMethodTests.cs` |"); sb.AppendLine("| did:webvh HTTP fetches lack resource limits (#51) | `tests/NetDid.Method.WebVh.Tests/DefaultWebVhHttpClientTests.cs` |"); sb.AppendLine("| did:peer numalgo 2 malformed key segments (#52) | `Issue52_*` in `tests/NetDid.Method.Peer.Tests/DidPeerMethodTests.cs` |"); + sb.AppendLine("| did:webvh multi-proof existential validation, unchecked proofPurpose, proof-shape/`created` schema gaps (#101) | `Issue101_*` in `tests/NetDid.Method.WebVh.Tests/LogChainValidatorMultiProofTests.cs` |"); sb.AppendLine(); sb.AppendLine("If a future audit finds a DID Core statement gap, add it to this"); sb.AppendLine("suite. If it finds a method-specific behaviour, add it to that"); diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index a431dcb..9acb384 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-07-11T19:02:32Z +Generated: 2026-07-13T18:31:36Z ## Scope and limitations @@ -20,6 +20,7 @@ tests in these locations: | did:webvh URL mapping unsafe encodings (#49) | `Issue49_*` in `tests/NetDid.Method.WebVh.Tests/DidUrlMapperTests.cs` + `DidWebVhMethodTests.cs` | | did:webvh HTTP fetches lack resource limits (#51) | `tests/NetDid.Method.WebVh.Tests/DefaultWebVhHttpClientTests.cs` | | did:peer numalgo 2 malformed key segments (#52) | `Issue52_*` in `tests/NetDid.Method.Peer.Tests/DidPeerMethodTests.cs` | +| did:webvh multi-proof existential validation, unchecked proofPurpose, proof-shape/`created` schema gaps (#101) | `Issue101_*` in `tests/NetDid.Method.WebVh.Tests/LogChainValidatorMultiProofTests.cs` | If a future audit finds a DID Core statement gap, add it to this suite. If it finds a method-specific behaviour, add it to that