Air-gapped signing: port the QR flow from Parity Signer to multipart UR #10

Open
opened 2026-09-15 08:23:12 +00:00 by grenade · 2 comments
Owner

Split out of #5, and deferred rather than dropped. Quantus has air-gapped signing; the extension just speaks the wrong protocol for it.

Not a size problem

#5 originally claimed a 5261/7219-byte signature could not fit a QR flow. That was wrong — @polkadot/react-qr already splits payloads into animated frames:

export const FRAME_SIZE = 1024;   // createFrames()

It is a protocol problem

@polkadot/react-qr implements the Parity Signer / Polkadot Vault wire format:

export const ADDRESS_PREFIX = 'substrate';
export const SUBSTRATE_ID = new Uint8Array([0x53]);
export const CRYPTO_SR25519 = new Uint8Array([0x01]);

The crypto type is a byte, and there is no ML-DSA value for it. No Quantus cold wallet speaks this format, and no Parity device could sign for a Quantus account even if one did.

Quantus already has both ends

Which is why this is a port rather than a feature:

  • quantus-cli has signing-qr"Print the QR a cold wallet scans to sign a call for an account, and stop", with --cold-request-out / --cold-response-in for file-based exchange
  • the mobile app scans and signs — mobile-app/lib/v2/components/animated_qr_scanner.dart, connect_keystone_screen.dart, with localised strings for frame progress ({scanned}/{total} frames scanned)
  • the encoding is multipart UR: quantus_ur crate, quantus_sdk/lib/src/utils/ur_qr.dart

The extension is the missing third participant. Doing this would let a user keep keys on a phone or dedicated device and approve dapp transactions from the desktop without the key ever reaching it — the same property the Parity Signer flow provides for Substrate.

Why it is deferred

It is not on the path to signing a first real extrinsic (#7), and it is a self-contained feature that can land any time after. Deferring it means the first working build does not carry a QR flow that cannot work; it does not mean the capability is unwanted.

Scope when picked up

  • Read quantus_ur and ur_qr.dart for the framing, and quantus signing-qr for what a request carries
  • Replace @polkadot/react-qr's display and scanner in Popup/Signing/Qr.tsx and Popup/ImportQr.tsx with UR equivalents
  • ImportQr.tsx currently hardcodes createAccountSuri(name, password, account.content, 'sr25519', …) — an imported cold account is ML-DSA and watch-only
  • Confirm against the real mobile app, not against our own encoder — the same rule the CLI-wallet fixtures follow (quantus/common#7)

Distinct from #9, which is address QR and has no protocol involved.

Split out of #5, and deferred rather than dropped. Quantus has air-gapped signing; the extension just speaks the wrong protocol for it. ## Not a size problem #5 originally claimed a 5261/7219-byte signature could not fit a QR flow. That was wrong — `@polkadot/react-qr` already splits payloads into animated frames: ```js export const FRAME_SIZE = 1024; // createFrames() ``` ## It is a protocol problem `@polkadot/react-qr` implements the Parity Signer / Polkadot Vault wire format: ```js export const ADDRESS_PREFIX = 'substrate'; export const SUBSTRATE_ID = new Uint8Array([0x53]); export const CRYPTO_SR25519 = new Uint8Array([0x01]); ``` The crypto type is a byte, and there is no ML-DSA value for it. No Quantus cold wallet speaks this format, and no Parity device could sign for a Quantus account even if one did. ## Quantus already has both ends Which is why this is a port rather than a feature: - `quantus-cli` has `signing-qr` — *"Print the QR a cold wallet scans to sign a call for an account, and stop"*, with `--cold-request-out` / `--cold-response-in` for file-based exchange - the mobile app scans and signs — `mobile-app/lib/v2/components/animated_qr_scanner.dart`, `connect_keystone_screen.dart`, with localised strings for frame progress (`{scanned}/{total} frames scanned`) - the encoding is multipart **UR**: `quantus_ur` crate, `quantus_sdk/lib/src/utils/ur_qr.dart` The extension is the missing third participant. Doing this would let a user keep keys on a phone or dedicated device and approve dapp transactions from the desktop without the key ever reaching it — the same property the Parity Signer flow provides for Substrate. ## Why it is deferred It is not on the path to signing a first real extrinsic (#7), and it is a self-contained feature that can land any time after. Deferring it means the first working build does not carry a QR flow that cannot work; it does not mean the capability is unwanted. ## Scope when picked up - Read `quantus_ur` and `ur_qr.dart` for the framing, and `quantus signing-qr` for what a request carries - Replace `@polkadot/react-qr`'s display and scanner in `Popup/Signing/Qr.tsx` and `Popup/ImportQr.tsx` with UR equivalents - `ImportQr.tsx` currently hardcodes `createAccountSuri(name, password, account.content, 'sr25519', …)` — an imported cold account is ML-DSA and watch-only - Confirm against the real mobile app, not against our own encoder — the same rule the CLI-wallet fixtures follow (quantus/common#7) Distinct from #9, which is address QR and has no protocol involved.
Author
Owner

A question this issue needs to answer before the QR-signer UI is removed, found while doing #5.

Removing ImportQr also removes watch-only accounts

createAccountExternal — which calls keyring.addExternal and produces an account with no key — has exactly one caller in the whole codebase:

packages/extension-ui/src/Popup/ImportQr.tsx:51
  createAccountExternal(name, account.content, account.genesisHash)

So the "attach external QR-signer account" flow is not merely a way to add a watch-only account, it is the only way. Delete it and the extension can no longer track an address it does not hold keys for — which is a useful capability quite independent of QR, and one nobody asked to lose.

That is why #5 stopped at Ledger and left this alone. The removal is not a cleanup; it is a product decision wearing a cleanup's clothes.

The three options

  1. Port to UR now. Air-gapped signing works, watch-only comes back with it. Most work.
  2. Remove the QR flow, keep watch-only by giving it its own entry point — "track an address", taking a pasted SS58 string. Small, and arguably a better UI than QR-import was, since pasting an address is the common case and scanning a Parity Signer QR never was for Quantus.
  3. Remove both and accept that the extension only holds accounts it has keys for, until the UR port lands.

Option 2 is probably right for the first working build: it drops the protocol that cannot work while keeping the capability that has nothing to do with the protocol. It also pairs naturally with #9 — if the extension can show an address QR, being able to add an address by scanning one is the same feature in reverse, and the mobile app already emits exactly that format.

What is unusable today, either way

ImportQr.tsx hardcodes createAccountSuri(name, password, account.content, 'sr25519', …) on one branch, and the QR payload it parses is Parity Signer's. An account created through it on a Quantus build is unusable. Whatever is decided, that should not ship as-is.

A question this issue needs to answer before the QR-signer UI is removed, found while doing #5. ## Removing ImportQr also removes watch-only accounts `createAccountExternal` — which calls `keyring.addExternal` and produces an account with no key — has exactly **one** caller in the whole codebase: ``` packages/extension-ui/src/Popup/ImportQr.tsx:51 createAccountExternal(name, account.content, account.genesisHash) ``` So the "attach external QR-signer account" flow is not merely *a* way to add a watch-only account, it is the **only** way. Delete it and the extension can no longer track an address it does not hold keys for — which is a useful capability quite independent of QR, and one nobody asked to lose. That is why #5 stopped at Ledger and left this alone. The removal is not a cleanup; it is a product decision wearing a cleanup's clothes. ## The three options 1. **Port to UR now.** Air-gapped signing works, watch-only comes back with it. Most work. 2. **Remove the QR flow, keep watch-only** by giving it its own entry point — "track an address", taking a pasted SS58 string. Small, and arguably a better UI than QR-import was, since pasting an address is the common case and scanning a Parity Signer QR never was for Quantus. 3. **Remove both** and accept that the extension only holds accounts it has keys for, until the UR port lands. Option 2 is probably right for the first working build: it drops the protocol that cannot work while keeping the capability that has nothing to do with the protocol. It also pairs naturally with #9 — if the extension can *show* an address QR, being able to *add* an address by scanning one is the same feature in reverse, and the mobile app already emits exactly that format. ## What is unusable today, either way `ImportQr.tsx` hardcodes `createAccountSuri(name, password, account.content, 'sr25519', …)` on one branch, and the QR payload it parses is Parity Signer's. An account created through it on a Quantus build is unusable. Whatever is decided, that should not ship as-is.
Author
Owner

Option 2 taken, on the strength of the capability actually being used:

yeah, i made good use of the address book and wouldn't want to lose that functionality.

Landed on quantus-remove-ledger (6dfd1099). Typecheck, lint and 65 tests clean; build:chrome completes.

One clarification worth recording

The extension has no address book in the polkadot-js/apps sense — keyring.addresses is never touched here. What it has is external accounts: keyless entries that appear in the account list and are injected into dapps. That is what was at risk, and that is what is preserved.

A true contacts list (addresses you send to, distinct from accounts you hold) would be a new feature. Worth knowing if that is what was meant — it is not what this preserved.

What replaced ImportQr

TrackAddress takes a pasted SS58 string. For Quantus that is the common case anyway: the address is on screen in another wallet, not on a signing device.

It round-trips through decodeAddress/encodeAddress, which does two jobs — rejects a mistyped address by its checksum rather than storing one that can never receive anything, and normalises the prefix so an address pasted from a tool using a different one displays the way the rest of the extension displays it.

The bug this would have been

isExternal && !isHardware previously rendered the QR signer. A tracked address satisfies exactly that condition. Left alone, adding a watch-only address and then having a dapp request a signature would have displayed a Parity Signer QR — for an account that can never sign, in a protocol nothing in the Quantus ecosystem can read.

External accounts now show the decoded call and say plainly that the extension holds no key for them. Two specs rewritten to assert that rather than the QR scanner.

Signing/Qr.tsx went with it, along with the CMD_MORTAL / CMD_SIGN_MESSAGE Parity command bytes and _onSignature, whose only job was accepting a signature produced outside the extension.

Kept for this issue

approveSignSignature in the background is deliberately left. Unlike Ledger, which is gone for good, external signing comes back when this issue ports the flow to multipart UR — the message it carries is the right shape for that, and deleting it would only mean writing it again.

What this issue still needs

The UR port itself. Its scope is unchanged, minus the removal work now done. When picked up, note that the entry point is a different shape than before: scanning to add an address is now #9's territory, and this issue is specifically the signing round trip.

Option 2 taken, on the strength of the capability actually being used: > yeah, i made good use of the address book and wouldn't want to lose that functionality. Landed on `quantus-remove-ledger` (`6dfd1099`). Typecheck, lint and 65 tests clean; `build:chrome` completes. ## One clarification worth recording The extension has **no address book** in the polkadot-js/apps sense — `keyring.addresses` is never touched here. What it has is **external accounts**: keyless entries that appear in the account list and are injected into dapps. That is what was at risk, and that is what is preserved. A true contacts list (addresses you send *to*, distinct from accounts you hold) would be a new feature. Worth knowing if that is what was meant — it is not what this preserved. ## What replaced ImportQr `TrackAddress` takes a pasted SS58 string. For Quantus that is the common case anyway: the address is on screen in another wallet, not on a signing device. It round-trips through `decodeAddress`/`encodeAddress`, which does two jobs — rejects a mistyped address by its checksum rather than storing one that can never receive anything, and normalises the prefix so an address pasted from a tool using a different one displays the way the rest of the extension displays it. ## The bug this would have been `isExternal && !isHardware` previously rendered the QR signer. **A tracked address satisfies exactly that condition.** Left alone, adding a watch-only address and then having a dapp request a signature would have displayed a Parity Signer QR — for an account that can never sign, in a protocol nothing in the Quantus ecosystem can read. External accounts now show the decoded call and say plainly that the extension holds no key for them. Two specs rewritten to assert that rather than the QR scanner. `Signing/Qr.tsx` went with it, along with the `CMD_MORTAL` / `CMD_SIGN_MESSAGE` Parity command bytes and `_onSignature`, whose only job was accepting a signature produced outside the extension. ## Kept for this issue `approveSignSignature` in the background is **deliberately left**. Unlike Ledger, which is gone for good, external signing comes back when this issue ports the flow to multipart UR — the message it carries is the right shape for that, and deleting it would only mean writing it again. ## What this issue still needs The UR port itself. Its scope is unchanged, minus the removal work now done. When picked up, note that the entry point is a different shape than before: scanning to *add* an address is now #9's territory, and this issue is specifically the signing round trip.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/extension#10