Skip to content

Ticket 64/work - #129

Merged
wistefan merged 37 commits into
mainfrom
ticket-64/work
Sep 29, 2026
Merged

wistefan merged 37 commits into
mainfrom
ticket-64/work

Conversation

@wistefan

Copy link
Copy Markdown
Collaborator

No description provided.

general-agent-3 and others added 20 commits September 28, 2026 07:33
Plan covers 6 steps: extract multikey helper, decode publicKeyMultibase
in DID documents, implement Data Integrity proof verification core
(ecdsa-rdfc-2019, eddsa-rdfc-2022), integrate into proof dispatch chain,
add end-to-end tests, and optionally support JCS-based suites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix SHA-256/SHA-384 hashing: make intermediate hash curve-conditional
  (P-256/Ed25519 use SHA-256, P-384 uses SHA-384) per W3C VC-DI-ECDSA
  §3.3.3/§3.3.4, in algorithm steps, function spec, and JCS section
- Remove misleading "applies its own hash" note from ECDSA verification
- Specify IEEE P1363 byte layout for ECDSA proofValue (r||s, not DER)
  and note to use ecdsa.Verify, not ecdsa.VerifyASN1
- Rephrase "fail-open" to "deferred failure" in Step 2 publicKeyMultibase
  error handling description
- Extend assertProofOptionsCovered description to check both the
  cryptosuite IRI and its canonicalized object value
- Specify VCDM 2.0 context URI (https://www.w3.org/ns/credentials/v2)
  for Data Integrity proof options, with explicit note not to use
  data-integrity/v2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…t-64/plan into ticket-64/work

Reviewed-on: http://localhost:3001/general-agent-3/VCVerifier/pulls/12
Factor multicodec-to-JWK conversion out of did/did_key.go into a reusable
helper so both did:key resolution and publicKeyMultibase decoding in DID
documents can share the same code path.

- Create did/multikey.go with exported DecodeMultibaseKey and MulticodecToJWK
- Export multicodec prefix constants (Ed25519, P-256, P-384, secp256k1)
- Move decodeCompressedEC, type constants to multikey.go
- Update did_key.go to call MulticodecToJWK instead of local function
- Add comprehensive parameterized tests in did/multikey_test.go

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add DecodeMultibaseKeyWithType mid-level helper to eliminate
  duplicated multibase decode + varint read + length check between
  DecodeMultibaseKey and KeyVDR.Read
- Refactor KeyVDR.Read to use DecodeMultibaseKeyWithType instead of
  reimplementing the multibase/varint/multicodec sequence
- Rename "key data too short for multicodec" test to "key data too
  short for varint and key" for precision

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ifier' (#13) from ticket-64/step-1 into ticket-64/work

Reviewed-on: http://localhost:3001/general-agent-3/VCVerifier/pulls/13
Reviewed-by: wistefan <wistefan@dev-env.local>
Make parseVerificationMethod in did/did_web.go call DecodeMultibaseKey
when publicKeyMultibase is present and publicKeyJwk is absent, so that
Multikey verification methods (Ed25519, P-256, P-384) produce usable
JWKs via JSONWebKey(). This is the prerequisite for Data Integrity proof
verification — without it, credentials signed with DI proofs fail at key
resolution.

Includes comprehensive tests covering all three key types, priority of
publicKeyJwk over publicKeyMultibase, graceful handling of invalid
multibase values, verification relationships, and end-to-end key
resolution through the Registry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Clarify TestKeyResolverWithMultikeyVM docstring in did/ to explicitly
  state it simulates the resolution path due to import-cycle constraints
  and cross-references the real integration test
- Add TestResolveKeyFromDID_MultikeyVM in verifier/ exercising the actual
  ResolveKeyFromDID with Ed25519, P-256, and P-384 Multikey VMs
- Add TestResolveKeyForRelationship_MultikeyVM in verifier/ verifying
  verification-relationship enforcement for Multikey VMs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove redundant TestKeyResolverWithMultikeyVM test from did/ package:
  it only simulated key resolution (reimplementing verifier/key_resolver.go
  logic), which is already covered by TestParseDIDDocument_MultikeyVerificationMethods
  in did/ and by real integration tests TestResolveKeyFromDID_MultikeyVM and
  TestResolveKeyForRelationship_MultikeyVM in verifier/key_resolver_test.go
- Remove associated multikeyMockVDR and resolveKeyFromRegistry helper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ifier' (#14) from ticket-64/step-2 into ticket-64/work

Reviewed-on: http://localhost:3001/general-agent-3/VCVerifier/pulls/14
…dsa-rdfc-2022)

