diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6b8f3..b301ecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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]) diff --git a/docs/typed-data-v1.md b/docs/typed-data-v1.md index 40539cf..d125e74 100644 --- a/docs/typed-data-v1.md +++ b/docs/typed-data-v1.md @@ -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, @@ -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. --- diff --git a/src/bls/sig.test.ts b/src/bls/sig.test.ts index 2d49eb3..4abf122 100644 --- a/src/bls/sig.test.ts +++ b/src/bls/sig.test.ts @@ -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(); diff --git a/src/typed-data/hash.test.ts b/src/typed-data/hash.test.ts index d63822f..0a724d1 100644 --- a/src/typed-data/hash.test.ts +++ b/src/typed-data/hash.test.ts @@ -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"], diff --git a/src/typed-data/hash.ts b/src/typed-data/hash.ts index 146bb5a..b65adc1 100644 --- a/src/typed-data/hash.ts +++ b/src/typed-data/hash.ts @@ -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); } @@ -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. @@ -194,8 +205,10 @@ function walkValueForPolicy( typeExpr: string, value: unknown, types: Record, + budget: ValueBudget, depth: number ): void { + visitValue(budget); if (depth > POLICY_LIMITS.maxNestingDepth) { fail("E_POLICY_LIMIT", `nesting depth exceeds floor ${POLICY_LIMITS.maxNestingDepth}`); } @@ -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; } @@ -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); } } } @@ -274,13 +287,14 @@ export function hashTypedDataDebug(input: HashTypedDataInput): HashTypedDataDebu const types = input.types; const domainValues = domainMessage(input.domain); const hashes = new Map(); + 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), @@ -307,10 +321,11 @@ export function hashTypedDataWithContext(input: HashTypedDataInput) { const types = input.types; const domainValues = domainMessage(input.domain); const hashes = new Map(); - 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 }; } @@ -554,8 +569,10 @@ function structHash( values: unknown, types: Record, hashes: Map, + budget: ValueBudget, depth = 1 ): Uint8Array { + visitValue(budget); const hash = sha256.create().update(typeHash(typeName, types, hashes)); const fields = types[typeName]!; if (!isPlainObject(values)) { @@ -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)) { @@ -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, - hashes: Map, depth: number + hashes: Map, budget: ValueBudget, depth: number ): Generator { 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`); @@ -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; } @@ -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 {