Skip to content

Commit 9ca8084

Browse files
fix: require expected policy in proof verification
1 parent 3dc31a1 commit 9ca8084

4 files changed

Lines changed: 124 additions & 63 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ import { createRangeProof, verifyRangeProof } from '@forgesworn/range-proof';
2727
// Prove that `value` is in [min, max] without revealing `value`
2828
const proof = createRangeProof(value, min, max);
2929

30-
// Anyone can verify the proof
31-
const valid = verifyRangeProof(proof); // true
30+
// Verifiers must supply the public range they expect
31+
const valid = verifyRangeProof(proof, min, max); // true
3232
```
3333

3434
### Age range proofs
@@ -38,10 +38,11 @@ import { createAgeRangeProof, verifyAgeRangeProof } from '@forgesworn/range-proo
3838

3939
// Prove age is between 8 and 12 (e.g. child category)
4040
const proof = createAgeRangeProof(10, '8-12');
41-
const valid = verifyAgeRangeProof(proof); // true
41+
const valid = verifyAgeRangeProof(proof, '8-12'); // true
4242

4343
// Prove age is 18 or over
4444
const adultProof = createAgeRangeProof(25, '18+');
45+
const adultValid = verifyAgeRangeProof(adultProof, '18+'); // true
4546
```
4647

4748
### Binding context
@@ -50,6 +51,7 @@ Pass an optional context string to bind the proof to a specific credential or id
5051

5152
```typescript
5253
const proof = createRangeProof(value, min, max, 'subject-pubkey-hex');
54+
const valid = verifyRangeProof(proof, min, max, 'subject-pubkey-hex');
5355
```
5456

5557
### Pedersen commitments

llms.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Prove a value is in range:
1818
import { createRangeProof, verifyRangeProof } from '@forgesworn/range-proof';
1919

