Skip to content

fix(keepkey/eip712): say why the device refused, and fall back when it cannot parse - #64

Merged
BitHighlander merged 9 commits into
masterfrom
feat/712-structured-host
Aug 21, 2026
Merged

fix(keepkey/eip712): say why the device refused, and fall back when it cannot parse#64
BitHighlander merged 9 commits into
masterfrom
feat/712-structured-host

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

Two defects that combine into a dead end for x402 payments on firmware 7.14.2.

1. The refusal text was thrown away

} catch (error) {
  console.error({ error });
  throw new Error("Failed to sign typed ETH message");
}

transport.call throws the raw failure event, so the device's own words sit at error.message.message — and this replaced all of them with one string. "Enable AdvancedMode to blind-sign typed hashes", "Structured EIP-712 disabled pending canonical display hardening", and an unplugged cable were indistinguishable to the user. The difference between them is the only part they can act on.

Now: surface the firmware's text when there is one, rethrow real Errors unchanged so ActionCancelled keeps its identity, and use the generic string only for a non-Error with nothing to say.

2. x402 hard-failed instead of degrading

isX402Eip3009() routes EIP-3009 TransferWithAuthorization to the structured endpoint. Firmware 7.14.2 withdrew that endpoint, so the call is answered with a Failure and the payment simply died — reporting the generic message above.

Now the structured attempt is caught, and only a refusal meaning "this device has no structured endpoint" falls through to the hashed path: Failure_UnexpectedMessage from firmware predating the message, or the "Structured EIP-712 disabled" text from firmware that withdrew it. Every other failure propagates — masking a real error with a silent downgrade is how a signing bug becomes invisible.

Detection is by attempt, not version number. There is no capability bit for this, and a version table would need editing on every branch that toggles the flag. Retrying is safe: the firmware refuses at the top of the handler, before touching session state.

The fallback is not a silent loss of protection — the hashed path still shows the blind-sign warning and still requires AdvancedMode.

…t cannot parse

Two defects that combine into a dead end for x402 payments on firmware 7.14.2.

1. The refusal text was thrown away.

   } catch (error) {
     console.error({ error });
     throw new Error("Failed to sign typed ETH message");
   }

   transport.call throws the raw failure event, so the device's own words are
   sitting at error.message.message -- and this replaced all of them with one
   string. "Enable AdvancedMode to blind-sign typed hashes", "Structured EIP-712
   disabled pending canonical display hardening" and an unplugged cable were
   indistinguishable to the user, and the difference between them is the only
   part they can act on.

   Now: surface the firmware's text when there is one, rethrow real Errors
   unchanged (ActionCancelled keeps its identity), and fall back to the generic
   string only for a non-Error with nothing to say.

2. x402 hard-failed instead of degrading.

   isX402Eip3009() routes EIP-3009 TransferWithAuthorization to the structured
   endpoint. Firmware 7.14.2 withdrew that endpoint -- its JSON parser could not
   guarantee the displayed value was the value being hashed -- so the call is
   answered with a Failure and the payment simply died, reporting the generic
   message above.

   Now the structured attempt is caught, and ONLY a refusal that means "this
   device has no structured endpoint" falls through to the hashed path:
   Failure_UnexpectedMessage from firmware predating the message, or the
   "Structured EIP-712 disabled" text from firmware that has withdrawn it.
   Every other failure propagates, because masking a real error with a silent
   downgrade is how a signing bug becomes invisible.

Detection is by ATTEMPT, not by version number. There is no capability bit for
this, and a version table would need editing on every branch that toggles the
flag. Retrying is safe because the firmware refuses at the top of the handler,
before it touches session state.

The fallback is not a silent loss of protection: the hashed path still shows
the device's blind-sign warning and still requires AdvancedMode. It is the same
treatment every other typed-data payload already gets on this firmware.
@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hdwallet-sandbox Ready Ready Preview Aug 21, 2026 11:35pm

Request Review

The answering side of the streaming protocol: the device drives, this owns the
document and serves whatever it addresses.

Three parts, each independently tested (18 cases, all green):

parseSolidityType -- "uint256", "bytes32", "Person[3]", "int16[2][][4]" into
the wire description. array_levels is in WRITTEN order, left to right, because
that is the order encodeType reproduces; reversing it silently changes typeHash
and therefore the signature.

  It refuses a bare "uint"/"int". That is not canonical EIP-712, and the old
  firmware accepted it and hashed it as 256 bits -- producing a type string no
  compliant verifier reproduces. Refusing at the host means the device never
  sees it.

encodeValue -- one leaf as the exact bytes the device will hash AND display.
Raw big-endian at the declared width, never JSON. The device does no number
parsing at all, which is what removes the old path's strtoll ceiling of
2^63-1 -- i.e. every unlimited ERC-20 approval ever issued, the most common
permit there is. There is a test for exactly that value.

  The flip side is that width correctness is now entirely the host's job, so
  this is strict rather than lenient: out-of-range values throw, a JS number
  that has already lost precision throws rather than being silently converted
  into a number the caller never had, and a wrong-width address or bytesN
  throws.

resolveMemberPath -- walks a device-supplied member_path into the document.
path[0] is 0 for domain, 1 for message. A path stopping on an ARRAY is the
device asking for its length; a path stopping on a STRUCT is a protocol error,
because the device walks into structs rather than asking for them.

Fixtures are Permit2's PermitSingle nesting PermitDetails -- the payload that
started this, and the one a flat-structs-only design cannot sign.

Note for anyone extending this: BigInt LITERALS (1n) are ES2020 syntax and this
repo targets ES2016 with an es2020 lib, so the BigInt function is available but
the literal form does not compile. Hence the ZERO/ONE/EIGHT constants.
…oder

All five were in code written today, all five confirmed by independent
verification, and four of them sign the wrong thing rather than merely failing.

1. A fixed array dimension of ZERO was conflated with a dynamic one.
   "uint256[0]" parsed to arrayLevels [0], byte-identical to "uint256[]" -- and
   0 is the wire's dynamic sentinel, so the device spells it back as "[]".
   Confirmed against ethers 5.7.2: Foo(uint256[0] a) and Foo(uint256[] a) have
   different hashStructs. Leading zeros re-spelled the same way. Both refused.

2. Non-canonical integer widths were silently NORMALISED. "uint0256" became
   uint256 and was hashed as "uint256", while a verifier reading the document
   sees "uint0256". Same failure as the bare "uint" this module already
   refused, one spelling further on. Same for "bytes032".

   The integer regex is now anchored to digits, so a struct legitimately named
   "interest" is not caught by it.

3. and 4. A declared fixed dimension was never checked against the document, in
   either the element branch or the length branch. address[2] carrying three
   elements reported length 3 and served all three. The dimension is part of
   the type string and therefore part of typeHash, so this signs a document
   whose type says two -- and the device cannot notice, because the only count
   it ever sees is the one we give it.

5. Dynamic bytes and string had no length cap, while
   EthereumTypedDataValueAck.value is max_size:1024. A 2000-byte string encoded
   fine and then could not be sent, so the ceremony died at the transport layer
   where the error cannot name the field.

30 tests, all green. Every case above has a regression test that fails against
the previous version.
eslint jest/no-conditional-expect was right to object. The union returned by
resolveMemberPath was being narrowed with an if, so the assertions sat inside a
branch -- and a guarded expect that never runs proves nothing while reading as
if it did. asValue() narrows by throwing instead, which fails loudly on the
wrong variant and keeps every expect on the main path.

Plus import sort and one prettier wrap.
The answering half of the walk. The DEVICE leads -- it asks for one struct
definition or one leaf value at a time, and runEip712Walk answers until a
signature comes back.

The host never chooses the order, and that is the property, not an accident of
the API: the device hashes what it displays, in the order it picked, so a host
that answered a different question would produce a digest that does not verify.

`call` is injected rather than taking a Transport, so the loop is testable
against a scripted device with no USB. Five cases, modelling the exact sequence
the firmware state machine emits for Permit2 PermitSingle -- domain first, then
the message, walking into PermitDetails:

- every struct and value answered in order, and the nested uint160 comes back
  as exactly 20 big-endian bytes;
- a nested struct's member list served in declaration order, with uint160
  reported as size 20 (BYTES, as the wire wants);
- an undefined struct REFUSED rather than answered with an empty member list,
  which the device would hash as a valid empty struct and sign a document
  neither side meant;
- an unexpected message rejected instead of continuing blindly;
- a device that never finishes the walk cut off at 512 round trips rather than
  hanging the host.

eip712Wire.ts hand-writes the five messages because they are not in the
published @keepkey/device-protocol package yet -- same approach as
LoadClearsignSigner in ethereum.ts, and deletable the day the package ships
generated classes. member_path is decoded accepting BOTH packed and unpacked
repeated uint32: the device's encoder is not ours to pin, and assuming one
form would break on the other.

35 tests green.
Host to device only -- the device never sends a StructAck, so there is nothing
to parse. eslint was right that the parameter is dead; the underscore says it
is dead on purpose rather than forgotten.

Also: the previous commit's lint check printed OK unconditionally, so this got
through. The check now reports its own exit status.
…path

The wiring that makes the walk reachable. ethSignTypedData now tries the
streaming path for EVERY document, not just x402, and falls back only when the
device says it cannot do it.

Three pieces:

1. typeRegistry registers message types 1704-1708 by hand. The registry builds
   itself from Messages.MessageType, and the published device-protocol package
   does not carry these yet, so the reducers cannot see them. Without this the
   transport can SEND a request and then fail to decode the reply -- the walk
   would stall on its first StructRequest. Delete the block when the package
   ships generated classes; the reducers pick them up on their own.

2. RawPayload carries already-serialised bytes through transport.call, which
   wants a jspb.Message. The walk produces bytes; re-encoding them through a
   second object is a chance for the two encodings to differ, and the whole
   point of this protocol is that they cannot.

3. The fallback predicate now covers two refusals rather than one:

   - "Structured EIP-712 disabled" -- 7.14.2 withdrew the old endpoint.
   - "arrays are not supported"    -- the new walk is present but cannot walk
     THIS document.

   The second matters more than it looks. PermitBatch and Seaport nest arrays,
   and a hard failure there would read as a bug rather than a limitation.
   Degrading costs the user the field display and keeps the payment working,
   which is exactly the deal every typed-data payload gets today.

   Everything else still propagates. A refused screen, a malformed document, a
   value that does not match its declared type -- those are real answers, and
   masking them behind a silent downgrade is how a signing bug becomes
   invisible.

AdvancedMode is deliberately NOT in that list: the hashed path requires it too,
so falling back would fail again with a worse message.

35 tests green.
The mock answered EVERY call as Ethereum712TypesValues:

    const phase = request.getEip712typevals() ?? 0;

Now that ethSignTypedData tries streaming first for every document, the first
call is message type 1704 carrying a RawPayload, and that line threw
"request.getEip712typevals is not a function".

The mock was modelling a device that cannot exist -- one that answers an
unknown message type as though it understood it. Real firmware without the
streaming endpoint rejects 1704 with Failure_UnexpectedMessage, which is
exactly what 7.14.x does, so the mock now does that.

This makes the test cover MORE than it did: the x402 payload still reaches the
old structured endpoint, and it now gets there through the fallback rather than
because nothing else was tried. That fallback is the path every device in the
field takes today, and until now nothing exercised it.
The count assertion said two. It is three: the streaming probe this mock
rejects, then the two old-path calls.

Asserting two would be asserting that we never TRY the streaming path -- which
is the opposite of the intended behaviour and would go green precisely when the
feature stopped working. The call types are now asserted explicitly (1704, then
114 twice) so the sequence is visible rather than implied by a count.
@BitHighlander
BitHighlander merged commit 79f0e57 into master Aug 21, 2026
5 checks passed
@BitHighlander
BitHighlander deleted the feat/712-structured-host branch August 21, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant