diff --git a/docs/handoff-testing/01-persistent-identity-icons.md b/docs/handoff-testing/01-persistent-identity-icons.md new file mode 100644 index 00000000..03dba5fa --- /dev/null +++ b/docs/handoff-testing/01-persistent-identity-icons.md @@ -0,0 +1,43 @@ +# 01 — Persistent clear-sign identities + logo render + +**What:** the "KeepKey + identity" model. A clear-sign signer loaded with +`persist=true` survives reboot (flash storage, `STORAGE_VERSION 17→18`), and +every clear-sign now **leads with the identity's logo + alias + fingerprint** +instead of the "NOT verified by KeepKey" banner — on both the load-consent +screen and every per-tx confirm. + +**Where:** +- firmware `release/7.15.0-rc7` (`BitHighlander/keepkey-firmware#299`, OPEN) — + `lib/firmware/storage.{c,h}`, `signed_metadata.c`, `fsm_msg_ethereum.h`, + `lib/board/layout.c`, `include/keepkey/board/layout.h`. +- hdwallet `keepkey/hdwallet#53` **MERGED** → pinned at `modules/hdwallet` `eccc3fa6`. +- vault `feat/clearsign-identity-icons-vault` (`keepkey/keepkey-vault#342`, OPEN). + +## Test (emulator, fastest) +1. Emulator dylib from rc7 (handoff 12), reloaded in Vault; Vault has #342. +2. `cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk` + `KEEPKEY_API_KEY=… node tests/evm-clearsign/clearsign-signer-flows.js` + (sends the Pioneer compass icon + `persist:true`). + +## Verify +- [ ] **Load screen** shows the compass logo, full "LOAD CLEARSIGNER" title, + unclipped "Trust 'Pioneer' (fp)…" body (no first-letter clipping). +- [ ] **Per-tx confirm** leads with the same logo + "Identity" + alias + fp, + then decoded pages, **no raw hex**, no "NOT verified by KeepKey" scare. +- [ ] **Persistence:** load with `persist:true` → reboot the emulator → drive a + clear-sign for the SAME `key_id` **without** re-loading → it still verifies + and renders (reads the flash slot). WipeDevice clears it. +- [ ] **Migration (brick-risk):** seed an emulator on the OLD (v17) dylib, then + swap to rc7 → wallet loads, keys/label/policies intact, identities empty + (no data loss, no reset). +- [ ] Load an icon > 384 B or dims > 64 → device rejects at load (validated). +- [ ] Load `persist:true` into all persistent slots, then one more → honest + "No free persistent identity slot" failure (not silent RAM-only). + +## Status / gotchas +- Firmware unit tests 75/75 pass (storage round-trip + migration + clamp). +- Icon column is 40px; identity icons generated at 40px + centered (handoff 07). +- device-protocol pinned to fork `33521a8` (icon fields) — **upstream-pin swap + is the final-release gate**, not the rc. +- Boot click-through review of trusted identities = deferred (Stage 3). + diff --git a/docs/handoff-testing/02-clearsign-allowlist-loadsigner.md b/docs/handoff-testing/02-clearsign-allowlist-loadsigner.md new file mode 100644 index 00000000..344441c5 --- /dev/null +++ b/docs/handoff-testing/02-clearsign-allowlist-loadsigner.md @@ -0,0 +1,31 @@ +# 02 — Clearsign vendored allowlist + LoadClearsignSigner endpoint + +**What:** Pioneer's `/descriptors/decode` + `/descriptors/sign` are 404 in prod, +so the vault stopped calling them. `needsBlindSigning` is now keyed off a +**vendored `firmwareClearSigns` allowlist** that mirrors the device's own +`ethereum_contractHandled` pins (THOR/Maya router deposit, 0x proxy, standard +ERC-20 transfer/approve, addLiquidityETH). Also adds the +`POST /eth/clearsign/load-signer` REST route. + +**Where:** vault `#337` (MERGED, develop) — +`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/calldata-decoder.ts`, +`rest-api.ts`, `SigningApproval.tsx`, `engine-controller.ts`, `FirmwareDropZone.tsx`. + +## Test +- Unit: `cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault && bun test __tests__/firmware-clearsign-gate.test.ts` (7/7 — spoof guard, wrong-address rejection, ERC-20 path). +- Device (rc7): drive real EVM txs and watch the trust badge / blind-sign gate. + +## Verify +- [ ] THOR/Maya router **deposit** → clear-signs (trust badge "known"). +- [ ] Same deposit selector to a **different** address → blind-signs ("unknown"). +- [ ] Standard 68-byte ERC-20 transfer/approve → clear-signs. +- [ ] Uniswap/1inch/relay (firmware-unknown) → blind-signs, **not** falsely "known". +- [ ] `FirmwareDropZone`: flashing against a **wallet-mode** device is blocked + until it reports `bootloaderMode=true` (no stalled HID freeze). + +## Status / gotchas +- MERGED to develop but **allowlist was never device-verified** — confirm on rc7 + that the vendored pins actually match what the firmware clear-signs. +- Supersedes the old `EVM_INSIGHT` flag-gated Pioneer path (removed in the #342 + merge reconciliation). + diff --git a/docs/handoff-testing/03-emulator-deadclick.md b/docs/handoff-testing/03-emulator-deadclick.md new file mode 100644 index 00000000..6f1ece60 --- /dev/null +++ b/docs/handoff-testing/03-emulator-deadclick.md @@ -0,0 +1,27 @@ +# 03 — Emulator never-dead-click + +**What:** `initEmulator()` caught every dylib-load failure and returned a normal +`EmulatorStatus{state:'error'}` object; the RPC handlers forwarded that as a +*successful* response, so the UI silently reverted (a dead click, real error +stranded in console). Now the handlers throw and the UI surfaces the error. + +**Where:** vault `#338` (MERGED, develop) — +`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/index.ts` +(`emulatorInit`/`emulatorSwitchWallet`/`emulatorImportWallet` throw), +`src/mainview/components/DeviceGrid.tsx` (`handleStartEmu` surfaces any +non-running result). + +## Test (needs a FAILING emulator to see the value) +1. Install a **stale/incompatible** dylib (e.g. one missing `kkemu_start`) at + `~/.keepkey/emulator/libkkemu.dylib`. +2. Click **Start** on the emulator card in Vault. + +## Verify +- [ ] A red error banner appears with the real reason (e.g. `Symbol "kkemu_start" + not found` / `kkemu_init returned N`) — **not** a silent revert. +- [ ] With a good dylib, Start still boots the emulator normally. + +## Status / gotchas +- This is exactly the bug that masked the earlier `kkemu_start` mismatch — + before the fix it just dead-clicked. + diff --git a/docs/handoff-testing/04-emulator-capture-seedreveal.md b/docs/handoff-testing/04-emulator-capture-seedreveal.md new file mode 100644 index 00000000..9d82e4c1 --- /dev/null +++ b/docs/handoff-testing/04-emulator-capture-seedreveal.md @@ -0,0 +1,33 @@ +# 04 — Emulator capture-frame + seed-reveal/version tooling + +**What:** emulator-only dev tooling — grab the live OLED as a PNG (visual proof +for automated test drivers) and reveal the active flash's saved seed / show +version controls. + +**Where:** vault `#339` (MERGED, develop) — +`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/emulator-window.ts` +(`captureCurrentFrame` + `/_emu/capture` bridge), `index.ts` +(`emulatorCaptureFrame`, `emulatorRevealSeed` RPCs), `rest-api.ts` +(`POST /emulator/capture`, auth'd, `engine.isEmulator`-gated), +`DeviceSettingsDrawer.tsx`. + +## Test +- Capture (used already this session to verify the identity logo): + `POST http://localhost:1646/emulator/capture` with a bearer token → returns + `{ dataUrl }` (PNG). Example driver: + `projects/keepkey-sdk/tests/evm-clearsign/_capture-load.js` pattern (removed; + reconstruct from git if needed). +- Seed reveal: DeviceSettingsDrawer → reveal seed (emulator wallet only). + +## Verify +- [ ] `POST /emulator/capture` returns a valid PNG data URL of the current OLED. +- [ ] `emulatorRevealSeed` shows the active flash's saved mnemonic; **rejects on + a real device** (`engine.isEmulator` gate). +- [ ] Version/seed controls in DeviceSettingsDrawer work; the two prior + race-condition fixes hold (stale revealed seed on wallet-switch/wipe; + concurrent mutating actions mutually exclusive). + +## Status / gotchas +- REST capture route is registered manually (not in tsoa swagger) — probe it, + don't expect it in `/spec/swagger.json`. + diff --git a/docs/handoff-testing/05-loadsigner-confirm-button.md b/docs/handoff-testing/05-loadsigner-confirm-button.md new file mode 100644 index 00000000..fca75f21 --- /dev/null +++ b/docs/handoff-testing/05-loadsigner-confirm-button.md @@ -0,0 +1,25 @@ +# 05 — LoadClearsignSigner emulator confirm button + +**What:** the `/eth/clearsign/load-signer` route called `loadClearsignSigner()` +directly, unlike every other signing route which wraps in `emuWrap()`. Loading a +signer raises a mandatory on-device "Trust identity" confirm — on the emulator +that button press must be armed via `emuWrap`, or the OLED shows the trust screen +but **no green approve button ever appears and the call hangs**. Now wrapped. +`emuWrap` is a no-op on real hardware. + +**Where:** vault `#341` (MERGED, develop) — +`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/rest-api.ts` +(the `/eth/clearsign/load-signer` handler now `emuWrap(() => wallet.loadClearsignSigner(...))`). + +## Test +- Emulator + rc7: run `clearsign-signer-flows.js` (handoff 01/08). The load step + hits this path. + +## Verify +- [ ] On the emulator, the "Trust identity 'Pioneer'" load screen shows a + clickable **green approve button** (armed) — the load does not hang. +- [ ] Real device (rc7): physical button confirms the load as before. + +## Status / gotchas +- Prerequisite for testing handoff 01 on the emulator (without it the load hangs). + diff --git a/docs/handoff-testing/06-idletimeout-consecutive-sign.md b/docs/handoff-testing/06-idletimeout-consecutive-sign.md new file mode 100644 index 00000000..b30bb9cf --- /dev/null +++ b/docs/handoff-testing/06-idletimeout-consecutive-sign.md @@ -0,0 +1,30 @@ +# 06 — Signing idle-timeout fix (consecutive signs) + +**What:** `Bun.serve` had no `idleTimeout` → default 120s. A human-gated sign +blocks the response while the user confirms; on the SECOND consecutive sign the +wait exceeded the socket idle window, Bun closed the connection +(`other side closed`), aborting the in-flight sign mid-confirm (device returned +home). Set `idleTimeout: 0` — device signing must not time out at the socket. + +**Where:** vault `#342` (OPEN) — +`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/rest-api.ts` +(`Bun.serve({ idleTimeout: 0, … })`). Test helper: `CLEARSIGN_FLOW=` runs a +single flow in `clearsign-signer-flows.js`. + +## Test +- Emulator + rc7, Vault with #342: run the FULL 3-flow suite (no `CLEARSIGN_FLOW`): + `node tests/evm-clearsign/clearsign-signer-flows.js`. + +## Verify +- [ ] All **3 consecutive** flows (aave-supply, erc20-transfer, + erc20-approve-unlimited) sign in one run — the 2nd no longer hangs / + `other side closed`. +- [ ] Each single flow via `CLEARSIGN_FLOW=` also passes (already confirmed + erc20-transfer + approve-unlimited individually this session). + +## Status / gotchas +- **Root cause vs symptom:** idle-timeout removes the socket-close *symptom*. If + the 2nd sign STILL hangs (no close, just stuck), there's a deeper emulator + poll-thread/transport state bug — that was the original "busy/queue" report and + is NOT yet root-caused. Confirm 3-in-a-row genuinely completes before closing. + diff --git a/docs/handoff-testing/07-icon-encoder-tooling.md b/docs/handoff-testing/07-icon-encoder-tooling.md new file mode 100644 index 00000000..7a920c5d --- /dev/null +++ b/docs/handoff-testing/07-icon-encoder-tooling.md @@ -0,0 +1,32 @@ +# 07 — Clear-sign icon encoder + catalog tooling + +**What:** turn any protocol/identity logo into the KeepKey 1bpp mono-RLE bitmap +the OLED renders (`draw_bitmap_mono_rle`), ≤384 B, ≤64×64. The encoder owns only +the RLE codec (must match the firmware decoder byte-for-byte) and self-verifies +every output by round-tripping through a decoder mirror; ImageMagick does the +resize/center/threshold. + +**Where:** vault `#342` (OPEN) — +`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/scripts/clearsign-icons/` +(`encode-icon.mjs`, `build-catalog.mjs`, `README.md`, `sources/`, `generated/`). + +## Test +- Codec self-test: `cd .../scripts/clearsign-icons && bun encode-icon.mjs --self-test`. +- One image: `bun encode-icon.mjs sources/pioneer.png --json`. +- Batch: `bun build-catalog.mjs` (encodes `sources/*` → `generated/protocol-icons.json`). +- ASCII-preview to eyeball a shape: see the `rleDecode` one-liner in the README. + +## Verify +- [ ] `--self-test` prints the exact expected byte stream + round-trip OK. +- [ ] Pioneer compass encodes ≤384 B at 40×40 and previews as a clean compass. +- [ ] End-to-end: the generated `identity-icons.json` icon renders correctly on + the device (covered by handoff 01) — proves the encoder matches firmware. +- [ ] An oversized/detailed logo fails with a clear ">384B" message (not silent). + +## Status / gotchas +- Confirm-column width is 40px → generate confirm icons at **40px** (not the + handoff's aspirational 48). Boot-review (bigger) can use larger later. +- To add a real protocol logo: drop `sources/.png`, run `build-catalog.mjs`. +- Per-tx **protocol** glyphs (Aave/Uniswap beside the method) are a separate + transient path — tooling produces them, wiring is a follow-up. + diff --git a/docs/handoff-testing/08-sdk-clearsign-tests.md b/docs/handoff-testing/08-sdk-clearsign-tests.md new file mode 100644 index 00000000..884e94d9 --- /dev/null +++ b/docs/handoff-testing/08-sdk-clearsign-tests.md @@ -0,0 +1,28 @@ +# 08 — keepkey-sdk clear-sign test train + +**What:** the runtime-signer clear-sign test suite (recovered from untracked +files): shared blob builder + offline parity gate + on-device flows. + +**Where:** vault `#340` (MERGED, develop) — +`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/` +(`_clearsign.js`, `clearsign-offline-parity.js`, +`evm-clearsign/{clearsign-signer-flows,loadsigner-sign-flows,relay-v2-schema-flow}.js`, +`fixtures/clearsign-golden.json`). + +## Test +- Offline (no device): `cd .../projects/keepkey-sdk && node tests/clearsign-offline-parity.js`. +- On-device (rc7): `KEEPKEY_API_KEY=… node tests/evm-clearsign/clearsign-signer-flows.js` + (self mode; `CLEARSIGN_MODE=pioneer` mode is dead until Pioneer /sign returns). + +## Verify +- [ ] Offline parity: **51/51** catalog flows match python-keepkey @1545299 + (blob sha256+len + JS sighash) — already green this session. +- [ ] On-device self mode: load signer → 3 flows sign, no raw hex (ties into + handoffs 01/06). + +## Status / gotchas +- `CLEARSIGN_MODE=pioneer` calls `/api/v1/descriptors/sign` which is 404 in prod + — only `self` mode is viable now. +- The test now sends the Pioneer identity icon + `persist:true` (handoff 01/07); + `CLEARSIGN_NO_ICON=1` for the text-only path. + diff --git a/docs/handoff-testing/09-perf-telemetry.md b/docs/handoff-testing/09-perf-telemetry.md new file mode 100644 index 00000000..0c4dc049 --- /dev/null +++ b/docs/handoff-testing/09-perf-telemetry.md @@ -0,0 +1,32 @@ +# 09 — Vault↔API perf telemetry + +**What:** split perceived-slow portfolio loads into API vs network/client time. +Server stamps `serverMs`/`traceId` on `POST /portfolio` and exposes +`POST /api/v1/telemetry/vault`; the vault times every `GetPortfolioBalances` +(wrapped once on the pioneer client singleton) and posts +`{traceId, serverMs, clientTotalMs, outcome, …}`; backend computes +`networkMs = clientTotalMs − serverMs`. + +**Where:** +- pioneer `#164` (MERGED) — server stamp + ingest. +- vault `#334` (MERGED, develop) — + `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/perf-telemetry.ts` + + `pioneer.ts` hook; tests `__tests__/perf-telemetry.test.ts`. + +## Test +- Unit: `cd .../projects/keepkey-vault && bun test __tests__/perf-telemetry.test.ts` (11/11). +- Data-flow: run a Vault build with #334 against a pioneer that has #164 deployed + with the `/api/v1/telemetry/vault` route live; load a portfolio a few times. + +## Verify +- [ ] Records batch + flush to `POST /api/v1/telemetry/vault` (fire-and-forget — + a failed flush must never block/error a load). +- [ ] Backend joins on `traceId` and reports `networkMs`/`serverMs` split. +- [ ] Outcome classification: ok / slow (>3s) / degraded / timeout / error. +- [ ] `appVersion` matches the shipped build; endpoint no-ops gracefully until + the pioneer ingest is deployed. + +## Status / gotchas +- Both halves merged; the **pioneer prod deploy of the ingest endpoint** + a + dashboard read endpoint are the remaining pieces. + diff --git a/docs/handoff-testing/10-ruji-dashboard-icon.md b/docs/handoff-testing/10-ruji-dashboard-icon.md new file mode 100644 index 00000000..b633641f --- /dev/null +++ b/docs/handoff-testing/10-ruji-dashboard-icon.md @@ -0,0 +1,24 @@ +# 10 — RUJI dashboard icon (CDN) + +**What:** RUJI showed a broken "?" icon on the dashboard. Two causes: (1) the +icon was never uploaded to the coin-icon CDN, and (2) `assetData.json` keyed RUJI +under the old CAIP (`cosmos:thorchain-1/slip44:ruji`) while the runtime CAIP is +`cosmos:thorchain-mainnet-v1/denom:x/ruji`. Uploaded `ruji.png` to DO Spaces +under **both** base64 keys (200 now). + +**Where:** CDN only (no code change). Source PNG: +`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-website-v7/public/images/chains/ruji.png`. +CDN keys: `coins/Y29zbW9zOnRob3JjaGFpbi1tYWlubmV0LXYxL2Rlbm9tOngvcnVqaQ.png` + +`coins/Y29zbW9zOnRob3JjaGFpbi0xL3NsaXA0NDpydWpp.png`. + +## Test / Verify +- [ ] `curl -sIL https://api.keepkey.info/coins/Y29zbW9zOnRob3JjaGFpbi1tYWlubmV0LXYxL2Rlbm9tOngvcnVqaQ.png` + → 200 image/png (both keys). +- [ ] RUJI shows its real logo on the Vault dashboard (no "?" box) — next load, + no rebuild needed. + +## Status / gotchas +- Dashboard `` tags have no `onError` fallback (unlike `AssetIcon.tsx`) → + a missing CDN icon renders the browser's raw broken-image glyph. Separate + follow-up if you want a graceful letter-bubble there too. + diff --git a/docs/handoff-testing/11-rujira-thornode-failover.md b/docs/handoff-testing/11-rujira-thornode-failover.md new file mode 100644 index 00000000..ff0c223d --- /dev/null +++ b/docs/handoff-testing/11-rujira-thornode-failover.md @@ -0,0 +1,30 @@ +# 11 — Rujira THORNode failover + +**What:** `intergrations/rujira/src/index.ts` hardcoded a single THORNode host +(`thornode.thorchain.liquify.com`) for FIN contract smart-queries. That host is +dead (connection refused) with no fallback → **every** Rujira FIN quote (RUJI, +TCY swaps) failed. Added the same 2-host failover the main `thorchain` +integration already uses. + +**Where:** pioneer `#165` (MERGED) — +`.../projects/pioneer/modules/intergrations/rujira/src/index.ts` +(`THORNODE_HOSTS = [thornode.thorchain.network, rest.cosmos.directory/thorchain]`, +`smartQuery` loops hosts). + +## Test +- Needs a pioneer build/deploy with #165 (`make start` in the pioneer monorepo), + then a RUJI or TCY swap quote through the vault. + +## Verify +- [ ] RUJI → (USDC/BTC) and TCY swap **quotes return** (no "Unable to fetch + pools" / empty quote from the FIN path). +- [ ] Both failover hosts serve the cosmwasm smart-query (verified live this + session: `thornode.thorchain.network` returns out-of-gas simulate, + `rest.cosmos.directory/thorchain` returns `{returned,fee}`). + +## Status / gotchas +- The "thorchain: Unable to fetch pools" you saw may have been this FIN-path + failure surfacing under a generic label (prod's main `/thorchain/pools` tested + healthy). Retry a RUJI swap after the pioneer deploy to confirm. +- Rujira FIN is gated behind `FEATURE_RUJIRA_SWAPS` (api-blue only). + diff --git a/docs/handoff-testing/12-emulator-abi-graft.md b/docs/handoff-testing/12-emulator-abi-graft.md new file mode 100644 index 00000000..669de278 --- /dev/null +++ b/docs/handoff-testing/12-emulator-abi-graft.md @@ -0,0 +1,34 @@ +# 12 — Firmware emulator-ABI graft (testable dylib) + +**What:** the clear-sign firmware line (RC6) and the emulator-dylib ABI line +(`kkemu_start`, poll thread) had **diverged** — neither branch had both, so an +emulator built from the clear-sign branch was missing `kkemu_start` (dead click, +handoff 03). Cherry-picked the 4 emulator-ABI commits (#249–252) onto the +clear-sign branch so the feature is emulator-testable. Those commits touch +`lib/emulator/*` only (no-op for device firmware). + +**Where:** firmware `feat/clearsign-persistent-identity-icons` / +`release/7.15.0-rc7` (`BitHighlander/keepkey-firmware`). Submodule at +`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/modules/keepkey-firmware`. + +## Test / build +``` +cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/modules/keepkey-firmware +git checkout release/7.15.0-rc7 # (or feat/clearsign-persistent-identity-icons) +cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11 +make build-emulator # installs ~/.keepkey/emulator/libkkemu.dylib +``` +then **reload the emulator in Vault**. + +## Verify +- [ ] `nm -gU ~/.keepkey/emulator/libkkemu.dylib | grep kkemu_start` → present + (all 12 `kkemu_*` FFI symbols the vault requires). +- [ ] Emulator boots in Vault; clear-sign firmware (`signed_metadata.c`) is + compiled in (handoffs 01/05 exercise it). + +## Status / gotchas +- This is the **prerequisite** for emulator-testing handoffs 01/03/04/05. +- The device firmware build ignores these commits (emulator-only files); they're + cleanly separable if the eventual PR wants the feature without the emu graft. +- device-protocol pinned to fork `33521a8` (msg-117 icon fields). + diff --git a/docs/handoff-testing/INDEX.md b/docs/handoff-testing/INDEX.md new file mode 100644 index 00000000..1544df56 --- /dev/null +++ b/docs/handoff-testing/INDEX.md @@ -0,0 +1,37 @@ +# New-feature test handoffs (session 2026-07-08) + +Every NEW feature/fix from this session, each with its own handoff below. Test +in roughly this order (device-independent first, then emulator, then swaps). +Full paths per repo convention. + +| # | Feature | Repo / PR | Merged? | Needs | +|---|---------|-----------|---------|-------| +| 01 | Persistent clear-sign identities + logo render | firmware #299, hdwallet #53, vault #342 | all OPEN (rc7) | emulator + device | +| 02 | Clearsign vendored allowlist + LoadClearsignSigner endpoint | vault #337 | merged | device (blind-sign gating) | +| 03 | Emulator never-dead-click | vault #338 | merged | emulator (failure path) | +| 04 | Emulator capture-frame + seed-reveal/version tooling | vault #339 | merged | emulator | +| 05 | LoadClearsignSigner emulator confirm button | vault #341 | merged | emulator | +| 06 | Signing idle-timeout fix (consecutive signs) | vault #342 | OPEN | emulator (3 consecutive signs) | +| 07 | Clear-sign icon encoder + catalog tooling | vault #342 | OPEN | local (self-test) + emulator | +| 08 | keepkey-sdk clear-sign test train | vault #340 | merged | local (offline) + device | +| 09 | Vault↔API perf telemetry | vault #334, pioneer #164 | merged | deploy + data-flow | +| 10 | RUJI dashboard icon (CDN) | CDN upload | live | dashboard eyeball | +| 11 | Rujira THORNode failover | pioneer #165 | merged | RUJI/TCY swap quote | +| 12 | Firmware emulator-ABI graft (testable dylib) | firmware feat/clearsign-plus-emu-dylib | rc7 branch | prerequisite for 01/03/04/05 | + +## Environment prerequisites +- Emulator dylib: `~/.keepkey/emulator/libkkemu.dylib` must be built from a + firmware branch that has **both** clear-sign AND the emu ABI (`kkemu_start`). + That is `feat/clearsign-persistent-identity-icons` / `release/7.15.0-rc7` + (handoff 12). Rebuild: `cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11 && make build-emulator`, then **reload the emulator in Vault**. +- Vault on :1646 built from a checkout that has vault #342 (icon route + + idle-timeout). `make vault` from + `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11`. +- Real device: a `release/7.15.0-rc7` DEBUG_LINK build for the automated + python/SDK suites (they wipe + load the public test seed — never real funds). + +## Known open items (not per-feature) +- Consecutive-sign root cause: idle-timeout (06) removes the socket-close + symptom; confirm 3 signs in a row actually complete before calling it done. +- Firmware #299 SOP gate: CI green + device-protocol **upstream**-pin swap + before the final release (rc is fork-pinned). diff --git a/modules/hdwallet b/modules/hdwallet index 1ae8ea07..eccc3fa6 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit 1ae8ea0724719e34611f57583b103f2a3ac73a73 +Subproject commit eccc3fa680d6ad79d1f4799f3860679697631551 diff --git a/projects/keepkey-sdk/src/index.ts b/projects/keepkey-sdk/src/index.ts index 76fdac7f..51ae9f77 100644 --- a/projects/keepkey-sdk/src/index.ts +++ b/projects/keepkey-sdk/src/index.ts @@ -333,9 +333,12 @@ export class KeepKeySdk { this.client.post('/eth/sign-transaction', params), /** - * Load a runtime EVM clear-sign signer into a device key slot (RAM-only, - * user-confirmed on device). The device shows a trust screen naming the - * alias + pubkey fingerprint. Dropped on reboot/wipe — reload per session. + * Load an EVM clear-sign signer into a device key slot (user-confirmed on + * device). The device shows a trust screen naming the alias + pubkey + * fingerprint. Default is RAM-only (dropped on reboot — reload per session); + * pass `persist: true` to keep it in device flash across reboots (until + * WipeDevice). An optional `icon` (+ `iconWidth`/`iconHeight`) renders the + * identity's logo on the trust screen and every clear-sign it vouches for. * Firmware 7.15.0+. Used to trust a metadata-signing key (e.g. a CI test * key in slot 3) so `ethSignTransaction`'s `txMetadata` blobs verify. */ diff --git a/projects/keepkey-sdk/src/types.ts b/projects/keepkey-sdk/src/types.ts index 3b7772d8..ed57ca6c 100644 --- a/projects/keepkey-sdk/src/types.ts +++ b/projects/keepkey-sdk/src/types.ts @@ -113,6 +113,15 @@ export interface LoadClearsignSignerParams { pubkey: string /** Signer label shown on the device trust screen. `[A-Za-z0-9 _-]`, 1-31 chars. */ alias: string + /** Optional identity logo: 1bpp mono RLE bitmap as hex, <= 384 bytes. Shown on + * the trust screen and led before every clear-sign the identity vouches for. + * `icon`, `iconWidth`, and `iconHeight` must be supplied together — the REST + * layer rejects a half-specified icon. */ + icon?: string + iconWidth?: number + iconHeight?: number + /** Persist the identity in device flash across reboots (until WipeDevice). */ + persist?: boolean } export interface LoadClearsignSignerResult { diff --git a/projects/keepkey-sdk/tests/evm-clearsign/clearsign-signer-flows.js b/projects/keepkey-sdk/tests/evm-clearsign/clearsign-signer-flows.js index 82cdaf8d..30399462 100644 --- a/projects/keepkey-sdk/tests/evm-clearsign/clearsign-signer-flows.js +++ b/projects/keepkey-sdk/tests/evm-clearsign/clearsign-signer-flows.js @@ -28,6 +28,17 @@ const { run, ETH_PATH, erc20Approve } = require('../_helpers') const { buildFlowBlob, sighashLegacy, CI_TEST_PUBKEY, CI_SIGNER_ALIAS, TEST_KEY_ID } = require('../_clearsign') +// Identity logo (1bpp mono RLE) generated by +// keepkey-vault/scripts/clearsign-icons/encode-icon.mjs — sent with the signer +// so the device renders it on the trust screen + every per-tx confirm. +// Set CLEARSIGN_NO_ICON=1 to load a text-only identity instead. +let IDENTITY_ICON = null +if (!process.env.CLEARSIGN_NO_ICON) { + try { + IDENTITY_ICON = require('../../../keepkey-vault/scripts/clearsign-icons/generated/identity-icons.json').pioneer + } catch { /* catalog not generated — fall back to text-only */ } +} + const MODE = process.env.CLEARSIGN_MODE || 'self' const PIONEER_URL = (process.env.PIONEER_URL || 'http://localhost:9001').replace(/\/+$/, '') @@ -36,7 +47,10 @@ const PIONEER_URL = (process.env.PIONEER_URL || 'http://localhost:9001').replace const NONCE = '0x0', GAS_PRICE = '0x4a817c800' /* 20 gwei */, GAS_LIMIT = '0x3d090' /* 250000 */ // self mode: golden catalog flows the CI key signs offline (slot 3, byte-parity-proven). -const SELF_FLOWS = ['aave-v3-supply', 'erc20-transfer', 'erc20-approve-unlimited'] +// CLEARSIGN_FLOW= runs a single flow (useful for isolating one flow / avoiding +// the consecutive-sign hang while that's investigated separately). +const ALL_SELF_FLOWS = ['aave-v3-supply', 'erc20-transfer', 'erc20-approve-unlimited'] +const SELF_FLOWS = process.env.CLEARSIGN_FLOW ? [process.env.CLEARSIGN_FLOW] : ALL_SELF_FLOWS // pioneer mode: real contracts Pioneer classifies VERIFIED. Extend as coverage grows. const PIONEER_FLOWS = [ @@ -57,10 +71,15 @@ function selfProvider() { const b = buildFlowBlob(k) return { label: k, tx: b.tx, blobHex: b.blobHex, keyId: b.keyId } }) - return { - signer: { keyId: TEST_KEY_ID, pubkey: CI_TEST_PUBKEY, alias: CI_SIGNER_ALIAS }, - flows, + const signer = { keyId: TEST_KEY_ID, pubkey: CI_TEST_PUBKEY, alias: CI_SIGNER_ALIAS } + if (IDENTITY_ICON) { + signer.alias = IDENTITY_ICON.alias || signer.alias // e.g. "Pioneer" (compass logo) + signer.icon = IDENTITY_ICON.hex + signer.iconWidth = IDENTITY_ICON.width + signer.iconHeight = IDENTITY_ICON.height + signer.persist = true // exercise the persist-to-flash path (survives reboot) } + return { signer, flows } } /** pioneer mode: fetch each blob + the signer pubkey from live Pioneer. */ diff --git a/projects/keepkey-vault/__tests__/firmware-clearsign-gate.test.ts b/projects/keepkey-vault/__tests__/firmware-clearsign-gate.test.ts new file mode 100644 index 00000000..27531eb6 --- /dev/null +++ b/projects/keepkey-vault/__tests__/firmware-clearsign-gate.test.ts @@ -0,0 +1,64 @@ +/** + * firmwareClearSigns allowlist guard (pure unit — no device, no network). + * + * `firmwareClearSigns` decides whether the Vault overlay forces AdvancedMode: + * it must mirror the rc3 device's `ethereum_contractHandled` + native ERC-20 + * path EXACTLY. Two failure directions this locks down: + * - under-warn: claiming a tx is clear-signed when the device blind-signs it + * - over-force: forcing global AdvancedMode on a tx the device clear-signs + * natively (re-opens the drain vector PR #261/#303 closed). + * + * Run: cd projects/keepkey-vault && bun test __tests__/firmware-clearsign-gate.test.ts + */ +import { describe, test, expect } from 'bun:test' +import { firmwareClearSigns } from '../src/bun/calldata-decoder' + +const ZX = '0xdef1c0ded9bec7f1a1670819833240f027b25eff' +const UNIV2 = '0x7a250d5630b4cf539739df2c5dacb4c659f2488d' +const THOR = '0xd37bbe5744d730a1d98d8dc97c42f0ca46ad7146' +const MAYA = '0xd89dce570de35a6f42d3bca7dba50a6d89bfc2a2' +const RANDOM = '0x1111111111111111111111111111111111111111' +const word = '00'.repeat(32) +const erc20Transfer = '0xa9059cbb' + word + word // 68-byte standard transfer +const erc20Approve = '0x095ea7b3' + word + word + +describe('firmwareClearSigns mirrors the rc3 native clear-sign allowlist', () => { + test('THORChain/Maya deposit to the pinned router → clear-signs', () => { + expect(firmwareClearSigns(THOR, '0x1fece7b4' + word)).toBe(true) + expect(firmwareClearSigns(THOR, '0x44bc937b' + word)).toBe(true) + expect(firmwareClearSigns(MAYA, '0x1fece7b4' + word)).toBe(true) + }) + + test('deposit selector to a DIFFERENT address → blind (spoof guard)', () => { + expect(firmwareClearSigns(RANDOM, '0x1fece7b4' + word)).toBe(false) + }) + + test('0x proxy methods only at the pinned ExchangeProxy/router', () => { + expect(firmwareClearSigns(ZX, '0x415565b0' + word)).toBe(true) // transformERC20 + expect(firmwareClearSigns(ZX, '0xd9627aa4' + word)).toBe(true) // sellToUniswap + expect(firmwareClearSigns(UNIV2, '0xf305d719' + word)).toBe(true) // addLiquidityETH + expect(firmwareClearSigns(RANDOM, '0x415565b0' + word)).toBe(false) + expect(firmwareClearSigns(UNIV2, '0x415565b0' + word)).toBe(false) // right selector, wrong pin + }) + + test('standard 68-byte ERC-20 transfer/approve → clear-signs (any token addr)', () => { + expect(firmwareClearSigns(RANDOM, erc20Transfer)).toBe(true) + expect(firmwareClearSigns(RANDOM, erc20Approve)).toBe(true) + }) + + test('non-standard-length transfer selector → blind (not the 68-byte path)', () => { + expect(firmwareClearSigns(RANDOM, '0xa9059cbb' + word)).toBe(false) // 36-byte, malformed + }) + + test('firmware-unknown contracts → blind (Uniswap / 1inch / relay)', () => { + expect(firmwareClearSigns(UNIV2, '0x38ed1739' + word)).toBe(false) // swapExactTokensForTokens + expect(firmwareClearSigns(RANDOM, '0x12aa3caf' + word)).toBe(false) // 1inch + expect(firmwareClearSigns(RANDOM, '0xdeadbeef' + word)).toBe(false) // relay/opaque + }) + + test('empty / missing calldata → blind', () => { + expect(firmwareClearSigns(THOR, '0x')).toBe(false) + expect(firmwareClearSigns(undefined, erc20Transfer)).toBe(false) + expect(firmwareClearSigns(THOR, undefined)).toBe(false) + }) +}) diff --git a/projects/keepkey-vault/scripts/clearsign-icons/README.md b/projects/keepkey-vault/scripts/clearsign-icons/README.md new file mode 100644 index 00000000..a52139cd --- /dev/null +++ b/projects/keepkey-vault/scripts/clearsign-icons/README.md @@ -0,0 +1,41 @@ +# Clear-sign icons + +Tooling to turn protocol/identity logos into the 1bpp mono-RLE bitmap the +KeepKey OLED renders (`draw_bitmap_mono_rle`), for clear-sign **identity** icons +(the signer's logo, persisted on-device via `LoadClearsignSigner.icon`) and +**protocol** icons (per-tx dApp glyphs the vault sends). + +Device constraints: **≤ 384 bytes**, **≤ 64×64** (48×48 recommended), 1bpp mono. + +## Add an icon + +1. Drop a logo into `sources/` named for its key (lowercase protocol/dApp or + signer slug), e.g. `sources/aave.png`, `sources/pioneer.svg`. PNG/SVG/JPG all + work — ImageMagick resizes/centers/thresholds. +2. `bun build-catalog.mjs` → writes `generated/protocol-icons.json` + (`{ "": { hex, width, height, bytes } }`). Each icon is self-verified by + round-tripping through a mirror of the firmware decoder and checked against + the 384-byte cap. + +Simple, chunky, high-contrast logos compress best. If one exceeds 384 B: +`ICON_SIZE=32 bun build-catalog.mjs`, simplify the art, or start from a mono +source. `ICON_GRAY=1` keeps antialiased grayscale instead of a hard threshold. + +## Encode one file / preview + +```sh +bun encode-icon.mjs [--size 48] [--gray] [--invert] [--json] +bun encode-icon.mjs --self-test # verify the RLE codec +``` + +The identity icon used by the clear-sign device test lives in +`generated/identity-icons.json` (consumed by +`keepkey-sdk/tests/evm-clearsign/clearsign-signer-flows.js`). + +## Format (why a custom encoder) + +The firmware decoder reads a control byte as `int8`: `n>0` repeats the next +value byte `n` times; `n<0` takes the next `-n` bytes as literal pixels. Pixel = +`value * frame.color / 100`; identity frames use `color=100`, so value bytes are +direct 0–255 intensities. `encode-icon.mjs` owns this encoding and self-checks +every output; ImageMagick handles everything else. diff --git a/projects/keepkey-vault/scripts/clearsign-icons/build-catalog.mjs b/projects/keepkey-vault/scripts/clearsign-icons/build-catalog.mjs new file mode 100644 index 00000000..10b79ae8 --- /dev/null +++ b/projects/keepkey-vault/scripts/clearsign-icons/build-catalog.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env bun +/** + * Batch-encode every logo in sources/ into the device mono-RLE catalog that the + * vault sends as clear-sign identity / protocol icons. + * + * sources/.{png,svg,jpg} -> generated/protocol-icons.json + * { "": { hex, width, height, bytes } , ... } + * + * Drop a logo in sources/, run `bun build-catalog.mjs`, done. Each icon is + * self-verified (encode round-trips through a firmware-decoder mirror) and + * checked against the 384-byte device cap; oversized ones are reported, not + * silently dropped. + * + * Naming convention for : the lowercase protocol/dApp key or a signer + * alias slug (e.g. `aave`, `uniswap`, `pioneer`). The vault maps a tx's + * dappName / the loaded signer to this key. + */ +import { readdirSync, mkdirSync } from 'node:fs' +import { join, extname, basename } from 'node:path' +import { encodeIconFile } from './encode-icon.mjs' + +const HERE = new URL('.', import.meta.url).pathname +const SRC = join(HERE, 'sources') +const OUT = join(HERE, 'generated', 'protocol-icons.json') +const SIZE = Number(process.env.ICON_SIZE || 48) +const GRAY = process.env.ICON_GRAY === '1' + +mkdirSync(join(HERE, 'generated'), { recursive: true }) + +const files = readdirSync(SRC).filter((f) => ['.png', '.svg', '.jpg', '.jpeg'].includes(extname(f).toLowerCase())) +const catalog = {} +const failures = [] + +for (const f of files.sort()) { + const key = basename(f, extname(f)).toLowerCase() + try { + const { hex, width, height, bytes } = encodeIconFile(join(SRC, f), { size: SIZE, gray: GRAY }) + catalog[key] = { hex, width, height, bytes } + console.log(` ✓ ${key.padEnd(20)} ${String(bytes).padStart(3)}B ${width}x${height}`) + } catch (e) { + failures.push({ key, err: e.message }) + console.log(` ✗ ${key.padEnd(20)} ${e.message}`) + } +} + +await Bun.write(OUT, JSON.stringify(catalog, null, 2) + '\n') +console.log(`\nwrote ${Object.keys(catalog).length} icon(s) -> ${OUT}`) +if (failures.length) { + console.log(`${failures.length} failed (too large / unreadable) — simplify the logo, try --size 32, or a cleaner mono source.`) + process.exit(1) +} diff --git a/projects/keepkey-vault/scripts/clearsign-icons/encode-icon.mjs b/projects/keepkey-vault/scripts/clearsign-icons/encode-icon.mjs new file mode 100644 index 00000000..4d8bec66 --- /dev/null +++ b/projects/keepkey-vault/scripts/clearsign-icons/encode-icon.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env bun +/** + * clearsign icon encoder — any image → the KeepKey 1bpp/grayscale mono-RLE + * bitmap format that firmware's draw_bitmap_mono_rle() decodes (device-protocol + * LoadClearsignSigner.icon, <=384 bytes, <=64x64). + * + * ImageMagick does the resize/center/threshold; this tool owns only the RLE + * encoding (the part that must match the firmware decoder byte-for-byte) and + * self-verifies every encode by round-tripping through a decoder mirror. + * + * Format (see modules/keepkey-firmware/lib/board/draw.c): + * control byte read as int8: + * n > 0 : the next single value byte repeats n times -> [n][value] + * n < 0 : the next (-n) bytes are literal pixel values -> [n][v1..v_-n] + * pixel = value * frame.color / 100; identity frames use color=100, so the + * value bytes are direct 0-255 intensities (0x00 off, 0xFF on for mono). + * + * Usage: + * bun encode-icon.mjs [--size 48] [--gray] [--invert] [--json] + * bun encode-icon.mjs --self-test + * + * Prints { hex, width, height, bytes } (or a human summary without --json). + */ +import { execFileSync } from 'node:child_process' + +const ICON_MAX_BYTES = 384 +const MAX_DIM = 64 + +// ── RLE (must mirror draw_bitmap_mono_rle) ──────────────────────────────── + +/** Encode a row-major pixel array (0-255) to the mono-RLE byte stream. */ +export function rleEncode(pixels) { + const out = [] + const n = pixels.length + let i = 0 + while (i < n) { + // Longest run of identical pixels at i, capped at int8 max (127). + let run = 1 + while (i + run < n && pixels[i + run] === pixels[i] && run < 127) run++ + if (run >= 2) { + out.push(run, pixels[i] & 0xff) + i += run + } else { + // Literal packet: pixels up to the next >=2 run, capped at 127. The + // control byte carries -len as int8; -128 (from len 128) overflows the + // firmware's int8 count and trips the draw.c assertion, so 127 is the max. + const lit = [] + while (i < n && lit.length < 127) { + if (i + 1 < n && pixels[i + 1] === pixels[i]) break // a run starts next + lit.push(pixels[i] & 0xff) + i++ + } + out.push((256 - lit.length) & 0xff, ...lit) // -len as an unsigned byte + } + } + return Uint8Array.from(out) +} + +/** Decode — a faithful mirror of the firmware loop, used only to self-verify. */ +export function rleDecode(data, w, h) { + const pixels = new Array(w * h) + let idx = 0 // pixel_index into data + let sequence = 0 + let nonsequence = 0 + for (let p = 0; p < w * h; p++) { + if (sequence === 0 && nonsequence === 0) { + let ctrl = data[idx++] + if (ctrl > 127) ctrl -= 256 // int8 + sequence = ctrl + if (sequence < 0) { + nonsequence = -sequence + sequence = 0 + } + } + pixels[p] = data[idx] // value byte (color=100 => identity) + if (sequence > 0) { + sequence-- + if (sequence === 0) idx++ + } else { + idx++ + nonsequence-- + } + } + return pixels +} + +// ── Image → pixels (via ImageMagick) ────────────────────────────────────── + +function rasterize(imagePath, size, { gray, invert }) { + // Fit into size x size on a black canvas, centered; grayscale 8-bit raw out. + const args = [ + imagePath[0] === '-' ? 'png:-' : imagePath, // (stdin unused today, keep simple) + '-alpha', 'remove', '-alpha', 'off', + '-resize', `${size}x${size}`, + '-background', 'black', '-gravity', 'center', '-extent', `${size}x${size}`, + '-colorspace', 'Gray', '-depth', '8', + ] + if (!gray) args.push('-threshold', '50%') + if (invert) args.push('-negate') + args.push('gray:-') + const raw = execFileSync('magick', args, { maxBuffer: 1 << 24 }) + return Array.from(raw) // one byte per pixel, row-major +} + +// ── Public: encode a file ───────────────────────────────────────────────── + +export function encodeIconFile(imagePath, { size = 48, gray = false, invert = false } = {}) { + if (size < 1 || size > MAX_DIM) throw new Error(`size must be 1..${MAX_DIM}`) + const pixels = rasterize(imagePath, size, { gray, invert }) + const data = rleEncode(pixels) + + // Self-verify: the firmware decoder must reproduce the exact pixels. + const back = rleDecode(data, size, size) + for (let i = 0; i < pixels.length; i++) { + if (back[i] !== pixels[i]) throw new Error(`RLE self-check failed at pixel ${i}`) + } + if (data.length > ICON_MAX_BYTES) { + throw new Error( + `encoded ${data.length}B > ${ICON_MAX_BYTES}B cap. Use --size 32, a simpler/mono logo, or drop --gray.`, + ) + } + return { + hex: Buffer.from(data).toString('hex'), + width: size, + height: size, + bytes: data.length, + } +} + +// ── CLI ─────────────────────────────────────────────────────────────────── + +function selfTest() { + // A tiny hand-checked pattern: run of 3 zeros, 2 literals, run of 4 of 0xff. + const px = [0, 0, 0, 0x10, 0x20, 255, 255, 255, 255] + const enc = rleEncode(px) + const dec = rleDecode(enc, 3, 3) + const ok = px.every((v, i) => v === dec[i]) + if (!ok) { console.error('SELF-TEST FAILED', { px, enc: [...enc], dec }); process.exit(1) } + // Expected stream: [3,0][-2,0x10,0x20][4,255] + const expect = [3, 0, 254, 0x10, 0x20, 4, 255] + const got = [...enc] + if (JSON.stringify(got) !== JSON.stringify(expect)) { + console.error('SELF-TEST byte mismatch', { expect, got }); process.exit(1) + } + // Boundary: a long literal stretch (alternating pixels → no run ≥2) must + // split into ≤127-length packets and never emit control 0x80 (int8 -128), + // which overflows the firmware's int8 negated count. Values are mono + // (0x00/0xFF) so a value byte can't be mistaken for a 0x80 control byte. + const alt = Array.from({ length: 300 }, (_, k) => (k % 2) * 0xff) + const altEnc = rleEncode(alt) + for (let k = 0; k < altEnc.length;) { + let c = altEnc[k]; if (c > 127) c -= 256 + if (c === -128) { console.error('SELF-TEST: emitted 0x80/-128 literal control byte'); process.exit(1) } + k += c >= 2 ? 2 : 1 + (-c) // run packet [n][v] : literal packet [n][v..] + } + if (!alt.every((v, k) => v === rleDecode(altEnc, 30, 10)[k])) { + console.error('SELF-TEST: alternating round-trip failed'); process.exit(1) + } + console.log('self-test OK (round-trip + exact byte stream + 127-literal boundary)') +} + +if (import.meta.main) { + const argv = process.argv.slice(2) + if (argv.includes('--self-test')) { selfTest(); process.exit(0) } + const image = argv.find((a) => !a.startsWith('--')) + if (!image) { + console.error('usage: bun encode-icon.mjs [--size 48] [--gray] [--invert] [--json]') + process.exit(1) + } + const sizeArg = argv.indexOf('--size') + const opts = { + size: sizeArg >= 0 ? parseInt(argv[sizeArg + 1], 10) : 48, + gray: argv.includes('--gray'), + invert: argv.includes('--invert'), + } + const res = encodeIconFile(image, opts) + if (argv.includes('--json')) console.log(JSON.stringify(res)) + else console.log(`${res.width}x${res.height} ${res.bytes}B (cap ${ICON_MAX_BYTES})\n${res.hex}`) +} diff --git a/projects/keepkey-vault/scripts/clearsign-icons/generated/identity-icons.json b/projects/keepkey-vault/scripts/clearsign-icons/generated/identity-icons.json new file mode 100644 index 00000000..6e451583 --- /dev/null +++ b/projects/keepkey-vault/scripts/clearsign-icons/generated/identity-icons.json @@ -0,0 +1,9 @@ +{ + "pioneer": { + "hex": "7f000a0007ff1e000dff190011ff15000affff000aff12000bffff000bff10000cffff000cff0e000dffff000dff0c000dff03000dff0b000dff03000dff0a000eff03000eff09000eff03000eff08000eff05000eff07000eff05000eff07000eff05000eff06000fff05000fff05000bff0d000bff050007ff150007ff050003ff1d0003ff050007ff150007ff05000bff0d000bff05000fff05000fff06000eff05000eff07000eff05000eff07000eff05000eff08000eff03000eff09000eff03000eff0a000dff03000dff0b000dff03000dff0c000dffff000dff0e000cffff000cff10000bffff000bff12000affff000aff150011ff19000dff1e0007ff6000", + "width": 40, + "height": 40, + "bytes": 260, + "alias": "Pioneer" + } +} diff --git a/projects/keepkey-vault/scripts/clearsign-icons/generated/protocol-icons.json b/projects/keepkey-vault/scripts/clearsign-icons/generated/protocol-icons.json new file mode 100644 index 00000000..a3db16ae --- /dev/null +++ b/projects/keepkey-vault/scripts/clearsign-icons/generated/protocol-icons.json @@ -0,0 +1,8 @@ +{ + "pioneer": { + "hex": "7f00260007ff25000fff1f0013ff1b0017ff18000cffff000cff15000effff000eff12000fffff000fff100010ffff0010ff0f000fff03000fff0e0010ff030010ff0c0011ff030011ff0b0011ff030011ff0a0012ff030012ff090012ff030012ff080012ff050012ff070012ff050012ff070012ff050012ff070012ff050012ff060013ff050013ff05000eff0f000eff050008ff1b0008ff050004ff230004ff050008ff1b0008ff05000eff0f000eff050013ff050013ff060012ff050012ff070012ff050012ff070012ff050012ff070012ff050012ff080012ff030012ff090012ff030012ff0a0011ff030011ff0b0011ff030011ff0c0010ff030010ff0e000fff03000fff0f0010ffff0010ff10000fffff000fff12000effff000eff15000cffff000cff180017ff1b0013ff1f000fff250007ff7400", + "width": 48, + "height": 48, + "bytes": 316 + } +} diff --git a/projects/keepkey-vault/scripts/clearsign-icons/sources/pioneer.png b/projects/keepkey-vault/scripts/clearsign-icons/sources/pioneer.png new file mode 100644 index 00000000..5e28dfcb Binary files /dev/null and b/projects/keepkey-vault/scripts/clearsign-icons/sources/pioneer.png differ diff --git a/projects/keepkey-vault/src/bun/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index 31efb2ef..ec28d6d9 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -1123,12 +1123,25 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 const server = Bun.serve({ port, maxRequestBodySize: 1024 * 1024, // 1 MB max (addresses/signing payloads are small) - async fetch(req) { + // Keep Bun's default idle timeout for the server as a whole (so idle/ + // unauthenticated connections still get reaped) — but lift it per-request + // for human-gated device operations below, where the response legitimately + // blocks while the user confirms on the device. Without that, Bun closes + // the socket mid-confirm ("other side closed" → the device cancels the + // confirm and returns home), which killed the second consecutive sign. + async fetch(req, server) { const url = new URL(req.url) const path = url.pathname const method = req.method const requestStart = Date.now() + // Human-gated device ops (signing + the clear-sign trust confirm) block + // the socket while the user presses the physical button. Disable the idle + // timeout for just these requests, not the whole server. + if (method === 'POST' && (SIGNING_ROUTES.has(path) || path === '/eth/clearsign/load-signer')) { + server.timeout(req, 0) + } + // Resolve app info from bearer token (or 'public') const resolveAppInfo = (): { appName: string; imageUrl: string } => { const token = auth.extractBearerToken(req) @@ -2138,7 +2151,10 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 if (typeof (wallet as any).loadClearsignSigner !== 'function') { throw new HttpError(501, 'Connected device does not support LoadClearsignSigner (requires firmware 7.15.0+)') } - console.log(`[REST] clearsign load-signer: slot=${body.keyId} alias="${body.alias}" pubkey=${body.pubkey}`) + const icon = body.icon ? parseHex(body.icon, 'icon') : undefined + if (icon && icon.length > 384) throw new HttpError(400, `icon must be <= 384 bytes, got ${icon.length}`) + console.log(`[REST] clearsign load-signer: slot=${body.keyId} alias="${body.alias}" pubkey=${body.pubkey}` + + `${icon ? ` icon=${icon.length}B ${body.iconWidth}x${body.iconHeight}` : ''}${body.persist ? ' persist' : ''}`) try { // Loading a signer raises a mandatory on-device "Trust signer" confirm. // On the emulator that button press must be armed via emuWrap (interactive @@ -2146,7 +2162,11 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // trust screen but no green button ever appears and the call hangs. // emuWrap is a transparent no-op on real hardware. await emuWrap( - () => (wallet as any).loadClearsignSigner({ keyId: body.keyId, pubkey, alias: body.alias }), + () => (wallet as any).loadClearsignSigner({ + keyId: body.keyId, pubkey, alias: body.alias, + icon, iconWidth: body.iconWidth, iconHeight: body.iconHeight, + persist: body.persist === true, + }), { operation: 'loadClearsignSigner', opLabel: 'Trust Signer', chain: 'Ethereum' }, ) return json({ ok: true, keyId: body.keyId, alias: body.alias }) diff --git a/projects/keepkey-vault/src/bun/schemas.ts b/projects/keepkey-vault/src/bun/schemas.ts index bdb61854..e831ac85 100644 --- a/projects/keepkey-vault/src/bun/schemas.ts +++ b/projects/keepkey-vault/src/bun/schemas.ts @@ -79,7 +79,19 @@ export const LoadClearsignSignerRequest = z.object({ keyId: z.number().int().min(1).max(3), // slot 0 = built-in production key, not loadable pubkey: z.string().regex(/^(0x)?[0-9a-fA-F]{66}$/), // 33-byte compressed secp256k1 hex alias: z.string().min(1).max(31).regex(/^[A-Za-z0-9 _-]+$/), // shown on the device trust screen -}).strip() + // Optional identity logo (1bpp mono RLE, <=384 bytes hex) + its dimensions; + // shown on the trust screen and led before every clear-sign it vouches for. + icon: z.string().regex(/^(0x)?[0-9a-fA-F]{2,768}$/).optional(), + iconWidth: z.number().int().min(1).max(64).optional(), + iconHeight: z.number().int().min(1).max(64).optional(), + // Persist the identity in device flash across reboots (until WipeDevice). + persist: z.boolean().optional(), +}).strip().refine( + // icon + its dimensions travel together: firmware rejects an icon without + // dimensions, and dimensions without an icon are silently dropped. + (v) => [v.icon, v.iconWidth, v.iconHeight].filter((x) => x !== undefined).length % 3 === 0, + { message: 'icon, iconWidth, and iconHeight must all be present or all absent' }, +) /** POST /eth/sign-typed-data */ export const EthSignTypedDataRequest = z.object({