Release 0.5.3 — security: 15 patches from cross-implementation audit
Closes the issues raised in
#39,
a cross-implementation review of the four open-source did:webvh
resolvers. Patch series tightens authorization, DID-URL parsing, and
witness-proof validation. No public-API breakage; consumers on 0.5.x
should upgrade. Pre-existing logs continue to resolve; the changes
reject malformed or malicious inputs that previously slipped through.
MSRV: 1.94.0 → 1.95.0 (required by transitive lockfile bumps stable
since 2026-04-14).
Security
- Reject mismatched
did:keybody/fragment in log-entry proof
authorization.check_signing_key_authorized()compared only the
fragment of the proof'sverificationMethodagainst
updateKeys, but signature verification decoded the public key from
the DID body. An attacker could set
verificationMethod = "did:key:<attacker-mb>#<authorized-mb>"— the
fragment matched an authorized key so authorization passed, while the
signature was verified against the attacker's own key. This allowed
anyone to forge arbitrary DID log entries for anydid:webvhDID.
TheverificationMethodis now required to be exactly
did:key:{mb}#{mb}where{mb}is an authorized multibase. - Disable HTTP redirects in DID resolution.
reqwestfollowed up
to 10 redirects by default. A malicious host serving adid:webvh
DID could 302-redirect thedid.jsonl/did-witness.jsonfetch to
an internal address (cloud metadata, localhost, RFC 1918), turning
the resolver into an SSRF proxy and bypassing the existing
IP-address rejection inWebVHURL::parse_did_url(). The native
client now setsredirect(Policy::none()); the WASM path is
unchanged (governed by the browser's fetch/CORS model). - Reject duplicate witness IDs.
Witnesses::validate()checked
the count met the threshold but not for duplicates.
WitnessProofCollection::validate_log_entry()increments
valid_proofsonce per listed witness, so a controller declaring
threshold: 3, witnesses: [W1, W1, W1]could satisfy the threshold
with a single proof fromW1. Duplicate witness IDs are now
rejected. - Reject path-traversal segments when converting DID → HTTP URL.
WebVHURL::parse_did_url()joined the colon-separated path
components of adid:webvhidentifier into the HTTP path with no
validation. A DID such asdid:webvh:<scid>:example.com:..:..:other
resolved tohttps://example.com/../../other/did.jsonl..,..,
empty segments, and segments containing/are now rejected. - Percent-decode path segments before the traversal check. The
raw check could be defeated by encoding:…:%2E%2E:xpassed the
literal..test, became…/%2E%2E/x/…, andUrl::parsecollapsed
it. Segments are now percent-decoded before the check.\is also
rejected. - Match lowercase
%3awhen splitting host:port in DID URL
parsing. Percent-encoding is case-insensitive (RFC 3986 §2.1) but
the parser only split on literal%3A. A DID using
did:webvh:<scid>:127.0.0.1%3a8080left
domain = "127.0.0.1%3a8080", which did not parse as anIpAddr
and slipped pastreject_ip_address(). Both encodings are now
recognised. - Re-check host after
Url::parseto block percent-encoded IP
bypass.reject_ip_address()ran on the raw DID domain segment
before percent-decoding, sodid:webvh:SCID:127%2E0%2E0%2E1did not
parse asIpAddrand passed —Url::parsethen decoded the host to
127.0.0.1and the resolver fetched from localhost. Same for
169.254.169.254(cloud metadata). All threeget_http_*_url()
functions now re-checkurl.host()after parsing and reject
Host::Ipv4/Host::Ipv6. The earlyreject_ip_address()remains
as a cheap pre-check. - Don't re-include stripped fragment when DID URL has no query.
The query-split fallback used the wrong variable, gluing an
already-stripped fragment back onto the prefix before scid/domain
splitting. Fordid:webvh:<scid>:127.0.0.1#xthis yielded
domain = "127.0.0.1#x", again bypassing
reject_ip_address(). - Verify "later version" witness proofs before counting toward
threshold. A witness proof for published versionN > currentwas
counted toward the current entry's threshold without signature
verification, on the assumption it would be verified when entryN
itself was processed. But if the witness was rotated out before
entryN, the proof was never verified anywhere — letting a
compromised controller forge "later" proofs for rotated-out
witnesses and satisfy the threshold for every earlier entry. The
signature is now verified against its own versionId in this branch
before incrementingvalid_proofs. - Bind witness proofs to this log's versionIds. A witness signs
{"versionId": X}. Nothing in that payload names the DID, so a
genuine signatureWmade for another DID's entry was
cryptographically valid here too. Since the witness-proofs file is
fetched from the (potentially compromised) DID host, an attacker
could replayW's proof from another DID into this one. After
generate_proof_state, proofs whose versionId is not present in
this log are now dropped. - Enforce
eddsa-jcs-2022cryptosuite on controller proofs.
verify_log_entrycheckedproofPurposebut notcryptosuite,
while witness proofs already enforcededdsa-jcs-2022via
enforce_witness_proof_shape(). The didwebvh 1.0 spec mandates
eddsa-jcs-2022for log-entry proofs too — without this check, the
proof's suite chose the canonicalization pipeline while public-key
bytes were decoded fromdid:keyindependently, an
algorithm-substitution surface that grows as the upstream library
adds suites. The widened check still admitsMlDsa44Jcs2024and
SlhDsa128Jcs2024when theexperimental-pqcfeature is enabled. - Bind the DID document's SCID to the verified
parameters.scid.
verify_scid()proved thatparameters.scidis the genesis
self-hash, but nothing checked that the SCID embedded in
state["id"]— the DID the resolver matches the request against —
is that same value. An attacker could publish a genesis with
state.id = "did:webvh:<anything>:host"while
parameters.scid = <real genesis hash>and a user resolving
did:webvh:<anything>:hostgot a "validated" log with no
cryptographic binding between the resolved DID and the genesis. The
third colon-segment ofstate["id"]is now required to equal
parameters.scid. - Enforce SCID immutability across every entry, not just genesis.
verify_portability()let a portable DID changestate["id"]to
anything as long as the previous DID appeared inalsoKnownAs—
including a new id with a different SCID segment, reopening the
self-certifying bypass on every non-genesis entry. The SCID segment
ofstate["id"]is now required to equalparameters.scidon every
entry. The spec is explicit that portability moves the host/path;
the SCID is the cryptographic anchor and never changes. - Require
updateKeyswhen previous entry committed
nextKeyHashes. When entryNsetnextKeyHashes(pre-rotation
active), entryN+1could omitupdateKeysentirely — theNone/
empty arms inheritedprevious.active_update_keysunchanged and the
proof was then authorized against those inherited old keys. An
attacker who compromised an old update key after the controller
pre-committed its replacement could forge entryN+1by leaving
updateKeysout, a complete bypass of the pre-rotation guarantee.
Absent and emptyupdateKeysare now rejected when the previous
entry had pre-rotation active.
Fixed
- Don't panic on malformed
idin
convert_webvh_id_to_web_id. Theidpassed in is read from the
DID document'sstate["id"]field, which is attacker-controlled.
A value with fewer than three:segments (e.g."foo") panicked
on theparts[3..]slice, crashing any resolver that called
to_web_did()on a hostile log. Malformed input now degrades to a
baredid:webinstead of a DoS. - Distinguish older-version proofs from current-version proofs in
the witness verifier. When the stored proof for a witness had
oldest_id < version_number, the code fell through to the
"current version" branch and tried to verify against the current
entry's versionId — which the proof was not signed over. The
verifier failed with "signature invalid", masking the actual
semantic: this proof simply does not cover this entry. The
threshold check at the end now correctly surfaces
"threshold (N) was not met. Only (M) proofs were validated"
instead of a misleading signature error. Surfaces a real
cross-implementation interop issue: Python, Java, and Java-EECC
witness files keep stale per-version proofs (they do not cull) so
the threshold cannot be met by older-version proofs alone. Rust's
culling behaviour (keep only the latest per witness) was already
correct.
Changed
- MSRV bumped 1.94.0 → 1.95.0. Required by transitive
dependencies pulled in bycargo update(stable since 2026-04-14).
CI MSRV job's toolchain matrix updated accordingly. No source
changes required. serde_withconstraint widened3.18→3.20to reflect the
actual minimum.Cargo.lockrefreshed.affinidi-crypto 0.1.5 → 0.1.6(fixes
upstreamml-dsaAPI drift onKeyGen),tokio 1.52.1 → 1.52.3,
reqwest 0.13.2 → 0.13.3,serde_json 1.0.149 → 1.0.150,
serde_with 3.18.0 → 3.20.0, plus transitive bumps (tower-http,
wasm-bindgenfamily,web-sys,winnow,zerofrom). No
major-version churn; noCargo.tomlconstraints widened.
CI
cargo audit: ignore RUSTSEC-2026-0104 (rustls-webpki CRL
panic). Same crate version (0.101.7) and same transitive chain
(reqwest 0.11via the optionalssifeature) as already-ignored
RUSTSEC-2026-0098 and RUSTSEC-2026-0099. Thessifeature is
optional and not enabled indefault, so the default build has
zero advisories. Inline comments now separate vulnerabilities from
unmaintained-package warnings to make triage easier.