Skip to content

Commit 6b2c52e

Browse files
brody-0125claude
andauthored
Enforce low-S canonicalization on ECDSA signatures to prevent malleability (#3)
* Enforce ECDSA low-S canonicalization to block signature malleability JDK's SunEC ECDSA provider emits both low-S and high-S signatures, and its verifier accepts either. That makes (r, s) and (r, n - s) both valid for the same message, so any system that treats the signature bytes as a stable identifier (revocation lists, credential tracking, dedup) can be bypassed by swapping in the malleable twin. This change: - Normalizes every P-256 signature emitted by signEcdsaP256Raw and SelectiveDisclosure.signEcdsaP256 to low-S (s <= n/2). - Rejects high-S and out-of-range (r, s) values up front in verifyEcdsaP256Raw before delegating to the JDK verifier. - Adds regression tests covering the malleability attack (forged high-S twin must be rejected), the low-S invariant on fresh signatures, and malformed signature lengths. https://claude.ai/code/session_011UnXmEtWXkFn8sz5bnNkUT * Simplify low-S ECDSA normalization Apply review feedback on top of the malleability fix: - Drop the speculative BigInteger parameter from normalizeToLowS; it was always called with P256_N and now uses the existing P256_HALF_N constant directly instead of recomputing n >> 1 on each call. - Extract the two JCA algorithm name strings into shared constants on CredentialSigner, removed four duplicated string literals across CredentialSigner and SelectiveDisclosure. - Remove a stale WHAT-style comment and cut the low-S regression loop from 50 to 20 iterations (2^-20 false-negative probability is already well past the flaky-test threshold, and the JSON-LD canonicalization per iteration was dominating test time). https://claude.ai/code/session_011UnXmEtWXkFn8sz5bnNkUT * Close the three review-gap holes in ECDSA malleability tests Self-review flagged three blind spots in the original malleability regression suite: - No test exercised the r ∈ [1, n-1] bounds check on verify — only the s-side high-S rejection was covered. Added ecdsaVerificationShouldRejectOutOfRangeR to forge r == 0 and r == n via byte-level tampering and confirm both are rejected. - The DER fallback path in signEcdsaP256Raw/verifyEcdsaP256Raw is effectively dead on modern JDKs, leaving derToP1363/p1363ToDer untested. Added a direct round-trip test against a real JDK DER signature plus a crafted high-bit-set input that exercises the DER sign-extension branch of p1363ToDer. - SelectiveDisclosure.signEcdsaP256 shares the low-S normalization contract with CredentialSigner but had no test locking it in. Added selectiveDisclosureBaseSignatureIsAlwaysLowS, which reads the 64-byte base signature out of the fixed CBOR prefix emitted by CborEncoder and asserts s <= n/2 across 20 iterations. The new tests live in src/test/java/work/brodykim/signet/credential/ so they can reach the package-private helpers (derToP1363, p1363ToDer, normalizeToLowS, P256_* constants) without widening production visibility. https://claude.ai/code/session_011UnXmEtWXkFn8sz5bnNkUT * Simplify gap-coverage tests Apply review feedback on the ECDSA test additions: - Reuse the new verifyWithForgedSignature helper from the two earlier forgery tests (malleable high-S variant, truncated signature), dropping the proof-wrapping boilerplate that was duplicated three times. - Replace two hand-rolled fill loops in p1363ToDerHandlesHighBitSetOn BothComponents with Arrays.fill. - Narrow the derToP1363RoundTripsForRealJdkDerSignature throws clause from Exception to the three specific checked exceptions the body can throw (NoSuchAlgorithmException, InvalidKeyException, SignatureException) plus JOSEException from key extraction. - Extract CBOR_BASE_SIG_OFFSET and CBOR_BASE_SIG_END constants so the intent of the bare `cbor[6..70]` slice is self-documenting. https://claude.ai/code/session_011UnXmEtWXkFn8sz5bnNkUT --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent adf2e86 commit 6b2c52e

4 files changed

Lines changed: 392 additions & 12 deletions

File tree

src/main/java/work/brodykim/signet/credential/CredentialSigner.java

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@
4141
*/
4242
public class CredentialSigner {
4343

44+
// P-256 (secp256r1) curve order n, per SEC 2 / FIPS 186-5.
45+
// Used to enforce low-S canonicalization on ECDSA signatures and reject
46+
// malleable high-S variants (defense-in-depth against signature malleability
47+
// where (r, s) and (r, n - s) are both mathematically valid).
48+
static final BigInteger P256_N = new BigInteger(
49+
"FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16);
50+
static final BigInteger P256_HALF_N = P256_N.shiftRight(1);
51+
static final int P256_COMPONENT_LEN = 32;
52+
53+
// JCA algorithm names. P1363 (raw r||s) is the W3C VC-DI-ECDSA wire format;
54+
// the DER path is a fallback for JREs without the P1363 algorithm alias.
55+
// Package-private so SelectiveDisclosure can reuse them.
56+
static final String ALGO_ECDSA_P1363 = "SHA256withECDSAinP1363Format";
57+
static final String ALGO_ECDSA_DER = "SHA256withECDSA";
58+
4459
private final ObjectMapper objectMapper;
4560
private final JsonLdProcessor jsonLdProcessor;
4661

@@ -287,41 +302,59 @@ private PublicKey decodeEd25519PublicKey(byte[] rawKey) throws GeneralSecurityEx
287302
/**
288303
* Sign data with ECDSA P-256 in IEEE P1363 format (r || s, 64 bytes).
289304
* The W3C Data Integrity ECDSA spec requires P1363 format, not DER.
305+
*
306+
* <p>The returned signature is always in low-S canonical form
307+
* (s &lt;= n/2) to avoid signature malleability. JDK's SunEC provider
308+
* does not enforce low-S, so we normalize the output here.
290309
*/
291310
private byte[] signEcdsaP256Raw(byte[] data, ECKey privateKey) throws GeneralSecurityException {
292311
try {
293312
ECPrivateKey ecPrivateKey = privateKey.toECPrivateKey();
313+
byte[] p1363;
294314
try {
295-
Signature sig = Signature.getInstance("SHA256withECDSAinP1363Format");
315+
Signature sig = Signature.getInstance(ALGO_ECDSA_P1363);
296316
sig.initSign(ecPrivateKey);
297317
sig.update(data);
298-
return sig.sign();
318+
p1363 = sig.sign();
299319
} catch (java.security.NoSuchAlgorithmException e) {
300-
// Fallback: use DER format and convert to P1363
301-
Signature sig = Signature.getInstance("SHA256withECDSA");
320+
Signature sig = Signature.getInstance(ALGO_ECDSA_DER);
302321
sig.initSign(ecPrivateKey);
303322
sig.update(data);
304323
byte[] derSig = sig.sign();
305-
return derToP1363(derSig, 32);
324+
p1363 = derToP1363(derSig, P256_COMPONENT_LEN);
306325
}
326+
return normalizeToLowS(p1363);
307327
} catch (com.nimbusds.jose.JOSEException e) {
308328
throw new GeneralSecurityException("Failed to extract EC private key from JWK", e);
309329
}
310330
}
311331

312332
private boolean verifyEcdsaP256Raw(byte[] data, byte[] signature, ECKey publicKey)
313333
throws GeneralSecurityException {
334+
// Enforce low-S canonical form and reject out-of-range (r, s) before
335+
// delegating to the underlying verifier. This prevents signature
336+
// malleability: without this check, both (r, s) and (r, n - s) would
337+
// verify as valid for the same message, breaking any system that
338+
// treats the signature bytes as a stable identifier (revocation,
339+
// tracking, deduplication, etc.).
340+
if (signature == null || signature.length != P256_COMPONENT_LEN * 2) {
341+
return false;
342+
}
343+
BigInteger r = new BigInteger(1, Arrays.copyOfRange(signature, 0, P256_COMPONENT_LEN));
344+
BigInteger s = new BigInteger(1, Arrays.copyOfRange(signature, P256_COMPONENT_LEN, signature.length));
345+
if (r.signum() <= 0 || r.compareTo(P256_N) >= 0) return false;
346+
if (s.signum() <= 0 || s.compareTo(P256_HALF_N) > 0) return false;
347+
314348
try {
315349
ECPublicKey ecPublicKey = publicKey.toECPublicKey();
316350
try {
317-
Signature sig = Signature.getInstance("SHA256withECDSAinP1363Format");
351+
Signature sig = Signature.getInstance(ALGO_ECDSA_P1363);
318352
sig.initVerify(ecPublicKey);
319353
sig.update(data);
320354
return sig.verify(signature);
321355
} catch (java.security.NoSuchAlgorithmException e) {
322-
// Fallback: convert P1363 to DER and verify
323356
byte[] derSig = p1363ToDer(signature);
324-
Signature sig = Signature.getInstance("SHA256withECDSA");
357+
Signature sig = Signature.getInstance(ALGO_ECDSA_DER);
325358
sig.initVerify(ecPublicKey);
326359
sig.update(data);
327360
return sig.verify(derSig);
@@ -331,6 +364,26 @@ private boolean verifyEcdsaP256Raw(byte[] data, byte[] signature, ECKey publicKe
331364
}
332365
}
333366

367+
/**
368+
* Normalize an IEEE P1363 ECDSA P-256 signature to low-S canonical form.
369+
* If s &gt; n/2, replaces s with n - s (which is mathematically equivalent
370+
* as an ECDSA signature). Otherwise returns the input unchanged.
371+
*
372+
* <p>Package-private so {@link SelectiveDisclosure} can reuse it.
373+
*/
374+
static byte[] normalizeToLowS(byte[] p1363Sig) {
375+
int half = p1363Sig.length / 2;
376+
BigInteger s = new BigInteger(1, Arrays.copyOfRange(p1363Sig, half, p1363Sig.length));
377+
if (s.compareTo(P256_HALF_N) <= 0) {
378+
return p1363Sig;
379+
}
380+
byte[] sBytes = P256_N.subtract(s).toByteArray();
381+
byte[] result = p1363Sig.clone();
382+
Arrays.fill(result, half, result.length, (byte) 0);
383+
copyToFixed(sBytes, result, half, half);
384+
return result;
385+
}
386+
334387
/**
335388
* Convert a DER-encoded ECDSA signature to IEEE P1363 format (r || s).
336389
*/

src/main/java/work/brodykim/signet/credential/SelectiveDisclosure.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,18 +235,20 @@ private List<Integer> resolveMandatoryIndexes(List<String> quads,
235235
private byte[] signEcdsaP256(byte[] data, ECKey privateKey) throws GeneralSecurityException {
236236
try {
237237
ECPrivateKey ecPrivateKey = privateKey.toECPrivateKey();
238+
byte[] p1363;
238239
try {
239-
Signature sig = Signature.getInstance("SHA256withECDSAinP1363Format");
240+
Signature sig = Signature.getInstance(CredentialSigner.ALGO_ECDSA_P1363);
240241
sig.initSign(ecPrivateKey);
241242
sig.update(data);
242-
return sig.sign();
243+
p1363 = sig.sign();
243244
} catch (java.security.NoSuchAlgorithmException e) {
244-
Signature sig = Signature.getInstance("SHA256withECDSA");
245+
Signature sig = Signature.getInstance(CredentialSigner.ALGO_ECDSA_DER);
245246
sig.initSign(ecPrivateKey);
246247
sig.update(data);
247248
byte[] derSig = sig.sign();
248-
return CredentialSigner.derToP1363(derSig, 32);
249+
p1363 = CredentialSigner.derToP1363(derSig, CredentialSigner.P256_COMPONENT_LEN);
249250
}
251+
return CredentialSigner.normalizeToLowS(p1363);
250252
} catch (com.nimbusds.jose.JOSEException e) {
251253
throw new GeneralSecurityException("Failed to extract EC private key", e);
252254
}

src/test/java/work/brodykim/signet/CredentialSignerTest.java

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
import com.fasterxml.jackson.databind.ObjectMapper;
44
import work.brodykim.signet.credential.CredentialSigner;
55
import work.brodykim.signet.credential.KeyPairManager;
6+
import work.brodykim.signet.credential.Multibase;
67
import work.brodykim.signet.jsonld.CachedDocumentLoader;
78
import work.brodykim.signet.jsonld.JsonLdProcessor;
89
import com.nimbusds.jose.jwk.ECKey;
910
import com.nimbusds.jose.jwk.OctetKeyPair;
1011
import org.junit.jupiter.api.Test;
1112

13+
import java.math.BigInteger;
14+
import java.util.Arrays;
1215
import java.util.LinkedHashMap;
1316
import java.util.List;
1417
import java.util.Map;
@@ -177,6 +180,147 @@ void shouldFailEcdsaVerificationWithTamperedDocument() {
177180
assertFalse(valid, "Should fail ECDSA verification when document is tampered");
178181
}
179182

183+
// ── ECDSA signature malleability (low-S enforcement) regression tests ───
184+
185+
/** P-256 curve order n. */
186+
private static final BigInteger P256_N = new BigInteger(
187+
"FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16);
188+
private static final BigInteger P256_HALF_N = P256_N.shiftRight(1);
189+
190+
@Test
191+
@SuppressWarnings("unchecked")
192+
void ecdsaSignaturesShouldAlwaysBeInLowSForm() {
193+
// Regression: JDK SunEC produces high-S signatures roughly half the time.
194+
// After normalization every signature must have s <= n/2. 20 iterations
195+
// gives P(all happen to be low-S anyway) ≈ 2^-20 ≈ 1e-6 — low enough
196+
// that a regression won't be masked by luck.
197+
ECKey keyPair = KeyPairManager.generateP256KeyPair();
198+
199+
for (int i = 0; i < 20; i++) {
200+
Map<String, Object> credential = buildSampleCredential();
201+
credential.put("id", "https://example.com/cred/" + i);
202+
203+
Map<String, Object> signed = signer.signWithEcdsaDataIntegrity(
204+
credential, keyPair, "https://example.com/issuers/1#key-1");
205+
206+
Map<String, Object> proof = (Map<String, Object>) signed.get("proof");
207+
String proofValue = (String) proof.get("proofValue");
208+
byte[] sig = Multibase.decodeBase58Btc(proofValue);
209+
assertEquals(64, sig.length, "P-256 P1363 signature must be 64 bytes");
210+
211+
BigInteger s = new BigInteger(1, Arrays.copyOfRange(sig, 32, 64));
212+
assertTrue(s.compareTo(P256_HALF_N) <= 0,
213+
"s must be <= n/2 (low-S canonical form) on iteration " + i
214+
+ ", got s=" + s.toString(16));
215+
assertTrue(s.signum() > 0, "s must be positive");
216+
}
217+
}
218+
219+
@Test
220+
@SuppressWarnings("unchecked")
221+
void ecdsaVerificationShouldRejectMalleableHighSVariant() {
222+
// Malleability attack: given a valid signature (r, s), compute (r, n-s).
223+
// Without low-S enforcement, this would also verify — allowing the same
224+
// credential to be represented by two distinct proofValues, breaking
225+
// revocation/tracking systems that key off the signature bytes.
226+
ECKey keyPair = KeyPairManager.generateP256KeyPair();
227+
Map<String, Object> credential = buildSampleCredential();
228+
229+
Map<String, Object> signed = signer.signWithEcdsaDataIntegrity(
230+
credential, keyPair, "https://example.com/issuers/1#key-1");
231+
232+
// Sanity: the original signature verifies.
233+
assertTrue(signer.verifyEcdsaDataIntegrity(signed, keyPair.toPublicJWK()));
234+
235+
byte[] origSig = Multibase.decodeBase58Btc(
236+
(String) ((Map<String, Object>) signed.get("proof")).get("proofValue"));
237+
byte[] forged = flipS(origSig, P256_N);
238+
239+
// Preconditions: r is unchanged, s has actually been flipped to high-S.
240+
assertArrayEquals(Arrays.copyOfRange(origSig, 0, 32),
241+
Arrays.copyOfRange(forged, 0, 32),
242+
"forgery should leave r untouched");
243+
BigInteger forgedS = new BigInteger(1, Arrays.copyOfRange(forged, 32, 64));
244+
assertTrue(forgedS.compareTo(P256_HALF_N) > 0,
245+
"forged signature must be in high-S region to exercise malleability");
246+
247+
assertFalse(verifyWithForgedSignature(signed, keyPair, forged),
248+
"Verifier must reject the malleable high-S twin of a valid signature");
249+
}
250+
251+
@Test
252+
@SuppressWarnings("unchecked")
253+
void ecdsaVerificationShouldRejectMalformedSignatureLength() {
254+
ECKey keyPair = KeyPairManager.generateP256KeyPair();
255+
Map<String, Object> credential = buildSampleCredential();
256+
257+
Map<String, Object> signed = signer.signWithEcdsaDataIntegrity(
258+
credential, keyPair, "https://example.com/issuers/1#key-1");
259+
260+
byte[] origSig = Multibase.decodeBase58Btc(
261+
(String) ((Map<String, Object>) signed.get("proof")).get("proofValue"));
262+
byte[] truncated = Arrays.copyOfRange(origSig, 0, 32);
263+
264+
assertFalse(verifyWithForgedSignature(signed, keyPair, truncated),
265+
"Verifier must reject signatures that are not 64 bytes");
266+
}
267+
268+
@Test
269+
@SuppressWarnings("unchecked")
270+
void ecdsaVerificationShouldRejectOutOfRangeR() {
271+
// Locks in the r ∈ [1, n-1] range check. The verifier pre-rejects r == 0
272+
// and r >= n before delegating to the underlying ECDSA verifier — without
273+
// this, a subsequent implementation change that skipped the range check
274+
// would go unnoticed.
275+
ECKey keyPair = KeyPairManager.generateP256KeyPair();
276+
Map<String, Object> credential = buildSampleCredential();
277+
278+
Map<String, Object> signed = signer.signWithEcdsaDataIntegrity(
279+
credential, keyPair, "https://example.com/issuers/1#key-1");
280+
byte[] origSig = Multibase.decodeBase58Btc(
281+
(String) ((Map<String, Object>) signed.get("proof")).get("proofValue"));
282+
283+
// Forge r = 0 (zero out the first 32 bytes).
284+
byte[] zeroR = origSig.clone();
285+
Arrays.fill(zeroR, 0, 32, (byte) 0);
286+
assertFalse(verifyWithForgedSignature(signed, keyPair, zeroR),
287+
"Verifier must reject r == 0");
288+
289+
// Forge r = n (encode the curve order directly in the r slot).
290+
byte[] rEqualsN = origSig.clone();
291+
byte[] nBytes = P256_N.toByteArray();
292+
// P256_N.toByteArray() may return 33 bytes (leading 0x00 sign byte) since n > 2^255.
293+
System.arraycopy(nBytes, nBytes.length - 32, rEqualsN, 0, 32);
294+
assertFalse(verifyWithForgedSignature(signed, keyPair, rEqualsN),
295+
"Verifier must reject r == n (r must be strictly less than n)");
296+
}
297+
298+
@SuppressWarnings("unchecked")
299+
private boolean verifyWithForgedSignature(Map<String, Object> signed, ECKey keyPair, byte[] forgedSig) {
300+
Map<String, Object> proof = new LinkedHashMap<>((Map<String, Object>) signed.get("proof"));
301+
proof.put("proofValue", Multibase.encodeBase58Btc(forgedSig));
302+
Map<String, Object> tampered = new LinkedHashMap<>(signed);
303+
tampered.put("proof", proof);
304+
return signer.verifyEcdsaDataIntegrity(tampered, keyPair.toPublicJWK());
305+
}
306+
307+
/** Flip s to n - s in an IEEE P1363 (r || s) signature, keeping r intact. */
308+
private static byte[] flipS(byte[] p1363, BigInteger n) {
309+
int half = p1363.length / 2;
310+
BigInteger s = new BigInteger(1, Arrays.copyOfRange(p1363, half, p1363.length));
311+
BigInteger sPrime = n.subtract(s);
312+
byte[] sBytes = sPrime.toByteArray();
313+
byte[] out = p1363.clone();
314+
Arrays.fill(out, half, out.length, (byte) 0);
315+
// Right-align sBytes into the s region, stripping any BigInteger sign byte.
316+
if (sBytes.length > half) {
317+
System.arraycopy(sBytes, sBytes.length - half, out, half, half);
318+
} else {
319+
System.arraycopy(sBytes, 0, out, half + (half - sBytes.length), sBytes.length);
320+
}
321+
return out;
322+
}
323+
180324
// ── Helper ──────────────────────────────────────────────────────────────
181325

182326
private Map<String, Object> buildSampleCredential() {

0 commit comments

Comments
 (0)