Skip to content

Commit abbb03b

Browse files
fix: security audit — bind commitment to sub-proofs, sanitise errors, validate inputs
CRITICAL: Add commitment-binding Schnorr proof (C - lowerC - min*G = (r - r_lower)*H) so verifyRangeProof rejects a substituted commitment field. Previously the commitment was entirely unverified — an attacker could swap it and verification still passed. HIGH: Remove secret value from error message in createRangeProof (was leaking the value in the thrown ValidationError string). HIGH: Add Number.isSafeInteger and non-negative checks to commit(). MEDIUM: Add hex format/length validation in deserializeRangeProof for all compressed-point and scalar fields (top-level and bit proofs). MEDIUM: Require digits-only segments in createAgeRangeProof before parseInt to reject inputs like "0x8-12" or "8.5-12" that parseInt silently misparses.
1 parent 7a4abcf commit abbb03b

2 files changed

Lines changed: 226 additions & 8 deletions

File tree

src/range-proof.ts

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,23 @@ function pointToBytes(p: ProjectivePoint): Uint8Array {
2323

2424
const DOMAIN_BIT_PROOF = 'pedersen-bit-proof-v1';
2525
const DOMAIN_SUM_BINDING = 'pedersen-sum-binding-v1';
26+
const DOMAIN_COMMITMENT_BINDING = 'pedersen-commitment-binding-v1';
2627

2728
/** Empty context (no binding) */
2829
const EMPTY_CONTEXT = new Uint8Array(0);
2930

31+
/** Hex validation patterns */
32+
const HEX_COMPRESSED_POINT = /^(?:02|03)[0-9a-f]{64}$/i;
33+
const HEX_SCALAR_64 = /^[0-9a-f]{64}$/i;
34+
35+
function isValidCompressedPoint(hex: string): boolean {
36+
return HEX_COMPRESSED_POINT.test(hex);
37+
}
38+
39+
function isValidScalarHex(hex: string): boolean {
40+
return HEX_SCALAR_64.test(hex);
41+
}
42+
3043
// --- Pedersen Commitment ---
3144

3245
export interface PedersenCommitment {
@@ -44,6 +57,9 @@ export interface PedersenCommitment {
4457
* @param blinding - Optional blinding factor (random if not provided)
4558
*/
4659
export function commit(value: number, blinding?: bigint): PedersenCommitment {
60+
if (!Number.isSafeInteger(value)) throw new ValidationError('Value must be a safe integer');
61+
if (value < 0) throw new ValidationError('Value must be non-negative');
62+
4763
const v = BigInt(value);
4864
const r = blinding ?? randomScalar();
4965

@@ -232,6 +248,10 @@ export interface RangeProof {
232248
/** Sum-binding proof: DLog of (lowerC + upperC - range*G) w.r.t. H */
233249
sumBindingE: string;
234250
sumBindingS: string;
251+
/** Commitment-binding proof: DLog of (C - lowerC - min*G) w.r.t. H.
252+
* Proves C is algebraically bound to the verified lowerCommitment. */
253+
commitBindingE: string;
254+
commitBindingS: string;
235255
/** Optional binding context (e.g. subject pubkey hex) included in Fiat-Shamir
236256
* challenges to prevent proof transplanting between credentials. */
237257
context?: string;
@@ -294,6 +314,53 @@ function verifySumBinding(
294314
}
295315
}
296316

317+
/**
318+
* Prove knowledge of the discrete log of D w.r.t. H (commitment binding).
319+
* D = C - lowerC - min*G = (r - r_lower)*H, proving C is bound to lowerC.
320+
*/
321+
function proveCommitmentBinding(
322+
rDiff: bigint,
323+
D: ProjectivePoint,
324+
context: Uint8Array = EMPTY_CONTEXT
325+
): { e: string; s: string } {
326+
const k = randomScalar();
327+
const R = safeMultiply(H, k);
328+
329+
const e = hashToScalar(
330+
utf8ToBytes(DOMAIN_COMMITMENT_BINDING),
331+
context,
332+
pointToBytes(D),
333+
pointToBytes(R)
334+
);
335+
336+
const s = mod(k - mod(e * rDiff));
337+
338+
return { e: scalarToHex(e), s: scalarToHex(s) };
339+
}
340+
341+
function verifyCommitmentBinding(
342+
D: ProjectivePoint,
343+
eHex: string,
344+
sHex: string,
345+
context: Uint8Array = EMPTY_CONTEXT
346+
): boolean {
347+
try {
348+
const e = hexToScalar(eHex);
349+
const s = hexToScalar(sHex);
350+
351+
const R = safeMultiply(H, s).add(safeMultiply(D, e));
352+
const eCheck = hashToScalar(
353+
utf8ToBytes(DOMAIN_COMMITMENT_BINDING),
354+
context,
355+
pointToBytes(D),
356+
pointToBytes(R)
357+
);
358+
return scalarEqual(eCheck, e);
359+
} catch {
360+
return false;
361+
}
362+
}
363+
297364
/**
298365
* Create a range proof proving value ∈ [min, max].
299366
*
@@ -310,7 +377,7 @@ export function createRangeProof(value: number, min: number, max: number, bindin
310377
}
311378
if (min < 0) throw new ValidationError('Minimum must be non-negative');
312379
if (max < min) throw new ValidationError('Maximum must be >= minimum');
313-
if (value < min || value > max) throw new ValidationError(`Value ${value} not in range [${min}, ${max}]`);
380+
if (value < min || value > max) throw new ValidationError('Value is not within the specified range');
314381

315382
const context = bindingContext ? utf8ToBytes(bindingContext) : EMPTY_CONTEXT;
316383

@@ -363,6 +430,13 @@ export function createRangeProof(value: number, min: number, max: number, bindin
363430
const D = lowerC.add(upperC).add(rangeG.negate());
364431
const sumBinding = proveSumBinding(rTotal, D, context);
365432

433+
// Commitment-binding proof: prove C - lowerC - min*G = (r - r_lower)*H
434+
// This binds the overall commitment to the verified lower commitment
435+
const rDiff = mod(blinding - lowerBlindingSum);
436+
const minG = safeMultiply(G, BigInt(min));
437+
const commitD = C.add(lowerC.negate()).add(minG.negate());
438+
const commitBinding = proveCommitmentBinding(rDiff, commitD, context);
439+
366440
return {
367441
commitment: C.toHex(true),
368442
min,
@@ -374,6 +448,8 @@ export function createRangeProof(value: number, min: number, max: number, bindin
374448
upperCommitment: upperC.toHex(true),
375449
sumBindingE: sumBinding.e,
376450
sumBindingS: sumBinding.s,
451+
commitBindingE: commitBinding.e,
452+
commitBindingS: commitBinding.s,
377453
context: bindingContext,
378454
};
379455
}
@@ -441,6 +517,16 @@ export function verifyRangeProof(proof: RangeProof): boolean {
441517
return false;
442518
}
443519

520+
// 6. Verify commitment-binding: C - lowerC - min*G = (r - r_lower)*H
521+
// This proves the overall commitment is algebraically bound to the verified lower commitment
522+
const C = Point.fromHex(proof.commitment);
523+
C.assertValidity();
524+
const minG = safeMultiply(G, BigInt(min));
525+
const commitD = C.add(lowerC.negate()).add(minG.negate());
526+
if (!verifyCommitmentBinding(commitD, proof.commitBindingE, proof.commitBindingS, context)) {
527+
return false;
528+
}
529+
444530
return true;
445531
} catch {
446532
return false;
@@ -456,20 +542,23 @@ export function verifyRangeProof(proof: RangeProof): boolean {
456542
* @returns The range proof
457543
*/
458544
export function createAgeRangeProof(age: number, ageRange: string, subjectPubkey?: string): RangeProof {
545+
const DIGITS_ONLY = /^\d+$/;
546+
459547
// Handle "18+" format (no upper bound — use 150 as practical max)
460548
if (ageRange.endsWith('+')) {
461-
const min = parseInt(ageRange.slice(0, -1), 10);
462-
if (isNaN(min)) throw new ValidationError(`Invalid age range format: ${ageRange}`);
463-
return createRangeProof(age, min, 150, subjectPubkey);
549+
const minStr = ageRange.slice(0, -1);
550+
if (!DIGITS_ONLY.test(minStr)) throw new ValidationError(`Invalid age range format: ${ageRange}`);
551+
return createRangeProof(age, parseInt(minStr, 10), 150, subjectPubkey);
464552
}
465553

466554
const parts = ageRange.split('-');
467555
if (parts.length !== 2) throw new ValidationError(`Invalid age range format: ${ageRange} (expected "min-max")`);
556+
if (!DIGITS_ONLY.test(parts[0]) || !DIGITS_ONLY.test(parts[1])) {
557+
throw new ValidationError(`Invalid age range format: ${ageRange}`);
558+
}
468559
const min = parseInt(parts[0], 10);
469560
const max = parseInt(parts[1], 10);
470561

471-
if (isNaN(min) || isNaN(max)) throw new ValidationError(`Invalid age range format: ${ageRange}`);
472-
473562
return createRangeProof(age, min, max, subjectPubkey);
474563
}
475564

@@ -508,6 +597,9 @@ export function deserializeRangeProof(json: string): RangeProof {
508597
if (typeof p.bits !== 'number' || typeof p.commitment !== 'string') {
509598
throw new ValidationError('Invalid range proof: missing bits or commitment');
510599
}
600+
if (!isValidCompressedPoint(p.commitment as string)) {
601+
throw new ValidationError('Invalid range proof: commitment is not valid compressed-point hex');
602+
}
511603
if (!Number.isSafeInteger(p.bits) || (p.bits as number) < 1 || (p.bits as number) > 32) {
512604
throw new ValidationError('Invalid range proof: bits must be between 1 and 32');
513605
}
@@ -520,9 +612,21 @@ export function deserializeRangeProof(json: string): RangeProof {
520612
if (typeof p.lowerCommitment !== 'string' || typeof p.upperCommitment !== 'string') {
521613
throw new ValidationError('Invalid range proof: missing lowerCommitment/upperCommitment');
522614
}
615+
if (!isValidCompressedPoint(p.lowerCommitment as string) || !isValidCompressedPoint(p.upperCommitment as string)) {
616+
throw new ValidationError('Invalid range proof: lowerCommitment/upperCommitment is not valid compressed-point hex');
617+
}
523618
if (typeof p.sumBindingE !== 'string' || typeof p.sumBindingS !== 'string') {
524619
throw new ValidationError('Invalid range proof: missing sumBindingE/sumBindingS');
525620
}
621+
if (!isValidScalarHex(p.sumBindingE as string) || !isValidScalarHex(p.sumBindingS as string)) {
622+
throw new ValidationError('Invalid range proof: sumBindingE/sumBindingS is not valid scalar hex');
623+
}
624+
if (typeof p.commitBindingE !== 'string' || typeof p.commitBindingS !== 'string') {
625+
throw new ValidationError('Invalid range proof: missing commitBindingE/commitBindingS');
626+
}
627+
if (!isValidScalarHex(p.commitBindingE as string) || !isValidScalarHex(p.commitBindingS as string)) {
628+
throw new ValidationError('Invalid range proof: commitBindingE/commitBindingS is not valid scalar hex');
629+
}
526630
// Validate bit proof array contents
527631
for (const bp of [...(p.lowerProof as unknown[]), ...(p.upperProof as unknown[])]) {
528632
if (typeof bp !== 'object' || bp === null) throw new ValidationError('Invalid range proof: bit proof is not an object');
@@ -531,6 +635,13 @@ export function deserializeRangeProof(json: string): RangeProof {
531635
typeof bpRec.s0 !== 'string' || typeof bpRec.e1 !== 'string' || typeof bpRec.s1 !== 'string') {
532636
throw new ValidationError('Invalid range proof: bit proof missing required fields');
533637
}
638+
if (!isValidCompressedPoint(bpRec.commitment as string)) {
639+
throw new ValidationError('Invalid range proof: bit proof commitment is not valid compressed-point hex');
640+
}
641+
if (!isValidScalarHex(bpRec.e0 as string) || !isValidScalarHex(bpRec.s0 as string) ||
642+
!isValidScalarHex(bpRec.e1 as string) || !isValidScalarHex(bpRec.s1 as string)) {
643+
throw new ValidationError('Invalid range proof: bit proof scalar is not valid hex');
644+
}
534645
}
535646
if (p.context !== undefined && typeof p.context !== 'string') {
536647
throw new ValidationError('Invalid range proof: context must be a string if present');

tests/range-proof.test.ts

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,11 @@ describe('range-proof', () => {
5858
});
5959

6060
it('rejects value below range', () => {
61-
expect(() => createRangeProof(4, 5, 10)).toThrow('not in range');
61+
expect(() => createRangeProof(4, 5, 10)).toThrow('not within the specified range');
6262
});
6363

6464
it('rejects value above range', () => {
65-
expect(() => createRangeProof(11, 5, 10)).toThrow('not in range');
65+
expect(() => createRangeProof(11, 5, 10)).toThrow('not within the specified range');
6666
});
6767

6868
it('proves single-value range', () => {
@@ -156,5 +156,112 @@ describe('range-proof', () => {
156156
const tampered = { ...proof, bits: proof.bits + 1 };
157157
expect(verifyRangeProof(tampered)).toBe(false);
158158
});
159+
160+
it('rejects commitment substitution (commitment not bound to sub-proofs)', () => {
161+
// Create two proofs for different values in the same range
162+
const proofA = createRangeProof(7, 0, 15);
163+
const proofB = createRangeProof(3, 0, 15);
164+
165+
// Swap the commitment — sub-proofs stay from proofA but commitment from proofB
166+
const tampered = { ...proofA, commitment: proofB.commitment };
167+
expect(verifyRangeProof(tampered)).toBe(false);
168+
});
169+
170+
it('rejects commitment substitution even when range and min match', () => {
171+
// Same range, different values — commitment binding must still catch it
172+
const proof1 = createRangeProof(6, 5, 10);
173+
const proof2 = createRangeProof(8, 5, 10);
174+
175+
const tampered = { ...proof1, commitment: proof2.commitment };
176+
expect(verifyRangeProof(tampered)).toBe(false);
177+
});
178+
179+
it('commit() rejects negative values', () => {
180+
expect(() => commit(-1)).toThrow('Value must be non-negative');
181+
expect(() => commit(-100)).toThrow('Value must be non-negative');
182+
});
183+
184+
it('commit() rejects non-integer values', () => {
185+
expect(() => commit(3.14)).toThrow('Value must be a safe integer');
186+
expect(() => commit(NaN)).toThrow('Value must be a safe integer');
187+
expect(() => commit(Infinity)).toThrow('Value must be a safe integer');
188+
});
189+
190+
it('commit() rejects values beyond Number.MAX_SAFE_INTEGER', () => {
191+
expect(() => commit(Number.MAX_SAFE_INTEGER + 1)).toThrow('Value must be a safe integer');
192+
});
193+
194+
it('error message does not leak the secret value', () => {
195+
const secretValue = 42;
196+
try {
197+
createRangeProof(secretValue, 100, 200);
198+
expect.unreachable('should have thrown');
199+
} catch (err) {
200+
const message = (err as Error).message;
201+
expect(message).not.toContain(String(secretValue));
202+
expect(message).not.toContain('42');
203+
// Ensure it also does not contain the range bounds in a way that leaks structure
204+
expect(message).toBe('Value is not within the specified range');
205+
}
206+
});
207+
208+
it('deserialisation rejects malformed hex in commitment', () => {
209+
const proof = createRangeProof(7, 5, 10);
210+
const json = serializeRangeProof(proof);
211+
const obj = JSON.parse(json);
212+
213+
// Invalid prefix (not 02 or 03)
214+
obj.commitment = '04' + 'ab'.repeat(32);
215+
expect(() => deserializeRangeProof(JSON.stringify(obj))).toThrow('not valid compressed-point hex');
216+
});
217+
218+
it('deserialisation rejects short hex in commitment', () => {
219+
const proof = createRangeProof(7, 5, 10);
220+
const json = serializeRangeProof(proof);
221+
const obj = JSON.parse(json);
222+
223+
obj.commitment = '02abcd';
224+
expect(() => deserializeRangeProof(JSON.stringify(obj))).toThrow('not valid compressed-point hex');
225+
});
226+
227+
it('deserialisation rejects non-hex characters in scalar fields', () => {
228+
const proof = createRangeProof(7, 5, 10);
229+
const json = serializeRangeProof(proof);
230+
const obj = JSON.parse(json);
231+
232+
obj.sumBindingE = 'zz' + '00'.repeat(31);
233+
expect(() => deserializeRangeProof(JSON.stringify(obj))).toThrow('not valid scalar hex');
234+
});
235+
236+
it('deserialisation rejects malformed hex in bit proof commitment', () => {
237+
const proof = createRangeProof(7, 5, 10);
238+
const json = serializeRangeProof(proof);
239+
const obj = JSON.parse(json);
240+
241+
obj.lowerProof[0].commitment = 'not-hex';
242+
expect(() => deserializeRangeProof(JSON.stringify(obj))).toThrow('not valid compressed-point hex');
243+
});
244+
245+
it('deserialisation rejects malformed hex in bit proof scalar', () => {
246+
const proof = createRangeProof(7, 5, 10);
247+
const json = serializeRangeProof(proof);
248+
const obj = JSON.parse(json);
249+
250+
obj.lowerProof[0].e0 = 'xyz';
251+
expect(() => deserializeRangeProof(JSON.stringify(obj))).toThrow('not valid hex');
252+
});
253+
254+
it('age range parser rejects non-numeric segments', () => {
255+
expect(() => createAgeRangeProof(10, 'abc-12')).toThrow('Invalid age range format');
256+
expect(() => createAgeRangeProof(10, '8-xyz')).toThrow('Invalid age range format');
257+
expect(() => createAgeRangeProof(10, '8.5-12')).toThrow('Invalid age range format');
258+
expect(() => createAgeRangeProof(20, 'abc+')).toThrow('Invalid age range format');
259+
expect(() => createAgeRangeProof(20, '18.5+')).toThrow('Invalid age range format');
260+
});
261+
262+
it('age range parser rejects segments with leading hex-like content', () => {
263+
// parseInt('0x12', 10) returns 0, which could silently produce wrong ranges
264+
expect(() => createAgeRangeProof(10, '0x8-12')).toThrow('Invalid age range format');
265+
});
159266
});
160267
});

0 commit comments

Comments
 (0)