Implement W3C ecdsa-sd-2023 disclosure proof derivation and verification#5
Merged
brody-0125 merged 4 commits intomainfrom Apr 16, 2026
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:labelMapthat maps canonical blank-node labels to their HMAC-derived equivalents0xd95d01with base signature, public key, per-quad signatures, label map, and mandatory indexesSelectiveDisclosure.verifyDerivedProof(): Verifies a derived proof by:labelMapSHA256(proofConfig) || SHA256(mandatoryQuads)CborDecoder(new): Minimal CBOR decoder supporting the subset of types needed for ecdsa-sd-2023:0xd95d02)0xd95d01)CborEncoder.encodeDerivedProofValue(): Encodes derived proof values as CBOR, intentionally omitting the HMAC key to prevent verifiers from reversing blank-node maskingHelper methods:
decompressP256PublicKey(): Decompresses SEC1 33-byte compressed P-256 public keysbuildLabelMap(): Maps canonical labels to HMAC-derived labelsapplyLabelMap(): Rewrites N-Quads using the label mapverifyEcdsaP256(): Validates ECDSA P-256 signatures with fallback for different JCA implementationsComprehensive test suite (
SelectiveDisclosureTest): Validates:Notable Implementation Details
labelMapenables verifiers to reconstruct the exact canonical form the issuer signed without holding the HMAC keymandatoryIndexesto distinguish base-signature-covered quads from individually-signed quadshttps://claude.ai/code/session_01Gr5Er7AoKJj7UH3aeJat4L