Skip to content

fix(core): resolve interop failures from did:webvh test suite (issue #2)#4

Merged
swcurran merged 9 commits into
mainfrom
fix/interop-test-suite-issues
May 28, 2026
Merged

fix(core): resolve interop failures from did:webvh test suite (issue #2)#4
swcurran merged 9 commits into
mainfrom
fix/interop-test-suite-issues

Conversation

@IVIR3zaM

Copy link
Copy Markdown
Collaborator

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": {} object
when no witnesses are configured. Two interop bugs followed:

  • NPE on resolve: Gson instantiated WitnessConfig via
    Unsafe.allocateInstance, bypassing the constructor and leaving the
    internal witnesses list null. The first call to isActive() or
    getWitnesses() threw NPE on every Python/TS log.
  • SCID, entry hash, and proof verification failed: Java re-serialised
    WitnessConfig as {"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 uses
Constructor.newInstance instead of Unsafe) and a WitnessConfigTypeAdapter
that 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-end
    once canonicalization is correct; the existing create-side check already
    accepts empty arrays.
  • versionTime must be after previous entry failures (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:

  • No pre-rotation: active = previous entry's updateKeys
  • Pre-rotation active: active = the current entry's own updateKeys

LogChainValidator unconditionally used the previous entry's keys for i > 0,
causing "signing key not in active updateKeys at entry 2" against every
pre-rotation-consume log (rust and java-eecc). DeactivateDidOperation made
the same mistake when emitting its intermediate pre-rotation-consuming entry.

Fix: branch on whether the previous entry's nextKeyHashes is non-empty;
if so, authorize against the current entry's updateKeys. Update
DeactivateDidOperation to sign the intermediate entry with
nextRotationSigner. Regenerated pre-rotation-log.jsonl under 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.json to retain only the latest proof per witness.

WitnessValidator required an exact-versionId proof per log entry and failed
with "missing witness proof" when the file was pruned. It also compared
authorized witness ids as did:key:<multikey> against the Rust
implementation's bare-multikey form, yielding 0 authorized proofs.

Fix: rewrite WitnessValidator to 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 bare z6Mk… forms.


Test coverage

Interop vectors vendored from swcurran/didwebvh-test-suite@c11fda3 into
didwebvh-core/src/test/resources/interop/
(see README.md there for the full table). New JUnit test classes:

Test class Vectors
InteropEmptyWitnessObjectTest basic-create/python
InteropTsBasicUpdateTest basic-update/ts
InteropJavaEeccLogsTest basic-update, deactivate, key-rotation, multi-update, services, witness-update (java-eecc)
InteropPreRotationConsumeTest pre-rotation-consume/rust, /java-eecc
InteropRustWitnessProofsTest witness-update/rust, witness-threshold/rust

Full repo mvn verify: all tests pass.


Closes #2

@swcurran

Copy link
Copy Markdown
Collaborator

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. ¯_(ツ)_/¯

Negative scenario Expected error Old library (0.2.0) PR branch (fix/interop-test-suite-issues)
negative-cross-did-witness-replay invalidDid ✅ PASS ❌ FAIL (resolver accepted invalid log)
negative-portable-scid-swap invalidDid ✅ PASS ❌ FAIL (resolver accepted invalid log)
negative-pre-rotation-omit-updatekeys invalidParameters ✅ PASS ❌ FAIL (resolver accepted invalid log)

I'll approve this and you can merge this or take a look at these failures as well.

swcurran
swcurran previously approved these changes May 27, 2026
@IVIR3zaM IVIR3zaM dismissed swcurran’s stale review May 27, 2026 21:50

The merge-base changed after approval.

IVIR3zaM added a commit that referenced this pull request May 28, 2026
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
IVIR3zaM added 7 commits May 28, 2026 10:14
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
@IVIR3zaM IVIR3zaM force-pushed the fix/interop-test-suite-issues branch from b1cf2d7 to 7835839 Compare May 28, 2026 09:11
@IVIR3zaM

Copy link
Copy Markdown
Collaborator Author

Thanks @swcurran , I made the changes to fix the issues.

@swcurran

Copy link
Copy Markdown
Collaborator

Nice - I'll review, approve and merge. Thanks!! Nce work.

@swcurran

Copy link
Copy Markdown
Collaborator

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.

  1. One remaining negative test is not passing -- negative-cross-did-witness-replay
  2. The library is not adding the implicit "/whois" and "/files" services into the resolved DIDs. This should always be done unless the services are already present in the resolved DID. So the "diff.txt" is showing that these are missing in all resolution tests. See below. As I recall, this was working before the move to DIF, so it's just something simple.
=== basic-create / python — resolutionResult.json ===
--- expected
+++ actual (java resolver)
     "controller": "did:webvh:QmXhVjFG6EBTosDastaaHMRypm2qSv4SMGctADsx878Yux:example.com",
     "id": "did:webvh:QmXhVjFG6EBTosDastaaHMRypm2qSv4SMGctADsx878Yux:example.com",
     "keyAgreement": [],
-    "service": [
-      {
-        "id": "did:webvh:QmXhVjFG6EBTosDastaaHMRypm2qSv4SMGctADsx878Yux:example.com#files",
-        "serviceEndpoint": "https://example.com/",
-        "type": "relativeRef"
-      },
-      {
-        "@context": "https://identity.foundation/linked-vp/contexts/v1",
-        "id": "did:webvh:QmXhVjFG6EBTosDastaaHMRypm2qSv4SMGctADsx878Yux:example.com#whois",
-        "serviceEndpoint": "https://example.com/whois.vp",
-        "type": "LinkedVerifiablePresentation"
-      }
-    ],
     "verificationMethod": [
       {
         "id": "did:webvh:QmXhVjFG6EBTosDastaaHMRypm2qSv4SMGctADsx878Yux:example.com#P5RDjVJG",
...
     "versionId": "1-QmWFwGhwjwRBMcQRzHHSQwpebk7iQ8CKd8rihrzbU1BGRt",
     "versionNumber": 1,
     "versionTime": "2000-01-01T00:00:00Z"
+  },
+  "didResolutionMetadata": {
+    "contentType": "application/did+ld+json"
   }
 }

swcurran
swcurran previously approved these changes May 28, 2026
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.
@IVIR3zaM

Copy link
Copy Markdown
Collaborator Author

Hey @swcurran,

Thanks for checking it and detailed comments. I fixed both issues and made sure no change to the previous test suites.
Also I made it now ready for the v0.3.0. However still I am waiting for the GPG issue to be resolved, then I would release both versions

@swcurran swcurran left a comment

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.

LGTM -- all negative tests now passing, and the implicit services are in the resolution results.

@swcurran swcurran merged commit c50db5b into main May 28, 2026
4 checks passed
@IVIR3zaM IVIR3zaM deleted the fix/interop-test-suite-issues branch May 29, 2026 09:28
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.

Problems uncovered by the did:webvh test suite

2 participants