Skip to content

Latest commit

 

History

History
460 lines (342 loc) · 23 KB

File metadata and controls

460 lines (342 loc) · 23 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Overview

This repo provides language-agnostic compliance test vectors for the did:webvh specification. It targets v1.0 of the spec.

The approach is a multi-implementation cross-resolution pattern:

  1. Human-readable YAML scripts in vectors/<scenario>/script.yaml describe the intent of each test scenario.
  2. Each implementation reads the scripts and produces its own signed artifacts — committed under vectors/<scenario>/<impl>/.
  3. Each implementation generates its DID logs and resolves every other implementation's committed logs, then writes implementations/<impl>/status.md (and diffs.txt) showing both DID Creation results and cross-resolution results.
  4. Divergences surface as differences in status.md across implementations — these are candidates for Working Group discussion and spec clarification.

There is no single "reference" implementation. The spec and Working Group are the arbiter of correctness.

Repository Structure

vectors/                        # Committed artifacts, organized by scenario then implementation
  <scenario-name>/
    script.yaml                 # YAML DSL script describing the scenario
    ts/
      did.jsonl                 # DID log generated by the TS implementation
      resolutionResult.json     # resolution result at HEAD
      resolutionResult.<n>.json # resolution result at version N (optional)
      did-witness.json          # witness proofs (optional)
    python/
      did.jsonl
      resolutionResult*.json
    rust/
      did.jsonl
      resolutionResult*.json
    java/
      did.jsonl
      resolutionResult*.json
    java-eecc/
      did.jsonl
      resolutionResult*.json
implementations/
  ts/
    Dockerfile                  # Docker build for the TS harness
    docker-entrypoint.sh        # (entrypoint baked into image, not this file)
    src/                        # TypeScript generator + resolver
      generator.ts              # reads vectors/<scenario>/script.yaml, writes vectors/<scenario>/ts/
      cryptography.ts
      interfaces.ts
    status.md                   # generated, do not edit by hand
    diffs.txt                   # diff details for any DIFF results
    config.yaml                 # implementation metadata (home_repo, version, commit)
  python/
    Dockerfile
    docker-entrypoint.sh
    generate.py                 # generates vectors/<scenario>/python/ (skips negative scenarios)
    test_vectors.py             # pytest: resolves all impl logs with Python resolver
    test_generate.py            # pytest: verifies Python generates same log as TS
    status.md
    diffs.txt
    config.yaml
  rust/
    Dockerfile
    docker-entrypoint.sh
    src/main.rs                 # resolves all impl logs, writes implementations/rust/status.md
    src/generate.rs             # generates vectors/<scenario>/rust/
    status.md
    diffs.txt
    config.yaml
  java/
    Dockerfile
    docker-entrypoint.sh
    src/.../GenerateVectors.java  # reads scripts, writes vectors/<scenario>/java/
    src/.../TestVectors.java      # resolves all impl logs, writes implementations/java/status.md
    status.md
    diffs.txt
    config.yaml
  java-eecc/
    Dockerfile
    docker-entrypoint.sh
    src/.../GenerateVectors.java
    src/.../TestVectors.java
    status.md
    diffs.txt
    config.yaml
scripts/
  run       # build + run a single implementation via Docker
  run-all   # two-pass run of all implementations
package.json

Commands

Docker (primary — requires Docker, no other toolchain needed)

# Run all implementations (two-pass: generate then cross-resolve with fresh vectors)
scripts/run-all

# Run a subset
scripts/run-all ts python

# Run a single implementation at main branch of its library
scripts/run ts
scripts/run python
scripts/run rust
scripts/run java
scripts/run java-eecc

# Run against a PR branch (using default repo)
scripts/run ts feat/my-pr

# Run against a fork + branch
scripts/run ts https://github.com/me/didwebvh-ts:feat/my-pr

Each scripts/run call builds (or rebuilds from cache) a Docker image, runs generate + cross-resolution, and appends a provenance footer to status.md with the exact library commit used.

Direct (without Docker, for local development)

