Skip to content

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

Draft
n13 wants to merge 13 commits into
mainfrom
cold-wallet-signing
Draft

Cold wallet signing over QR (Keystone / Quantus cold wallet app)#123
n13 wants to merge 13 commits 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 use it with any extrinsic command — quantus send --from <cold-wallet>, quantus multisig approve --from <cold-wallet>, etc. The CLI shows the transaction as a ur:quantus-sign-request QR, you sign on the device, and it scans the animated signature UR back with the laptop camera. wallet import-cold scans the device's address QR (or takes --address).

Key design decisions

  • Commands don't know whether a wallet is hot or cold. A WalletSigner enum (Hot(QuantumKeyPair) / Cold { name, address }) replaces raw keypairs at every submit call site, and the shared submit stage in cli::common branches to the QR flow when the signer is watch-only. Every extrinsic command gets cold signing through the one shared path — no per-command special-casing — and commands that submit several extrinsics (e.g. runtime update, tech-referenda submit-with-preimage) do one QR roundtrip per extrinsic. Wormhole is the one deliberate exception: it derives secrets from the wallet's mnemonic and submits unsigned extrinsics, so it refuses cold wallets like any other key-requiring path.
  • Cold I/O flags are global. --cold-request-out, --cold-response-in <file|->, and --camera-index are global CLI flags installed once from main, so scripted/headless flows work with any command; builds without the default camera feature support only this path.
  • Fee preflight works for cold wallets via a dummy-signature estimate — the fixed-length Dilithium signature means the estimate is as accurate as a real one, with no key material needed.
  • 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); the signing machinery is generic over any call payload, which is what lets the shared submit stage route every command through it.

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).

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 Dismissed

@n13 n13 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Approve ✅

Reviewed the full diff locally (branch checked out at cbf0553), plus cargo check --all-targets, cargo clippy --all-targets (both clean), and the new tests (cold_signing 4/4, qr 8/8, wallet 35/35 pass).

This is a carefully built feature. The things that matter most for signing code are done right:

  • Single-capture invariant: nonce/era/block context captured once into TxContext and reused verbatim for QR payload and submitted extrinsic, with a runtime cross-check (partial.signer_payload() == signable_payload(raw)) that hard-fails before submission if the two constructions ever drift. The old hardware_mark_1 nonce-refetch bug is structurally excluded.
  • Response verification before submit: length, poseidon pubkey→address binding, and the ML-DSA-87 signature itself. Wrong-key responses abort hard and name the offending address; no silent rebuild/resign retry loop.
  • Protocol fidelity: the golden-layout test pinning the raw payload byte-for-byte (call ‖ era ‖ nonce ‖ tip ‖ mode ‖ specV ‖ txV ‖ genesis ‖ blockHash ‖ metadataHash) against the cold-wallet parser layout, plus the test pinning our manual builder to subxt's canonical signer_payload() above/below the 256-byte hash threshold, is exactly the right way to lock this down.
  • Wallet-file compat: serde-defaulted wallet_type is the correct migration — old files read as hot, old binaries ignore the new field, and the legacy-JSON test proves it. All key-requiring paths (load_keypair_from_wallet, export_mnemonic, decrypt) refuse cold wallets before any password prompt.
  • Cold send correctly mirrors the hot path: same get_latest_block + .mortal(256) anchor, same effective_tip_amount/positive_tip_amount helpers, same result summary via the extracted print_send_result.

Minor, non-blocking nits:

  1. handle_cold_send balance preflight excludes the fee (src/cli/send.rs ~700): the comment says "fee estimation needs a signer", but sign_and_submit_cold already estimates the fee with a zeroed fixed-length Dilithium signature. You could reuse that estimate to fail before the QR dance when balance < amount + tip + fee, instead of letting the chain reject an already-signed extrinsic. At minimum the comment is slightly contradicted by the estimator's existence.
  2. Non-interactive session without --cold-request-out (cold_signing.rs ~283): when stdin is not a terminal and no request file is given, the request is never surfaced anywhere and the CLI just waits for a response that can't be produced. An early error like "non-interactive cold signing requires --cold-request-out" would fail faster.
  3. scan_ur_from_stdin ignores the timeout (src/qr/scanner.rs:92): UrSource::StdinLines blocks until complete/EOF regardless of the timeout parameter. Fine in practice (Ctrl-C kills it), but the unused deadline is a small surprise in the API.
  4. --cold-request-out / --cold-response-in / --camera-index are silently ignored for hot wallets — a one-line warning when they're passed with a hot --from would catch user confusion.

None of these block merge. Ship it. 🧊

n13 added 2 commits July 30, 2026 11:51
Introduce WalletSigner (Hot/Cold) and route every command through it:
the shared submit stage in cli::common branches to the QR signing flow
when the wallet is watch-only, so commands no longer special-case cold
wallets. Promote --cold-request-out/--cold-response-in/--camera-index
to global flags installed once from main, add cold fee estimation via
dummy signature, and drop the send-only cold path.
@n13
n13 marked this pull request as draft August 8, 2026 08:06
n13 and others added 2 commits August 8, 2026 16:30
Reconciles cold wallet signing with main's V12 wallet hardening (#126):

- WalletSigner::account_id_ss58check -> try_account_id_ss58check, since main
  deliberately removed the infallible accessors (they returned the all-zero
  account on malformed keys).
- list_wallets / find_wallet_address branch on WalletType::Cold before main's
  empty-password authentication: a watch-only wallet has no encrypted keypair
  to authenticate against, and would otherwise error or vanish from listings.
- submit_transaction_with_inclusion_block now takes the WalletSigner and owns
  the cold branch, so cold wallets keep the correct inclusion block instead of
  falling back to the moving tip.
- keystore decrypt refuses cold wallets before main's encryption_version check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bring cold-wallet QR signing onto current main (ML-DSA-65/87, vesting,
checkphrases, spec 143, exercise budget). Reconcile WalletSigner through
the new submit/fee/scheme-check paths, including vesting. Devices still
sign ML-DSA-87 only.

Also:
- Bump quantus_ur to 1.6.0 to match the mobile/cold-wallet apps
- Fail non-interactive cold signing without --cold-request-out
- Warn when cold I/O flags are passed with a hot wallet
- Honor stdin scan timeout
@n13

n13 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Merged current main into this branch and finished the leftover wiring so cold signing compiles and works as a complete CLI path again.

What was blocking:

  • The branch was 12 commits behind and conflicting with main (ML-DSA-65/87, vesting, checkphrases, spec 143, exercise budget).
  • The crypto types on main renamed DilithiumDilithium87. Cold signing (devices are ML-DSA-87 only) now uses those types.
  • New vesting submit/claim paths went through load_keypair and would have refused a cold --from. They now use WalletSigner like every other extrinsic command.
  • quantus_ur was still on 1.4.0 while the mobile/cold-wallet apps pin 1.6.0. Bumped; Keystone 1.4.0 encodings still decode.

Review nits from the earlier pass, now done:

  • Non-interactive sessions without --cold-request-out fail immediately instead of waiting for a response that cannot be produced.
  • --cold-request-out / --cold-response-in / --camera-index warn when used with a hot wallet.
  • Stdin UR collection honors the timeout.

Still not done here: a live camera scan against a real Keystone / cold-wallet app. Devices only sign Planck/Heisenberg genesis + the whitelisted calls (transfers, reversible, and on the app also multisig). Dev-node e2e is still the file-based developer cold-sign-sim path.

Cold/QR/wallet unit tests pass locally (5 cold_signing, 8 qr, wallet suite).

Library transfer/multisig helpers took a QuantumKeyPair and always
signed locally. They now take WalletSigner, so a cold wallet takes the
QR path in submit_transaction the same way CLI commands already do.

Examples that called subxt sign_and_submit_then_watch_default now use
the shared submit helper instead of a second signing path.
@n13

n13 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Every signed submit now goes through one fork in submit_transaction / submit_transaction_with_inclusion_block / submit_transaction_with_nonce:

  • Hot WalletSigner → local Dilithium sign
  • Cold WalletSigner → QR request/response (sign_and_submit_cold)

CLI commands already loaded wallets via load_signer_from_wallet. The remaining gap was the library helpers (transfer, batch_transfer, create_multisig, propose_*, approve_proposal, cancel_proposal) and a few examples that signed with QuantumKeyPair / raw subxt sign_and_submit. Those now take WalletSigner too, so a cold --from or a cold library signer cannot skip the QR path.

Unsigned wormhole/collect-rewards submits are unchanged (no key).

n13 added 7 commits August 13, 2026 21:30
The laptop scanner asked for max resolution and then downsampled every
frame to 1280px via get_pixel so rqrr could keep up with ~5 fps. The
cold wallet animates UR fragments at 15–50 fps, so that path missed
frames.

Open the camera at its highest frame rate, decode native-resolution
frames, and convert RGB to luma in a tight loop.
The cold wallet app draws white modules on a transparent (dark) background.
rqrr only looks for dark-on-light codes, so a static address QR that a
phone reads instantly never decoded. Try both polarities, a half-scale
pass, and swallow rqrr panics on bad frames.

Open a live preview window of the camera feed so the QR can be aimed
instead of scanning blind.
minifb/AppKit aborted when the preview window was opened from tokio's
blocking pool (NSMenu must be set on the main thread). Build the window
on the block_on task and only push scaled frames from the capture loop.
Apple cameras deliver NV12; nokhwa tags that as YUYV so RGB conversion
produced the doubled, posterized preview and unreadable QRs. Detect NV12
by buffer size and convert it as NV12.

The preview also handed AppKit a pointer to a Vec that was dropped
before the next paint, which flashed garbage. Keep the last frame alive
until it is replaced.
The scan pipeline was verified end to end: frames arrive as real YUYV,
luma is correct, and rqrr decodes clean synthetic QRs — but rqrr gives
up under the defocus a fixed-focus Mac camera produces at phone
distance, so nothing ever scanned. rxing's adaptive binarizer decodes
those blurred frames (and is ~4x faster per 1080p pass with the QR-only
hint). Defocus regression test added at a blur level rqrr fails.
The fountain decoder locks onto the first captured part's stream; if the
cold wallet was still animating the previous signature when the camera
opened, every part of the new animation was silently rejected and the
scan never completed despite capturing everything. Completion now
retries on every suffix of the capture, so dropping the oldest parts
recovers the newest consistent stream (regression-tested).

Scan output is now linear: each captured part prints a persistent
'Part 7/25' line, the live frame counter no longer overwrites capture
progress, and a failed scan reports exactly which fragments are missing
(or that a stale stream poisoned an otherwise complete capture).

@n13 n13 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES

The core signing invariant is sound, but this head still has blocking lifecycle and validation defects.

  1. Cold import can overwrite a concurrently-created wallet (src/wallet/mod.rs:271-291). create_cold_wallet performs a check-then-save with Keystore::save_wallet, whose rename intentionally replaces an existing destination. If another CLI process creates a hot wallet with the same name after the check, the cold import replaces that file and can permanently discard its encrypted keys. Every existing creation path takes lock_wallet_create and finishes with the no-replace save_new_wallet; reuse that path here and add the concurrent-creation regression.

  2. The documented file transport consumes stale sessions as current ones (src/qr/scanner.rs:79-90, src/cli/cold_signing.rs:367-380, and the simulator request read around src/cli/cold_signing.rs:470-478). A complete response file from extrinsic N is returned immediately for extrinsic N+1, before the external signer can replace it; validation then reports BadSignature and aborts. The simulator has the symmetric problem with an old request file. This makes reused file paths and multi-extrinsic commands unreliable in the headless flow. Establish per-roundtrip freshness/consumption semantics (or unique session files) and cover two consecutive exchanges using the same configured paths.

  3. Arbitrary non-ASCII QR/file input can panic the scanner (src/qr/scanner.rs:35-37). trimmed[..3] slices at a byte offset that need not be a UTF-8 boundary (for example, a decoded QR beginning with two multi-byte characters). This input is explicitly untrusted and should be ignored or returned as an error, not unwind the CLI; use a boundary-safe prefix check and add a regression.

  4. The required Clippy gate fails at this head: SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warnings rejects src/qr/scanner.rs:229-230 (field_reassign_with_default) and src/qr/scanner.rs:396 (manual_is_multiple_of). GitHub's Analysis check is failing for the same reason.

Validation on exact base f0a6432 / head 44804ef: git diff --check and cargo +nightly fmt --all -- --check passed; SKIP_CIRCUIT_BUILD=1 cargo check --locked --no-default-features passed; the library run passed 274/275 tests, including all new cold-signing, QR, and wallet tests, with only the unrelated generated-bins test failing because SKIP_CIRCUIT_BUILD=1 intentionally omits those artifacts. No blocking issue was found in the nonce/era reuse, raw-payload/subxt equivalence check, signer-address binding, or pre-submission signature verification.

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