Skip to content
Open
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
103 changes: 103 additions & 0 deletions packages/crypto-transaction/source/deserializer.fuzz.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { Contracts } from "@mainsail/contracts";
import { Identifiers } from "@mainsail/constants";
import { Application } from "@mainsail/kernel";
import { describe } from "@mainsail/test-runner";

import { wallet } from "../test/fixtures/index.js";
import { encodeLegacy, encodeList, encodeRlp } from "../test/helpers/canonical-transaction";
import { prepareSandbox } from "../test/helpers/prepare-sandbox";
import { createRandom, MUTATIONS, randomFields } from "../test/helpers/rlp-fuzz";

// 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, canonical: boolean][] = [
["zero address", encodeLegacy(withField(3, new Uint8Array(20))), true],
["64 KiB data", encodeLegacy(withField(5, new Uint8Array(65_536))), true],
["even v below 35", encodeLegacy(withField(6, Uint8Array.of(34))), false],
["0x7f as 0x81 0x7f", withRawItem(5, [0x81, 0x7f]), false],
["55-byte string in long form", withRawItem(5, [0xb8, 55, ...new Uint8Array(55)]), false],
["55-byte list in long form", Buffer.concat([Buffer.from([0xf8, 55]), payloadOf55]), false],
["nested list as a field", withRawItem(5, [0xc0]), false],
];

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<Contracts.Crypto.TransactionDeserializer>(
Identifiers.Cryptography.Transaction.Deserializer,
);
context.serializer = context.app.get<Contracts.Crypto.TransactionSerializer>(
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");
}

const accepts = async (input: Buffer, label: string): Promise<boolean> => {
let reserialized: Buffer;
try {
// fromBytes hashes a transaction with this serializer, so it rejects whatever the serializer throws on.
reserialized = await serializer.serialize((await deserializer.deserialize(input)).data);
} catch {
return false;
}

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, canonical] of REGRESSIONS) {
if (!(await accepts(input, name)) && canonical) {
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}`);
}
}
});
});
45 changes: 45 additions & 0 deletions packages/crypto-transaction/source/deserializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { parseTransaction } from "viem";
import { Deserialized, Serialized } from "../test/fixtures/index.js";
import {
encodeLegacy,
encodeList,
encodeRlp,
fixedWidth32,
legacyRlpFields,
signTransfer,
Expand Down Expand Up @@ -195,4 +197,47 @@ 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 an empty legacy second signature field", 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()])),
"decoded RLP legacy second signature is empty",
);
});

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",
);
});
});
10 changes: 7 additions & 3 deletions packages/crypto-transaction/source/deserializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 };
Expand All @@ -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");
}

Expand Down Expand Up @@ -183,6 +183,10 @@ export class Deserializer implements Contracts.Crypto.TransactionDeserializer {

// Legacy second signature
if (fields.length === 10) {
if (fields[9] === "0x") {
throw new Error("decoded RLP legacy second signature is empty");
}

legacySecondSignature = fields[9].slice(2);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`);
129 changes: 129 additions & 0 deletions packages/crypto-transaction/test/helpers/rlp-fuzz.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createRandom>;

const randomInt = (random: Random, max: number): number => Math.floor(random() * (max + 1));

const pick = <T>(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);
},
],
];
Loading