2020
const proof = createRangeProof(25, 18, 150);
21-
const valid = verifyRangeProof(proof); // true
21+
const valid = verifyRangeProof(proof, 18, 150); // true
2222
```
2323

2424
Bind a proof to a subject identity (prevents transplant attacks):
@@ -46,9 +46,9 @@ An optional context string is included in all Fiat-Shamir challenges, binding th
4646
### Core Functions
4747

4848
- `createRangeProof(value, min, max, bindingContext?)` — prove `value` is in `[min, max]`
49-
- `verifyRangeProof(proof)` — verify a range proof (returns boolean)
49+
- `verifyRangeProof(proof, expectedMin, expectedMax, expectedBindingContext?)` — verify a range proof against expected public inputs
5050
- `createAgeRangeProof(age, ageRange, subjectPubkey?)` — convenience wrapper accepting `'18+'` or `'8-12'` format
51-
- `verifyAgeRangeProof(proof)` — verify an age range proof
51+
- `verifyAgeRangeProof(proof, expectedAgeRange, expectedSubjectPubkey?)` — verify an age range proof against an expected policy
5252

5353
### Pedersen Commitments
5454

src/range-proof.ts

Lines changed: 72 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,51 @@ function isValidScalarHex(hex: string): boolean {
4343
return HEX_SCALAR_64.test(hex);
4444
}
4545

46+
function normalizeBindingContext(bindingContext?: string): string | undefined {
47+
return bindingContext === '' ? undefined : bindingContext;
48+
}
49+
50+
function bindingContextToBytes(bindingContext?: string): Uint8Array {
51+
const normalized = normalizeBindingContext(bindingContext);
52+
if (normalized === undefined) return EMPTY_CONTEXT;
53+
if (normalized.length > MAX_CONTEXT_BYTES) {
54+
throw new ValidationError('Binding context exceeds maximum length (1024 bytes)');
55+
}
56+
const context = utf8ToBytes(normalized);
57+
if (context.length > MAX_CONTEXT_BYTES) {
58+
throw new ValidationError('Binding context exceeds maximum length (1024 bytes)');
59+
}
60+
return context;
61+
}
62+
63+
function sameBindingContext(left?: string, right?: string): boolean {
64+
return normalizeBindingContext(left) === normalizeBindingContext(right);
65+
}
66+
67+
function parseAgeRange(ageRange: string): { min: number; max: number } {
68+
const DIGITS_ONLY = /^\d+$/;
69+
70+
if (ageRange.endsWith('+')) {
71+
const minStr = ageRange.slice(0, -1);
72+
if (!DIGITS_ONLY.test(minStr)) {
73+
throw new ValidationError('Invalid age range format (expected "min-max" or "min+")');
74+
}
75+
return { min: parseInt(minStr, 10), max: 150 };
76+
}
77+
78+
const parts = ageRange.split('-');
79+
if (parts.length !== 2) {
80+
throw new ValidationError('Invalid age range format (expected "min-max" or "min+")');
81+
}
82+
if (!DIGITS_ONLY.test(parts[0]) || !DIGITS_ONLY.test(parts[1])) {
83+
throw new ValidationError('Invalid age range format (expected "min-max" or "min+")');
84+
}
85+
return {
86+
min: parseInt(parts[0], 10),
87+
max: parseInt(parts[1], 10),
88+
};
89+
}
90+
4691
// --- Pedersen Commitment ---
4792

4893
export interface PedersenCommitment {
@@ -387,11 +432,8 @@ export function createRangeProof(value: number, min: number, max: number, bindin
387432
if (max < min) throw new ValidationError('Maximum must be >= minimum');
388433
if (value < min || value > max) throw new ValidationError('Value is not within the specified range');
389434

390-
if (bindingContext !== undefined && bindingContext.length > MAX_CONTEXT_BYTES) {
391-
throw new ValidationError('Binding context exceeds maximum length (1024 bytes)');
392-
}
393-
const context = bindingContext ? utf8ToBytes(bindingContext) : EMPTY_CONTEXT;
394-
if (context.length > MAX_CONTEXT_BYTES) throw new ValidationError('Binding context exceeds maximum length (1024 bytes)');
435+
const normalizedBindingContext = normalizeBindingContext(bindingContext);
436+
const context = bindingContextToBytes(normalizedBindingContext);
395437

396438
const range = max - min;
397439
const bits = bitsNeeded(range);
@@ -462,26 +504,38 @@ export function createRangeProof(value: number, min: number, max: number, bindin
462504
sumBindingS: sumBinding.s,
463505
commitBindingE: commitBinding.e,
464506
commitBindingS: commitBinding.s,
465-
context: bindingContext,
507+
...(normalizedBindingContext !== undefined ? { context: normalizedBindingContext } : {}),
466508
};
467509
}
468510

469511
/**
470512
* Verify a range proof.
471513
*
472514
* @param proof - The range proof to verify
515+
* @param expectedMin - The minimum bound the verifier expects
516+
* @param expectedMax - The maximum bound the verifier expects
517+
* @param expectedBindingContext - Optional binding context the verifier expects
473518
* @returns true if the proof is valid
474519
*/
475-
export function verifyRangeProof(proof: RangeProof): boolean {
520+
export function verifyRangeProof(
521+
proof: RangeProof,
522+
expectedMin: number,
523+
expectedMax: number,
524+
expectedBindingContext?: string
525+
): boolean {
476526
try {
477527
const { min, max, bits, lowerProof, upperProof } = proof;
478-
if (proof.context && proof.context.length > MAX_CONTEXT_BYTES) return false;
479-
const context = proof.context ? utf8ToBytes(proof.context) : EMPTY_CONTEXT;
480-
if (context.length > MAX_CONTEXT_BYTES) return false;
528+
if (!Number.isSafeInteger(expectedMin) || !Number.isSafeInteger(expectedMax)) return false;
529+
if (expectedMin < 0 || expectedMax < 0 || expectedMax < expectedMin) return false;
530+
531+
const context = bindingContextToBytes(proof.context);
532+
bindingContextToBytes(expectedBindingContext);
481533

482534
// Validate range bounds
483535
if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max)) return false;
484536
if (min < 0 || max < 0 || max < min) return false;
537+
if (min !== expectedMin || max !== expectedMax) return false;
538+
if (!sameBindingContext(proof.context, expectedBindingContext)) return false;
485539

486540
// Recompute expected bits from range — do not trust proof.bits blindly
487541
const expectedBits = bitsNeeded(max - min);
@@ -557,31 +611,21 @@ export function verifyRangeProof(proof: RangeProof): boolean {
557611
* @returns The range proof
558612
*/
559613
export function createAgeRangeProof(age: number, ageRange: string, subjectPubkey?: string): RangeProof {
560-
const DIGITS_ONLY = /^\d+$/;
561-
562-
// Handle "18+" format (no upper bound — use 150 as practical max)
563-
if (ageRange.endsWith('+')) {
564-
const minStr = ageRange.slice(0, -1);
565-
if (!DIGITS_ONLY.test(minStr)) throw new ValidationError('Invalid age range format (expected "min-max" or "min+")');
566-
return createRangeProof(age, parseInt(minStr, 10), 150, subjectPubkey);
567-
}
568-
569-
const parts = ageRange.split('-');
570-
if (parts.length !== 2) throw new ValidationError('Invalid age range format (expected "min-max" or "min+")');
571-
if (!DIGITS_ONLY.test(parts[0]) || !DIGITS_ONLY.test(parts[1])) {
572-
throw new ValidationError('Invalid age range format (expected "min-max" or "min+")');
573-
}
574-
const min = parseInt(parts[0], 10);
575-
const max = parseInt(parts[1], 10);
614+
const { min, max } = parseAgeRange(ageRange);
576615

577616
return createRangeProof(age, min, max, subjectPubkey);
578617
}
579618

580619
/**
581620
* Verify an age range proof.
582621
*/
583-
export function verifyAgeRangeProof(proof: RangeProof): boolean {
584-
return verifyRangeProof(proof);
622+
export function verifyAgeRangeProof(proof: RangeProof, expectedAgeRange: string, expectedSubjectPubkey?: string): boolean {
623+
try {
624+
const { min, max } = parseAgeRange(expectedAgeRange);
625+
return verifyRangeProof(proof, min, max, expectedSubjectPubkey);
626+
} catch {
627+
return false;
628+
}
585629
}
586630

587631
/**

0 commit comments

Comments
 (0)