Skip to content

fix(rest): gate message-sign routes through the Vault approval overlay#267

Merged
BitHighlander merged 2 commits into
developfrom
fix/rest-sign-gating
Jun 19, 2026
Merged

fix(rest): gate message-sign routes through the Vault approval overlay#267
BitHighlander merged 2 commits into
developfrom
fix/rest-sign-gating

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

The holes

Four REST signing endpoints reached the device with no in-Vault review — only the firmware OLED gated them:

Route Standard
/tron/sign-message TIP-191
/tron/sign-typed-hash TIP-712 (hash mode)
/ton/sign-message bare Ed25519
/solana/sign-offchain-message Solana off-chain

None were in SIGNING_ROUTES, so the central gate in rest-api.ts (which opens the approval overlay and rejects empty probes) was skipped entirely. A paired REST caller could POST a message and reach wallet.*SignMessage() with no Vault window ever appearing — violating the invariant: no device signature without a gated Vault UI showing what's being signed.

Proof (deterministic, no device needed)

An empty {} probe is a litmus test — the gate rejects empties with a distinctive string before it opens the overlay. Pre-fix, on develop:

/tron/sign-transaction   -> {"error":"Missing signing payload — expected one of: ..."}   # GATED
/tron/sign-message       -> {"error":"Validation error: message: ..."}                    # bypassed gate → device

The Validation error came from the signing handler's own zod check — reached only because the overlay gate never ran.

The fix

  • Add the four routes to SIGNING_ROUTES + requiredSigningFields.
  • Overlay preview decoders: message routes decode the payload to text and render it via the generic message section (new standard badge → TIP-191 / Ed25519 / Solana off-chain); tron/sign-typed-hash surfaces the domain/message hashes and flags blind signing (hash mode has no structured data to decode).
  • SigningApproval: method labels for the four routes + dynamic standard badge.

Test

__tests__/rest-sign-gating.test.ts (make test-sign-gating) — deterministic empty-probe differential asserting every sign route enters the overlay gate, locking this against regression. RUN_LIVE_SIGN=1 drives a real signature through the overlay.

Test plan

  • make test-sign-gating (after rebuild) — all sign routes gated, 0 fail
  • Live: RUN_LIVE_SIGN=1 make test-sign-gating — Vault overlay appears before the KeepKey prompt, shows the message text; approve both → signature
  • Spot-check overlay renders message text for tron/ton and the blind-sign warning for typed-hash

Scope note

The swap holes (/api/v2/swap/execute headless, /api/v2/swap/confirm) are a separate class already addressed by #266 — intentionally not duplicated here.

Four signing endpoints reached the device with no in-Vault review — only
the firmware OLED gated them:

  /tron/sign-message        (TIP-191)
  /tron/sign-typed-hash     (TIP-712 hash mode)
  /ton/sign-message         (bare Ed25519)
  /solana/sign-offchain-message

None were in SIGNING_ROUTES, so the central gate in rest-api.ts that opens
the approval overlay (and rejects empty probes) was skipped entirely. A
paired REST caller could POST a message and reach wallet.*SignMessage()
with no Vault window ever appearing — violating "no device signature
without a gated Vault UI showing what's signed."

Fix:
- Add the four routes to SIGNING_ROUTES + requiredSigningFields (empty-probe
  rejection parity).
- Add overlay preview decoders: tron/ton/solana message routes decode the
  payload to text and render it via the generic message section (new
  `standard` badge → TIP-191 / Ed25519 / Solana off-chain); tron typed-hash
  surfaces the domain/message hashes and flags blind signing (hash mode has
  no structured data to decode).
- SigningApproval: method labels for the four routes + dynamic standard badge.

Adds __tests__/rest-sign-gating.test.ts (make test-sign-gating): a
deterministic empty-probe differential that asserts every sign route enters
the overlay gate, locking the fix against regression. RUN_LIVE_SIGN=1 drives
a real signature through the overlay.

Note: the swap holes (/api/v2/swap/execute headless, /api/v2/swap/confirm)
are a separate class fixed by PR #266 — not duplicated here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uard

P2 (rest-api.ts): is_text=false hex previews marked any valid UTF-8 as
readable, so blank/control-byte payloads (e.g. "00", whitespace) rendered as
an empty/ambiguous message while the device signed real bytes. Add
isMostlyPrintable() (>=80% printable, common whitespace allowed) + strict
even-length hex guard; non-readable payloads now leave messageText undefined
so the overlay forces the raw-hex view + warning.

P2 (test): the guard claimed "every sign route" but listed only 8. Extract
SIGNING_ROUTES + requiredSigningFields into a side-effect-free module
(src/bun/signing-routes.ts) imported by both rest-api.ts and the test, and
derive the test's route list from it — all 32 current sign routes are now
covered, and adding a route without a real gate fails the guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@BitHighlander
BitHighlander merged commit 7f7e699 into develop Jun 19, 2026
1 check passed
@BitHighlander
BitHighlander deleted the fix/rest-sign-gating branch June 19, 2026 23:53
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