Skip to content

Implement W3C ecdsa-sd-2023 disclosure proof derivation and verification#5

Merged
brody-0125 merged 4 commits intomainfrom
claude/fix-hmac-key-management-O1aS8
Apr 16, 2026
Merged

Implement W3C ecdsa-sd-2023 disclosure proof derivation and verification#5
brody-0125 merged 4 commits intomainfrom
claude/fix-hmac-key-management-O1aS8

Conversation

@brody-0125
Copy link
Copy Markdown
Owner

Summary

Implements the holder-side and verifier-side logic for W3C ecdsa-sd-2023 selective disclosure, enabling credential holders to derive disclosure proofs from base proofs without exposing the issuer's HMAC key, and enabling verifiers to validate those derived proofs.

Key Changes

  • SelectiveDisclosure.deriveProof(): Derives a disclosure proof from a base proof by:

    • Re-canonicalizing the credential to reproduce the exact quad list the issuer signed
    • Building a labelMap that maps canonical blank-node labels to their HMAC-derived equivalents
    • Stripping the HMAC key from the proof (critical security property)
    • Encoding the result as CBOR tag 0xd95d01 with base signature, public key, per-quad signatures, label map, and mandatory indexes
  • SelectiveDisclosure.verifyDerivedProof(): Verifies a derived proof by:

    • Canonicalizing the revealed document and rewriting labels via the labelMap
    • Reconstructing the signed canonical form without needing the HMAC key
    • Splitting quads into mandatory (verified via base signature) and non-mandatory (individually verified)
    • Validating the base signature over SHA256(proofConfig) || SHA256(mandatoryQuads)
    • Validating each non-mandatory quad's individual ECDSA P-256 signature
  • CborDecoder (new): Minimal CBOR decoder supporting the subset of types needed for ecdsa-sd-2023:

    • Decodes base proof values (tag 0xd95d02)
    • Decodes derived proof values (tag 0xd95d01)
    • Supports unsigned integers, byte strings, text strings, arrays, maps, and 2-byte CBOR tags
  • CborEncoder.encodeDerivedProofValue(): Encodes derived proof values as CBOR, intentionally omitting the HMAC key to prevent verifiers from reversing blank-node masking

  • Helper methods:

    • decompressP256PublicKey(): Decompresses SEC1 33-byte compressed P-256 public keys
    • buildLabelMap(): Maps canonical labels to HMAC-derived labels
    • applyLabelMap(): Rewrites N-Quads using the label map
    • verifyEcdsaP256(): Validates ECDSA P-256 signatures with fallback for different JCA implementations
  • Comprehensive test suite (SelectiveDisclosureTest): Validates:

    • Derived proof structure and CBOR tag correctness
    • HMAC key is stripped from derived proofs (security guarantee)
    • Round-trip derivation and verification succeeds
    • Verification fails on tampered documents or corrupted signatures
    • Derivation is deterministic for fixed inputs

Notable Implementation Details

  • The HMAC key is intentionally excluded from derived proofs — including it would allow any verifier to brute-force the small canonical-label space and recover undisclosed claims via dictionary attacks
  • Verification never requires the HMAC key, enabling true selective disclosure
  • The labelMap enables verifiers to reconstruct the exact canonical form the issuer signed without holding the HMAC key
  • Mandatory vs. non-mandatory quad split is preserved via mandatoryIndexes to distinguish base-signature-covered quads from individually-signed quads
  • Initial implementation reveals the entire document; selective subsetting at presentation time is deferred to a follow-up

https://claude.ai/code/session_01Gr5Er7AoKJj7UH3aeJat4L

claude added 4 commits April 15, 2026 09:42
Adds the missing W3C ecdsa-sd-2023 disclosure-proof derivation and
verification path. Previously only createBaseProof was implemented, so
the issuer's HMAC key was embedded in every proofValue presented to
verifiers — letting any verifier brute-force the small canonical-label
space (_:c14n0, _:c14n1, ...) to reverse blank-node masking and
dictionary-attack per-quad signatures to recover undisclosed claims.

- SelectiveDisclosure.deriveProof: holder-side step that decodes the
  base proof, re-canonicalizes with the issuer's HMAC, and emits a
  derived proof (CBOR tag 0xd95d01) carrying baseSignature, publicKey,
  per-quad signatures, a c14n→HMAC labelMap, and mandatoryIndexes —
  but NOT the HMAC key itself.
- SelectiveDisclosure.verifyDerivedProof: verifier-side reconstruction
  of the signed canonical form via labelMap (no HMAC key needed).
- CborDecoder (new) + CborEncoder.encodeDerivedProofValue: wire format
  support for both base (0x5d02) and derived (0x5d01) proof variants.
- SelectiveDisclosureTest: round-trip, tag/shape, tamper-detection, and
  the central security assertion that the HMAC key bytes do not appear
  in any derived proofValue.

Scope note: this implementation reveals the full credential (no claim
subsetting yet). Subset-by-JSON-Pointer is a follow-up; the security
fix — HMAC key never leaves the holder — is delivered now.
Addresses five review findings against the W3C VC Data Integrity ECDSA
Cryptosuites 1.0 spec and Open Badges 3.0 (which defers to VC-DI):

A. Base proof CBOR header corrected from 0xd9 0x5d 0x02 to 0xd9 0x5d 0x00
   (spec §3.5.2). The digitalbazaar reference implementation confirms
   CBOR_PREFIX_BASE = [0xd9, 0x5d, 0x00].

B. proofValue multibase prefix corrected from 'z' (base58btc) to 'u'
   (base64url-no-pad) for both base and derived proofs (spec §3.5.2,
   §3.5.3, §3.5.7, §3.5.8 — "If the proofValue string does not start
   with `u`, an error MUST be raised"). base58btc is retained for the
   other cryptosuites (eddsa-rdfc-2022, ecdsa-rdfc-2022) that legitimately
   require it. Adds Multibase.encode/decodeBase64UrlNoPad().

#1. verifyDerivedProof now fails fast on a non-ecdsa-sd-2023 cryptosuite
    before attempting CBOR decode (spec §2.2.1).

#3. Javadoc on verifyDerivedProof documents the public-key trust contract:
    this primitive only verifies signatures under the proof-embedded
    publicKey and does NOT resolve verificationMethod / check controller
    binding — callers MUST layer VC-DI §2.6 issuer/controller validation
    before trusting the boolean result.

#5. verifyDerivedProof rejects mandatoryIndexes that are null, negative,
    or ≥ quadList.size() explicitly, giving a clean failure path instead
    of relying on incidental downstream hash mismatch.

Regression tests added: base-proof header/prefix compliance, derived-proof
header/prefix compliance, cryptosuite-mismatch rejection, and
base-proof-fed-to-derived-verifier rejection. Existing tests updated for
the new 'u' prefix. 89/89 tests pass.

Known remaining interop gap (NOT addressed here): the labelMap is still
serialized as CBOR text→text rather than the spec's compressed
int→bytes form (§3.5.5), so cross-verification with
@digitalbazaar/ecdsa-sd-2023-cryptosuite still fails on the labelMap
element. This will be a separate follow-up PR.
Review-driven cleanup targeting only code introduced in this PR:

- deriveProof: drop redundant replaceBlankNodesWithHmac call. The HMAC'd
  quads were only ever passed to resolveMandatoryIndexes, which matches
  against predicate IRIs — unaffected by blank-node relabelling. Skipping
  the rewrite halves the HMAC/regex work in this code path.
- deriveProof: drop the "new ArrayList<>(mandatoryIdx)" copy; the local
  list is not reused anywhere.
- verifyDerivedProof: drop null check on Integer indexes (CBOR decoder
  never emits null elements) and replace java.util.Set/HashSet FQNs with
  proper imports.
- buildLabelMap: replace per-byte String.format("%02x", ...) hex loop
  with java.util.HexFormat.formatHex (JDK 17, already on build classpath).
- decompressP256PublicKey: cache the P-256 ECParameterSpec in a static
  final field so per-verification AlgorithmParameters construction is a
  one-shot class-init cost.
- Extract CRYPTOSUITE_ECDSA_SD_2023 constant for the two new usages
  (guard + verifier-side proofConfig rebuild). Existing createBaseProof
  string literals left untouched to keep the diff narrow.

Existing public API, wire format, and 89/89 tests pass unchanged.
@brody-0125 brody-0125 merged commit 6cc24dc into main Apr 16, 2026
2 checks passed
brody-0125 pushed a commit that referenced this pull request Apr 16, 2026
Conflicts in CredentialSigner.signEcdsaP256Raw and
SelectiveDisclosure.signEcdsaP256 came from PR #3 (low-S canonicalization)
and PR #5 (ecdsa-sd-2023 derivation) landing on main alongside this
branch's key-zeroization rewrite of the same methods.

Resolution:
- CredentialSigner.signEcdsaP256Raw: keep ecPrivateKey hoisted out of
  the inner try (needed by our finally{tryDestroy}) and add the byte[]
  p1363 staging variable from main so normalizeToLowS can run on the
  result before return.
- SelectiveDisclosure.signEcdsaP256: keep our pre-extracted ECPrivateKey
  parameter shape (avoids re-running toECPrivateKey per quad) and adopt
  main's algorithm-name constants (CredentialSigner.ALGO_ECDSA_*),
  P256_COMPONENT_LEN, and final normalizeToLowS step. The outer JOSE
  try/catch is dropped because the JOSE call is now in the caller.

Drive-by: EcdsaInternalsTest.selectiveDisclosureBaseSignatureIsAlwaysLowS
was broken on plain main — PR #5 changed createBaseProof to W3C-required
multibase-base64url-no-pad ('u' prefix) and updated the CBOR base-proof
tag low byte from 0x02 to 0x00, but PR #3's test still called
decodeBase58Btc and asserted 0x02. Switch the test to
decodeBase64UrlNoPad and the 0x00 tag so this branch's CI is green.

https://claude.ai/code/session_01Tjyxx8f3ZhfXdNEFMP1avf
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