Add VerifyDataIntegrityProof for W3C Data Integrity proofs using
proofValue (multibase-encoded raw signature) instead of detached JWS.

Supported cryptosuites:
- ecdsa-rdfc-2019: ECDSA with P-256 (SHA-256) or P-384 (SHA-384)
- eddsa-rdfc-2022: EdDSA with Ed25519 (SHA-256)

Implementation details:
- New error sentinels for DI-specific failures (missing proofValue,
  malformed proofValue, unsupported cryptosuite, key mismatch)
- Curve-conditional hash computation (SHA-256 for P-256/Ed25519,
  SHA-384 for P-384)
- IEEE P1363 signature decoding (r||s) for ECDSA verification
- buildProofOptions extended to include cryptosuite when present
- assertProofOptionsCovered extended to verify cryptosuite IRI coverage
- EnsureDataIntegrityContext adds VCDM 2.0 context for DI proof terms
- ensureProofContext dispatches between JWS-2020 and VCDM 2.0 contexts

Comprehensive tests cover all three key types, tampered documents,
tampered proofValues, key-type mismatches, unsupported suites, missing
fields, invalid multibase, wrong proof types, and signature length
mismatches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix doc comments on p256CoordSize and p384CoordSize to match identifier
  names and reference the production p1363CoordinateSize map
- Add doc comment to copyMap noting it performs a shallow copy
- Add P-384 tampered-document test case to cover the SHA-384 path
- Replace local ed25519SignatureSize constant with ed25519.SignatureSize
  from the standard library

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…of verification core' (#15) from ticket-64/step-3 into ticket-64/work

Reviewed-on: http://localhost:3001/general-agent-3/VCVerifier/pulls/15
Wire DataIntegrityProof verification into the existing LDProofChecker
pipeline so both JsonWebSignature2020 and DataIntegrityProof proofs are
verified end-to-end through the same VerifyCredential/VerifyPresentation
API.

Changes:
- Add selectProofVerifier() dispatch function in ld_proof_checker.go
  that routes by proof type to the appropriate verification function
- Modify verifyLDProofWithCandidateKeys() to use the dispatch function
  instead of directly calling VerifyLinkedDataProof
- Unsupported proof types are rejected with ErrorLDProofUnsupportedType
- Add comprehensive integration tests covering:
  - Valid VC/VP verification with ecdsa-rdfc-2019 (P-256)
  - Valid VC/VP verification with eddsa-rdfc-2022 (Ed25519)
  - Multikey verification method type integration
  - Tampered document rejection
  - Issuer/holder mismatch rejection
  - Wrong proof purpose rejection
  - Unsupported cryptosuite rejection
  - Unsupported proof type rejection
  - Verification relationship enforcement
  - JWS and DI coexistence in same checker instance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Reuse testP256KeySize from ld_proof_checker_test.go instead of
  duplicating it as diP256CoordSize in the DI test file
- Replace bare type assertion with require.IsType for clearer failure
  messages when extracting JWS proof in coexistence test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ifier' (#16) from ticket-64/step-4 into ticket-64/work

Reviewed-on: http://localhost:3001/general-agent-3/VCVerifier/pulls/16
Add comprehensive e2e tests exercising the full VP parsing pipeline with
DataIntegrityProof-signed credentials and presentations:
- Full pipeline tests for ecdsa-rdfc-2019 and eddsa-rdfc-2022
- Mixed proof type scenarios (JWS VP + DI credential and vice versa)
- Multikey verification method type support
- Proof freshness check for DI proofs (parameterized: fresh, stale, disabled)
- Tampered credential rejection through DI proofs
- Unsigned credential rejection in DI-signed VPs
- Holder binding verification with DI proofs

Update docs/json-ld-proof-verification.md with a new section on Data Integrity
proof verification covering supported cryptosuites, algorithm details, Multikey
VM support, cross-checks, and integration with the proof pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix hash algorithm description in §1 to note SHA-384 for P-384 (was
  unconditionally SHA-256)
- Document suite-specific proof options context: JWS-2020 for
  JsonWebSignature2020, VCDM 2.0 for DataIntegrityProof
- Fix multicodec table to use decoded integer values matching the code
  constants (0x1200, 0x1201, 0xed, 0xe7) instead of wire-encoded varints
- Remove unsupported codecs (X25519, RSA) from the table
- Fix algorithm step 2 hash description to be curve-conditional

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tation' (#17) from ticket-64/step-5 into ticket-64/work

Reviewed-on: http://localhost:3001/general-agent-3/VCVerifier/pulls/17
@github-actions

Copy link
Copy Markdown

Please apply one of the following labels to the PR: patch, minor, or major. See CONTRIBUTING.md for details.

The section named error identifiers and functions that do not exist, and
described the proof-type dispatch as permissive where the code rejects:

- ErrorLDProofCurveMismatch -> ErrorLDProofCryptosuiteKeyMismatch for a
  key that does not match the cryptosuite, and
  ErrorLDProofVerifyDataIntegrity -> ErrorLDProofMalformedProofValue for a
  proofValue that does not decode. Both named errors belong to other paths.
- "dispatch is in LDProofChecker.verifyLDProof ... otherwise it falls
  through to VerifyLinkedDataProof" described neither the real function
  (selectProofVerifier) nor the real behaviour: an unrecognized proof type
  is rejected with ErrorLDProofUnsupportedType, not passed to the JWS
  verifier.
- The multicodec table listed secp256k1 (0xe7) among the codecs that are
  decoded into JWKs, and as OKP. MulticodecToJWK rejects it, and it is an
  EC curve.

Add a Limitations subsection recording what the implementation does not
do yet: proof options are rebuilt from a fixed field set rather than
copied from the proof (so expires/nonce/id/previousProof break
verification), created is required although the spec makes it optional,
the proof options @context is extended rather than copied, and the JCS,
selective-disclosure, proof-set and VCDM 1.1 cases are unsupported.
The Known Gaps list still claimed that proofValue-based cryptosuites are
"parsed but not verified", which stopped being true when
VerifyDataIntegrityProof landed. Replace that entry with what is actually
missing: proof options rebuilt from a fixed field set rather than copied
from the proof, the created requirement the spec does not impose, the
extended rather than copied proof-options context, and the suites that
remain unimplemented (JCS, selective disclosure, proof sets/chains, DI on
VCDM 1.1).

Record in the JSON-LD section what a reader now needs to know first: both
proof families are verified, dispatch is exhaustive, the hash is
curve-conditional, cryptosuite is covered by the signature, and
did/multikey.go is the reason key resolution reaches the signature check
at all for a Multikey verification method.
Every other Data Integrity test signs its fixtures with a helper that
mirrors the production hash-data construction, so the suite proves
self-consistency rather than conformance: the two hashes concatenated in
the wrong order, the second ECDSA hash omitted, SHA-256 used for P-384 or
the proof options canonicalized under the wrong context would all pass it.

Add the three published vectors - eddsa-rdfc-2022 (VC-DI-EDDSA B.1),
ecdsa-rdfc-2019 with P-256 (VC-DI-ECDSA A.1) and with P-384 (A.3) - which
the specification authors produced and this codebase did not. Each was
checked to fail when the hash-data concatenation is reversed.

The eddsa vector additionally asserts the two intermediate hashes it
publishes, so a canonicalization regression names the input that drifted
instead of only reporting a failed signature.