# TS — install dependencies (corepack activates the pinned pnpm version)
corepack enable
pnpm install

# Regenerate all TS vectors from scripts + run TS cross-resolution (writes ts/status.md)
pnpm run generate

# Regenerate a single TS vector + its cross-resolution status (by scenario name)
pnpm run generate basic-create

# Verify committed TS vectors match what the generator would produce (CI check)
pnpm run verify

# Regenerate all negative test vector artifacts (ts/ only)
pnpm run generate-negative

# Python — generate vectors (skips negative scenarios automatically)
cd implementations/python && source .venv/bin/activate && python generate.py

# Python — cross-resolution + negative tests
cd implementations/python && source .venv/bin/activate && pytest test_vectors.py

# Rust — generate vectors
cd implementations/rust && cargo run --bin generate-vectors

# Rust — cross-resolution + negative tests (writes rust/status.md)
cd implementations/rust && cargo run --bin test-vectors

# Java — generate vectors
cd implementations/java && mvn compile exec:java@generate-vectors

# Java — cross-resolution + negative tests (writes java/status.md)
cd implementations/java && mvn compile exec:java

# Java-EECC — generate vectors
cd implementations/java-eecc && mvn compile exec:java@generate-vectors

# Java-EECC — cross-resolution + negative tests
cd implementations/java-eecc && mvn compile exec:java

Maintenance: Testing Whether XFAILs Are Fixed

Shortcut prompt

"Test if any XFAILs are fixed for the <impl> implementation"

Use <impl> = ts, python, rust, java, or java-eecc.

What this prompt means

Each harness contains guards — pre-flight checks that skip or mark a test as XFAIL before the resolver runs. They exist because a known library bug or spec incompatibility would otherwise cause a confusing FAIL. When a library is upgraded, some guards may no longer be needed.

When you give Claude this prompt it will:

  1. Identify all active XFAIL guards for that implementation (see locations below).
  2. Temporarily comment them out one at a time (or all at once if independent).
  3. Re-run the harness with scripts/run <impl> and read the updated status.md.
    • The harness source (where guards live) is mounted from the host, so guard edits take effect immediately — no Docker rebuild needed.
    • If a new library version is what's expected to fix the XFAIL, first run scripts/run <impl> <branch> to build with the new library, then re-edit the guard and run again.
  4. Report which XFAILs became PASS (guard can be permanently deleted) and which became FAIL or DIFF (library still broken — guard must stay).
  5. Apply whichever permanent changes you approve.

Where guards live per implementation

Implementation Guard type Location
Java Pre-flight functions (logHasEmptyNextKeyHashes, logHasEmptyWitness) + runtime pattern list (isKnownTsCompatError) implementations/java/src/main/java/org/didwebvh/compliance/TestVectors.java
Java EECC Same pattern as Java implementations/java-eecc/src/main/java/org/didwebvh/compliance/TestVectors.java
Python Pre-flight functions (e.g. _log_has_empty_next_key_hashes) called with pytest.xfail() implementations/python/test_vectors.py
Rust Pre-flight pattern list checked before resolution implementations/rust/src/main.rs
TS Pattern list in runNegativeResolutionTest / cross-resolution loop implementations/ts/src/generator.ts

Important nuance for Python

Python's pytest.xfail() is unconditional — it checks log content, not resolver behaviour. Even after a Python library fix, the guard will still fire if the TS log still contains the offending content (e.g. nextKeyHashes: []). The correct action when a Python fix lands is to delete the guard entirely rather than just commenting it out, then rerun to confirm PASS.

Upgrading the library first

With Docker (preferred): just pass the branch to scripts/run — the image always clones fresh from the specified branch, so there is nothing to manually update:

scripts/run python https://github.com/decentralized-identity/didwebvh-py:my-fix-branch

Without Docker: update the library version in the implementation's build file and config.yaml, then recompile/rebuild before running the XFAIL test:

