diff --git a/CHANGELOG.md b/CHANGELOG.md index 8949664..bb78e78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,33 @@ Results: - Regression tests: 4 pass, 0 fail. `wordToBytes` now throws for any bytesPerWord that would let the byte range wrap around `Field.ORDER`. +### Commit 3, `wordToBytesCanonical`, a sound decomposition for `bytesPerWord` greater than 31 + +Commit 2 makes `wordToBytes` safe by rejecting `bytesPerWord` greater than 31 outright, which is sufficient for the finding as scoped, but it leaves no way to decompose a `Field` into more than 31 bytes at all. A sibling function is added here to give downstream users a known safe option to reach for when they need more than 31 bytes, usable whether inside or outside circuitry, instead of each one rolling its own unconstrained, unsafe decomposition. + +- **`src/sha/utils.ts`**: + - Added `isCanonicalFieldBytesLE(bytes: UInt8[]): Bool`, asserting a little-endian byte array is strictly less than the field prime `p` (Pallas base field), against a new `FIELD_PRIME_LE` constant. + - Added `wordToBytesCanonical(word: Field, bytesPerWord = 8): UInt8[]`. At `bytesPerWord <= 31` it delegates to `wordToBytes`. Above 31, it performs the same `bytesToWord(bytes).assertEquals(word)` reconstruction check, then additionally asserts `isCanonicalFieldBytesLE(bytes)`. That second check is the fix: the reconstruction equality alone only holds mod `p`, so a prover solving the constraint system directly, not bound to our witness-generation code, could satisfy it with `bytes = word + k*p` for `k >= 1` instead of `word` itself. For example `bytes = p` satisfies the equality for `word = 0` just as validly as `bytes = 0`. The range check rules out every such alternate. + - `wordToBytesCanonical`, `isCanonicalFieldBytesLE`, `FIELD_PRIME_LE` added to the module's export list. +- **`src/index.ts`** / **`src/index.min.ts`**: `wordToBytesCanonical` and `isCanonicalFieldBytesLE` exported as public API siblings of `wordToBytes`. +- **`src/sha/1f602_regression.spec.ts`**: added a `regression_1f602_wordToBytesCanonical` block: + - Genuine-witness round-trip at 31 bytes (delegation boundary) and 32 bytes. + - `isCanonicalFieldBytesLE` accepts the boundary-legal values `0` and `p - 1`. + - `isCanonicalFieldBytesLE` rejects `p` and `p + 1` even though `bytesToWord` reduces them to `0` and `1`. + - `FIELD_PRIME_LE` checked against `Field.ORDER`. + +Gate cost (`check_wordtobytes_cost.mjs`, `ZkProgram.analyzeMethods`): + +- `wordToBytes` at `bytesPerWord` 31: 94 rows. +- `wordToBytesCanonical` at `bytesPerWord` 32: 342 rows. +- The canonicity check adds 248 rows over `wordToBytes`. + +Run: `npm run test:jest -- src/sha/1f602_regression.spec.ts` + +Results: + +- Regression tests: 11 pass, 0 fail. + --- ## 4/8/26 - Updating o1js diff --git a/README.md b/README.md index 5b16275..7b14dae 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,8 @@ The package exports via the `/min` path, useful types and some limited utilities import { parsePlonkPublicInputsProvable, wordToBytes, + wordToBytesCanonical, + isCanonicalFieldBytesLE, NodeProofLeft, FrC, DeferredPromise diff --git a/src/index.min.ts b/src/index.min.ts index 9921cfd..9b9f57e 100644 --- a/src/index.min.ts +++ b/src/index.min.ts @@ -14,7 +14,11 @@ export type * from '@nori-zk/proof-conversion-utils'; // Utilities export { parsePublicInputsProvable as parsePlonkPublicInputsProvable } from './plonk/parse_pi.js'; -export { wordToBytes } from './sha/utils.js'; +export { + wordToBytes, + wordToBytesCanonical, + isCanonicalFieldBytesLE, +} from './sha/utils.js'; export { NodeProofLeft } from './structs.js'; export { FrC } from './towers/fr.js'; export { DeferredPromise } from './utils/DeferredPromise.js'; diff --git a/src/index.ts b/src/index.ts index e69a38b..a1ae65b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,11 @@ export type * from './api/sp1/schema.js'; export * from '@nori-zk/proof-conversion-utils'; export { parsePublicInputsProvable as parsePlonkPublicInputsProvable } from './plonk/parse_pi.js'; -export { wordToBytes } from './sha/utils.js'; +export { + wordToBytes, + wordToBytesCanonical, + isCanonicalFieldBytesLE, +} from './sha/utils.js'; export { NodeProofLeft } from './structs.js'; export { FrC } from './towers/fr.js'; export { DeferredPromise } from './utils/DeferredPromise.js'; diff --git a/src/sha/1f602_regression.spec.ts b/src/sha/1f602_regression.spec.ts index 7cac31d..de9d640 100644 --- a/src/sha/1f602_regression.spec.ts +++ b/src/sha/1f602_regression.spec.ts @@ -1,5 +1,11 @@ -import { Field } from 'o1js'; -import { bytesToWord, wordToBytes } from './utils.js'; +import { Field, UInt8 } from 'o1js'; +import { + bytesToWord, + wordToBytes, + wordToBytesCanonical, + isCanonicalFieldBytesLE, + FIELD_PRIME_LE, +} from './utils.js'; describe('regression_1f602_wordToBytes_canonicity_bound', () => { let maxSafeBytesPerWord: number; @@ -34,3 +40,59 @@ describe('regression_1f602_wordToBytes_canonicity_bound', () => { expect(() => wordToBytes(Field(42n))).not.toThrow(); }); }); + +function bigintToBytesLE(x: bigint, len: number): UInt8[] { + return Array.from({ length: len }, (_, k) => UInt8.from((x >> BigInt(8 * k)) & 0xffn)); +} + +describe('regression_1f602_wordToBytesCanonical', () => { + test('at the safe bound (31 bytes), delegates to wordToBytes and round-trips', () => { + const word = Field(123456789n); + const bytes = wordToBytesCanonical(word, 31); + expect(bytesToWord(bytes).toBigInt()).toBe(word.toBigInt()); + }); + + test('one past the safe bound (32 bytes), a genuine witness does not throw and round-trips', () => { + const word = Field(123456789n); + const bytes = wordToBytesCanonical(word, 32); + expect(bytesToWord(bytes).toBigInt()).toBe(word.toBigInt()); + }); + + test('isCanonicalFieldBytesLE accepts p-1, the largest canonical value', () => { + const bytes = bigintToBytesLE(Field.ORDER - 1n, 32); + expect(() => isCanonicalFieldBytesLE(bytes).assertTrue()).not.toThrow(); + }); + + test('isCanonicalFieldBytesLE accepts 0', () => { + const bytes = bigintToBytesLE(0n, 32); + expect(() => isCanonicalFieldBytesLE(bytes).assertTrue()).not.toThrow(); + }); + + test('isCanonicalFieldBytesLE rejects p itself, even though it reduces to 0 mod p', () => { + const pBytes = bigintToBytesLE(Field.ORDER, 32); + + // this is exactly the hole finding 1f602 describes: the mod-p + // reconstruction equality alone cannot distinguish word=0 from the + // non-canonical witness bytes=p, since p === 0 (mod p). + expect(bytesToWord(pBytes).toBigInt()).toBe(0n); + + // isCanonicalFieldBytesLE is the fix: it must still reject bytes=p as + // a non-canonical representation of 0. + expect(() => isCanonicalFieldBytesLE(pBytes).assertTrue()).toThrow(); + }); + + test('isCanonicalFieldBytesLE rejects p+1, even though it reduces to 1 mod p', () => { + const pPlusOneBytes = bigintToBytesLE(Field.ORDER + 1n, 32); + + expect(bytesToWord(pPlusOneBytes).toBigInt()).toBe(1n); + expect(() => isCanonicalFieldBytesLE(pPlusOneBytes).assertTrue()).toThrow(); + }); + + test('FIELD_PRIME_LE matches Field.ORDER', () => { + const pFromConst = FIELD_PRIME_LE.reduce( + (acc, b, i) => acc + (b << BigInt(8 * i)), + 0n + ); + expect(pFromConst).toBe(Field.ORDER); + }); +}); diff --git a/src/sha/utils.ts b/src/sha/utils.ts index 744346e..b0ff0de 100644 --- a/src/sha/utils.ts +++ b/src/sha/utils.ts @@ -62,9 +62,75 @@ function wordToBytes(word: Field, bytesPerWord = 8): UInt8[] { return bytes; } +// Field prime p (Pallas base field), little-endian bytes. p fits in 32 bytes, +// so any candidate byte array longer than 32 bytes is canonical only if every +// byte above index 31 is zero. +const FIELD_PRIME_LE: bigint[] = [ + 1n, 0n, 0n, 0n, 237n, 48n, 45n, 153n, 27n, 249n, 76n, 9n, 252n, 152n, 70n, + 34n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 64n, +]; + +// Asserts that `bytes`, read little-endian, is strictly less than the field +// prime p. Scans most-significant byte to least-significant; `strictlyLess` +// latches the first byte-wise difference from p and is never overwritten +// after that, `different` latches true the moment any byte diverges from p. +function isCanonicalFieldBytesLE(bytes: UInt8[]): Bool { + let strictlyLess = Bool(false); + let different = Bool(false); + + for (let i = bytes.length - 1; i >= 0; i--) { + // UInt8.lessThan uses an 8-bit-bounded comparison gadget (single + // rangeCheck8) instead of Field.lessThan's generic full-field + // comparison gadget, which is far more expensive since it doesn't + // know either operand is byte-sized. + const pByte = UInt8.from(i < 32 ? FIELD_PRIME_LE[i] : 0n); + const byte = bytes[i]; + + const isLess = byte.lessThan(pByte); + const isDifferent = byte.value.equals(pByte.value).not(); + + strictlyLess = Provable.if(different, strictlyLess, isLess); + different = different.or(isDifferent); + } + + return strictlyLess; +} + +// wordToBytesCanonical - safe sibling of wordToBytes for bytesPerWord > 31 +// (Finding 1f602). At bytesPerWord <= 31 the reconstruction constraint alone +// already pins the byte array down uniquely (2^(8*bytesPerWord) <= p), so +// this delegates straight to wordToBytes. Above that, the reconstruction +// equality is only mod p, so `bytes` could witness `word + k*p` for some +// k >= 1 instead of `word` itself; this adds the missing range constraint, +// asserting `bytes` is the canonical representative in [0, p), which rules +// out every such alternate witness regardless of k. +function wordToBytesCanonical(word: Field, bytesPerWord = 8): UInt8[] { + if (1n << BigInt(8 * bytesPerWord) <= Field.ORDER) { + return wordToBytes(word, bytesPerWord); + } + + let bytes = Provable.witness(Provable.Array(UInt8, bytesPerWord), () => { + let w = word.toBigInt(); + return Array.from({ length: bytesPerWord }, (_, k) => + UInt8.from((w >> BigInt(8 * k)) & 0xffn) + ); + }); + + bytesToWord(bytes).assertEquals(word); + + isCanonicalFieldBytesLE(bytes).assertTrue( + 'wordToBytesCanonical: witnessed bytes are not the canonical representation of word' + ); + + return bytes; +} + export { provableBn254BaseFieldToBytes, provableBn254ScalarFieldToBytes, wordToBytes, + wordToBytesCanonical, bytesToWord, + isCanonicalFieldBytesLE, + FIELD_PRIME_LE, };