The keys are derived through did.DecodeMultibaseKey, so the vectors cover
the Multikey decoding path as well. The example context the vectors use is
served from memory; it is not security-relevant and stays out of
common/contexts.
P-384 was exercised only by the unit tests in common: the verifier-level
tests hardcoded P-256, so the combination a P-384 issuer actually produces
- Multikey verification methods in the DID document plus SHA-384 hashing
through the whole pipeline - was never verified end to end.

Generalize the ECDSA signing helper over the key's curve (coordinate size
and hash both follow from it, per VC-DI-ECDSA) instead of assuming P-256,
and make computeDITestHashData take the hash choice explicitly. Add a
P-384 case to the credential dispatch table and a P-384 pipeline test with
Multikey verification methods.

The freshness helper stays on P-256: those tests are about `created`, not
about the curve.
Follows the RELEASE_NOTES_*.md convention: what is now verified, the
Multikey prerequisite that had to land first, the dispatch behaviour, the
absence of configuration impact, and the suites and proof shapes that stay
out of scope.

States explicitly that this is an interoperability fix rather than a
security fix - the previous behaviour rejected DataIntegrityProof
credentials, it did not accept them unverified - since "parsed but not
verified" invited the opposite reading.
The proof configuration that is canonicalized and hashed was rebuilt from
the seven fields LDProof models. VC-DI-ECDSA 3.2.5 and the
JsonWebSignature2020 equivalent use a copy of the whole proof minus its
signature member, and the difference is not cosmetic: expires, nonce, id
and previousProof are all defined for a proof and all expand to real
triples, so the issuer signed over them. Reconstructing the options
without them produced a different canonical form and rejected a
conformant proof - fail-closed, but the interoperability failure this
codebase exists to avoid.

LDProof now keeps the proof as it was parsed, and
buildVerificationProofOptions copies it minus proofValue/jws. A proof
built in code rather than parsed carries no raw map and falls back to the
struct, which for it holds everything there is, so signing is unchanged.

The @context also follows the specification now: verbatim for
DataIntegrityProof (VC-DI-ECDSA 3.2.5 step 4), where
EnsureDataIntegrityContext used to append the VCDM 2.0 context. The two
coincide for a VCDM 2.0 document, which is why the published test vectors
passed either way; they no longer coincide for a document that does not
carry it, and there the appended context was the wrong one.
JsonWebSignature2020 keeps its suite context - that suite predates VCDM
2.0 and its terms are defined nowhere else.

The tests that matter are the tampering ones: carrying a member into the
proof options is only worth doing if changing it afterwards is detected.
VC-DATA-INTEGRITY 2.1 makes `created` OPTIONAL, and VC-DI-ECDSA 3.2.5
constrains it only "if proofConfig.created is set". VerifyDataIntegrityProof
required it, so a conformant credential proof without one was rejected
before its signature was ever checked.

Drop the requirement and check the format instead: a value that is present
but unparseable is now rejected with ErrorLDProofMalformedCreated rather
than carried along as an opaque string that nothing downstream can use.

Presentations keep their time bound. VerifyLDVPProofFreshness still
rejects a VP proof without `created` on the grants that have no
server-issued nonce, which is where replay protection depends on it;
credential proofs have validFrom/validUntil for that.

JsonWebSignature2020 is left alone: it predates VCDM 2.0, its proofs
always carry `created`, and loosening it buys nothing.
`expires` became part of the canonicalized proof configuration when
verification started building the options from the proof itself, so it is
now covered by the signature - but nothing acted on it. A proof that
declares when it stops being valid and is then honoured forever is worse
than one that never declared it.

LDProofChecker.assertProofNotExpired rejects an expired proof on both
entry points, credentials and presentations, tolerating the same clock
skew as the freshness check. A proof without `expires` never expires; a
value that cannot be parsed is rejected rather than ignored, since a proof
declaring an expiry nobody can read has not been shown to be valid.

The checker takes its time from a common.Clock so tests can fix it,
defaulting to the real one.