Implementation Build file Key field
Java implementations/java/pom.xml <version> under didwebvh-core dependency
Java EECC implementations/java-eecc/pom.xml <version> under webvh dependency
Python implementations/python/ pip install --upgrade did-webvh then update config.yaml
Rust implementations/rust/Cargo.toml add rev = "<commit>" to pin, or remove to track HEAD
TS package.json version of didwebvh-ts

TS-specific: the test-suite's own harness (this repo) runs on Node.js + pnpm + tsx, aligned with didwebvh-ts PR #156 (merged 2026-07-14), which moved the library itself off bun onto the same toolchain (packageManager: pnpm@11.10.0, requires Node.js ≥22.13; corepack fetches the pinned pnpm version automatically). If package.json's didwebvh-ts dependency points at a local checkout (file:../didwebvh-ts), that checkout must be rebuilt before new library code takes effect — installing in the test suite does not rebuild a file: dependency. Rebuild with:

cd ../didwebvh-ts   # or wherever the local checkout lives
corepack enable
pnpm install --frozen-lockfile
pnpm run build

then relink and regenerate in this repo:

rm -rf node_modules/didwebvh-ts && pnpm install
pnpm run generate && pnpm run verify

Older commits/branches of didwebvh-ts (before PR #156) still build with bun install && bun run build — check for pnpm-lock.yaml vs bun.lock in the checkout to tell which toolchain applies. The Docker path (implementations/ts/Dockerfile) detects this automatically (it keeps a bun install available purely as a fallback for building those older library commits/branches) and needs no manual intervention either way.

Implementation config.yaml

Each implementation directory contains a config.yaml with:

name: ts
home_repo: https://github.com/decentralized-identity/didwebvh-ts
version: "1.0.0"   # version/tag of the implementation being tested
commit: "abc1234"  # commit hash (optional)

This metadata is stamped into the generated status.md files so a PR tells you exactly what version produced the artifacts.

DSL Script Format

Each script is a YAML file. Example:

description: "Create a DID and resolve at genesis"
spec_ref: "https://identity.foundation/didwebvh/v1.0/#creating-a-did"

keys:
  - id: key-0
    type: ed25519
    # 32-byte hex seed — deterministic, never random
    seed: "0000000000000000000000000000000000000000000000000000000000000001"

steps:
  - op: create
    domain: example.com
    signer: key-0
    params:
      updateKeys: ["key-0"]

  - op: resolve
    # omitting 'versionId' means resolve at HEAD
    expect: resolutionResult.json

Supported op values

op description
create Create the DID log (first entry)
update Append an update entry
deactivate Append a deactivation entry
resolve Assert resolution output matches the named expect file

Supported params (on create / update)

These map directly to did:webvh DID log entry parameters:

param type notes
updateKeys string[] multikey identifiers of keys authorised for future updates
nextKeyHashes string[] pre-rotated key hashes (pre-rotation)
witness object {threshold, witnesses:[{id,weight}]}
portable boolean whether the DID is portable
context string[] additional @context entries
alsoKnownAs string[] alsoKnownAs entries
services object[] service endpoint entries
verificationMethods object[] additional verification methods

Key types supported

  • ed25519 — seeded with a 32-byte hex seed

Additional key types (e.g. mldsa44) may be added later as experimental extensions.

Artifact Format

did.jsonl

A newline-delimited JSON file. Each line is a complete DID log entry as defined by the did:webvh v1.0 spec. The file is the minimal valid input to any conforming resolver.

resolutionResult.json

Follows the DID Resolution response envelope:

{
  "didDocument": { ... },
  "didDocumentMetadata": { ... },
  "didResolutionMetadata": { "contentType": "application/did+ld+json" }
}

Adding a New Test Vector

  1. Create vectors/<scenario-name>/script.yaml following the DSL format above.
  2. Run pnpm run generate <scenario-name>.
  3. Inspect the generated files in vectors/<scenario-name>/ts/.
  4. Commit both the script and the generated artifacts together.

Scenario Coverage

All happy-path scenarios listed below are implemented and committed to vectors/.

Scenario Description
basic-create Minimal create + resolve at HEAD
basic-update Create + single update + resolve
key-rotation Create + update rotating the update key
pre-rotation Create with nextKeyHashes commitment
pre-rotation-consume Create with nextKeyHashes; update signed by the pre-rotated key
deactivate Create + deactivate + resolve (deactivated state)
portable Create with portable: true
portable-move Create portable DID; update migrating to a new domain
witness-threshold Create with a witness list; witness proof provided on resolve
witness-update Create with 2-of-2 witness config; update reducing to 1-of-1
multi-update Create + two updates; resolve at v1, v2, and HEAD
multiple-update-keys Create with two updateKeys; update signed by the second key
services Create with service endpoints; update adding a second service

Negative Test Vectors

16 negative scenarios are implemented in vectors/negative-*/. Each has a negative: true flag in its script.yaml. Artifacts are generated by implementations/ts/src/negative-generator.ts (run via pnpm run generate-negative) and committed under vectors/negative-<scenario>/ts/. The resolutionResult.json for negative cases expresses the expected error:

{ "didDocument": null, "didDocumentMetadata": {}, "didResolutionMetadata": { "error": "invalidDid" } }

All five harnesses resolve negative vectors and report results in their status.md under ## Negative Resolution. PASS = resolver correctly rejected the invalid log; FAIL = resolver accepted it (conformance bug); SKIP = URL-only test (empty did.jsonl).

Five scenarios are URL-only (empty did.jsonl) — they test DID URL parsing, not log content. Each harness resolves these by calling its library's URL parser directly on the DID string from the script's resolve-did op, rather than running the log resolver:

Harness URL parse call
Rust WebVHURL::parse_did_url(did)
Java DidWebVhUrl.parse(did)
Java EECC DidUrlTransformer.toLogUrl(did)
Python resolve_did(did, local_history=log) with empty log
TS resolveDID(did) with fetch intercepted

How Implementations Participate

Each implementation lives under implementations/<lang>/ with its own harness code and a config.yaml pointing to its home repo and version. When an implementer runs their harness:

  1. It generates DID logs for each happy-path scenario (from vectors/<scenario>/script.yaml) and writes them to vectors/<scenario>/<lang>/.
  2. It resolves every other implementation's committed did.jsonl files and records the outcome.
  3. It resolves every negative test vector's ts/did.jsonl (or tests the DID URL for URL-only vectors) and records PASS/FAIL.
  4. It writes implementations/<lang>/status.md with three sections: DID Creation, Negative Resolution, and Cross-Resolution.
  5. The implementer opens a PR to commit their latest artifacts and status files.

A GitHub Action on merge aggregates all status.md files into a top-level summary matrix.

Divergences are expected and valuable. When two implementations disagree, the diff is surfaced in status.md and becomes a Working Group discussion item about the spec — there is no single reference implementation that auto-wins.

Canonical Comparison via JCS

Both sides of each comparison are serialized through a JSON Canonicalization Scheme (JCS) library before comparing. This eliminates false failures caused by differences in key ordering or whitespace that are semantically irrelevant.

  • TypeScript: implemented — uses json-canonicalize (already a dep; canonicalize was already imported for hashing)
  • Python: implemented — uses jsoncanon (already a transitive dep of did-webvh; implements RFC 8785)
  • Rust: implemented — uses serde_jcs (serde_jcs::to_vec)

The comparison pattern in each harness should be:

assert jcs(actual_resolution_result) == jcs(expected_resolution_result)

This applies to the full resolution result object, including didDocument, didDocumentMetadata, and didResolutionMetadata.

Future Work

Explicit DID Document Input

TODO: Scripts should support referencing a fully-specified DID Document file rather than relying solely on the generated-from-params template. The motivation is to verify that a did:webvh implementation faithfully preserves whatever the DID Controller places in the DIDDoc — it must not silently reorder, strip, or transform controller-supplied content.

Proposed DSL addition on create / update steps:

- op: create
  domain: example.com
  signer: key-0
  didDoc: diddoc-fixture.json   # path relative to the script file
  params:
    updateKeys: ["key-0"]       # did:webvh envelope params still applied as normal

When didDoc is present the generator uses it as the DID Document body instead of constructing one from params. The did:webvh-required fields (e.g. id, @context) are merged or overridden from the fixture so the log entry is still spec-compliant, but any additional controller-supplied content is carried through verbatim.

The corresponding resolutionResult.json will then contain that exact content, and a conforming implementation must return it unchanged. This gives test coverage of the pass-through guarantee: the protocol layer must not alter DID Controller content.

Implementation notes (TS generator)

This was prototyped and verified working. Key findings:

  • The didwebvh-ts library's createDID builds the DIDDoc internally via createDIDDoc — there is no API parameter to inject a pre-built doc. The generator must bypass createDID entirely for this case.
  • The correct approach is a createDIDWithCustomDoc helper in the generator that mirrors the library's log-entry construction:
    1. Build initialLogEntry with state: customDoc (file contents, with {SCID} as placeholder throughout)
    2. scid = deriveHashSync(initialLogEntry) — the existing deriveHashSync in the generator produces the correct value; createSCID in the library is a no-op (returns the hash unchanged)
    3. prelimEntry = JSON.parse(JSON.stringify(initialLogEntry).replaceAll('{SCID}', scid)) — substitutes SCID everywhere in the DIDDoc and parameters
    4. versionId = "1-" + deriveHashSync(prelimEntry) — compute final entry hash
    5. Sign with Ed25519Signer; proof verificationMethod will be did:key:<pubkey>#<pubkey> (the update-signer key, independent of what's in the DIDDoc body)
  • The params block (updateKeys, nextKeyHashes, witness, portable, etc.) still comes from the script's params: field and is applied as normal — only the DIDDoc state comes from the file.
  • Services appearing in resolved DIDDocs (e.g. #files, #whois) are spec-correct implicit services added by the resolver per the did:webvh spec, not by the generator. They will appear in resolutionResult.json consistently and are not a bug.
  • The diddoc: field name (lowercase, matching YAML conventions) is preferred over didDoc: for consistency with the rest of the DSL.
  • update steps with a custom DIDDoc require a different substitution strategy (SCID is already known from the log), and were not prototyped. Implement separately.

Scenario to add once this is implemented:

  • custom-diddoc — create with an explicit DIDDoc fixture containing extra service endpoints, verification methods, and non-standard properties; resolve and assert the document is returned unmodified.

Additional Negative Test Vectors

The 17 current negative scenarios cover a broad set of violations but candidates for future addition include:

Scenario Violated rule
migrate-non-portable Domain change without portable: true
update-after-deactivate Any update after a deactivation entry
double-deactivate Second deactivation entry on an already-deactivated DID
pre-rotation-wrong-key Update whose signing key hash does not appear in the previous entry's nextKeyHashes

The negative generator (implementations/ts/src/negative-generator.ts) already supports the DSL ops needed for most of these: create, update, migrate, corrupt, sign-witness-proof, and resolve-did. The PermissiveVerifier bypass is already in place for steps that the library's own validation would otherwise block.

Cross-Language Error Vectors

implementations/python/test-suite/ contains hand-authored invalid logs (invalid-json, invalid-method, invalid-scid, missing-proof, missing-witness) and a resolver-suite.json test manifest. These are currently consumed only by the Python harness (test_resolver_suite.py). The vectors/negative-*/ approach supersedes this for new work — the old suite is not a priority to migrate.

Generator Design Notes

  • Keys are always derived deterministically from the seed field — the generator never calls crypto.getRandomValues(). This guarantees that running the generator twice on the same script produces bit-for-bit identical artifacts.
  • The generator imports the same createDID / updateDID / deactivateDID / resolveDID functions from the didwebvh-ts library (or a pinned version of it). It is intentionally thin — it only handles key management and script parsing; all DID logic lives in the library.
  • Because artifacts are committed, a PR that changes the generator or the library and also changes the committed artifacts is self-documenting: the diff shows exactly what changed in the generated output.