Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

# 02/07/26 - Audit 1a697 and a9dea: Field.toBigInt debug-only usage and undocumented value-dependence
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/index.min.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
66 changes: 64 additions & 2 deletions src/sha/1f602_regression.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
});
});
66 changes: 66 additions & 0 deletions src/sha/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Loading