From 459972b54624c665994f8a1d6079b413fbcfe9f4 Mon Sep 17 00:00:00 2001 From: Eric Santana Date: Sat, 9 May 2026 18:58:47 -0300 Subject: [PATCH] test(verifier): add typescript-solidity differential fuzz harness --- .github/workflows/test.yml | 8 ++ .../protokoll/test/differential/fuzz.test.ts | 127 +++++++++++++++++ .../protokoll/test/differential/harness.ts | 128 ++++++++++++++++++ .../protokoll/test/differential/proofArgs.ts | 38 ++++++ .../protokoll/test/differential/smoke.test.ts | 29 ++++ 5 files changed, 330 insertions(+) create mode 100644 packages/protokoll/test/differential/fuzz.test.ts create mode 100644 packages/protokoll/test/differential/harness.ts create mode 100644 packages/protokoll/test/differential/proofArgs.ts create mode 100644 packages/protokoll/test/differential/smoke.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 21f6793..0fa925b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,10 +61,18 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + submodules: recursive + - uses: foundry-rs/foundry-toolchain@c7450ba673e133f5ee30098b3b54f444d3a2ca2d # v1 + with: + version: 1.7.0 + token: ${{ secrets.GITHUB_TOKEN }} - uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: .nvmrc cache: pnpm - run: pnpm install --frozen-lockfile + - working-directory: packages/protokoll + run: forge build - run: pnpm -r test diff --git a/packages/protokoll/test/differential/fuzz.test.ts b/packages/protokoll/test/differential/fuzz.test.ts new file mode 100644 index 0000000..9b2e860 --- /dev/null +++ b/packages/protokoll/test/differential/fuzz.test.ts @@ -0,0 +1,127 @@ +import { describe, beforeAll, afterAll, it, expect } from 'vitest'; +import { generateOracleProof, encodePointHex } from '../../src/oracle/proof.js'; +import { scalarMul, G1_GENERATOR, CURVE_ORDER } from '../../src/math/curve.js'; +import { startHarness, stopHarness, verify, type Harness } from './harness.js'; +import { proofToArgs, flipBitInHex, makeRoundId, bytesToHex } from './proofArgs.js'; + +const MUTATIONS_PER_CATEGORY = Number.parseInt(process.env.HARNESS_MUTATIONS ?? '50', 10); + +describe('differential harness - mutation matrix', () => { + let h: Harness; + let baselineArgs: ReturnType; + + beforeAll(async () => { + h = await startHarness(); + const k = 0xdeadbeefn; + const roundId = makeRoundId('fuzz-baseline'); + const proof = await generateOracleProof(k, roundId); + baselineArgs = proofToArgs(proof, roundId); + const ok = await verify(h, baselineArgs); + if (!ok) throw new Error('baseline proof failed to verify - cannot fuzz'); + }, 120_000); + + afterAll(async () => { if (h) await stopHarness(h); }); + + it(`gamma bit flip x ${MUTATIONS_PER_CATEGORY}`, async () => { + const indices = pickBitIndices(128 * 8, MUTATIONS_PER_CATEGORY); + for (const bit of indices) { + const tampered = flipBitInHex(baselineArgs.gamma, bit); + const out = await verify(h, { ...baselineArgs, gamma: tampered }); + if (out) throw new Error(`gamma bit flip at index ${bit} verified - leak`); + expect(out).toBe(false); + } + }, 120_000); + + it(`c bit flip x ${MUTATIONS_PER_CATEGORY}`, async () => { + const cBytes = scalarToBytes(baselineArgs.c); + const indices = pickBitIndices(cBytes.length * 8, MUTATIONS_PER_CATEGORY); + for (const bit of indices) { + const mutated = flipBit(cBytes, bit); + const c = bytesToBigint(mutated) % CURVE_ORDER; + if (c === baselineArgs.c) continue; + const out = await verify(h, { ...baselineArgs, c }); + if (out) throw new Error(`c bit flip at index ${bit} verified - leak`); + expect(out).toBe(false); + } + }, 120_000); + + it(`s bit flip x ${MUTATIONS_PER_CATEGORY}`, async () => { + const sBytes = scalarToBytes(baselineArgs.s); + const indices = pickBitIndices(sBytes.length * 8, MUTATIONS_PER_CATEGORY); + for (const bit of indices) { + const mutated = flipBit(sBytes, bit); + const s = bytesToBigint(mutated) % CURVE_ORDER; + if (s === baselineArgs.s) continue; + const out = await verify(h, { ...baselineArgs, s }); + if (out) throw new Error(`s bit flip at index ${bit} verified - leak`); + expect(out).toBe(false); + } + }, 120_000); + + it(`mutated roundId x ${MUTATIONS_PER_CATEGORY}`, async () => { + for (let i = 0; i < MUTATIONS_PER_CATEGORY; i++) { + const altered = randomBytes32(); + if (altered === baselineArgs.roundId) continue; + const out = await verify(h, { ...baselineArgs, roundId: altered }); + if (out) throw new Error(`mutated roundId ${altered} verified - leak`); + expect(out).toBe(false); + } + }, 120_000); + + it(`mutated publicKey x ${MUTATIONS_PER_CATEGORY}`, async () => { + for (let i = 0; i < MUTATIONS_PER_CATEGORY; i++) { + const r = randomScalar(); + const fakePoint = scalarMul(r, G1_GENERATOR); + if (fakePoint === 'infinity') continue; + const fakeKeyHex = encodePointHex(fakePoint); + if (fakeKeyHex === baselineArgs.publicKey) continue; + const out = await verify(h, { ...baselineArgs, publicKey: fakeKeyHex }); + if (out) throw new Error(`mutated publicKey ${fakeKeyHex} verified - leak`); + expect(out).toBe(false); + } + }, 240_000); +}); + +function pickBitIndices(total: number, count: number): number[] { + const out = new Set(); + while (out.size < count && out.size < total) { + out.add(Math.floor(Math.random() * total)); + } + return [...out]; +} + +function scalarToBytes(n: bigint): Uint8Array { + const buf = new Uint8Array(32); + let v = n; + for (let i = 31; i >= 0; i--) { buf[i] = Number(v & 0xffn); v >>= 8n; } + return buf; +} + +function bytesToBigint(b: Uint8Array): bigint { + let n = 0n; + for (const x of b) n = (n << 8n) | BigInt(x); + return n; +} + +function flipBit(b: Uint8Array, bitIndex: number): Uint8Array { + const out = new Uint8Array(b); + const byteIdx = Math.floor(bitIndex / 8); + const bit = bitIndex % 8; + out[byteIdx] ^= (1 << (7 - bit)); + return out; +} + +function randomBytes32(): `0x${string}` { + const buf = new Uint8Array(32); + for (let i = 0; i < 32; i++) buf[i] = Math.floor(Math.random() * 256); + return bytesToHex(buf); +} + +function randomScalar(): bigint { + const buf = new Uint8Array(32); + for (let i = 0; i < 32; i++) buf[i] = Math.floor(Math.random() * 256); + let n = 0n; + for (const x of buf) n = (n << 8n) | BigInt(x); + n = n % CURVE_ORDER; + return n === 0n ? 1n : n; +} diff --git a/packages/protokoll/test/differential/harness.ts b/packages/protokoll/test/differential/harness.ts new file mode 100644 index 0000000..91c871d --- /dev/null +++ b/packages/protokoll/test/differential/harness.ts @@ -0,0 +1,128 @@ +import { spawn, type ChildProcess, execSync } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createPublicClient, createWalletClient, http, type Address, type Hex, type PublicClient, type WalletClient } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { foundry } from 'viem/chains'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = join(__dirname, '..', '..'); +const ARTIFACT_PATH = join(PACKAGE_ROOT, 'out', 'MonadVRFVerifier.sol', 'MonadVRFVerifier.json'); + +const DEV_PRIVATE_KEY: Hex = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + +export type Harness = { + anvil: ChildProcess; + port: number; + publicClient: PublicClient; + walletClient: WalletClient; + verifier: Address; + rpcUrl: string; +}; + +type Artifact = { + abi: unknown[]; + bytecode: { object: Hex } | Hex; +}; + +function pickPort(): number { + return 30000 + Math.floor(Math.random() * 20000); +} + +async function waitForRpc(rpcUrl: string, timeoutMs = 15_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'web3_clientVersion', params: [] }), + }); + if (res.ok) return; + } catch {} + await new Promise(r => setTimeout(r, 100)); + } + throw new Error(`anvil did not respond on ${rpcUrl} within ${timeoutMs}ms`); +} + +function loadArtifact(): Artifact { + if (!existsSync(ARTIFACT_PATH)) { + execSync('forge build', { cwd: PACKAGE_ROOT, stdio: 'inherit' }); + } + const json = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8')); + return { abi: json.abi, bytecode: json.bytecode }; +} + +export async function startHarness(): Promise { + const port = pickPort(); + const rpcUrl = `http://127.0.0.1:${port}`; + + const anvil = spawn( + 'anvil', + ['--port', String(port), '--hardfork', 'prague', '--silent'], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + anvil.stderr?.on('data', (chunk) => { + if (process.env.HARNESS_DEBUG) process.stderr.write(`[anvil] ${chunk}`); + }); + + await waitForRpc(rpcUrl); + + const account = privateKeyToAccount(DEV_PRIVATE_KEY); + const transport = http(rpcUrl); + const publicClient = createPublicClient({ chain: foundry, transport }); + const walletClient = createWalletClient({ account, chain: foundry, transport }); + + const artifact = loadArtifact(); + const bytecode: Hex = typeof artifact.bytecode === 'string' ? artifact.bytecode : artifact.bytecode.object; + + const hash = await walletClient.deployContract({ + abi: artifact.abi as never, + account, + chain: foundry, + bytecode, + args: [], + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (!receipt.contractAddress) throw new Error('verifier deployment failed: no contractAddress'); + + return { anvil, port, publicClient, walletClient, verifier: receipt.contractAddress, rpcUrl }; +} + +export async function stopHarness(h: Harness): Promise { + return new Promise((resolve) => { + h.anvil.once('exit', () => resolve()); + h.anvil.kill('SIGTERM'); + setTimeout(() => { try { h.anvil.kill('SIGKILL'); } catch {} }, 2000); + }); +} + +export const VERIFIER_ABI = [ + { + type: 'function', + name: 'verifyProof', + stateMutability: 'view', + inputs: [ + { name: 'publicKey', type: 'bytes' }, + { name: 'roundId', type: 'bytes32' }, + { name: 'gamma', type: 'bytes' }, + { name: 'c', type: 'uint256' }, + { name: 's', type: 'uint256' }, + ], + outputs: [{ type: 'bool' }], + }, +] as const; + +export async function verify( + h: Harness, + args: { publicKey: Hex; roundId: Hex; gamma: Hex; c: bigint; s: bigint }, +): Promise { + const result = await h.publicClient.readContract({ + address: h.verifier, + abi: VERIFIER_ABI, + functionName: 'verifyProof', + args: [args.publicKey, args.roundId, args.gamma, args.c, args.s], + }); + return result as boolean; +} diff --git a/packages/protokoll/test/differential/proofArgs.ts b/packages/protokoll/test/differential/proofArgs.ts new file mode 100644 index 0000000..d7aed1a --- /dev/null +++ b/packages/protokoll/test/differential/proofArgs.ts @@ -0,0 +1,38 @@ +import type { Hex } from 'viem'; +import { encodePoint, encodePointHex, type OracleProof } from '../../src/oracle/proof.js'; + +export function bytesToHex(bytes: Uint8Array): Hex { + return `0x${Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')}` as Hex; +} + +export type VerifyArgs = { publicKey: Hex; roundId: Hex; gamma: Hex; c: bigint; s: bigint }; + +export function proofToArgs(proof: OracleProof, roundId: Uint8Array): VerifyArgs { + return { + publicKey: encodePointHex(proof.publicKey), + roundId: bytesToHex(roundId) as Hex, + gamma: encodePointHex(proof.gamma), + c: proof.c, + s: proof.s, + }; +} + +export function flipBitInHex(hex: Hex, bitIndex: number): Hex { + const raw = hex.startsWith('0x') ? hex.slice(2) : hex; + const bytes = new Uint8Array(raw.length / 2); + for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(raw.slice(i * 2, i * 2 + 2), 16); + const byteIdx = Math.floor(bitIndex / 8); + const bit = bitIndex % 8; + if (byteIdx >= bytes.length) throw new Error(`bit index ${bitIndex} out of range for ${bytes.length}-byte buffer`); + bytes[byteIdx] ^= (1 << (7 - bit)); + return bytesToHex(bytes); +} + +export function makeRoundId(label: string): Uint8Array { + const b = new Uint8Array(32); + const enc = new TextEncoder().encode(label); + b.set(enc.slice(0, 32)); + return b; +} + +export { encodePoint, encodePointHex }; diff --git a/packages/protokoll/test/differential/smoke.test.ts b/packages/protokoll/test/differential/smoke.test.ts new file mode 100644 index 0000000..8cfaec1 --- /dev/null +++ b/packages/protokoll/test/differential/smoke.test.ts @@ -0,0 +1,29 @@ +import { describe, beforeAll, afterAll, it, expect } from 'vitest'; +import { generateOracleProof } from '../../src/oracle/proof.js'; +import { startHarness, stopHarness, verify, type Harness } from './harness.js'; +import { proofToArgs, flipBitInHex, makeRoundId } from './proofArgs.js'; + +describe('differential harness - smoke', () => { + let h: Harness; + + beforeAll(async () => { h = await startHarness(); }, 60_000); + afterAll(async () => { if (h) await stopHarness(h); }); + + it('valid proof verifies end-to-end', async () => { + const k = 42n; + const roundId = makeRoundId('round-1'); + const proof = await generateOracleProof(k, roundId); + const args = proofToArgs(proof, roundId); + expect(await verify(h, args)).toBe(true); + }); + + it('one-bit flip in gamma causes verification to fail', async () => { + const k = 42n; + const roundId = makeRoundId('round-1'); + const proof = await generateOracleProof(k, roundId); + const args = proofToArgs(proof, roundId); + const tamperedGamma = flipBitInHex(args.gamma, 1023); + const out = await verify(h, { ...args, gamma: tamperedGamma }); + expect(out).toBe(false); + }); +});