fix(core): resolve interop failures from did:webvh test suite (issue #2)#4
Conversation
1a4b005 to
943dcd2
Compare
|
Ran the https://github.com/decentralized-identity/didwebvh-test-suite against the new branch and the fixes are working. Remember that the code in the test suite is all AI generated, so you might want to consider them. We did add some negative tests and some of those are failing -- but seem to work with the existing library. ¯_(ツ)_/¯
I'll approve this and you can merge this or take a look at these failures as well. |
The merge-base changed after approval.
Two of three new negative scenarios in decentralized-identity/didwebvh-test-suite#4 (review comment 4558946830) were real bugs against this branch: * negative-pre-rotation-omit-updatekeys — when the previous entry committed nextKeyHashes, LogChainValidator only enforced the hash-match guard when the current entry actually set updateKeys. An entry that *omitted* the field inherited the old keys via Parameters.merge, and the old (possibly compromised) key kept signing — defeating pre-rotation entirely. Move the check into validateParameters and require entryDelta.updateKeys to be non-empty whenever prevActive.nextKeyHashes is non-empty. Surfaces as a parameter-validation failure (invalidParameters). * negative-portable-scid-swap — parameters.scid is the cryptographic anchor carried across the log; portability moves host/path only. LogChainValidator had no check that state.id's SCID segment matched parameters.scid after entry 0, so a portable migration could quietly swap the SCID while alsoKnownAs satisfied the host check. Add an explicit equality check against the third colon-delimited segment of state.id at every entry. Surfaces as invalidDid. The third scenario, negative-cross-did-witness-replay, is already correctly rejected by f13f750's pruned-proof filter: WitnessValidator.verifyProofs drops any witness proof whose versionId is not in this DID's published log, so a proof lifted from a sibling DID never counts toward the threshold. Added LogProcessorTest#crossDidWitnessReplayRejected to lock that in end-to-end (real DID-B → real signature → injected into DID-A's did-witness.json → resolver throws invalidDid), plus a WitnessValidatorTest unit covering the same shape. Refs: #4
Python and TS implementations serialise an empty `"witness": {}` object in
parameters when no witnesses are configured (and `"nextKeyHashes": []` /
`"watchers": []` similarly). Two interop bugs followed:
1. NPE on resolve: Gson instantiated WitnessConfig via Unsafe.allocateInstance,
skipping the constructor and leaving the internal list null. The first call
to isActive() / getWitnesses() then threw NPE on every Python/TS log.
2. SCID, entry hash, and proof verification failed against TS-produced logs
because Java re-serialised WitnessConfig as `{"threshold":0,"witnesses":[]}`
instead of `{}`, producing a different JCS canonical form.
Fix:
- Add a no-args constructor to WitnessConfig (Gson now uses
Constructor.newInstance, so field initialisers run).
- Add WitnessConfigTypeAdapter that emits `{}` when no witnesses are
configured and reads either form, matching the spec (§3.7.1) and the
Python/TS canonical representation.
This also clears two follow-on items in issue #2: the create-side
`nextKeyHashes: []` rejection (the existing check already accepts empty
lists) and the java-eecc `versionTime must be after previous entry` failures
(spec §535-536 mandates strict >, and the failures were a symptom of
validation short-circuiting on the NPE above).
Interop coverage vendored from swcurran/didwebvh-test-suite@c11fda3
under didwebvh-core/src/test/resources/interop/, exercised by:
- InteropEmptyWitnessObjectTest (basic-create/python)
- InteropTsBasicUpdateTest (basic-update/ts)
- InteropJavaEeccLogsTest (6 java-eecc vectors)
Refs: #2
Spec §3.7.5 (lines 1071-1082): while Key Pre-Rotation is active, each subsequent log entry's active updateKeys are the keys of the CURRENT entry, not the previous one. The previous entry's nextKeyHashes already pre-committed to those keys, so the rotation entry is self-signed by the newly-revealed key. The validator at LogChainValidator:96 unconditionally used the previous entry's updateKeys for i>0, causing "signing key not in active updateKeys at entry 2" against rust and java-eecc pre-rotation-consume vectors. DeactivateDidOperation made the same mistake when emitting the intermediate pre-rotation-consuming entry. Fix: - LogChainValidator: when prevNextKeyHashes is non-empty (pre-rotation active for this entry), authorize against the entry's own updateKeys. - DeactivateDidOperation: sign the intermediate "consume pre-rotation" entry with the nextRotationSigner rather than the previous signer. - Existing pre-rotation tests and the generator-produced pre-rotation-log.jsonl encoded the wrong (old-key-signs) convention; updated to the spec rules. Interop coverage: InteropPreRotationConsumeTest exercises the rust and java-eecc vectors from swcurran/didwebvh-test-suite@c11fda3 that previously failed with "signing key not in active updateKeys at entry 2". Refs: #2
Spec §3.7.8 (lines 1244-1247, 1286-1304): a witness's proof at versionId V implicitly approves every prior log entry, and DID Controllers SHOULD prune did-witness.json to keep only the latest proof per witness. Resolvers MUST accept pruned files and MUST ignore proofs whose versionId is not in the published log. WitnessValidator previously looked up an exact-versionId proof entry per log entry and rejected with "missing witness proof" when absent — so the Rust witness-update vector (single proof entry at versionId 2 covering both log entries) failed. It also reconstructed authorized witness ids as "did:key:<multikey>" from the proof's verificationMethod, which fails to match Rust's bare-multikey form in the witness-threshold vector. Rewrite to pre-verify every published proof once, then for each entry needing witnessing count distinct authorized witnesses whose verified proof targets a versionId at or after the entry's. Match authorized witness ids by their multikey portion so the spec form (did:key:z6Mk...) and the bare form (z6Mk...) both round-trip. Interop coverage: InteropRustWitnessProofsTest exercises the rust witness-update and witness-threshold vectors from swcurran/didwebvh-test-suite@c11fda3 that previously failed with "missing witness proof for entry 1-..." and "insufficient witness proofs ... need 1, got 0". Refs: #2
actions/checkout v4 → v5 actions/setup-java v4 → v5 codecov/codecov-action v4 → v5 softprops/action-gh-release v2 → v3 Node.js 20 is deprecated and will be removed from runners on September 16, 2026. Node.js 24 becomes the default June 2, 2026.
…n warning Transitive dependency actions/github-script@v7 (pinned by softprops/action-gh-release@v3) still targets Node 20. Setting FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true opts in early ahead of the June 2, 2026 enforcement deadline.
Two of three new negative scenarios in decentralized-identity/didwebvh-test-suite#4 (review comment 4558946830) were real bugs against this branch: * negative-pre-rotation-omit-updatekeys — when the previous entry committed nextKeyHashes, LogChainValidator only enforced the hash-match guard when the current entry actually set updateKeys. An entry that *omitted* the field inherited the old keys via Parameters.merge, and the old (possibly compromised) key kept signing — defeating pre-rotation entirely. Move the check into validateParameters and require entryDelta.updateKeys to be non-empty whenever prevActive.nextKeyHashes is non-empty. Surfaces as a parameter-validation failure (invalidParameters). * negative-portable-scid-swap — parameters.scid is the cryptographic anchor carried across the log; portability moves host/path only. LogChainValidator had no check that state.id's SCID segment matched parameters.scid after entry 0, so a portable migration could quietly swap the SCID while alsoKnownAs satisfied the host check. Add an explicit equality check against the third colon-delimited segment of state.id at every entry. Surfaces as invalidDid. The third scenario, negative-cross-did-witness-replay, is already correctly rejected by f13f750's pruned-proof filter: WitnessValidator.verifyProofs drops any witness proof whose versionId is not in this DID's published log, so a proof lifted from a sibling DID never counts toward the threshold. Added LogProcessorTest#crossDidWitnessReplayRejected to lock that in end-to-end (real DID-B → real signature → injected into DID-A's did-witness.json → resolver throws invalidDid), plus a WitnessValidatorTest unit covering the same shape. Refs: #4
b1cf2d7 to
7835839
Compare
|
Thanks @swcurran , I made the changes to fix the issues. |
|
Nice - I'll review, approve and merge. Thanks!! Nce work. |
|
Ran again and things look even better. Again, I'll approve, but leave to you to tell me to merge or deal with two more things -- one of which is pretty important and I didn't mention last time.
|
Two interop fixes flagged on PR #4 review: 1. `negative-cross-did-witness-replay` now resolves to `invalidDid`. Spec §3.7.5 (lines 884-889): when the `witness` parameter is set to `{}` while witnesses were active, the transition entry MUST itself be witnessed by the prior witnesses. `WitnessValidator` was merging the empty config in first and skipping the entry as inactive, which let an attacker disable witnessing and replay a stale (or cross-DID) proof for an earlier entry. Now the validator tracks the prior config and, on a witness-off transition, requires approval from the prior witnesses. 2. Resolved did:webvh DID Documents now include the implicit `#files` (relativeRef) and `#whois` (LinkedVerifiablePresentation) services required by spec §3.8 and §3.9, unless the controller has already declared services with the same id. The implicit-service logic was previously only applied when generating the parallel did:web document; extracted it into `ImplicitServices` and call it from `LogProcessor` so every resolution carries the services. `DidWebPublisher` now delegates to the same helper. Vendored the upstream `negative-cross-did-witness-replay/ts` fixture from swcurran/didwebvh-test-suite@c11fda3 and added JUnit coverage for both fixes.
Bump parent + module poms from 0.2.0-SNAPSHOT to 0.3.0-SNAPSHOT and
update README Maven/Gradle coordinates to 0.3.0 (also corrects the
groupId there from `io.github.decentralizedidentity` to the actual
`io.github.decentralized-identity`).
Reshape the CHANGELOG so current main maps to a single coherent
v0.2.0 entry: fold the previous standalone 0.2.1 namespace-migration
entry into 0.2.0 as a Changed bullet and re-date 0.2.0 to 2026-05-06.
Add a full 0.3.0 entry (2026-05-28) covering this branch's work:
- Cross-DID witness-proof replay rejected (spec §3.7.5 lines 884-889):
entries that disable witnessing while witnesses were active now
require approval from the prior witnesses.
- Implicit `#files` and `#whois` services emitted in resolved DID
Documents (spec §3.8/§3.9); shared `didweb.ImplicitServices`
helper reused by `DidWebPublisher`.
- `witness: {}` round-trip across Python/TS (NPE + JCS divergence).
- Pre-rotation entries authorized against the current entry's own
updateKeys when the previous entry committed `nextKeyHashes`;
`DeactivateDidOperation` signs its intermediate entry with the
rotation signer.
- Witness-proof pruning and bare-multikey witness ids accepted.
- Closed `negative-pre-rotation-omit-updatekeys` and
`negative-portable-scid-swap` gaps.
- Vendored interop fixtures from swcurran/didwebvh-test-suite and
added dedicated JUnit coverage for each.
- CI/release: GHA upgraded to Node.js 24-compatible versions and
base64-encoded OSSRH_TOKEN auto-decoded.
Refreshed the compare-link footer with v0.2.0 and v0.3.0 entries.
|
Hey @swcurran, Thanks for checking it and detailed comments. I fixed both issues and made sure no change to the previous test suites. |
swcurran
left a comment
There was a problem hiding this comment.
LGTM -- all negative tests now passing, and the implicit services are in the resolution results.
Summary
Addresses all failures reported in issue #2 ("Problems uncovered by the
did:webvh test suite") against
swcurran/didwebvh-test-suite@c11fda3.Five root-cause categories were investigated; three were genuine bugs, two
were symptoms of the first fix.
Fixes
1 — NPE and canonicalization mismatch on
witness: {}(Python/TS logs)Python and TS implementations always serialise an empty
"witness": {}objectwhen no witnesses are configured. Two interop bugs followed:
WitnessConfigviaUnsafe.allocateInstance, bypassing the constructor and leaving theinternal
witnesseslistnull. The first call toisActive()orgetWitnesses()threw NPE on every Python/TS log.WitnessConfigas{"threshold":0,"witnesses":[]}instead of{},producing a different JCS canonical form used to compute and verify hashes.
Fix: added a no-arg constructor to
WitnessConfig(so Gson usesConstructor.newInstanceinstead ofUnsafe) and aWitnessConfigTypeAdapterthat round-trips the empty-object form.
This also cleared two items in the issue table that were symptoms of this bug:
nextKeyHashes: []XFAIL (TS logs): the TS logs validate end-to-endonce canonicalization is correct; the existing create-side check already
accepts empty arrays.
versionTime must be after previous entryfailures (java-eecc logs):chain validation was aborting at the NPE before reaching the versionTime
check. All six java-eecc vectors now validate; the spec (§535–536) mandates
strict
>and the existing check is correct.2 — Pre-rotation entries authorized against wrong key set
Spec §3.7.5 defines two modes for the "active updateKeys" that must authorize
each log entry:
updateKeysupdateKeysLogChainValidatorunconditionally used the previous entry's keys fori > 0,causing "signing key not in active updateKeys at entry 2" against every
pre-rotation-consume log (rust and java-eecc).
DeactivateDidOperationmadethe same mistake when emitting its intermediate pre-rotation-consuming entry.
Fix: branch on whether the previous entry's
nextKeyHashesis non-empty;if so, authorize against the current entry's
updateKeys. UpdateDeactivateDidOperationto sign the intermediate entry withnextRotationSigner. Regeneratedpre-rotation-log.jsonlunder correct rules.3 — Witness proof pruning and bare-multikey witness ids (Rust logs)
Spec §3.7.8 states that a valid witness proof at versionId V implicitly
approves all prior log entries, and the DID Controller SHOULD prune
did-witness.jsonto retain only the latest proof per witness.WitnessValidatorrequired an exact-versionId proof per log entry and failedwith "missing witness proof" when the file was pruned. It also compared
authorized witness ids as
did:key:<multikey>against the Rustimplementation's bare-multikey form, yielding 0 authorized proofs.
Fix: rewrite
WitnessValidatorto pre-verify all published proofs once,then for each witnessed entry count distinct authorized witnesses whose verified
proof targets a versionId at or after the entry's version number. Match witness
ids by multikey portion to accept both
did:key:z6Mk…and barez6Mk…forms.Test coverage
Interop vectors vendored from
swcurran/didwebvh-test-suite@c11fda3intodidwebvh-core/src/test/resources/interop/(see
README.mdthere for the full table). New JUnit test classes:InteropEmptyWitnessObjectTestbasic-create/pythonInteropTsBasicUpdateTestbasic-update/tsInteropJavaEeccLogsTestbasic-update,deactivate,key-rotation,multi-update,services,witness-update(java-eecc)InteropPreRotationConsumeTestpre-rotation-consume/rust,/java-eeccInteropRustWitnessProofsTestwitness-update/rust,witness-threshold/rustFull repo
mvn verify: all tests pass.Closes #2