diff --git a/packages/crypto-transaction/source/deserializer.fuzz.test.ts b/packages/crypto-transaction/source/deserializer.fuzz.test.ts new file mode 100644 index 0000000000..edd2b6bf21 --- /dev/null +++ b/packages/crypto-transaction/source/deserializer.fuzz.test.ts @@ -0,0 +1,116 @@ +import type { Contracts } from "@mainsail/contracts"; +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; +import { describe } from "@mainsail/test-runner"; +import { InvalidAddressError } from "viem"; + +import { wallet } from "../test/fixtures/index.js"; +import { encodeLegacy, encodeList, encodeRlp } from "../test/helpers/canonical-transaction.js"; +import { prepareSandbox } from "../test/helpers/prepare-sandbox.js"; +import { createRandom, MUTATIONS, randomFields } from "../test/helpers/rlp-fuzz.js"; + +// Reproduce a failure with the FUZZ_SEED and FUZZ_RUNS it prints. Run longer with e.g. FUZZ_RUNS=100000. +const SEED = Number(process.env.FUZZ_SEED ?? 1403); +const RUNS = Number(process.env.FUZZ_RUNS ?? 10000); + +const BASE = [[1], [1], [1], [...Buffer.from(wallet.address.slice(2), "hex")], [1], [], [37], [1], [1]].map((field) => + Uint8Array.from(field), +); + +const withField = (index: number, value: Uint8Array): Uint8Array[] => + BASE.map((field, position) => (position === index ? value : field)); + +const withRawItem = (index: number, item: number[]): Buffer => + encodeList(BASE.map((field, position) => (position === index ? Buffer.from(item) : encodeRlp(field)))); + +const payloadOf55 = Buffer.concat( + withField(5, new Uint8Array(55 - encodeLegacy(BASE).length + 1)).map((field) => encodeRlp(field)), +); + +const REGRESSIONS: [name: string, input: Buffer, expected: "accept" | "reject"][] = [ + ["zero address", encodeLegacy(withField(3, new Uint8Array(20))), "accept"], + ["64 KiB data", encodeLegacy(withField(5, new Uint8Array(65_536))), "accept"], + ["0x7f as 0x81 0x7f", withRawItem(5, [0x81, 0x7f]), "reject"], + ["55-byte string in long form", withRawItem(5, [0xb8, 55, ...new Uint8Array(55)]), "reject"], + ["55-byte list in long form", Buffer.concat([Buffer.from([0xf8, 55]), payloadOf55]), "reject"], + ["nested list as a field", withRawItem(5, [0xc0]), "reject"], +]; + +describe<{ + app: Application; + deserializer: Contracts.Crypto.TransactionDeserializer; + serializer: Contracts.Crypto.TransactionSerializer; +}>("Deserializer (fuzz)", ({ it, beforeEach }) => { + beforeEach(async (context) => { + await prepareSandbox(context); + + context.deserializer = context.app.get( + Identifiers.Cryptography.Transaction.Deserializer, + ); + context.serializer = context.app.get( + Identifiers.Cryptography.Transaction.Serializer, + ); + }); + + // Nodes rebuild stored transactions with the serializer when they serve a block, so any accepted + // encoding that re-serializes differently no longer matches the block's payloadSize. + it("should accept only encodings that re-serialize to identical bytes", async ({ deserializer, serializer }) => { + if (!Number.isSafeInteger(SEED) || !Number.isSafeInteger(RUNS) || RUNS < 1) { + throw new Error("FUZZ_SEED and FUZZ_RUNS must be integers, FUZZ_RUNS at least 1"); + } + + // Only the deserializer's own errors (and viem's checksum error for a malformed `to`) count as + // rejections; anything else is a crash on malformed input and fails the run. + const isRejection = (error: unknown): boolean => + error instanceof InvalidAddressError || (error instanceof Error && /^decoded? RLP /.test(error.message)); + + const accepts = async (input: Buffer, label: string): Promise => { + let reserialized: Buffer; + try { + reserialized = await serializer.serialize((await deserializer.deserialize(input)).data); + } catch (error) { + if (isRejection(error)) { + return false; + } + + throw new Error(`${label}: crashed on input ${input.toString("hex")}`, { cause: error }); + } + + if (!reserialized.equals(input)) { + throw new Error( + `${label}: accepted an encoding that re-serializes differently\n input: ${input.toString("hex")}\n reserialized: ${reserialized.toString("hex")}`, + ); + } + + return true; + }; + + // Boundary inputs the random generators rarely or never produce. + for (const [name, input, expected] of REGRESSIONS) { + const accepted = await accepts(input, name); + if (accepted && expected === "reject") { + throw new Error(`${name}: accepted a non-canonical encoding\n input: ${input.toString("hex")}`); + } + + if (!accepted && expected === "accept") { + throw new Error(`${name}: rejected a canonical encoding\n input: ${input.toString("hex")}`); + } + } + + const random = createRandom(SEED); + + for (let run = 0; run < RUNS; run++) { + const label = `FUZZ_SEED=${SEED} FUZZ_RUNS=${run + 1}`; + const fields = randomFields(random); + const canonical = encodeLegacy(fields); + + if (!(await accepts(canonical, label))) { + throw new Error(`${label}: rejected a canonical encoding\n input: ${canonical.toString("hex")}`); + } + + for (const [name, mutate] of MUTATIONS) { + await accepts(mutate(random, fields), `${label} ${name}`); + } + } + }); +}); diff --git a/packages/crypto-transaction/source/deserializer.test.ts b/packages/crypto-transaction/source/deserializer.test.ts index eb6dd5148f..443749f39a 100644 --- a/packages/crypto-transaction/source/deserializer.test.ts +++ b/packages/crypto-transaction/source/deserializer.test.ts @@ -6,6 +6,8 @@ import { parseTransaction } from "viem"; import { Deserialized, Serialized } from "../test/fixtures/index.js"; import { encodeLegacy, + encodeList, + encodeRlp, fixedWidth32, legacyRlpFields, signTransfer, @@ -195,4 +197,93 @@ describe<{ await assert.rejects(() => deserializer.deserialize(encodeLegacy(orderFields)), "out of range"); } }); + + it("should reject a transaction with 11 fields", async ({ app, deserializer }) => { + const fields = legacyRlpFields(await signTransfer(app)); + + await assert.resolves(() => deserializer.deserialize(encodeLegacy([...fields, new Uint8Array(65)]))); + await assert.rejects( + () => deserializer.deserialize(encodeLegacy([...fields, new Uint8Array(65), new Uint8Array()])), + "decoded RLP contains too many fields", + ); + }); + + it("should reject a legacy second signature that is not 65 bytes", async ({ app, deserializer }) => { + const fields = legacyRlpFields(await signTransfer(app)); + + await assert.resolves(() => deserializer.deserialize(encodeLegacy([...fields, new Uint8Array(65)]))); + + for (const length of [0, 1, 64, 66]) { + await assert.rejects( + () => deserializer.deserialize(encodeLegacy([...fields, new Uint8Array(length).fill(1)])), + "decoded RLP legacy second signature is not 65 bytes", + ); + } + }); + + it("should reject number fields above the safe integer range", async ({ app, deserializer }) => { + const canonical = await signTransfer(app); + const maxSafe = Uint8Array.of(0x1f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff); // 2^53 - 1 + const aboveSafe = Uint8Array.of(0x20, 0, 0, 0, 0, 0, 0); // 2^53 + + for (const [index, field] of [ + [1, "gasPrice"], + [2, "gasLimit"], + [6, "v"], + ] as const) { + const accepted = legacyRlpFields(canonical); + accepted[index] = maxSafe; + await assert.resolves(() => deserializer.deserialize(encodeLegacy(accepted))); + + for (const value of [aboveSafe, new Uint8Array(200).fill(0xff)]) { + const rejected = legacyRlpFields(canonical); + rejected[index] = value; + await assert.rejects( + () => deserializer.deserialize(encodeLegacy(rejected)), + `decoded RLP ${field} exceeds the safe integer range`, + ); + } + } + }); + + it("should reject a v below 35 (pre-EIP-155)", async ({ app, deserializer }) => { + const canonical = await signTransfer(app); + + const lowest = legacyRlpFields(canonical); + lowest[6] = Uint8Array.of(35); + await assert.resolves(() => deserializer.deserialize(encodeLegacy(lowest))); + + for (const v of [new Uint8Array(), Uint8Array.of(27), Uint8Array.of(28), Uint8Array.of(34)]) { + const fields = legacyRlpFields(canonical); + fields[6] = v; + + await assert.rejects( + () => deserializer.deserialize(encodeLegacy(fields)), + "decoded RLP v is below 35 (not EIP-155)", + ); + } + }); + + it("should reject length prefixes that overflow 32 bits", async ({ app, deserializer }) => { + const items = [...legacyRlpFields(await signTransfer(app)), new Uint8Array(65)].map((item) => encodeRlp(item)); + const payload = Buffer.concat(items); + assert.true(payload.length < 256); + + await assert.resolves(() => deserializer.deserialize(encodeList(items))); + await assert.rejects( + () => + deserializer.deserialize(Buffer.concat([Buffer.from([0xfc, 0x01, 0, 0, 0, payload.length]), payload])), + "decode RLP truncated long list", + ); + await assert.rejects( + () => + deserializer.deserialize( + encodeList([ + ...items.slice(0, 9), + Buffer.concat([Buffer.from([0xbc, 0x01, 0, 0, 0, 65]), new Uint8Array(65)]), + ]), + ), + "decode RLP truncated long str", + ); + }); }); diff --git a/packages/crypto-transaction/source/deserializer.ts b/packages/crypto-transaction/source/deserializer.ts index 54130af89e..1409c3638b 100644 --- a/packages/crypto-transaction/source/deserializer.ts +++ b/packages/crypto-transaction/source/deserializer.ts @@ -36,7 +36,7 @@ function decodeListBounds(buffer: Uint8Array): { start: number; end: number } { throw new Error("decode RLP leading zero in length"); } - length = (length << 8) | v; + length = length * 256 + v; } if (length < 56) { throw new Error("decode RLP non-minimal long list"); @@ -118,7 +118,7 @@ function readLength(buffer: Uint8Array, offset: number, lengthOfLength: number) if (index === 0 && lengthOfLength > 1 && v === 0) { throw new Error("decode RLP leading zero in length"); } - length = (length << 8) | v; + length = length * 256 + v; } return { len: length, next: offset + lengthOfLength }; @@ -138,7 +138,7 @@ export class Deserializer implements Contracts.Crypto.TransactionDeserializer { const fields: Hex[] = []; let offset = start; while (offset < end) { - if (fields.length > 10) { + if (fields.length >= 10) { throw new Error("decoded RLP contains too many fields"); } @@ -167,6 +167,10 @@ export class Deserializer implements Contracts.Crypto.TransactionDeserializer { // Signature const v = this.#parseNumber(fields[6], "v"); + if (v < 35) { + throw new Error("decoded RLP v is below 35 (not EIP-155)"); + } + const chainId = Math.floor((v - 35) / 2); const network = chainId; @@ -183,6 +187,11 @@ export class Deserializer implements Contracts.Crypto.TransactionDeserializer { // Legacy second signature if (fields.length === 10) { + // r (32) + s (32) + v (1); the verifier slices exactly these offsets and the AJV schema pins 130 hex chars. + if (fields[9].length !== 2 + 65 * 2) { + throw new Error("decoded RLP legacy second signature is not 65 bytes"); + } + legacySecondSignature = fields[9].slice(2); } @@ -232,7 +241,17 @@ export class Deserializer implements Contracts.Crypto.TransactionDeserializer { #parseNumber(value: Hex, field: string): number { this.#assertCanonicalInteger(value, field); - return value === "0x" ? 0 : Number(value); + if (value === "0x") { + return 0; + } + + // Number() silently loses precision above 2^53 and turns very long integers into Infinity. + const number = Number(value); + if (!Number.isSafeInteger(number)) { + throw new Error(`decoded RLP ${field} exceeds the safe integer range`); + } + + return number; } #parseBigInt(value: Hex, field: string): bigint { diff --git a/packages/crypto-transaction/test/helpers/canonical-transaction.ts b/packages/crypto-transaction/test/helpers/canonical-transaction.ts index 4998f60906..2b517000d3 100644 --- a/packages/crypto-transaction/test/helpers/canonical-transaction.ts +++ b/packages/crypto-transaction/test/helpers/canonical-transaction.ts @@ -57,7 +57,16 @@ export const legacyRlpFields = (transaction: Contracts.Crypto.TransactionData): ]; }; -export const encodeLegacy = (fields: Uint8Array[]): Buffer => Buffer.from(toRlp(fields).slice(2), "hex"); +export const encodeRlp = (value: Uint8Array | Uint8Array[]): Buffer => Buffer.from(toRlp(value).slice(2), "hex"); + +export const encodeLegacy = (fields: Uint8Array[]): Buffer => encodeRlp(fields); + +export const encodeList = (items: Buffer[]): Buffer => { + const payload = Buffer.concat(items); + const lengthBytes = minimalInteger(BigInt(payload.length)); + const header = payload.length < 56 ? [0xc0 + payload.length] : [0xf7 + lengthBytes.length, ...lengthBytes]; + return Buffer.concat([Buffer.from(header), payload]); +}; // A 32-byte big-endian buffer for a hex string, preserving leading zero bytes (unlike minimalInteger). export const fixedWidth32 = (hex: string): Uint8Array => hexToBytes(`0x${hex.padStart(64, "0")}`); diff --git a/packages/crypto-transaction/test/helpers/rlp-fuzz.ts b/packages/crypto-transaction/test/helpers/rlp-fuzz.ts new file mode 100644 index 0000000000..2fdeb93705 --- /dev/null +++ b/packages/crypto-transaction/test/helpers/rlp-fuzz.ts @@ -0,0 +1,129 @@ +import { toBytesCompat } from "../../source/serializer.js"; +import { encodeLegacy, encodeList, encodeRlp } from "./canonical-transaction.js"; + +export const createRandom = (seed: number) => () => { + seed = (seed + 0x6d_2b_79_f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; +}; + +type Random = ReturnType; + +const randomInt = (random: Random, max: number): number => Math.floor(random() * (max + 1)); + +const pick = (random: Random, values: T[]): T => values[randomInt(random, values.length - 1)]; + +const randomBytes = (random: Random, length: number): Uint8Array => + Uint8Array.from({ length }, () => randomInt(random, 255)); + +const randomInteger = (random: Random, minBytes: number, maxBytes: number): Uint8Array => { + const bytes = randomBytes(random, minBytes + randomInt(random, maxBytes - minBytes)); + if (bytes.length > 0 && bytes[0] === 0) { + bytes[0] = 1; + } + return bytes; +}; + +const randomLength = (random: Random): number => + pick(random, [0, 1, 2, 20, 32, 33, 55, 56, 65, 255, 256, randomInt(random, 300)]); + +export const randomFields = (random: Random): Uint8Array[] => { + const fields = [ + randomInteger(random, 0, 8), + randomInteger(random, 0, 6), + randomInteger(random, 0, 4), + random() < 0.5 ? new Uint8Array() : randomBytes(random, 20), + randomInteger(random, 0, 32), + randomBytes(random, randomLength(random)), + toBytesCompat(35 + randomInt(random, 16_777_215)), + randomInteger(random, 1, 32), + randomInteger(random, 1, 32), + ]; + return random() < 0.5 ? [...fields, randomBytes(random, 65)] : fields; +}; + +const longHeader = (random: Random, offset: 0x80 | 0xc0, length: number): number[] => { + const lengthBytes = [...toBytesCompat(length)]; + const minimum = length < 56 ? 1 : lengthBytes.length + 1; + const lengthOfLength = minimum + randomInt(random, 8 - minimum); + const padding = Array.from({ length: lengthOfLength - lengthBytes.length }, () => 0); + if (padding.length > 0 && random() < 0.5) { + padding[0] = 1 + randomInt(random, 254); + } + return [offset + 55 + lengthOfLength, ...padding, ...lengthBytes]; +}; + +export const MUTATIONS: [string, (random: Random, fields: Uint8Array[]) => Buffer][] = [ + [ + "header", + (random, fields) => { + const items = fields.map((field) => encodeRlp(field)); + if (random() < 0.5) { + const payload = Buffer.concat(items); + return Buffer.concat([Buffer.from(longHeader(random, 0xc0, payload.length)), payload]); + } + + const target = randomInt(random, fields.length - 1); + const field = fields[target]; + const header = + field.length === 1 && field[0] < 0x80 && random() < 0.5 + ? [0x81] + : longHeader(random, 0x80, field.length); + items[target] = Buffer.concat([Buffer.from(header), field]); + return encodeList(items); + }, + ], + [ + "append", + (random, fields) => + encodeLegacy([ + ...fields, + ...Array.from({ length: 1 + randomInt(random, 1) }, () => + randomBytes(random, pick(random, [0, 1, 65, randomInt(random, 80)])), + ), + ]), + ], + [ + "replace", + (random, fields) => { + const bytes = randomBytes(random, randomLength(random)); + if (bytes.length > 0 && random() < 0.5) { + bytes[0] = 0; + } + + const replaced = [...fields]; + replaced[randomInt(random, fields.length - 1)] = bytes; + return encodeLegacy(replaced); + }, + ], + [ + "bytes", + (random, fields) => { + const bytes = [...encodeLegacy(fields)]; + const position = randomInt(random, bytes.length - 1); + switch (randomInt(random, 4)) { + case 0: { + bytes[position] = randomInt(random, 255); + break; + } + case 1: { + bytes.splice(position, 0, randomInt(random, 255)); + break; + } + case 2: { + bytes.splice(position, 1); + break; + } + case 3: { + bytes.splice(position); + break; + } + default: { + bytes.push(...randomBytes(random, 1 + randomInt(random, 7))); + } + } + return Buffer.from(bytes); + }, + ], +];