TestLDProofChecker_ExpiresCannotBeExtended is the test that makes the rest
worth having: rewriting `expires` in a captured document breaks the
signature instead of prolonging the proof.
VC-DI-ECDSA 3.2.2 and VC-DI-EDDSA 3.1.2 both take "the Multibase decoded
base58-btc value". multibase.Decode accepted any alphabet, so a
base64url-encoded signature verified here and would be rejected by a
conforming verifier - a non-conforming issuer looked interoperable
against this implementation alone.

Replace the unchecked canonDoc.(string) / canonProof.(string) assertions
with a helper that returns an error. They are safe for the N-Quads format
the code asks for, but a verification path should not be one assertion
away from a panic.

Raise the failed publicKeyMultibase decode in the DID document parser from
Debug to Warn. The failure is deliberately not fatal - one unusable
verification method must not cost the document its other keys - but it
otherwise only surfaced much later as a missing verification key for a
proof that named that method.

Note on VerificationMethod.Value that it keeps the original encoding while
jsonWebKey is what callers use, and on TypeMultikey that key resolution
deliberately does not branch on the verification method type.
The notes were written against the implementation as it stood and listed
three limitations that the following commits removed: proof options
rebuilt from a fixed field set, a mandatory created, and an extended
rather than copied proof options context. Replace them with what the
release actually does - proof members beyond the modelled ones are
covered and expires is enforced, created is optional, proofValue is
pinned to base58-btc.
Add ecdsa-jcs-2019 and eddsa-jcs-2022. They are the same signatures over
the same hash data as their -rdfc- counterparts; only the
canonicalization differs, so a cryptosuite identifier now selects a
canonicalization and an algorithm independently (dataIntegritySuites)
rather than being switched on by name in three places.

common/jcs.go implements RFC 8785: ECMAScript number serialization, the
five predefined string escapes with lowercase \uhhhh for the remaining
control characters, and property names sorted by UTF-16 code units.
Sorting the UTF-8 bytes instead would agree for everything below U+FFFF
and disagree above it, which is exactly what the RFC's sorting vector is
built from. NaN, the infinities and invalid Unicode terminate
canonicalization rather than being serialized as something.

Two differences from the RDFC path are worth stating:

A JCS proof configuration is the proof verbatim, including the @context
the proof itself carries - a conforming issuer copies the document's
context into the proof before signing (VC-DI-ECDSA 3.3.5). Nothing is
substituted for it, and rewriting it breaks the signature.

assertProofOptionsCovered does not apply. It exists because JSON-LD
expansion silently drops terms the context does not define, which would
leave challenge and domain outside the signature; JCS has no expansion
step and drops nothing, so every member is covered by construction.

Verified against the published W3C vectors for all three JCS
suite/curve combinations, and against the RFC 8785 test data for the
canonicalizer itself - including the appendix B number samples, where
Go's formatting and ECMAScript's disagree on the exponential thresholds
and on zero-padded exponents.
@github-actions

Copy link
Copy Markdown

Please apply one of the following labels to the PR: patch, minor, or major. See CONTRIBUTING.md for details.

@wistefan wistefan added the minor Should be applied for new functionality or bigger updates. label Sep 28, 2026
@wistefan
wistefan marked this pull request as ready for review September 28, 2026 14:20
@wistefan
wistefan requested a review from vramperez September 28, 2026 14:20
golangci-lint reported six issues; its max-same-issues default of 3 hid
nine more of the same kind, so this fixes fifteen sites.

QF1012: build the \uhhhh escape for a control character from its two hex
digits instead of WriteString(fmt.Sprintf(...)). Every character that
needs the escape is below U+0020, so the two high digits are constant.

QF1008 (12 sites): drop the embedded PublicKey from privKey.PublicKey.X
and .Y.

SA1019 (2 sites): elliptic.Marshal and elliptic.Curve.IsOnCurve are
deprecated. crypto/ecdh's PublicKey.Bytes() is the same uncompressed
SEC 1 encoding, and ecdsa.PublicKey.ECDH() performs the same on-curve
check - NewPublicKey rejects a point that is not on the curve - so the
test still asserts what it asserted before.

