Cold wallet signing over QR (Keystone / Quantus cold wallet app) - #123
Cold wallet signing over QR (Keystone / Quantus cold wallet app)#123n13 wants to merge 13 commits into
Conversation
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>
n13
left a comment
There was a problem hiding this comment.
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
TxContextand 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 oldhardware_mark_1nonce-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_typeis 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, sameeffective_tip_amount/positive_tip_amounthelpers, same result summary via the extractedprint_send_result.
Minor, non-blocking nits:
handle_cold_sendbalance preflight excludes the fee (src/cli/send.rs~700): the comment says "fee estimation needs a signer", butsign_and_submit_coldalready estimates the fee with a zeroed fixed-length Dilithium signature. You could reuse that estimate to fail before the QR dance whenbalance < 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.- 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. scan_ur_from_stdinignores the timeout (src/qr/scanner.rs:92):UrSource::StdinLinesblocks until complete/EOF regardless of thetimeoutparameter. Fine in practice (Ctrl-C kills it), but the unused deadline is a small surprise in the API.--cold-request-out/--cold-response-in/--camera-indexare silently ignored for hot wallets — a one-line warning when they're passed with a hot--fromwould catch user confusion.
None of these block merge. Ship it. 🧊
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.
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
|
Merged current What was blocking:
Review nits from the earlier pass, now done:
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 Cold/QR/wallet unit tests pass locally ( |
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.
|
Every signed submit now goes through one fork in
CLI commands already loaded wallets via Unsigned wormhole/collect-rewards submits are unchanged (no key). |
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
left a comment
There was a problem hiding this comment.
Verdict: REQUEST_CHANGES
The core signing invariant is sound, but this head still has blocking lifecycle and validation defects.
-
Cold import can overwrite a concurrently-created wallet (
src/wallet/mod.rs:271-291).create_cold_walletperforms a check-then-save withKeystore::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 takeslock_wallet_createand finishes with the no-replacesave_new_wallet; reuse that path here and add the concurrent-creation regression. -
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 aroundsrc/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 reportsBadSignatureand 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. -
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. -
The required Clippy gate fails at this head:
SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warningsrejectssrc/qr/scanner.rs:229-230(field_reassign_with_default) andsrc/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.
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 aur:quantus-sign-requestQR, you sign on the device, and it scans the animated signature UR back with the laptop camera.wallet import-coldscans the device's address QR (or takes--address).Key design decisions
WalletSignerenum (Hot(QuantumKeyPair)/Cold { name, address }) replaces raw keypairs at every submit call site, and the shared submit stage incli::commonbranches 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-request-out,--cold-response-in <file|->, and--camera-indexare global CLI flags installed once frommain, so scripted/headless flows work with any command; builds without the defaultcamerafeature support only this path.quantus_urcrate (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.ExtrinsicParamsEncodertraits. subxt's ownsigner_payload()blake2-hashes payloads >256 bytes, which would make them unparseable (and undisplayable) on the device.TxContextand reused verbatim for both the QR and the submitted extrinsic, with a runtime cross-check that the two constructions agree. The oldhardware_mark_1branch silently refetched the nonce between display and submit, invalidating signatures — that bug is structurally excluded here.wallet_typefield: old files read as hot, old CLI versions still parse cold files, andlist/findpaths needed no changes. All key-requiring paths refuse cold wallets before any password prompt.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
developer cold-sign-simcommand (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.shclean, full test suite passing,cargo build --no-default-featuresgreen.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).