@@ -23,10 +23,23 @@ function pointToBytes(p: ProjectivePoint): Uint8Array {
2323
2424const DOMAIN_BIT_PROOF = 'pedersen-bit-proof-v1' ;
2525const DOMAIN_SUM_BINDING = 'pedersen-sum-binding-v1' ;
26+ const DOMAIN_COMMITMENT_BINDING = 'pedersen-commitment-binding-v1' ;
2627
2728/** Empty context (no binding) */
2829const EMPTY_CONTEXT = new Uint8Array ( 0 ) ;
2930
31+ /** Hex validation patterns */
32+ const HEX_COMPRESSED_POINT = / ^ (?: 0 2 | 0 3 ) [ 0 - 9 a - f ] { 64 } $ / i;
33+ const HEX_SCALAR_64 = / ^ [ 0 - 9 a - 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
3245export interface PedersenCommitment {
@@ -44,6 +57,9 @@ export interface PedersenCommitment {
4457 * @param blinding - Optional blinding factor (random if not provided)
4558 */
4659export 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 */
458544export 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' ) ;
0 commit comments