Verified with staticcheck -checks=all: no QF or SA findings remain. The
ST (naming) findings it also reports are pre-existing across the
repository and are not enabled in the CI configuration.

@vramperez vramperez 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.

A few comments on the Data Integrity support: one spec deviation on the JCS path, one hardening suggestion, and some small doc/cleanup items.

Comment thread common/ldproof.go
Comment thread common/ldproof.go
Comment thread did/did_web.go Outdated
Comment thread common/jcs.go Outdated
Comment thread verifier/ld_proof_checker.go Outdated
Comment thread CLAUDE.md Outdated
VC-DI-ECDSA 3.3.2 step 4, and identically VC-DI-EDDSA 3.3.2, make an
@context carried by a JCS proof configuration govern the document as
well: the presented document's context must start with every entry of
the proof's, in the same order, and the document is canonicalized under
the proof's context rather than its own.

Neither half was implemented. The document was hashed under whatever
context it presented, so a credential whose context was extended after
issuance - which the specification explicitly permits - failed with a
signature mismatch, and the context the proof carried bound nothing
about the document it secures.

The divergence was invisible against the published test vectors, where
the two contexts are equal by construction.

The prefix rule is what bounds the rebinding: entries may be appended,
never reordered or replaced, so the base context entry that
DetectVCDataModelVersion reads cannot be swapped out.
LDProofChecker.assertProofNotExpired rejects a proof past its expires
timestamp, and both its comment and CLAUDE.md justified that by the
timestamp being signed. assertProofOptionsCovered - the guard that
turns exactly that assumption into a hard failure for created,
challenge, domain and cryptosuite - did not list expires.

Under a context that does not define the term the expiry expands to
nothing, drops out of the canonical proof options and is enforced
without ever having been signed. Add it to the list, matched
verbatim the way created is: HasNQuad strips the xsd:dateTime
datatype, so a proof cannot be covered for an expiry other than the
one it presents.
Decoding a publicKeyMultibase in a DID document warned for every
failure, including the one that is not a failure of the document: a
verification method whose key type cannot verify signatures at all.
X25519 key agreement keys are published routinely next to signing keys,
so the warning fired on every resolution of a perfectly valid document
and trained operators to ignore the line that matters.

MulticodecToJWK now wraps ErrorUnsupportedMulticodec, which did_web.go
logs at Debug. Warn stays for an actual decoding failure, where the
document is malformed and the key silently missing later.

did:key keeps failing hard on both: there the multibase value is the
identifier, so an undecodable one leaves no key at all.
The branch wrote the code point and continued, in front of a default
case that writes the code point: the distinction it documents - range
yields RuneError for invalid encoding as well as for a literal U+FFFD -
is real, but ValidString has already ruled out the first case, so the
comment belongs on the default and the branch does not need to exist.

ErrorJCSInvalidString read as though it guarded Data Integrity
verification against lone surrogates. It does not: every string on that
path comes through encoding/json, which substitutes U+FFFD first. The
check is still right for the exported CanonicalizeJSON, which takes Go
values directly - say so rather than leaving it to be read as a control
it is not.

Both behaviours are now pinned by tests, so the deleted branch cannot
come back as a silent behaviour change.
The JCS suites landed without the prose catching up. LDProofChecker
still named only the two rdfc suites, and three places narrowed the
SHA-384 rule to ecdsa-rdfc-2019.

The code was right on both counts: selectProofVerifier dispatches on
the suite map, and shouldUseSHA384 branches on the suite's signature
algorithm rather than its name, so a P-384 key gets SHA-384 under
ecdsa-jcs-2019 too - which VC-DI-ECDSA 3.3.4 requires and the published
P-384 JCS vector already pins.
@vramperez
vramperez self-requested a review September 29, 2026 06:40
@wistefan
wistefan merged commit ba145c2 into main Sep 29, 2026
17 checks passed
@wistefan
wistefan deleted the ticket-64/work branch September 29, 2026 06:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

minor Should be applied for new functionality or bigger updates.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants