Skip to content

Bound the type-graph traversal and fail with a coded error #6

Description

@ichbindas

hashTypedData places no bound on the size or depth of the type graph it walks. A payload built from a linear chain of struct types — L0.next → L1.next → …, each instantiated once — costs time quadratic in the chain length, because encodeType(T) emits T plus all of its transitive dependencies, so n distinct types each build a string of length O(n).

Measured against the current sources:

depth   500    41,089 bytes  ->  ok in 227ms
depth  1000    82,089 bytes  ->  ok in 707ms
depth  2000   166,089 bytes  ->  ok in 3,103ms
depth  4000   334,089 bytes  ->  RangeError: Maximum call stack size exceeded

There are two separate problems here.

Quadratic cost. Doubling the depth roughly quadruples the time. 3.1 seconds on a 166 KB payload.

Uncatchable failure. Past roughly depth 3000, collectStructDeps exhausts the call stack and throws a plain RangeError, not a TypedDataError. The library's error convention is that malformed input produces a coded TypedDataError, so a consumer following that convention does not catch this. In a signer, an uncaught throw on the approval path is what blanks the approval screen.

Memoizing typeHash does not fix it

This was tried in a patched copy before proposing anything else:

depth |  plain ms | memoized ms | same digest
 1600 |      1902 |        1822 |        true

No improvement. Every type in the chain is distinct, so the cache never hits. The quadratic is inherent to the definition of encodeType, not a missing cache. A fix has to bound the input rather than memoize the work.

The exposed party is the verifier, not the signer

The Wallet is not affected. src/background/rpc.js calls checkPolicyLimits(typedInput) at line 1029 before hashTypedDataHex at line 1031, and the policy check rejects every payload above in under a millisecond.

Verifiers are affected. verifyTypedDataSignature hashes before it compares anything:

depth  500  ->    245ms before returning E_SIG_INVALID
depth 1000  ->    772ms before returning E_SIG_INVALID
depth 2000  ->   3172ms before returning E_SIG_INVALID

Three seconds spent on a payload that was always going to be rejected, without reaching the cheap chainId comparison. A dApp backend accepting signatures from untrusted clients can be stalled by small, well-formed requests.

@dusk/connect is the package those verifiers actually install. It exports hashTypedData, hashTypedDataHex, hashTypedDataDebug, validateTypedDataParams and verifyTypedDataSignature, and no limit function at all — src/entrypoints.test.ts:32 asserts checkPolicyLimits is absent. Its docs/typed-data-v1.md directs integrators to @dusk/typed-data/policy, which for a dApp that installed only @dusk/connect means importing from a transitive dependency.

That entry-point split came out of an earlier review comment and it was right for its stated reason: a verifier that applies signer limits rejects signatures over digests everyone agrees on. The consequence was that the hash path lost any reachable bound on the surface most likely to face hostile input.

Proposal

Two things are currently conflated under "limits" and should be separated.

Resource policy — how large a payload a signer is willing to accept — is signer-side, optional, and correctly lives at @dusk/typed-data/policy. No change proposed.

Structural safety of the encoder — that hashing terminates in bounded time and fails cleanly — is not policy. It is a property the encoder owes every caller, verifiers included, and it cannot be delegated to an optional import.

Concretely:

  1. The encoder bounds its own traversal and rejects with a coded TypedDataErrorE_COMPLEXITY, or whichever code fits — rather than a RangeError.
  2. The specification fixes that bound as a constant. It is a ceiling that every implementation MUST refuse above, distinct from the §11 floor that every implementation MUST accept below. Both are normative.
  3. The ceiling belongs in the specification rather than in each implementation. If one implementation bounds at depth 1024 and another at 4096, a payload at depth 2000 is accepted by one and refused by the other, which is the kind of divergence the vector corpus exists to prevent.
  4. Because the ceiling sits far above the floor, no conforming payload is affected and no digest changes.

A fixed ceiling also wants vectors: one payload just under it that every implementation must accept, one just over it that every implementation must refuse with the same code.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions