Skip to content

spec: clarify normative language for resolver validation (26 edits from cross-impl security audit)#282

Open
stormer78 wants to merge 5 commits into
decentralized-identity:mainfrom
stormer78:spec-clarifications-from-security-audit
Open

spec: clarify normative language for resolver validation (26 edits from cross-impl security audit)#282
stormer78 wants to merge 5 commits into
decentralized-identity:mainfrom
stormer78:spec-clarifications-from-security-audit

Conversation

@stormer78

Copy link
Copy Markdown

PR: Clarify normative language for resolver validation

Base branch: decentralized-identity/didwebvh:main
Head branch: stormer78:spec-clarifications-from-security-audit
Local commit: 889eb7a (DCO-signed)
Files changed: spec/specification.md, spec/security_and_privacy.md (+149 / −72 lines)

Summary

A cross-implementation security audit of four did:webvh v1.0 reference
implementations (Rust, Python, TypeScript, Java) found 25+ distinct vulnerabilities.
Six of those issues were reproduced by three of four implementations — strong
evidence that the defects trace to under-normative spec language rather than
isolated implementation negligence.

This PR applies 26 surgical edits that tighten normative requirements
without changing the wire format. The DID URL grammar, log-entry JSON
shape, witness-proof shape, multihash algorithm identifiers, and JCS
canonicalization process are untouched. Implementations that pass
the clarified text remain interoperable with implementations that passed
the pre-clarification text.

What changed

spec/specification.md

Edit Section Bug(s)
1 + 24 § The witness Parameter — distinct IDs, distinct counting, key-type check #3, R2, #11
2 § Witness Threshold Algorithm — four-step algorithm #3, P8
3 § nextKeyHashesupdateKeys mandatory while pre-rotation active #9
4 (×2) § method + § Authorized Keys — cryptosuite MUST #10
5 + 19 § Verifying Witness Proofs — six steps + per-proof attribution #11, #3
6 § Read step 6 — state.id SCID MUST equal parameters.scid #14
7 § DID Portability — SCID immutable #15
9 § DID-to-HTTPS step 4 — path segment validation after decoding #4, #13
10 § Witness DIDs — did:key body/fragment match #1
11 § Read step 4 — strict monotonic + future-time on every entry P1, P7, J8
12 § method — reject unknown values, no silent downgrade R6
13 (×2) § Verify SCID step 6 + § Create step 5.2 — anchored substitution P14, J6
14 § Read intro — every step for every entry (no fast-path) T6
15 After § The DID Log File — negative examples block #3, #9, #10, #14, #15, R6
16 § The Witness Proofs File — provenance bound to DID-derived URL #11
17 § portable — codify retroactivity + SCID immutability in parameter #15
18 § Read step 3 — stronger hash-chain MUSTs T6, P14, J6
20 § The Witness Proofs File — key from did:key body only #11
21 § Read step 7 — pre-rotation hash check applies to ALL keys #9 (defence-in-depth)
23 § Reading did:webvh DID URLs — SCID binds at API surface #14, #15
26 § Witnessing a DID Version Update — witnesses verify independently #11 (defence-in-depth)

spec/security_and_privacy.md

Edit Where Bug(s)
8 New subsection: Resolver Transport Hardening (SSRF and Network Boundary) #2, #5, #12
22 New subsection: Resolver Validation Checklist (informative) All (consolidation)
25 New subsection: Implementation Hygiene (informative) J1, J2, J11, T12, J12, J13

Hard constraints honoured

  • ✅ DID URL grammar unchanged
  • ✅ Log-entry JSON shape unchanged
  • ✅ Witness-proof shape unchanged
  • ✅ Multihash algorithm identifiers unchanged
  • ✅ JCS canonicalization process unchanged
  • ✅ Every edit targets normative-language strength, validation algorithm
    clarity, negative examples, or expanded Security Considerations — not
    wire format

