Skip to content

Cold wallet signing over QR (Keystone / Quantus cold wallet app)#123

Open
n13 wants to merge 1 commit into
mainfrom
cold-wallet-signing
Open

Cold wallet signing over QR (Keystone / Quantus cold wallet app)#123
n13 wants to merge 1 commit into
mainfrom
cold-wallet-signing

Conversation

@n13

@n13 n13 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Adds air-gapped signing to the CLI: import a Keystone 3 or Quantus cold wallet app account as a watch-only wallet, then quantus send --from <cold-wallet> signs by exchanging QR codes — show the transaction as a ur:quantus-sign-request QR, sign on the device, scan its animated signature UR back with the laptop camera. wallet import-cold scans the device's address QR (or takes --address).

Key design decisions

  • Byte-identical to the existing protocol. Uses the same quantus_ur crate (git tag 1.4.0) the mobile app, cold wallet app, and Keystone firmware pin, so the CLI is a drop-in third participant — no device-side changes needed.
  • The QR carries the raw unhashed signing payload, built manually from subxt's public ExtrinsicParamsEncoder traits. subxt's own signer_payload() blake2-hashes payloads >256 bytes, which would make them unparseable (and undisplayable) on the device.
  • Nonce/era are captured once into a TxContext and reused verbatim for both the QR and the submitted extrinsic, with a runtime cross-check that the two constructions agree. The old hardware_mark_1 branch silently refetched the nonce between display and submit, invalidating signatures — that bug is structurally excluded here.
  • Responses are verified before submission: length, pubkey→address (poseidon) binding, and the ML-DSA-87 signature itself. A response from the wrong device aborts hard (naming the offending address); an incomplete scan offers a rescan. Signed extrinsics are never rebuilt/resigned behind the user's back — no retry loop.
  • Cold wallets reuse the existing wallet-file format via a serde-defaulted wallet_type field: old files read as hot, old CLI versions still parse cold files, and list/find paths needed no changes. All key-requiring paths refuse cold wallets before any password prompt.
  • Camera is a default-on feature flag (nokhwa + rqrr); --cold-response-in <file|-> keeps headless/no-camera builds fully functional, and the signing machinery is generic over any call payload so multisig/reversible can adopt it later.

Testing

  • Golden byte-layout test pinning the payload to the field layout the cold-wallet-app/Keystone parsers expect, plus an equivalence test pinning it to subxt's canonical signer payload (both the raw ≤256 B and hashed >256 B cases), using the vendored metadata offline.
  • Signature validation unit tests: round-trip, truncated scan, wrong signer, stale payload.
  • UR round-trip tests including the always-multi-part 7219-byte response with shuffled frames.
  • Live e2e against a dev node using the new hidden developer cold-sign-sim command (plays the cold-wallet side with a local hot wallet, exchanging UR parts over files): transfer signed, submitted, and included in a block; fee preview matched the actual fee to the unit; wrong-signer response aborted as expected.
  • ./clippy.sh clean, full test suite passing, cargo build --no-default-features green.

Not yet tested: physical camera scanning against a real Keystone/cold-wallet-app (needs Heisenberg/Planck — real devices enforce a genesis allowlist that excludes dev nodes).

🤖 Generated with Claude Code

Adds watch-only cold wallets (`wallet import-cold`) and QR-based signing
for `send`: the CLI displays the raw V4 signing payload as a
ur:quantus-sign-request QR, scans the device's animated signature UR with
the laptop camera (or file/stdin for headless use), verifies the response
against the stored address, and submits.

Speaks the exact wire protocol of the mobile app / cold wallet app /
Keystone firmware (quantus_ur tag 1.4.0). Includes a hidden
`developer cold-sign-sim` command that plays the cold-wallet side with a
local hot wallet for dev-node e2e testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/wallet/mod.rs
assert!(json["encrypted_data"].as_array().unwrap().is_empty());

// Decryption paths refuse with ColdWalletNoKeys (no password prompt)
let result = wallet_manager.load_wallet("frosty", "");
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.

2 participants