UX decision: match upstream wherever the crypto allows, and where it does not #8

Open
opened 2026-09-15 06:34:16 +00:00 by grenade · 1 comment
Owner

Settling the shape of the extension before building more of it. Referenced by #3, #4, #5 and #6.

The rule

it's pretty important that the ux is as similar to the upstream ux as the underlying crypto allows. since the upstream mechanism stores enough encrypted to allow the user to sign and spend with only a storage password rather than a mnemonic (which i'd also rather not store, matching upstream), then that's the ux to aim for.

So: a password unlocks signing. The mnemonic is shown once at creation and never stored. Identical to upstream, and the ML-DSA key material supports it directly — signing needs the secret key, which is exactly what the encrypted PKCS8 blob holds.

Verified, not assumed: upstream's encodePair takes { publicKey, secretKey } only, and accountsCreateSuri drops the suri once addUri has derived from it. Nothing in extension-base/src/stores/ persists a seed. We match that.

One caveat to carry: pairToJson puts meta outside the encrypted field, so account metadata is plaintext at rest. Nothing sensitive goes there today and nothing sensitive should start.

What is unchanged from upstream

Every one of these works with ML-DSA with no UX difference at all:

flow why it survives
Create account generate mnemonic → show once → confirm → password → store encrypted key material
Import from mnemonic createFromUri, verified against quantus-cli for both schemes
Import from raw seed seedValidate already accepts a 256-bit hex seed; this is how dev-genesis accounts move
Import from extension JSON round-trips, quantus/common#3
Sign password unlocks the secret key; no mnemonic involved
Export JSON unchanged
Change password, forget account, rename unchanged

What the crypto forces to change

Exactly one thing: deriving a further account.

Upstream derives a child from the parent's key material, so an unlocked parent is enough. ML-DSA keys are not derivable from one another — the Quantus tree derives every account from the mnemonic independently — so pair.derive() refuses and cannot do otherwise (quantus/common#4).

Since we deliberately do not store the mnemonic, an unlocked account is not sufficient to produce account n+1. That is a genuine consequence of the two decisions together, not an implementation gap.

The UX that stays closest to upstream:

  • Keep the screens. "Add account" still exists and still takes an index.
  • Change the input. Where upstream asks for the parent's password, we ask for the recovery phrase, because that is the only thing that can answer.
  • Say why. "Quantus accounts each derive from your recovery phrase, so adding one needs the phrase rather than this account's password." A user who has used polkadot-js will otherwise read it as a bug.
  • Offer the account index, defaulting to the next unused one, so the common case is phrase + Next.

The Popup/Derive/* flow keyed on a parent account goes away — it has no ML-DSA meaning. canDerive.ts should return false for the ML-DSA types, and that is a permanent answer rather than a placeholder.

Alternative considered and rejected: offering to create accounts 0..n in one go at creation time, so the phrase is only needed once. It front-loads a decision the user cannot yet make and does not help anyone importing an existing wallet.

CLI import is a must-have

being able to import from the cli's output format sounds like a rather basic must-have

Agreed, and my earlier recommendation against it was wrong — see quantus/common#3, where I withdrew the reasoning. Filed as #8.

It converts at the edge: decrypt the CLI container, take the key material, re-encrypt as the extension's own PKCS8 under a password the user chooses. The keyring stays single-format, and the mnemonic inside the CLI file is derived-and-discarded, never stored — same posture as every other import.

Confirmed buildable with what is already on hand: @noble/hashes/argon2 is an existing util-crypto dependency and WebCrypto covers AES-GCM. A real CLI wallet has been decrypted this way already.

The export gap, which is a UX problem

quantus-cli cannot read the extension's JSON, and the extension will not write the CLI's. So an extension JSON backup is recoverable only through polkadot-js-format tooling.

The backup flow must therefore present the recovery phrase as the primary backup and the JSON as a convenience, saying plainly that the phrase works in every Quantus tool and the file does not. This is the one place where being less like upstream is right: upstream can treat its JSON as a universal backup because the whole ecosystem reads it, and ours cannot.

Accounts imported from a raw seed have no phrase to show. That flow must not claim otherwise.

Settling the shape of the extension before building more of it. Referenced by #3, #4, #5 and #6. ## The rule > it's pretty important that the ux is as similar to the upstream ux as the underlying crypto allows. since the upstream mechanism stores enough encrypted to allow the user to sign and spend with only a storage password rather than a mnemonic (which i'd also rather not store, matching upstream), then that's the ux to aim for. So: **a password unlocks signing. The mnemonic is shown once at creation and never stored.** Identical to upstream, and the ML-DSA key material supports it directly — signing needs the secret key, which is exactly what the encrypted PKCS8 blob holds. Verified, not assumed: upstream's `encodePair` takes `{ publicKey, secretKey }` only, and `accountsCreateSuri` drops the suri once `addUri` has derived from it. Nothing in `extension-base/src/stores/` persists a seed. We match that. One caveat to carry: `pairToJson` puts `meta` **outside** the encrypted field, so account metadata is plaintext at rest. Nothing sensitive goes there today and nothing sensitive should start. ## What is unchanged from upstream Every one of these works with ML-DSA with no UX difference at all: | flow | why it survives | |---|---| | Create account | generate mnemonic → show once → confirm → password → store encrypted key material | | Import from mnemonic | `createFromUri`, verified against `quantus-cli` for both schemes | | Import from raw seed | `seedValidate` already accepts a 256-bit hex seed; this is how dev-genesis accounts move | | Import from extension JSON | round-trips, quantus/common#3 | | **Sign** | password unlocks the secret key; no mnemonic involved | | Export JSON | unchanged | | Change password, forget account, rename | unchanged | ## What the crypto forces to change **Exactly one thing: deriving a further account.** Upstream derives a child from the parent's *key material*, so an unlocked parent is enough. ML-DSA keys are not derivable from one another — the Quantus tree derives every account from the mnemonic independently — so `pair.derive()` refuses and cannot do otherwise (quantus/common#4). Since we deliberately do not store the mnemonic, an unlocked account is **not** sufficient to produce account *n+1*. That is a genuine consequence of the two decisions together, not an implementation gap. The UX that stays closest to upstream: - **Keep the screens.** "Add account" still exists and still takes an index. - **Change the input.** Where upstream asks for the parent's password, we ask for the **recovery phrase**, because that is the only thing that can answer. - **Say why.** "Quantus accounts each derive from your recovery phrase, so adding one needs the phrase rather than this account's password." A user who has used polkadot-js will otherwise read it as a bug. - Offer the account index, defaulting to the next unused one, so the common case is phrase + Next. The `Popup/Derive/*` flow keyed on a *parent account* goes away — it has no ML-DSA meaning. `canDerive.ts` should return `false` for the ML-DSA types, and that is a permanent answer rather than a placeholder. Alternative considered and rejected: offering to create accounts 0..n in one go at creation time, so the phrase is only needed once. It front-loads a decision the user cannot yet make and does not help anyone importing an existing wallet. ## CLI import is a must-have > being able to import from the cli's output format sounds like a rather basic must-have Agreed, and my earlier recommendation against it was wrong — see quantus/common#3, where I withdrew the reasoning. Filed as #8. It converts at the edge: decrypt the CLI container, take the key material, re-encrypt as the extension's own PKCS8 under a password the user chooses. The keyring stays single-format, and the mnemonic inside the CLI file is **derived-and-discarded**, never stored — same posture as every other import. Confirmed buildable with what is already on hand: `@noble/hashes/argon2` is an existing `util-crypto` dependency and WebCrypto covers AES-GCM. A real CLI wallet has been decrypted this way already. ## The export gap, which is a UX problem `quantus-cli` cannot read the extension's JSON, and the extension will not write the CLI's. So an extension JSON backup is recoverable only through polkadot-js-format tooling. The backup flow must therefore present the **recovery phrase** as the primary backup and the JSON as a convenience, saying plainly that the phrase works in every Quantus tool and the file does not. This is the one place where being *less* like upstream is right: upstream can treat its JSON as a universal backup because the whole ecosystem reads it, and ours cannot. Accounts imported from a raw seed have no phrase to show. That flow must not claim otherwise.
Author
Owner

Correction to the body: the CLI-import work is quantus/common#7, not #8 — that number is this issue. The conversion lives in common because it is keyring-adjacent and testable there; the extension side is the import screen that calls it.

Correction to the body: the CLI-import work is **quantus/common#7**, not `#8` — that number is this issue. The conversion lives in `common` because it is keyring-adjacent and testable there; the extension side is the import screen that calls it.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/extension#8