For one cross-impl spec gap (#11 cross-DID witness replay) the root
cause is in the witness signing payload, which doesn't bind to a DID.
Fixing that root cause would require a wire-format change and is out of
scope. The edits here propose the in-scope mitigation that closes the
practical exploit: requiring the URL-derived fetch + versionId-match
as the binding mechanism (Edits 5, 16, 19, 20, 26).

Audit summary by severity

The audit findings the edits address:

Cross-impl status tables are available with the audit; the per-bug
provenance is summarised in the test-suite PR (negative test vectors)
that pairs with this spec PR — see
decentralized-identity/didwebvh-test-suite#5.

Why we're confident the edits don't break interop

  • Every edit either:
    • Strengthens an existing MUST to apply more broadly (e.g., "every entry" instead of "the last entry"), or
    • Tightens a SHOULD/MAY to a MUST in a place where 3+ implementations were already attempting to enforce, or
    • Adds an explicit MUST for behaviour where the spec was silent and divergent implementations were a security risk.
  • No edit adds a new wire-format field or changes any existing field's
    meaning.
  • No edit forbids a previously-required behaviour.

A compliant implementation against the pre-clarification text was free
to apply or skip these checks; with these edits, "skip" is no longer
compliant. Implementations that already applied the checks (e.g., Python
gets #11 and #14 right; TypeScript gets #1 and #10 right) remain
compliant unchanged.

Reviewer checklist

  • Each MUST upgrade is consistent with the WG's understanding of the
    intended security model
  • Negative examples (Edit 15) are accurate and helpful — open to
    adding/removing entries
  • New Security Considerations subsections (Edits 8, 22, 25) fit the
    style and granularity of existing content
  • Where two edits stack on the same concept (e.g., Proposal 2: did:fractal #15 has Edits 7
    + 17 + 23 reinforcing the SCID-immutability rule from three
    angles), this is intentional defence-in-depth; the WG may prefer
    to consolidate to a single location with cross-references

Provenance

The audit covered:

  • the 15 published security patches in didwebvh-rs (#1#15)
  • 10 additional Rust findings (R01R10)
  • 16 Python-originated findings (P01P16)
  • 16 TypeScript-originated findings (T01T16)
  • 13 Java-originated findings (J01J13)
  • Cross-pollination rescans of every implementation against every
    finding from the other three

Companion artefacts:

  • Negative test vectors covering 16 of these scenarios in
    decentralized-identity/didwebvh-test-suite#5.
  • Per-language vulnerability reports (104 markdown files) and an
    exploitability matrix produced during the audit are available on
    request from the author.

A four-implementation security audit (Rust, Python, TypeScript, Java) of
did:webvh v1.0 surfaced 25+ vulnerabilities reproduced across multiple
implementations. Six issues hit three of four implementations — strong
evidence that the defects trace to under-normative spec language rather
than implementation negligence.

This commit applies 26 surgical edits that:

- bind witness proofs to the resolved DID's URL and to distinct witness
  identities (cross-DID replay decentralized-identity#11, threshold inflation via duplicates decentralized-identity#3)
- require updateKeys explicitly in every entry under pre-rotation (decentralized-identity#9)
- bind state.id SCID to parameters.scid on every entry, including
  portable renames (decentralized-identity#14, decentralized-identity#15)
- mandate cryptosuite enforcement on every proof, not just proofPurpose
  (decentralized-identity#10)
- forbid silent downgrades of unknown method values (R6)
- forbid "fast resolve" paths that skip intermediate-entry verification
  (T6)
- harden the DID-to-HTTPS transformation against path traversal,
  IP-literal hosts, and percent-encoding case-folding bypasses
  (decentralized-identity#2, decentralized-identity#4, decentralized-identity#5, decentralized-identity#12, decentralized-identity#13)
- anchor SCID-placeholder substitution to structural locations
  (P14, J6)
- tighten versionTime monotonicity and future-time bounds on every
  entry (P1, P7, J8)
- add a Resolver Transport Hardening subsection and a Resolver
  Validation Checklist to Security Considerations
- add negative log-entry examples (what resolvers MUST reject) so
  implementers can self-test

No changes to the DID URL grammar, JSON log-entry shape, witness-proof
shape, multihash algorithms, or JCS canonicalization. The wire format
is unchanged. Implementations that pass these clarified requirements
remain interoperable with implementations that already passed the
pre-clarification text.

Full audit and per-bug rationale: see PR description.

Signed-off-by: Glenn Gore <glenn.gore@gmail.com>
Comment thread spec/specification.md Outdated

::: example

**Non-compliant log entries that resolvers MUST reject.** Illustrative — not exhaustive.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think we should be using normative language here. This is an example. Suggest:

Non-compliant Log Entry Examples (informative). Illustrative — not exhaustive.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Remove the "MUST reject" - though is it clear then as to what a resolver should do here?

Comment thread spec/specification.md Outdated
the previous step.
2. **Replace the placeholder `{SCID}`** Replace the placeholder "`{SCID}`" with the calculated [[ref: SCID]] from the previous step **only** at the following structurally-anchored locations within the preliminary JSON object: `parameters.scid`; `state.id`; `state.controller` (string or each array entry); `id` and `controller` of every entry in `state.verificationMethod`; every string entry of `state.authentication`, `state.assertionMethod`, `state.keyAgreement`, `state.capabilityInvocation`, `state.capabilityDelegation` (and `id`/`controller` for any object entries); `id` of every entry in `state.service`.

The replacement **MUST NOT** be performed by unanchored string substitution over the serialized JSON. Unanchored substitution corrupts unrelated fields that may legitimately contain the placeholder as a substring (e.g., `alsoKnownAs`, `serviceEndpoint`).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I had thought about this when this was written and thought that a global replacement of all instances of "{SCID}" with the calculated SCID value was the right thing to do.

What is a realistic example where you wouldn't want this?

Why is that not sufficient?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is just being super pedantic, but there is an edge case where someone may, for some reason, want to include "scid" in an ID field for a key or service record. A basic string substitution may incorrectly pick up on the "scid" string and replace it with the calculated SCID.

This is just being explicit on where the string "scid" may be used elsewhere, independent of the webvh standard itself. Though reading this the 2nd paragraph is probably enough to protect on this rare edge case.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

To be clear, the placeholder is "{SCID}", so pretty unlikely. My concern is that we are trying to list off all the places to specifically change, but if we miss any, the user is hooped. I think we are better off leaving it and perhaps putting in a caveat saying that EVERY literal "{SCID}" will be replaced so you can't have a DIDDoc that contains that string.

Comment thread spec/specification.md Outdated
file, applying the [[ref: parameters]] set from the current and previous
entries. As noted in the [DID Log File](#the-did-log-file) section, [[ref: log entries]]
are each a JSON object with the following properties:
To process the retrieved [[ref: DID Log]] file, the resolver **MUST** carry out the following steps on each of the [[ref: log entries]] in the order they appear in the file, applying the [[ref: parameters]] from the current and previous entries. Every step **MUST** be performed for **every** entry; in particular, [[ref: Data Integrity]] proof verification (step 2) and `entryHash` verification (step 3) **MUST NOT** be skipped for intermediate entries on the grounds that the resolver only needs the latest [[ref: DIDDoc]]. A "fast-resolve" implementation that verifies only a proper subset of entries is **non-compliant**: the proof chain is what binds the latest entry's authority to the genesis SCID, and breaking it at any point invalidates all later entries.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think normative language and all the words are needed here. This is already in the spec. I think some guidance is reasonable, but IMHO this equates to "Use the spec. Really. We mean it."

Comment thread spec/specification.md Outdated

##### Reading did:webvh DID URLs

A `did:webvh` DID identifies a log by its SCID; host/path locate where the log is hosted. When a resolver receives a request, the SCID segment of the requested DID **MUST** equal the SCID segment of `state.id` in at least one entry of the retrieved log **and** equal `parameters.scid` from the first entry. A log whose `state.id` SCID does not match the requested DID — even if host/path matches — **MUST NOT** be returned; resolution **MUST** terminate. This rule applies independently of whether portability is enabled.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why the "...equal the SCID segment of state.id in at least one entry of the retrieved log" -- it must be the same SCID in ALL entries.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed

Comment thread spec/specification.md Outdated
Comment thread spec/specification.md Outdated
changed (`did.jsonl` to `did-witness.json`). The media type of the file
**SHOULD** be `application/json`.

Resolvers **MUST** retrieve `did-witness.json` from the location derived from **the DID being resolved**, not from a location supplied by another DID's log or out of band. The host, port, and path **MUST** match the `did.jsonl` URL (only the final element differs). A `did-witness.json` from a different host/path **MUST NOT** be combined with a `did.jsonl` for this DID; doing so permits cross-DID transplantation, even if every individual proof verifies as a `did:key` signature, because the payload `{"versionId":"..."}` does not bind to a DID.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Would this invalidate the use of a Watcher for resolving a DID?

Not sure about the last line. If the payload does not bind to the DID it must be rejected no matter where the file came from.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, this should be from the derived URL or a valid watcher.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The statement "...doing so permits cross-DID transplantation..." is wrong. The did-witness.json file:

  • must have versionId values that match that of the DID, and those values are chained to the DID.
  • must use a did:key that is referenced in the witnesses list in the DID parameters.

There is no reason to require the did-witness.json file to come from the defined web location (e.g. watchers -- official or not) are fine, and as long as the resolvers are properly checking what witness proofs to include/exclude, there is a direct tie between the DID and the witness file.

Comment thread spec/specification.md Outdated
1. Complete all non-witness verifications of the [[ref: DID Log]] **before** processing any witness proof. Witness verification **MUST NOT** substitute for entry-hash or signature verification.
2. Retrieve `did-witness.json` from the location derived from **this** DID via the [DID-to-HTTPS Transformation](#the-did-to-https-transformation). A file from a different location, or supplied out of band, **MUST NOT** be accepted unless provenance is independently established.
3. For each entry in `did-witness.json`, confirm its `versionId` matches a `versionId` present in **this** `did.jsonl`. Non-matching entries **MUST** be discarded; resolvers **MUST NOT** treat them as evidence of witnessing for any other entry.
4. Because the signed payload (`{"versionId": "<n-hash>"}`) does **not** itself encode the DID, the location-and-`versionId`-match check in step 3 is the **sole** mechanism binding a proof to this DID; implementations **MUST NOT** skip it. A witness signature lifted from another DID's `did-witness.json` with the same `versionId` **MUST NOT** be accepted.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nothing incorrect about this, but it seems pretty unlikely and so unnecessary. Given that each versionID is GUID, it seems a stretch that a useful witness signature could be lifted from another DID's witness.json file.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

AI was getting paranoid by this stage of it's work - agreed it is not needed, as the witness checks should detect an invlaid signature that has been copied and inserted.

@swcurran

Copy link
Copy Markdown
Collaborator

Reading through this -- it looks good. I noted a few things that looked weird to me as I was going through it, but nothing too serious. As always with AI it adds a lot of words, but in this case, not the end of the world. And if it makes it easier for another AI to read the spec and produce a compliant, secure implementation -- great.

It appears that all of updates here are truly clarifications -- it doesn't change anything we intended in the v1.0 spec. Is that your read of it as well? If so, I think it would be best to keep the v1.0 spec and this "next" spec as the same. If you agree, copy the two updated files in /spec into /spec-v1.0, so that the two versions of the spec continue to match exactly.

Interested in what others think.

Thanks for this.

@swcurran

swcurran commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

We had a good discussion about this PR at the 20260604 did:webvh Work Item Meeting (Recording — starting at 14:15. While the vast majority of the PR is good, we do want some changes made to it to ensure it doesn’t change/break v1.0 and to address some mistakes.

@stormer78 — how do you want to proceed with this PR? One option is that I could make changes to your fork, PR to you and you could accept/reject the changes and then update your PR. Happy to do it that way and I think it would preferred so that we have a human pass across the generated text.

Here are the changes we want to see. The reference numbers is from the initial comment

  1. Most important is item 13 which says that the “{SCID}” for <scid> replacement should be anchored to specific JSON items vs. treating the entry JSON as a string and doing a pure text replacement. The latter is how it is intended in v1.0 and we feel is the correct way. We could add a caveat that “The literal string “{SCID}” is not allowed in an initial DIDDoc; it MAY be added in subsequent DIDDocs.” Not only would this be a breaking change, but attempting to enumerate and code the precise updates is going to invariably miss all possible usages.
  2. There is repetition in the changes. Rather than having the same text twice, internal links could be used. This is in several areas — SCIDs, witness handling, portablity.
  3. In 17 (I think) it references that the SCID must match at least one entry when portability is used. This is wrong — the SCID MUST be the same in ALL entries.
  4. In 20, there is no need to limit where the did-witness.json file is sourced. The proofs in the file must be on versionId that matches the DID, which both binds the proofs to the DID, and chains the log of updates. The example to taking a proof from the witness file of another DID is prevented by the versionId calculation and is not a risk. Any such an attempt would simply fail validation -- the versionId would not match any in the DID.
  5. I’d like to verify the pre-rotation updates are accurate. There are no changes (AFAIK) in the text, but I think re-verifying is needed.

Assuming these updates to the PR are made (especially 1, 3 and 4), we believe that the changes do not constitute breaking changes, just clarifications, and can thus be applied to both the “next” and “v1.0” versions of the spec. The current PR only covers “next”, but I would like them applied to “v1.0” as well.

What do you think?

.

Comment thread spec/specification.md Outdated
3. Form the set of **distinct** attributed `id` values. The threshold check applies to the size of this set, not to the raw proof count.
4. If `|set| ≥ threshold`, the update is "[[ref: witnessed]]"; otherwise resolution **MUST** terminate with an error.

A single witness submitting multiple proofs (e.g., signed by additional keys) **MUST NOT** satisfy threshold > 1.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Removing this line as it is not correct. There is no way to know if a single witness used multiple keys.

swcurran and others added 4 commits June 6, 2026 06:50
Signed-off-by: Stephen Curran <swcurran@gmail.com>
…sion

Signed-off-by: Stephen Curran <swcurran@gmail.com>
…-audit

Corrections and rewording of the clarifications to the spec
@swcurran

swcurran commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

Reiterating the comment I put in the PR to @stormer78 's PR:

I reworded/adjusted most of the changes to be “better” aligned with the intent of the spec. The following is the list of changes, first with the actual errors, and then a rundown of the rewording, all of which are evident from the edits I made. The first list is the important one.

Corrected errors:

  • Removed the description of doing targeted replacements of “{SCID}” with in both the generation and verification algorithms. Added a caveat saying that the literal string “{SCID}” is not permitted in the initial entry, but that it can be added in the second entry if needed.
  • Redesigned the verification algorithm concerning the requirement that the DID being resolved MUST appear in at least one entry. The new proposed wording was awkwardly placed in the middle of the processing for one entry.
  • Fixed error stating the SCID must match at least one id.state value. It must match ALL.
  • Removed the line that the did-witness.json file must be pulled from the HTTP address implied by the DID. The implication that the HTTP URL is the only tie to the DID is wrong — it’s cryptographically tied through the versionID that is signed in the proof. Also removed that a witness proof from another DID with the same versionId could be magically produced by an attacker.

Rewording rationale:

  • Adjusted the wording in the examples that fail verification to be informative and to adjust/remove statements that are inaccurate. The latter are also corrected elsewhere in the spec and listed below.
  • Added note that says while full verification MUST be done, if verification state is cached, it is possible to continue verification for new entries.
  • Reworded the now() tolerance of 5 minutes for versionTime to be more practical to implement.
  • Minor wording change to use “active” for a parameter vs. “apply”.
  • Removed references to the hash algorithms and cryptosuites that are specific to v1.0 of the spec to just say (effectively) “implied by the active method" changes won’t be missed in when we updated to v2.0 and beyond.
  • Tweaked the wording around the use of did:key to refer to the spec (follow it!). Left in the example of the did/fragment difference example.
  • Removed the example of a witness signing two proofs with different keys (there is no way to know that), and added a note that the client of the resolver can evaluate the witnesses based on the governance of the ecosystem — but that is outside the scope of the spec.
  • Reword around the copy of the DID Log the witness must hold.

@swcurran

swcurran commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

Would appreciate a review/approval from someone else @decentralized-identity/didwebvh-admins or @decentralized-identity/didwebvh-maintainers .

This PR does not change the spec, merely clarifies sections. As such, the PR keeps in sync the v1.0 spec and the Editor's Draft.

Let's get this merged so that we can tell the story about the use of Project Glasswing/Mythos to improve the implementations and the spec.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants