Skip to content

fix: fail closed on malformed JSON, never publish altered text, and enforce DID Core id/URI syntax (#145) - #149

Merged
moisesja merged 71 commits into
mainfrom
fix/issue-145-surrogate-member-name
Oct 28, 2026
Merged

moisesja merged 71 commits into
mainfrom
fix/issue-145-surrogate-member-name

Conversation

@moisesja

@moisesja moisesja commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Fixes #145.

Problem

NetDid rejects duplicate JSON members, which makes System.Text.Json decode every member name while parsing. A lone surrogate escape in a name ("\uD800":1) therefore throws InvalidOperationException from JsonDocument.Parse, and two boundaries caught only JsonException:

  • a fetched did:webvh log resolved as notFound instead of invalidDidLog;
  • Core DidDocumentSerializer.Deserialize leaked a raw exception.

Later rounds found more defects of the same kind: hostile input escaping a trust boundary as a raw exception, or being accepted and failing later. They also found the write-side mirror image (text silently altered on publish) and gaps against the DID Core id and URI rules. All of it is fixed here:

The full history is in tasks/todo20261028-issue145.md; the user-facing summary is the #145 entry in CHANGELOG.md.

Changes

Read side: every boundary reports its documented error

  • did:webvh logs: an undecodable string anywhere in an entry is invalidDidLog. UpdateAsync and DeactivateAsync throw their documented FormatException.

  • Core Deserialize throws JsonException for:

    • lone surrogates and ill-formed UTF-8;
    • a malformed publicKeyJwk, or one without kty;
    • shapes DID Core forbids, such as missing or non-string ids and types, null entries, or a missing, blank, empty or nested serviceEndpoint.

    A closing net maps any other JSON-access failure, so every document that is read can be written again.

  • did:peer: service blocks are consumed under the same rules as a document (duplicate members, reserved or colliding keys, depth). The embedded JSON is decoded as strict UTF-8.

  • did:peer:2 follows the DIF abbreviation rules: member names are abbreviated at every depth (also inside serviceEndpoint), and only the type value DIDCommMessaging becomes dm. The spec's example DID is produced byte for byte and resolved with accept/routingKeys. Explicit relative service ids are kept ("service id attributes MUST be relative"), and absolute ones are rejected.

  • did:ethr:

    • RPC client: undecodable or duplicate result members throw EthereumInteractionException, and receipt errors never echo node text.
    • Service values are decoded like ethr-did-resolver: strict UTF-8, JSON.parse keeping the last of a duplicate member, raw-string fallback, and its name grammar.
    • Labels: bytes32 labels are strict UTF-8. An event whose label is not valid UTF-8 is filtered out of the history, as the reference does.

Write side: never publish altered or unreadable output

  • Every writer checks the text it emits, including the contents of caller JsonElements, and throws ArgumentException instead of writing U+FFFD. This covers Core Serialize, did:webvh state, did.json, parameters and witness proofs, did:peer:2/4 Create, and did:ethr endpoints.
  • Writers also reject:
    • null required members and null list entries;
    • duplicate member names;
    • reserved service extension keys;
    • non-public JWK members (every public RFC 7517/7518/8037 member is kept).
  • ServiceEndpointValue.FromSet enforces DID Core §5.4 (no null entries, no nested sets) and snapshots its entries.
  • Caller JsonSerializerOptions now produce a real DID document; NetDid's converters always come first.
  • did:webvh builds did.json before signing, so an invalid projection never costs a signature.
  • did:peer:2 and did:peer:4 Create read the caller's input once and return the document their DID resolves to, so a caller list that reads differently twice cannot make Create report a different document.

DID Core id and URI syntax (§3.2, §5.1.3, §5.2, §5.4)

  • DIDs and DID URLs follow RFC 3986: \z anchoring, no raw whitespace, controls, non-ASCII or format characters, and no malformed % escapes.
  • Read and write both check:
    • verification-method ids and relationship references (absolute or relative DID URLs, §3.2.2: #key-1, /keys/1, keys/1#k, ?versionId=1#key-1; network-path //host is rejected);
    • service ids (URIs or relative DID URLs) and their uniqueness;
    • serviceEndpoint strings and alsoKnownAs entries (absolute URIs: RFC 3986 hier-part, IPv6/IPvFuture-only IP-literals).
  • Ids are compared after RFC 3986 §5 resolution against the DID (authority = method:method-specific-id), so keys/1#k, /keys/1#k and <did>/keys/1#k are one id. The one documented exception is NetDid's bare shorthand ([High] did:webvh implicit #files / #whois services are neither materialized in the resolved document nor resolvable as DID URLs #136): one plain segment files means #files.
  • That one comparison is shared by duplicate detection, the dereferencer and DefaultVerificationRelationshipResolver. Its cost is linear even for a megabyte-long did:peer DID, and an id of another DID never matches by fragment alone.
  • The dereferencer selects <did>/path#fragment for a DID URL with a path and a fragment (it used to ignore the path), and ?service= selects by fragment.
  • did:peer:2 Create and Resolve run the same producer checks. did:peer:4 rewrites a template's placeholder DID in ids as in references, at a DID boundary.
  • did:ethr UpdateAsync refuses service values and names ethr-did-resolver would drop. Prefix checks on untrusted text are ordinal (ICU ignores NUL).

Behavior changes

All of these apply to malformed, hostile, or non-conformant input only:

  • Typed errors (invalidDidLog, invalidDid, JsonException, FormatException, EthereumInteractionException, ArgumentException) replace notFound and raw exceptions.
  • Documents, DID URLs and did:ethr service values that break DID Core rules are rejected or dropped.
  • Nested serviceEndpoint sets cannot be built.
  • did:peer:2 Create refuses a member name that would resolve as another (t, s, r, a), the type dm, an absolute service id, and a key string containing .. It keeps explicit relative service ids instead of replacing them with #service.
  • A JWK needs kty.
  • Caller JsonSerializerOptions are locked on first use.
  • did:ethr public API:
    • Erc1056EventParser.Parse can return the new FilteredEvent.
    • AbiDecoder.DecodeDelegateChangedData and DecodeAttributeChangedData return the strict-UTF-8 label, or null when it is not valid UTF-8.
    • AbiDecoder.TryDecodeBytes32AsUtf8 is new.

NetDidVersion is not bumped; the changes are listed under [Unreleased].

Verification

  • Fail-first: every defect test failed against the pre-fix code of its round, by red-commit or by mutation of the committed fix. Positive controls pass.

  • Build: Release, 0 warnings / 0 errors.

  • Tests (vs main):

    Project main Now
    Core 439 748 (+309)
    WebVh 545 604 (+59)
    Ethr 491 529 (+38)
    Peer 48 114 (+66)
    Key 52 54 (+2)
    DI 26 26
    W3C conformance 233 233 (100%)
  • Real-EVM suite: 18/18 with NETDID_ETHR_INTEGRATION=1.

  • Samples: all five exit 0.

  • Interop: the DIF did:webvh vector replay is identical before and after the id/URI round.

  • Hygiene: git diff origin/main...HEAD --check and git diff --check are clean.

Note: unrequested "update by push" events moved this branch three times (10:07, 10:37, 10:38). Each pushed SHA was one of my local commits, and main and tags were untouched. The cause is not established; see the plan file.

🤖 Generated with Claude Code

moisesja and others added 20 commits October 28, 2026 08:44
…#145)

A lone UTF-16 surrogate escape in a JSON member name, at the did:webvh
log-entry boundary and at DidDocumentSerializer's consumption boundary,
at the root and nested. Against the pre-fix source, 14 webvh tests fail
(notFound / raw InvalidOperationException) and 16 Core tests fail (raw
InvalidOperationException). Value-position controls and the witness-file
pins pass on both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntent error (#145)

JsonDocument.Parse with AllowDuplicateProperties = false decodes every
member name to compare it, so a lone surrogate in a NAME throws
InvalidOperationException from Parse itself. LogEntrySerializer caught
only JsonException, so a fetched did:webvh log resolved as notFound
instead of invalidDidLog and Update/Deactivate leaked a raw
InvalidOperationException instead of FormatException.
DidDocumentSerializer.Deserialize leaked the same exception instead of
JsonException. Both catch sites now map it with fixed text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CHANGELOG [Unreleased] Fixed entry, PRD parse-boundary text and
DidDocumentSerializer consumption contract, <exception> docs on the
public Deserialize overloads (JsonException for malformed JSON,
InvalidDidException for an invalid DID string), a lessons.md entry, and
the issue plan file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ates, ill-formed UTF-8 (#145)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l review (#145)

- did:webvh: DeserializeEntry decodes every string value once, so a lone
  surrogate in a value the typed model never reads (e.g. an unknown
  publicKeyJwk member) fails at the parse boundary as invalidDidLog
  instead of surfacing during hashing as notFound.
- Core: Deserialize(string) maps a raw lone surrogate char (ArgumentException
  from UTF-8 transcoding) to JsonException; Deserialize(ReadOnlySpan<byte>)
  requires strict UTF-8, so an ill-formed nested extension name can no
  longer slip past the raw-byte duplicate check.
- DataIntegrityProofValue.Parse maps the raw-char ArgumentException to its
  documented JsonException.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orm first (#145)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
did:ethr RPC result member access (lone-surrogate name/value, duplicate
name), did:webvh witness authoring with non-JCS proofs, Core malformed
publicKeyJwk and undecodable extension values, and did:peer:4 documents
that resolve but cannot be serialized. Against 14205c1: ethr 10 failed,
Core 8 failed, webvh 9 failed, peer 2 failed; positive controls and the
resolution-parser guard pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mInteractionException (#145)

GetRpcObject now builds the JsonObject member dictionary once, so a
duplicate or lone-surrogate member name fails there instead of escaping
from the first indexer as ArgumentException/InvalidOperationException.
GetRpcString maps the lone-surrogate decode failure. A malformed
finalized-block response therefore disables event-history caching as
documented instead of failing resolution as internalError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oring path (#145)

A proof outside the JCS data model (lone surrogate, 1e400) made
MergeWitnessProofs throw NetCid.JcsFormatException out of Create, Update
and Deactivate. WitnessProofs and CurrentWitnessContent are now checked
at their trust boundaries and rejected with ArgumentException naming the
parameter. Resolution still treats such a proof as one invalid proof.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nsumption (#145)

DidDocumentSerializer.Deserialize now maps IdentityModel's
ArgumentException (IDX10805) for a malformed publicKeyJwk to JsonException,
and decodes every string value once in the duplicate-rejecting pre-parse.
A lone surrogate in an extension value, VM or service extension, or
serviceEndpoint map no longer deserializes into a document that cannot
be serialized (for example a did:peer:4 long form now fails closed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oring, Core JWK/values)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrogates (#145)

Core: DID Core-forbidden shapes (null/missing type and id, null array
entries, blank or nested serviceEndpoint, bad alsoKnownAs/relationship
entries) and unpaired surrogates in every string Serialize writes.
did:webvh: state shapes that crashed the model, authoring with lone
surrogates (services, NewDocument, watchers, pre-rotation commitments,
witness proofs), and null witness inputs. did:peer: numalgo 2/4 Create
with lone surrogates and numalgo 4 shapes. did:ethr: lone-surrogate
service endpoints and node text echoed in receipt messages.

Against a305a77: Core 30 failed, webvh 12, peer 3, ethr 4. Positive
controls and already-mapped shapes pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… write (#145)

Read: VM and service id/type must be strings, array entries must be
objects/strings as DID Core requires, serviceEndpoint is required and
must be a non-blank string, a map, or a non-empty set of strings and
maps (no nested sets). Any remaining JSON-access or model-construction
failure at the three Deserialize entry points maps to JsonException, so
no raw KeyNotFoundException/InvalidOperationException/ArgumentException
escapes and no accepted document crashes Serialize.

Write: every caller string the converters write is checked for
well-formed UTF-16 (new internal Utf16Text) and rejected with
ArgumentException, instead of Utf8JsonWriter silently publishing U+FFFD.
InternalsVisibleTo now includes did:webvh and did:ethr for Utf16Text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… altered (#145)

LogEntrySerializer now checks every string it writes (versionId,
parameters including watchers/nextKeyHashes/updateKeys, witness ids,
modeled proof members) for well-formed UTF-16 and throws
ArgumentException, instead of Utf8JsonWriter silently signing and
publishing U+FFFD. The witness-proof authoring guard (now
RequirePublishableProofs) also rejects null entries/proof lists/proofs/
versionIds and lone surrogates in a versionId or modeled proof member,
and observes the cancellation token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er (#145)

Numalgo2ServiceEncoder.Encode checks the service id, type, endpoint
strings, map keys and extension keys for well-formed UTF-16 and throws
ArgumentException, instead of JsonSerializer silently encoding U+FFFD
into the DID. Numalgo 4 Create is covered by the Core serializer check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode text in receipt errors (#145)

A service endpoint with an unpaired UTF-16 surrogate is rejected with
ArgumentException before any RPC traffic, instead of Encoding.UTF8
silently writing (or revoking) EF BF BD on-chain. Receipt status and
mismatched-hash errors no longer copy node-supplied text (unbounded,
CR/LF allowed) into exception messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@moisesja moisesja changed the title fix: map unpaired UTF-16 surrogates to each boundary's invalid-content error (#145) fix: map malformed JSON to each boundary's documented error and stop publishing altered text (#145) Oct 28, 2026
@moisesja moisesja self-assigned this Oct 28, 2026
@moisesja moisesja added this to net-did Oct 28, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in net-did Oct 28, 2026
@moisesja moisesja moved this from Backlog to In review in net-did Oct 28, 2026
@moisesja moisesja added this to the 3.2.0 milestone Oct 28, 2026

@moisesja moisesja left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not approve this yet. Two reproducible P2 concerns remain: the new read-side endpoint restriction lets did:peer:4 Create succeed with a DID that immediately resolves as notFound, and the writer hardening still silently replaces malformed text inside caller-supplied JsonElements. Details and requested regression tests are inline.

The 20 commit subjects are clear. The 23-file scope is broad but coherent for this shared boundary defect; the main concern is incomplete coverage of the new guarantees, not arbitrary scope. Independent review found no additional blocker in the ethr or webvh changes.

Validation at e20ec80: Release build 0 warnings/errors; default suite 2,015 passed, 7 opt-in EVM tests skipped (Core 533, Key 52, Peer 56, WebVh 598, Ethr 506, DI 26, W3C 233, integration gate 11). Separate real-EVM run: 18/18 passed. All five offline samples passed; diff whitespace check clean; GitHub CI green. Those results do not cover the two failing targeted scenarios below.

moisesja and others added 3 commits October 28, 2026 11:11
…sarial pass (#145)

Nested serviceEndpoint sets at the model/authoring boundary (with the
reviewer's did:peer:4 Create/Resolve scenario and flat mixed-set
controls), caller JsonElement contents with ill-formed UTF-8 or escaped
lone surrogates at every write position (Core, did:peer:2, did:webvh),
non-string/non-JsonElement @context entries, reserved service extension
keys and did:peer:2 key collisions, strict UTF-8 on did:peer:2/4
resolution, and a null did:ethr service endpoint.

Against e20ec80: Core 22 failed, peer 11, webvh 1, ethr 1; controls pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ServiceEndpointValue.FromSet copies its entries once and rejects null
  and nested-set entries (W3C DID Core §5.4), so no producer can build
  the shape the reader rejects. did:peer:4 Create could previously
  return a DID that immediately resolved as notFound.
- Every caller JsonElement the serializer writes (document, VM and
  service extensions, @context elements, endpoint map values) is
  decoded first via the shared Utf16Text.RequireDecodable; ill-formed
  UTF-8 or an escaped lone surrogate is ArgumentException instead of
  being written as U+FFFD (or as JsonException). The read side now uses
  the same helper.
- @context entries must be strings or JsonElements (ToString() replaced
  lone surrogates before any check and wrote objects as strings).
- A reserved name in Service.AdditionalProperties is ArgumentException,
  matching the document-level #121 rule (it emitted a duplicate member).

Issue136_DereferenceAsync_Path_EmptiedUriSetFailsClosed relied on
mutating the caller's list after FromSet to build an empty set. That
state is now unconstructible, so the test pins the stronger invariant
(clearing the caller's list leaves the validated endpoint unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-entry boundary (#145)

Replaces the private RequireDecodableStrings copy; behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moisesja and others added 12 commits October 28, 2026 14:36
…and, DID-scoped fragment lookup (#145)

IsAbsoluteUri now follows scheme ":" hier-part, so "[" and "]" are
accepted only around an IP-literal host. The bare-id shorthand is one
segment without ':', '/', '?' or '#', so real relative references
("/files", "//host/x", "?service=x") are no longer read as "#value".
Fragment dereferencing compares ids through ResolveRelativeId, so a VM
or service id belonging to another DID no longer matches by fragment
alone. A new internal ValidateForProduction runs Serialize's producer
checks for methods that build a document without serializing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#145)

The local duplicate-id check compared raw ids, so a bare "service"
beside the auto-generated "#service" slipped through, and id and
serviceEndpoint syntax were never checked. Create and Resolve now call
DidDocumentSerializer.ValidateForProduction, which resolves ids before
comparing and applies the DID Core id/URI rules; Resolve maps a
violation to invalidDid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t before signing (#145)

The did.json projection only recognised overrides in did:webvh form, so
"did:web:example.com#whois" got a second implicit #whois and the
document had duplicate service ids. Materialize now also matches the
projection DID's form. Create and Update build did.json before signing,
so an invalid projection fails before any signature is produced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eads them; refuse unresolvable did/svc writes (#145)

Resolution keeps only did/svc/ names in the reference grammar
(^did/svc/(\w+)(/\w+)?(/\w+)?$, typed by the first segment) while still
counting every did/svc/ event, and rebuilds JSON values with JSON.parse's
last-wins duplicate semantics instead of dropping them. AddServices
rejects a ServiceType that is not one [A-Za-z0-9_]+ segment, and
AddAttributes rejects a did/svc/ name or value the resolver would drop.
BrickProbeTests now publish their junk endpoints with a raw setAttribute,
as another ERC-1056 tool could, since NetDid no longer writes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n absolute URIs (#145)

The did.json collision tests now count signer calls on Create and
Update: with the pre-fix order each signed once and then threw (proven
by reverting DidWebVhMethod.cs to 9633c6b). The IsAbsoluteUri theory
gains "http://host:abc", which the path branch accepted after the
authority branch failed; it fails against ab17f1a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RFC 3986 hier-part is "//" authority path-abempty, or a path that cannot
start with "//". When the authority branch failed (for example a
non-numeric port), the path branch still accepted the whole "//host:abc"
remainder, so "http://host:abc" passed as an absolute URI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Id comparison cost in duplicate detection and dereferencing must follow
the ids, not the DID (a did:peer DID embeds its document); IP-literals
are IPv6address or IPvFuture only; did:peer:4 rewrites the template's
placeholder DID in ids as it does in references, at a DID boundary;
did:ethr filters events whose bytes32 label is not strict UTF-8 out of
counting and versionId, as ethr-did-resolver does; prefix checks on
untrusted text (did/pub, did/svc, did:key:, EcdsaSecp256k1) are ordinal.
16 defect rows fail against 22f78f9; the controls pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e prefix (#145)

Duplicate detection and dereferencing compared ids by building
"{did}#x" per element, so a long did:peer DID (which embeds its
document) cost services x DID length: 7 GB for one hostile did:peer:2.
ComparableId now maps the document's own ids ("#x", "x", "{did}#x") to
"#x" without touching the DID, and the dereferencer builds its target
once. The relationship branch no longer re-searches every VM per entry.
An IP-literal must be an IPv6address or IPvFuture. Context selection
compares "EcdsaSecp256k1" ordinally (ICU ignores NUL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t a DID boundary (#145)

A template naming a placeholder DID had its references rewritten to the
did:peer:4 DID but its VM and service ids left as-is, so its own
authentication reference no longer dereferenced. Ids are now rewritten
too, and both rewrites require a DID boundary ("did:example:abcd" is not
"did:example:abc" + "d") and compare ordinally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#145)

The check was culture-sensitive and ICU ignores NUL, so "d\0id:key:z..."
passed and yielded a shifted, bogus key instead of null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s ordinally, like ethr-did-resolver (#145)

bytes32 names and delegate types are decoded as strict UTF-8 like the
reference bytes32toString. An event whose label is not UTF-8 becomes a
FilteredEvent: it still links the history walk, but is not counted and
sets no versionId/updated/nextVersionId. NetDid used to decode labels as
ASCII ('?') and count them. did/pub and did/svc prefixes are ordinal
(ICU ignored NUL, so "d\0id/svc/x" was counted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CHANGELOG and PRD describe the RFC 3986 hier-part and IP-literal
grammar, the narrower bare shorthand, DID-scoped and linear-cost id
comparison, did:peer:2 producer checks, did:peer:4 placeholder-DID
rewriting, did.json overrides built before signing, and did:ethr
service names, last-wins JSON members, write guards, strict-UTF-8
labels with FilteredEvent, and ordinal prefixes. The plan file gains a
Review section; lessons gain four entries.

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

Copy link
Copy Markdown
Owner Author

Final pass on the id/URI round: all findings fixed (2daf183..abf8d84)

The final adversarial pass over the id/URI round found nine defects (F1–F9). I then ran one more independent adversarial pass over those fixes. It found five more (A1–A5), and re-reading surfaced two of the same kind. All are fixed in this push, each with fail-first tests. Nothing is deferred.

Final pass (F1–F9)

Tests are in 76de5d0; 21 defect rows were red against 9633c6b.

  • did:peer:2 Create and Resolve now run Core's full producer check (DidDocumentSerializer.ValidateForProduction).
    • A bare service beside the auto-generated #service is a duplicate.
    • Invalid service ids or endpoints are rejected: ArgumentException on Create, invalidDid on Resolve.
  • did:webvh did.json: a did:web:<host>#whois (or #files) service is the override there, so no second implicit service is added. did.json is now built before signing in Create and Update. A counting signer pins that no signature is produced for a refused document.
  • Fragment dereferencing compares ids with the shared normalization, so an id of another DID (did:other:1#x) no longer matches #x.
  • IsAbsoluteUri follows RFC 3986 scheme ":" hier-part:
    • [/] are allowed only around an IP-literal host;
    • // always starts an authority (http://host:abc is rejected).
  • The bare-id shorthand is one segment with no : / ? #. //evil.example/x, /files, ?service=x and a/b are no longer read as #….
  • did:ethr:
    • Service names follow ethr-did-resolver's ^did/svc/(\w+)(/\w+)?(/\w+)?$, typed by the first segment, and every did/svc/ event is still counted.
    • JSON values with duplicate members keep the last value at the first position, as JSON.parse does.
    • UpdateAsync rejects AddServices/AddAttributes values the resolver would drop.
    • BrickProbeTests now plant their junk endpoints with a raw setAttribute, because NetDid itself no longer writes them.

Adversarial pass over those fixes (A1–A5)

Tests are in a839a31; 16 defect rows were red against 22f78f9.

# Severity Defect Fix
A1 High Duplicate detection built <did>#x per service. A did:peer DID embeds its document, so one hostile 446 KB did:peer:2 allocated 7 GB, and a ~1 MB one killed the process. (Introduced earlier in this PR; never released.) DidParser.ComparableId compares own ids as #x, so cost follows the ids. Pinned by an allocation bound.
A2 Medium The dereferencer rebuilt <did>#x on every comparison (14 GB measured). The relationship branch searched every VM once per entry. Target built once; VM search hoisted.
A3 Low–Med did:ethr decoded bytes32 labels as ASCII and counted events whose label isn't UTF-8. ethr-did-resolver filters those. The did/pub and did/svc prefix checks were culture-sensitive (ICU ignores NUL). Strict-UTF-8 labels. Undecodable events become FilteredEvent: they link the history walk but are not counted and set no versionId/updated/nextVersionId. Prefix checks are ordinal. End-to-end test with real transactions on the emulator.
A4 Low did:peer:4 rewrote a template's placeholder DID in references but not in ids, so its own authentication reference stopped dereferencing. The reference rewrite had no DID boundary. Ids are rewritten too, only at a DID boundary.
A5 Low The IP-literal grammar accepted [foo] and [:]. RFC 3986 IPv6address / IPvFuture. Timed linear on 100K-char hostile inputs.
— Low Same class as A3: culture-sensitive did:key: check on a webvh proof verificationMethod (returned a bogus key, never a match), and on the EcdsaSecp256k1 prefix used for context selection. Ordinal.

Public API note (did:ethr):

  • Erc1056EventParser.Parse can now return the new FilteredEvent.
  • AbiDecoder.DecodeDelegateChangedData and DecodeAttributeChangedData return the strict-UTF-8 label, or null when it isn't valid UTF-8.
  • AbiDecoder.TryDecodeBytes32AsUtf8 is new.

Verification

  • Release build: 0 warnings, 0 errors.
  • Tests:
    • Core 706, Ethr 529, WebVh 604, Peer 98, Key 54, DI 26.
    • W3C conformance 233 (100%).
    • Real-EVM 18/18 with NETDID_ETHR_INTEGRATION=1.
  • All five samples exit 0, and git diff --check is clean.
  • DIF did:webvh vector replay: identical to the 2daf183 baseline.
  • Docs: the CHANGELOG [Unreleased] did:webvh: an unpaired-surrogate escape in a JSON member name resolves notFound instead of invalidDidLog #145 entry, the PRD (id/URI syntax, did:peer:4 contextualization, did:ethr label decoding), and the Review section in tasks/todo20261028-issue145.md.

The PR is ready for your review; I have not merged it.

🤖 Generated with Claude Code

The reviewer's record of the first review of PR #149 at e20ec80: the
two confirmed findings (nested serviceEndpoint sets on the authoring
path, unchecked JsonElement contents in writers), both fixed on this
branch, and the verification run at that head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@moisesja moisesja changed the title fix: map malformed JSON to each boundary's documented error and stop publishing altered text (#145) fix: fail closed on malformed JSON, never publish altered text, and enforce DID Core id/URI syntax (#145) Oct 28, 2026

@moisesja moisesja left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 0e8d868. Both original findings are fixed: nested endpoint sets are rejected and privately snapshotted, and malformed caller JsonElements now throw ArgumentException instead of publishing replacement text. I reran the original probes.

I still would not approve this revision. The expanded changes introduce one conformance regression and leave the new peer:2 producer validation bypassable. Both are reproduced through public APIs; the two inline comments give the exact triggers and requested tests.

Validation: Release build 0 warnings/errors; 2,261 default tests passed, with 7 opt-in EVM cases skipped (Core 706, Key 54, Peer 98, WebVh 604, Ethr 529, DI 26, W3C 233, integration gate 11). Separate real-EVM run 18/18; all five offline samples exit 0; CI run 40754984649 succeeded. Independent review found no additional actionable issue in the new ethr/webvh changes.

The PR has grown to 62 commits and 44 files. Commit subjects remain specific, and CHANGELOG/PRD explain the expansion, but please update the PR body: it still describes the initial scope and test counts, omitting the new URI/parser, identifier-rewriting and ethr service-decoding changes. Minor hygiene: git diff origin/main...HEAD --check also reports new blank lines at EOF in the Core Issue145 test file and DidKeyMethodTests.cs.

moisesja and others added 8 commits October 28, 2026 15:53
DID Core 3.2.2 relative DID URLs (path, query and fragment references)
must be read and written, and compared by RFC 3986 reference resolution
against the DID, also in DefaultVerificationRelationshipResolver, at
linear cost. did:peer:2 and did:peer:4 Create must return the document
their DID encodes, even when a caller list reads differently twice
(the reviewer's split enumerator/indexer repro). 26 defect rows fail
against 0e8d868; the controls and the retained negatives pass. Also
drops two blank lines at EOF reported by the branch-wide diff check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… RFC 3986 resolution (#145)

Relative DID URLs "MAY contain relative path components, query
parameters, and fragment identifiers" and are resolved by RFC 3986 §5
against the DID (authority = method:method-specific-id). The new
identifier validation accepted only absolute DID URLs, "#fragment" and
the bare shorthand, so "/keys/1", "keys/1#k" and "?versionId=1#key-1"
were rejected on read and write. IsValidDidReference now accepts the
RFC 3986 relative-ref forms except network-path (which replaces the DID
itself), and ComparableId resolves references against the DID (merge,
remove_dot_segments), so "keys/1#k", "/keys/1#k" and "<did>/keys/1#k"
are one id. The bare shorthand "x" keeps meaning "#x" (issue #136).
DefaultVerificationRelationshipResolver drops its own normalization,
which read "/keys/1" as "#/keys/1" and cost entries x DID length, for
the shared one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
did:peer:2 Create encoded options.Services by enumeration but built and
validated its document through the indexer (and enumerated Keys twice),
so a caller list whose reads disagree minted a DID resolving to
invalidDid while Create reported a valid document. Create now reads each
list once, encodes once, and returns that DID resolved, which also runs
the producer checks; failures throw ArgumentException. did:peer:4 Create
builds its document from the JSON it hashed instead of re-reading the
caller's InputDocument. BuildDocument and GenerateServiceId are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
?service= selects by fragment ("agent/v1" is "#agent/v1", never a
path); a DID URL with a path and a fragment names "<did>/path#fragment",
not "#fragment"; an id that is no URI reference never matches a valid
one; did:peer:2 Create rejects a key string containing the segment
separator, follows the DIF spec abbreviations (recursive over key
names, DIDCommMessaging<->dm for the type value only; the spec's own
example DID is reproduced and resolved), refuses names it cannot round
trip, preserves explicit relative service ids and rejects absolute ones
("service id attributes MUST be relative"). The old auto-id test now
uses services without ids. 20 defect rows fail against 3e70eb0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…145)

The ("#service", "svc") row relied on "svc" being dropped and replaced
by a generated "#service". The DIF spec keeps an explicit relative id
and generates one only when the id is not set, so the collisions are
an explicit "#service" beside a service without id, and the shorthand
"svc" beside "#svc".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erenced resource, invalid ids never match (#145)

The service parameter names a service by fragment ("agent/v1" selects
"#agent/v1"); routing it through RFC 3986 resolution made it "/agent/v1"
and lost the service. A DID URL with a path and a fragment now selects
"<did>/path#fragment" instead of ignoring the path, so "<did>/k#x" can
no longer return the method "#x" from another relationship. An id that
is no URI reference (a network-path "//host#k", "k 1") gets a form that
equals no valid id, so it cannot match "<did>//host#k".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es; key strings cannot add segments (#145)

Per the DIF spec, member names are abbreviated recursively (type,
serviceEndpoint, routingKeys, accept -> t, s, r, a, also inside "s")
and only the type value DIDCommMessaging becomes "dm". NetDid used one
table for names and values and no recursion, so it could not produce
the spec's example DID, resolved it with "a"/"r" endpoint keys, read a
type "t" back as "type" and a member "dm" as "DIDCommMessaging". Names
or the type "dm" that cannot round trip are now refused. Explicit
relative service ids are kept ("service id attributes MUST be
relative"); absolute ids are rejected on Create and Resolve instead of
being silently replaced. A key MultibasePublicKey containing '.' (the
segment separator) is rejected instead of injecting keys or services.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CHANGELOG and PRD: relative DID URLs (DID Core 3.2.2) accepted and
compared by RFC 3986 resolution with the bare-shorthand exception;
?service= by fragment; path+fragment dereferencing; did:peer Create
returns what its DID encodes; did:peer:2 DIF abbreviation and service
id rules. Corrects the earlier claim that "/files", "?service=x" and
"a/b" ids are rejected. Lessons gain three entries; net-did-verify runs
the branch-wide diff check; the plan file gains Review round 2.

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

Copy link
Copy Markdown
Owner Author

Review round 2: addressed (0e8d868..6f05f34)

Thanks for the re-review. Both P2 findings and the hygiene notes are fixed. Details are in the two inline threads.

  • Relative DID URLs (P2): DID Core §3.2.2 relative path, query and fragment references are accepted on read and write, and compared by RFC 3986 §5 resolution against the DID. The one documented exception is the bare shorthand from [High] did:webvh implicit #files / #whois services are neither materialized in the resolved document nor resolvable as DID URLs #136. DefaultVerificationRelationshipResolver now uses the same comparison.
  • did:peer Create snapshot (P2): did:peer:2 Create reads its input once and returns Resolve(encodedDid). did:peer:4 Create builds from the JSON it hashed.
  • Hygiene:
    • The two EOF blank lines are removed.
    • net-did-verify now also runs git diff origin/main...HEAD --check. My gate only checked the working tree, which is how they got past it.
    • The PR description is updated to the current scope, including this round.

Adversarial pass over these fixes: five confirmed findings, all fixed with fail-first tests.

  • ?service= must select by fragment. My first version had regressed ?service=agent/v1.
  • A DID URL with a path and a fragment ignored the path (pre-existing). <did>/k#x could return #x, a different key in a different relationship.
  • A non-URI id such as //evil#k could equal a valid one in the relationship resolver.
  • A did:peer:2 key string containing . could inject segments.
  • did:peer:2 abbreviations didn't follow the DIF spec: one table for names and values, no recursion into serviceEndpoint. NetDid couldn't produce or correctly resolve the spec's own example DID, and it silently replaced explicit relative service ids.

Behavior changes this round (CHANGELOG has the details):

  • relative DID URLs are now valid;
  • did:peer:2 Create refuses names that would resolve as something else (t/s/r/a), the type dm, absolute service ids and key strings with .;
  • explicit relative service ids are kept.

Fail-first: 26 defect rows were red against 0e8d868, and 20 more against 3e70eb0. Controls and your negative rows pass.

Verification at 6f05f34:

  • Release build: 0 warnings, 0 errors.
  • Tests: Core 748, Peer 114, WebVh 604, Ethr 529, Key 54, DI 26, W3C 233 (100%).
  • Real-EVM: 18/18.
  • All five samples exit 0.
  • DIF did:webvh vector replay is identical to the baseline.
  • git diff origin/main...HEAD --check is clean.

Ready for another look. I haven't merged.

🤖 Generated with Claude Code

@moisesja moisesja left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both review-round-2 findings are fixed and verified at 6f05f34. The fixes were already on the branch when this implementation pass began; I validated them rather than duplicate them.

  • Relative path/query DID URLs now pass the original public read/write probes, with shared RFC 3986 comparison and negative controls retained.
  • peer:2 validates and returns the document decoded from its exact encoded DID. The original split-indexer/enumerator regression now rejects the invalid input; normal Create/Resolve equality controls pass. peer:4 uses its encoded JSON too.

No remaining blocker found in one independent adversarial pass over these fixes. The two original round-1 findings remain fixed as well. I am resolving my four review threads.

Fresh verification: Release build 0 warnings/errors; 2,319 default tests passed (7 opt-in EVM cases skipped), separate real-EVM suite 18/18, W3C 233/233, all five offline samples exit 0, both diff checks clean. CI 40762475189 passed. Ready for merge from my review perspective; not merged.

@moisesja
moisesja merged commit 84c67c2 into main Oct 28, 2026
1 check passed
@moisesja
moisesja deleted the fix/issue-145-surrogate-member-name branch October 28, 2026 21:10
@github-project-automation github-project-automation Bot moved this from In review to Done in net-did Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

did:webvh: an unpaired-surrogate escape in a JSON member name resolves notFound instead of invalidDidLog

1 participant