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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

### Changed

- Bound per-call typed-value visits, including shared arrays and structs, with `E_COMPLEXITY` while preserving the JSON interoperability floor. ([#6])
- Require all implementations to report `E_COMPLEXITY` for above-floor encoder structural refusals. ([#6])
- Refuse excessive encoder structure with coded `E_COMPLEXITY` errors above the interoperability floor. ([#6])
- Require relying applications to enforce single-use and time-bounded authorization semantics. ([#7])
Expand Down
46 changes: 35 additions & 11 deletions docs/typed-data-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ not call the optional signer-policy helper. They throw `TypedDataError` with cod
| Distinct structs in each dependency closure | 128 |
| Total declared fields across each dependency closure | 8,192 |
| Characters in each `encodeType` / individual type expression | 1,048,576 |
| Total typed-value visits per call, across domain and primary message | 262,144 |

Dependency traversal counts entering a field type or unwrapping an array as one
level. Value traversal counts entering a field value or array element as one level,
Expand All @@ -501,17 +502,40 @@ refused with `E_COMPLEXITY`; accepted inputs retain their exact encoding.
Every otherwise-valid JSON input within the floor fits these guards. A future
universal ceiling would need a separate normative acceptance decision and vectors.

Type hashes are reused only within one call, complementing the bounds for distinct
types. Array/field encodings feed the existing incremental SHA-256 operation without
building an unbounded JavaScript argument list. These guards do not bound total
value work: shared objects and arrays are re-encoded per path, potentially expanding
exponentially in a compact object graph. JSON text cannot express shared references;
in-process inputs and structured-clone transports can preserve them.

These guards are not a wall-clock or peak-memory guarantee: string/bytes contents
and total value count still require work, and applications remain responsible for
transport size/rate limits. The initial `validateTypedDataParams` shape check alone
does not walk the full graph.
A typed-value visit counts one root struct, field value or array element, whether
atomic, array or struct (including empty structs). A struct is counted once, not
again when its hash is emitted. The domain's four canonical fields count, including
the implicit zero `verifyingContract`. One fresh counter is shared across both
roots for each hash/debug/verification call. The optional policy helper uses the
same visit budget across its two value walks, before compact-JSON serialization;
its ordinary floor refusals remain `E_POLICY_LIMIT`. It is not full value validation.

Shared values are charged again on every path, not deduplicated by object identity
or type. This bounds the number of typed-value visits even when a compact object
graph would expand exponentially. JSON text cannot express shared references;
in-process inputs and structured-clone transports can preserve them. Type hashes
are still reused only within one call; value hashes are not memoized. Array/field
encodings feed incremental SHA-256 without an unbounded JavaScript argument list.

**Floor preservation:** each visited value occurrence in an otherwise-valid JSON
input contributes at least one distinct byte to its compact serialization (a value
token or opening object/array delimiter). The one possible implicit domain-contract
visit can be charged to the top-level input object's opening delimiter, which is
not itself visited. Thus the visit count cannot exceed the complete compact-JSON
byte count, and every JSON input within the 262,144-byte floor fits this budget.
The argument also covers alias-preserving copies with the same expanded JSON
values. It does not rely on calling the policy helper or serializing from hashing.
For in-process objects whose hooks or property descriptors omit or replace typed
values during serialization, that serialization is not the complete §3 payload
and its length cannot establish this guarantee; the visit guard still applies.

These guards are not a wall-clock, peak-memory or arbitrary-JavaScript safety
guarantee. A visit is not a byte-work unit: large strings/bytes, type processing,
property enumeration, and policy serialization (including unused metadata) still
have costs not measured by this counter. Getters, proxies and serialization hooks
are not sandboxed or snapshotted. Applications remain responsible for transport
size/rate limits. The initial `validateTypedDataParams` shape check alone does not
walk the full graph.

---

Expand Down
13 changes: 13 additions & 0 deletions src/bls/sig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,19 @@ describe("./bls: verifyTypedDataSignature", () => {
throw new Error("Expected structural refusal");
});

it("propagates total-value refusal rather than returning a signature failure", () => {
const small = baseInput();
const { signatureHex } = signTypedDataInput(small, TEST_SK);
const count = 262144 - 6; // Seven non-element visits put this one over budget.
const over = baseInput({
types: { ...domainTypes, Greeting: [{ name: "values", type: `uint8[${count}]` }] },
message: { values: Array(count).fill(0) },
});
expect(() => verifyTypedDataSignature(over, signatureHex, TEST_PK_HEX, ACCEPTING_POLICY))
.toThrowError(expect.objectContaining({ code: "E_COMPLEXITY" }));
expect(verifyTypedDataSignature(small, signatureHex, TEST_PK_HEX, ACCEPTING_POLICY).ok).toBe(true);
});

it("throws on a malformed signatureHex", () => {
const input = baseInput();
expect(() => verifyTypedDataSignature(input, "not-hex", TEST_PK_HEX, ANY)).toThrow();
Expand Down
102 changes: 102 additions & 0 deletions src/typed-data/hash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,108 @@ describe("encoder structural guards", () => {
});
});

describe("encoder total-value budget", () => {
function arrayInput(type: string, values: unknown): HashTypedDataInput {
return { domain, origin, primaryType: "S",
types: { ...domainTypes, S: [{ name: "values", type }] }, message: { values } };
}

function arrayStructHash(type: string, bytes: Uint8Array): string {
return `0x${bytesToHex(sha256(concatBytes(
sha256(new TextEncoder().encode(`S(${type} values)`)), bytes,
)))}`;
}

it("counts both roots, arrays and atomics with an inclusive per-call limit", () => {
// Domain root + four fields + primary root + array = seven visits.
const count = 262144 - 7;
const input = arrayInput(`uint8[${count}]`, Array(count).fill(1));
const debug = hashTypedDataDebug(input);
expect(debug.structHash).toBe(arrayStructHash(`uint8[${count}]`, new Uint8Array(count).fill(1)));
expect(hashTypedDataHex(input)).toBe(debug.digestHex);
expect(`0x${bytesToHex(hashTypedData(input).digest)}`).toBe(debug.digestHex);
const over = arrayInput(`uint8[${count + 1}]`, Array(count + 1).fill(1));
for (const hash of [hashTypedData, hashTypedDataHex, hashTypedDataDebug]) {
expectCode(() => hash(over), "E_COMPLEXITY");
}
// Refusal must not poison a later call's budget.
const small = loadVector("sign_in_basic.json");
expect(hashTypedDataHex(small.input)).toBe(small.digestHex);
});

it("charges every shared array container as well as its atomic leaves", () => {
const count = (262144 - 7) / 3;
// Each outer element visits two shared array containers and one atomic.
const input = arrayInput(`uint8[1][1][${count}]`, Array(count).fill([[1]]));
expect(hashTypedDataDebug(input).structHash).toBe(arrayStructHash(
`uint8[1][1][${count}]`, new Uint8Array(count).fill(1),
));
expectCode(() => hashTypedDataHex(arrayInput(
`uint8[1][1][${count + 1}]`, Array(count + 1).fill([[1]]),
)), "E_COMPLEXITY");
});

it("charges shared empty structs exactly once per path, even with cached type hashes", () => {
const count = 262144 - 7;
const input = arrayInput(`Leaf[${count}]`, Array(count).fill({}));
input.types.Leaf = [];
const leafHash = sha256(sha256(new TextEncoder().encode("Leaf()")));
const expected = sha256.create().update(sha256(new TextEncoder().encode(`S(Leaf[${count}] values)Leaf()`)));
for (let i = 0; i < count; i++) expected.update(leafHash);
expect(hashTypedDataDebug(input).structHash).toBe(`0x${bytesToHex(expected.digest())}`);
const over = arrayInput(`Leaf[${count + 1}]`, Array(count + 1).fill({}));
over.types.Leaf = [];
expectCode(() => hashTypedDataHex(over), "E_COMPLEXITY");
}, 15000);

it.each(["arrays", "structs"])("bounds cloned shared %s before policy JSON serialization", kind => {
let values: unknown = kind === "arrays" ? 0 : {};
for (let i = 0; i < 3; i++) values = Array(64).fill(values);
const input = structuredClone(arrayInput(`${kind === "arrays" ? "uint8" : "Leaf"}[64][64][64]`, values));
if (kind === "structs") input.types.Leaf = [];
const outer = input.message.values as unknown[];
expect(outer[0]).toBe(outer[1]); // Structured clone preserves aliases.
validateTypedDataParams(input); // Shape checking is still not a value walk.
Object.defineProperty(input, "toJSON", {
value() { throw new Error("serialization reached before typed-value refusal"); },
});
// All individual array lengths, depths and schema dimensions fit the signer
// floor; the expanded value tree does not fit its compact-JSON byte limit.
for (const check of [hashTypedData, hashTypedDataHex, hashTypedDataDebug, checkPolicyLimits]) {
expectCode(() => check(input), "E_COMPLEXITY");
}
}, 15000);

it("shares the same inclusive budget across both policy roots", () => {
// 6 root/domain visits + 261125 + 772 + 241 = 262144.
const input = arrayInput("uint8[255][255][4]", Array(4).fill(Array(255).fill(Array(255).fill(0))));
input.types.S!.push({ name: "b", type: "uint8[256][3]" }, { name: "c", type: "uint8[240]" });
input.message.b = Array(3).fill(Array(256).fill(0));
input.message.c = Array(240).fill(0);
// The visit walk fits; the expanded JSON does not fit signer policy.
expectCode(() => checkPolicyLimits(input), "E_POLICY_LIMIT");
expect(() => checkPolicyLimits(input)).toThrow("compact JSON input");
input.types.S!.push({ name: "extra", type: "bool" });
input.message.extra = false;
expectCode(() => checkPolicyLimits(input), "E_COMPLEXITY");
});

it("preserves dense floor-sized JSON inputs and their alias-preserving equivalents", () => {
const type = "uint8[256][254][2]";
const input = { ...arrayInput(type, Array(2).fill(Array(254).fill(Array(256).fill(0)))), metadata: "" };
const length = Buffer.byteLength(JSON.stringify(input));
expect(length).toBeLessThanOrEqual(262144);
input.metadata = "x".repeat(262144 - length);
const wire = JSON.stringify(input);
expect(Buffer.byteLength(wire)).toBe(262144);
const parsed = JSON.parse(wire);
for (const value of [input, parsed]) expect(() => checkPolicyLimits(value)).not.toThrow();
const debug = hashTypedDataDebug(parsed);
expect(debug.structHash).toBe(arrayStructHash(type, new Uint8Array(256 * 254 * 2)));
expect(hashTypedDataHex(input)).toBe(debug.digestHex);
});
});

describe("encoding boundaries", () => {
it.each([
["a,uint8 b", "c"],
Expand Down
43 changes: 31 additions & 12 deletions src/typed-data/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,18 @@ const ENCODER_LIMITS = {
maxStructs: 128,
maxFields: 8192,
maxTypeChars: 1048576,
maxValueVisits: 262144,
};

type ValueBudget = { visits: number };

function visitValue(budget: ValueBudget): void {
// ponytail: visit count only; byte-work limits need a separate floor-preserving budget.
if (++budget.visits > ENCODER_LIMITS.maxValueVisits) {
fail("E_COMPLEXITY", `typed-data value visits exceed ${ENCODER_LIMITS.maxValueVisits}`);
}
}

function fail(code: TypedDataErrorCode, message: string): never {
throw new TypedDataError(code, message);
}
Expand Down Expand Up @@ -176,8 +186,9 @@ export function checkPolicyLimits(input: HashTypedDataInput): void {
}
}

walkValueForPolicy(DOMAIN_TYPE, domainMessage(input.domain), types, 1);
walkValueForPolicy(input.primaryType, input.message, types, 1);
const budget = { visits: 0 };
walkValueForPolicy(DOMAIN_TYPE, domainMessage(input.domain), types, budget, 1);
walkValueForPolicy(input.primaryType, input.message, types, budget, 1);

// Compact JSON UTF-8 bytes, including hex text and unused metadata (spec 11).
// This is not the sum of decoded field bytes or a peak-memory bound.
Expand All @@ -194,8 +205,10 @@ function walkValueForPolicy(
typeExpr: string,
value: unknown,
types: Record<string, FieldDef[]>,
budget: ValueBudget,
depth: number
): void {
visitValue(budget);
if (depth > POLICY_LIMITS.maxNestingDepth) {
fail("E_POLICY_LIMIT", `nesting depth exceeds floor ${POLICY_LIMITS.maxNestingDepth}`);
}
Expand All @@ -212,7 +225,7 @@ function walkValueForPolicy(
);
}
for (let i = 0; i < length; i++) {
walkValueForPolicy(t.elem, value[i], types, depth + 1);
walkValueForPolicy(t.elem, value[i], types, budget, depth + 1);
}
return;
}
Expand All @@ -235,7 +248,7 @@ function walkValueForPolicy(
for (let i = 0, n = fields.length; i < n; i++) {
const f = fields[i]!;
if (Object.hasOwn(value, f.name)) {
walkValueForPolicy(f.type, value[f.name], types, depth + 1);
walkValueForPolicy(f.type, value[f.name], types, budget, depth + 1);
}
}
}
Expand Down Expand Up @@ -274,13 +287,14 @@ export function hashTypedDataDebug(input: HashTypedDataInput): HashTypedDataDebu
const types = input.types;
const domainValues = domainMessage(input.domain);
const hashes = new Map<string, Uint8Array>();
const budget = { visits: 0 };

// Compute the digest stages in the same order as `hashTypedDataWithContext`, so that an
// input violating several rules at once reports the same error code from
// both entry points (spec section 10, "Reporting order").
const domainSeparator = structHash(DOMAIN_TYPE, domainValues, types, hashes);
const domainSeparator = structHash(DOMAIN_TYPE, domainValues, types, hashes, budget);
const originBind = originBindHash(input.origin);
const structHashPrimary = structHash(input.primaryType, input.message, types, hashes);
const structHashPrimary = structHash(input.primaryType, input.message, types, hashes, budget);

const reachable = new Set([
...collectStructDeps(DOMAIN_TYPE, types),
Expand All @@ -307,10 +321,11 @@ export function hashTypedDataWithContext(input: HashTypedDataInput) {
const types = input.types;
const domainValues = domainMessage(input.domain);
const hashes = new Map<string, Uint8Array>();
const domainSeparator = structHash(DOMAIN_TYPE, domainValues, types, hashes);
const budget = { visits: 0 };
const domainSeparator = structHash(DOMAIN_TYPE, domainValues, types, hashes, budget);
const origin = input.origin;
const originBind = originBindHash(origin);
const structHashPrimary = structHash(input.primaryType, input.message, types, hashes);
const structHashPrimary = structHash(input.primaryType, input.message, types, hashes, budget);
const digest = sha256(concat(PREAMBLE, domainSeparator, originBind, structHashPrimary));
return { digest, chainId: domainValues.chainId, origin };
}
Expand Down Expand Up @@ -554,8 +569,10 @@ function structHash(
values: unknown,
types: Record<string, FieldDef[]>,
hashes: Map<string, Uint8Array>,
budget: ValueBudget,
depth = 1
): Uint8Array {
visitValue(budget);
const hash = sha256.create().update(typeHash(typeName, types, hashes));
const fields = types[typeName]!;
if (!isPlainObject(values)) {
Expand All @@ -568,7 +585,7 @@ function structHash(
fail("E_FIELD_MISSING", `missing field ${typeName}.${f.name}`);
}
seen.add(f.name);
for (const part of encodeValue(f.type, values[f.name], types, hashes, depth + 1)) hash.update(part);
for (const part of encodeValue(f.type, values[f.name], types, hashes, budget, depth + 1)) hash.update(part);
}
// Include non-enumerable and symbol keys in the own-property check (spec 6.3).
for (const k of Reflect.ownKeys(values)) {
Expand All @@ -582,10 +599,12 @@ function structHash(
/** Arrays stream their indexed concatenated encoding without a JS argument list. */
function* encodeValue(
typeExpr: string, value: unknown, types: Record<string, FieldDef[]>,
hashes: Map<string, Uint8Array>, depth: number
hashes: Map<string, Uint8Array>, budget: ValueBudget, depth: number
): Generator<Uint8Array> {
checkDepth(depth);
const t = classifyType(typeExpr);
// Struct visits are charged in structHash, including the two root structs.
if (t.kind !== "struct") visitValue(budget);
if (t.kind === "array") {
if (!Array.isArray(value)) {
fail("E_VALUE_TYPE", `${typeExpr}: expected array`);
Expand All @@ -594,7 +613,7 @@ function* encodeValue(
fail("E_ARRAY_LENGTH", `${typeExpr}: expected length ${t.n}, got ${value.length}`);
}
for (let i = 0; i < t.n; i++) {
yield* encodeValue(t.elem, value[i], types, hashes, depth + 1);
yield* encodeValue(t.elem, value[i], types, hashes, budget, depth + 1);
}
return;
}
Expand All @@ -605,7 +624,7 @@ function* encodeValue(
if (!isPlainObject(value)) {
fail("E_VALUE_TYPE", `${t.name}: expected object`);
}
yield structHash(t.name, value, types, hashes, depth);
yield structHash(t.name, value, types, hashes, budget, depth);
}

function encAtomic(typeName: string, value: unknown): Uint8Array {
Expand Down