keyring: account backup and restore is impossible for Quantus as the format stands #3

Closed
opened 2026-09-10 10:12:46 +00:00 by grenade · 3 comments
Owner

Depends on #1, designed together with #2. This is the one that cannot be worked around downstream — file any discovery here.

Two independent breakages

1. decodePair reads a fixed-length public key. packages/keyring/src/pair/defaults.ts:

export const PUB_LENGTH = 32;
export const SEC_LENGTH = 64;
export const SEED_LENGTH = 32;

encodePair writes PAIR_HDR ‖ secretKey ‖ PAIR_DIV ‖ publicKey, which is length-agnostic and happens to work. decodePair then looks for PAIR_DIV at offset 16 + SEC_LENGTH, falls back to 16 + SEED_LENGTH, and throws if neither matches. An ML-DSA secret is 4032 or 4896 bytes, so neither offset matches and restore fails with Invalid encoding divider found in body.

It fails loudly, which is the good case. But note what that means: a Quantus account exported to JSON today cannot be imported by anything, so this is a data-durability issue, not a convenience one. It should land before any build ships that lets a user create an account.

2. createFromJson reconstructs the public key from the address. packages/keyring/src/keyring.ts, with the assumption stated outright in a comment:

// Here the address and publicKey are 32 bytes and isomorphic.
const publicKey = isHex(address) ? hexToU8a(address) : this.decodeAddress(address, ignoreChecksum);

For Quantus the address is a Poseidon2 hash. There is no inverse. The public key is in the file — encodePair put it there — but it is inside the encrypted blob, which cannot be opened until the user supplies a password.

That is a real ordering problem, not just a code smell: createFromJson returns a locked pair, and callers read pair.address off it before any password exists. The pair therefore has to carry its account id as data rather than compute it, at least until decodePkcs8 runs and the real public key arrives. Whatever shape that takes in createPair needs to be settled here and mirrored in quantus/ui#1, which has the identical problem in ui-keyring.restoreAccount.

The same function also whitelists crypto types and throws on anything else:

if (!['ed25519', 'sr25519', 'ecdsa', 'ethereum'].includes(cryptoType)) {

Compatibility with the wallets that already exist

Quantus keys are already stored by quantus-cli and by the mobile wallet, both of which record a scheme field (ml-dsa-65 / ml-dsa-87). Before inventing an encoding, check what those write and whether this format can round-trip with them — an extension that cannot import an existing Quantus wallet is a much smaller thing than one that can. If they are irreconcilable, say so on this issue and record why.

Whatever is chosen, the scheme must be recoverable from the file. encoding.content already carries the crypto type in the upstream format, which is probably enough; deriving it from the key length is a fallback, not a design.

Acceptance

  • export → import round-trips an ML-DSA-65 account and an ML-DSA-87 account
  • the restored pair reports the same address before and after unlock
  • a wrong password fails cleanly rather than producing a pair with a plausible wrong address
  • upstream-format ed25519/sr25519 JSON still imports unchanged
Depends on #1, designed together with #2. This is the one that cannot be worked around downstream — file any discovery here. ## Two independent breakages **1. `decodePair` reads a fixed-length public key.** `packages/keyring/src/pair/defaults.ts`: ```js export const PUB_LENGTH = 32; export const SEC_LENGTH = 64; export const SEED_LENGTH = 32; ``` `encodePair` writes `PAIR_HDR ‖ secretKey ‖ PAIR_DIV ‖ publicKey`, which is length-agnostic and happens to work. `decodePair` then looks for `PAIR_DIV` at offset `16 + SEC_LENGTH`, falls back to `16 + SEED_LENGTH`, and throws if neither matches. An ML-DSA secret is 4032 or 4896 bytes, so neither offset matches and restore fails with `Invalid encoding divider found in body`. It fails loudly, which is the good case. But note what that means: **a Quantus account exported to JSON today cannot be imported by anything**, so this is a data-durability issue, not a convenience one. It should land before any build ships that lets a user create an account. **2. `createFromJson` reconstructs the public key from the address.** `packages/keyring/src/keyring.ts`, with the assumption stated outright in a comment: ```js // Here the address and publicKey are 32 bytes and isomorphic. const publicKey = isHex(address) ? hexToU8a(address) : this.decodeAddress(address, ignoreChecksum); ``` For Quantus the address is a Poseidon2 hash. There is no inverse. The public key *is* in the file — `encodePair` put it there — but it is inside the encrypted blob, which cannot be opened until the user supplies a password. That is a real ordering problem, not just a code smell: `createFromJson` returns a **locked** pair, and callers read `pair.address` off it before any password exists. The pair therefore has to carry its account id as data rather than compute it, at least until `decodePkcs8` runs and the real public key arrives. Whatever shape that takes in `createPair` needs to be settled here and mirrored in quantus/ui#1, which has the identical problem in `ui-keyring.restoreAccount`. The same function also whitelists crypto types and throws on anything else: ```js if (!['ed25519', 'sr25519', 'ecdsa', 'ethereum'].includes(cryptoType)) { ``` ## Compatibility with the wallets that already exist Quantus keys are already stored by `quantus-cli` and by the mobile wallet, both of which record a `scheme` field (`ml-dsa-65` / `ml-dsa-87`). Before inventing an encoding, check what those write and whether this format can round-trip with them — an extension that cannot import an existing Quantus wallet is a much smaller thing than one that can. If they are irreconcilable, say so on this issue and record why. Whatever is chosen, the scheme must be recoverable from the file. `encoding.content` already carries the crypto type in the upstream format, which is probably enough; deriving it from the key length is a fallback, not a design. ## Acceptance - [ ] export → import round-trips an ML-DSA-65 account and an ML-DSA-87 account - [ ] the restored pair reports the same address before *and* after unlock - [ ] a wrong password fails cleanly rather than producing a pair with a plausible wrong address - [ ] upstream-format ed25519/sr25519 JSON still imports unchanged
Author
Owner

Done, on quantus-keypair-types (506b77351). 7 new specs; 3060 tests pass repo-wide.

Both breakages, and a third that was hiding behind them

decodePair now takes an optional secretLength. It located the divider by trying 64 then 32 — the two lengths every curve scheme uses — and an ML-DSA secret is 4032 or 4896, so neither matched.

The caller passes the length rather than this function searching for PAIR_DIV. Searching was tempting and is wrong: the divider is five bytes, so a 4032-byte secret contains a false match roughly once in 270 million keys, and the result would be a silently wrong key rather than an error. The caller knows the type, so it knows the length. The public key is read as the remainder, since its length varies too and the body ends there.

createFromJson no longer invents a public key from the address. PairInfo gains an optional accountId, and the ordering problem this issue identified is exactly what it solves: a restored pair is locked, callers read pair.address off it long before a password appears, and for ML-DSA there is nothing to compute that from until decodePkcs8 opens the blob. So the account id is carried as data and cleared once the real key arrives.

Third one, found on the way: decodePkcs8 decided "secret key or seed?" by length. A 4032-byte ML-DSA secret took the seed branch and was fed to keygen as entropy — producing a valid, completely wrong key, in silence. Now type-aware.

The integrity check earns its place

decodePkcs8 checks the derived account id against the carried one and throws if they differ. That is not defensive padding:

For every curve scheme, a JSON file with an edited address field simply fails to decode — the address is the public key. Here it decodes perfectly and yields a pair reporting an address its key does not control. A user would see someone else's address in their own wallet and believe they held it. Pinned by a test that tampers exactly that field.

Acceptance

  • export → import round-trips ML-DSA-65 and ML-DSA-87
  • the restored pair reports the same address before and after unlock
  • a wrong password fails cleanly, leaving the pair locked and its address intact
  • upstream-format sr25519 JSON still round-trips

The compatibility question is still open

This issue asked whether the format can round-trip with quantus-cli and the mobile wallet, both of which record a scheme field. I have not checked, and nothing here depends on the answer — the format used is upstream's own, with encoding.content[1] carrying dilithium65/dilithium87 as the crypto type, which is where upstream already puts it.

If those tools write something else, this will not import their wallets. That is worth settling before the extension ships, and it is a smaller change now than later. Leaving this issue open on that point alone.

Done, on `quantus-keypair-types` (`506b77351`). 7 new specs; 3060 tests pass repo-wide. ## Both breakages, and a third that was hiding behind them **`decodePair` now takes an optional `secretLength`.** It located the divider by trying 64 then 32 — the two lengths every curve scheme uses — and an ML-DSA secret is 4032 or 4896, so neither matched. The caller passes the length rather than this function searching for `PAIR_DIV`. Searching was tempting and is wrong: the divider is five bytes, so a 4032-byte secret contains a false match roughly once in 270 million keys, and the result would be a **silently wrong key** rather than an error. The caller knows the type, so it knows the length. The public key is read as the remainder, since its length varies too and the body ends there. **`createFromJson` no longer invents a public key from the address.** `PairInfo` gains an optional `accountId`, and the ordering problem this issue identified is exactly what it solves: a restored pair is locked, callers read `pair.address` off it long before a password appears, and for ML-DSA there is nothing to compute that from until `decodePkcs8` opens the blob. So the account id is carried as data and cleared once the real key arrives. **Third one, found on the way:** `decodePkcs8` decided "secret key or seed?" by length. A 4032-byte ML-DSA secret took the *seed* branch and was fed to keygen as entropy — producing a valid, completely wrong key, in silence. Now type-aware. ## The integrity check earns its place `decodePkcs8` checks the derived account id against the carried one and throws if they differ. That is not defensive padding: For every curve scheme, a JSON file with an edited `address` field simply fails to decode — the address *is* the public key. Here it decodes perfectly and yields a pair reporting an address its key does not control. A user would see someone else's address in their own wallet and believe they held it. Pinned by a test that tampers exactly that field. ## Acceptance - [x] export → import round-trips ML-DSA-65 and ML-DSA-87 - [x] the restored pair reports the same address before *and* after unlock - [x] a wrong password fails cleanly, leaving the pair locked and its address intact - [x] upstream-format sr25519 JSON still round-trips ## The compatibility question is still open This issue asked whether the format can round-trip with `quantus-cli` and the mobile wallet, both of which record a `scheme` field. **I have not checked**, and nothing here depends on the answer — the format used is upstream's own, with `encoding.content[1]` carrying `dilithium65`/`dilithium87` as the crypto type, which is where upstream already puts it. If those tools write something else, this will not import their wallets. That is worth settling before the extension ships, and it is a smaller change now than later. Leaving this issue open on that point alone.
Author
Owner

Settled. The two formats have nothing in common, and no round-trip is possible without implementing a second encryption container — but it turns out not to matter, for a reason worth writing down.

What quantus-cli actually writes

Decrypted a real wallet from ~/.quantus/wallets/ (CLI 2.2.2) to check rather than infer. The outer container:

{ "name": "crystal_alice",
  "address": "qzk1Nxai…",
  "wallet_type": "hot",
  "encrypted_data": [ 26528 bytes… ],
  "kyber_ciphertext": [], "kyber_public_key": [],
  "argon2_salt": [ 16 bytes… ],
  "argon2_params": "$argon2id$v=19$m=19456,t=2,p=1$…",
  "aes_nonce": [ 12 bytes… ],
  "encryption_version": 2,
  "created_at": "2026-09-10T10:50:25Z" }

Argon2id → AES-256-GCM. polkadot-js is scrypt → NaCl secretbox (xsalsa20-poly1305), N=2^17, r=8, p=1. Different KDF, different cipher, different container. Not a variant of each other.

The plaintext inside is JSON, not PKCS8:

{ "name": "…",
  "keypair": { "public_key": [ 2592 ], "private_key": [ 4896 ], "scheme": "ml-dsa-87" },
  "mnemonic": "…",
  "derivation_path": "m/44'/189189'/0'/0'/0'",
  "metadata": { "version": "1.0.0", "algorithm": "ML-DSA-87" } }

Two details worth having:

  • the scheme values are ml-dsa-65 / ml-dsa-87 — exactly what SCHEME_NAME in @quantus/crypto already uses, so that mapping was right
  • the CLI stores the mnemonic inside the wallet. polkadot-js never does

Why it does not matter

The mnemonic is right there, and quantus wallet export --format mnemonic hands it over. So a CLI wallet already moves into the extension — by mnemonic, which this branch supports and which is now verified against real CLI wallets rather than argued:

CLI wallet scheme how result
hd_ml_dsa_65 ml-dsa-65 mnemonic → //0 address and public key match byte-for-byte
hd_ml_dsa_87 ml-dsa-87 mnemonic → //0 match
crystal_alice ml-dsa-87 raw hex seed (no mnemonic stored) match

Note the third: dev wallets store derivation_path: "m/" and no mnemonic, so they move as a hex seed — which is why createFromUri taking a raw seed underived (quantus/common#4) matters beyond the genesis accounts.

Recommendation: do not implement CLI-format import

It is buildable — @noble/hashes has Argon2id and WebCrypto has AES-GCM — but:

  1. It would import the mnemonic into the extension. The CLI keeps one in every HD wallet; a file importer would pull it into extension storage, which is a worse security posture than the extension holding only key material.
  2. The format is still moving. encryption_version: 2, with kyber_ciphertext and kyber_public_key present but empty — an ML-KEM envelope mode that is either planned or optional. Chasing a format mid-flight for a path the mnemonic already covers is a poor trade.
  3. The mnemonic path is the one users are told to use for every other wallet, and it is already tested.

Revisit if someone actually asks. Reopening this would be cheap; unpicking a half-supported second container later would not be.

The reverse direction is the real gap

quantus-cli cannot read our JSON either, and that is the direction that can lose funds. An extension backup is readable only by polkadot-js-format tooling — so if a user's sole backup is the extension's JSON export and the extension is unavailable, the CLI is no help.

The fix is UX, not format: the extension's backup flow must offer the mnemonic at least as prominently as the JSON file, because the mnemonic is the artifact every Quantus tool can read. Filed against quantus/extension#3.

Closing this issue — the format question is answered and the compatibility that matters is verified.

Settled. The two formats have **nothing in common**, and no round-trip is possible without implementing a second encryption container — but it turns out not to matter, for a reason worth writing down. ## What `quantus-cli` actually writes Decrypted a real wallet from `~/.quantus/wallets/` (CLI 2.2.2) to check rather than infer. The outer container: ```json { "name": "crystal_alice", "address": "qzk1Nxai…", "wallet_type": "hot", "encrypted_data": [ …26528 bytes… ], "kyber_ciphertext": [], "kyber_public_key": [], "argon2_salt": [ …16 bytes… ], "argon2_params": "$argon2id$v=19$m=19456,t=2,p=1$…", "aes_nonce": [ …12 bytes… ], "encryption_version": 2, "created_at": "2026-09-10T10:50:25Z" } ``` **Argon2id → AES-256-GCM.** polkadot-js is **scrypt → NaCl secretbox** (xsalsa20-poly1305), N=2^17, r=8, p=1. Different KDF, different cipher, different container. Not a variant of each other. The plaintext inside is JSON, not PKCS8: ```json { "name": "…", "keypair": { "public_key": [ …2592… ], "private_key": [ …4896… ], "scheme": "ml-dsa-87" }, "mnemonic": "…", "derivation_path": "m/44'/189189'/0'/0'/0'", "metadata": { "version": "1.0.0", "algorithm": "ML-DSA-87" } } ``` Two details worth having: - **the `scheme` values are `ml-dsa-65` / `ml-dsa-87`** — exactly what `SCHEME_NAME` in `@quantus/crypto` already uses, so that mapping was right - **the CLI stores the mnemonic inside the wallet.** polkadot-js never does ## Why it does not matter The mnemonic is right there, and `quantus wallet export --format mnemonic` hands it over. So a CLI wallet already moves into the extension — by mnemonic, which this branch supports and which is now verified against real CLI wallets rather than argued: | CLI wallet | scheme | how | result | |---|---|---|---| | `hd_ml_dsa_65` | ml-dsa-65 | mnemonic → `//0` | address **and public key** match byte-for-byte | | `hd_ml_dsa_87` | ml-dsa-87 | mnemonic → `//0` | match | | `crystal_alice` | ml-dsa-87 | raw hex seed (no mnemonic stored) | match | Note the third: dev wallets store `derivation_path: "m/"` and no mnemonic, so they move as a hex seed — which is why `createFromUri` taking a raw seed underived (quantus/common#4) matters beyond the genesis accounts. ## Recommendation: do not implement CLI-format import It is buildable — `@noble/hashes` has Argon2id and WebCrypto has AES-GCM — but: 1. **It would import the mnemonic into the extension.** The CLI keeps one in every HD wallet; a file importer would pull it into extension storage, which is a worse security posture than the extension holding only key material. 2. **The format is still moving.** `encryption_version: 2`, with `kyber_ciphertext` and `kyber_public_key` present but empty — an ML-KEM envelope mode that is either planned or optional. Chasing a format mid-flight for a path the mnemonic already covers is a poor trade. 3. The mnemonic path is the one users are told to use for every other wallet, and it is already tested. Revisit if someone actually asks. Reopening this would be cheap; unpicking a half-supported second container later would not be. ## The reverse direction is the real gap `quantus-cli` cannot read **our** JSON either, and that is the direction that can lose funds. An extension backup is readable only by polkadot-js-format tooling — so if a user's sole backup is the extension's JSON export and the extension is unavailable, the CLI is no help. The fix is UX, not format: the extension's backup flow must offer the **mnemonic** at least as prominently as the JSON file, because the mnemonic is the artifact every Quantus tool can read. Filed against quantus/extension#3. Closing this issue — the format question is answered and the compatibility that matters is verified.
Author
Owner

Correcting reason (1) in the comment above — it was overstated, and the record should not carry it as written.

What the upstream extension actually stores

Checked rather than assumed:

  • encodePair receives { publicKey, secretKey } only. The encrypted blob is PAIR_HDR ‖ secretKey ‖ PAIR_DIV ‖ publicKey. There is no slot for a mnemonic.
  • accountsCreateSuri passes meta { genesisHash, name }. For a root account the suri is the mnemonic, and it is dropped once addUri has derived from it.
  • Nothing in extension-base/src/stores/ persists a seed or mnemonic.

One thing worth knowing while in here: pairToJson places meta outside the encrypted field, so meta is plaintext at rest. derivationCreate stores suri in meta for derived accounts — a path like //0, not a mnemonic, but it is worth remembering that meta is not protected.

Where the argument was wrong

I wrote "it would import the mnemonic into extension storage" as though that were a property of CLI-file import. It is not. An importer can derive the key and discard the mnemonic, which is precisely what the mnemonic-paste path does today. That reason described an implementation I had assumed rather than a constraint of the format.

The narrower point that does hold: an ML-DSA secret key compromises one account, while a mnemonic regenerates the whole tree — every index, both schemes, and the wormhole tree at m/44'/189189189'/…. So a stored mnemonic would be the same password guarding a much larger prize. That is a reason to build an importer carefully, not a reason to refuse one.

Revised recommendation — same conclusion, weaker grounds

Still: do not implement CLI-format import yet. But on cost and stability, not on principle:

  1. encryption_version: 2 with empty kyber_ciphertext/kyber_public_key — an ML-KEM mode planned or optional. Implementing against a format mid-flight means tracking it.
  2. It buys convenience, not capability: the mnemonic path already reaches the same end state. That convenience is real, though — it spares the user handling a mnemonic through screen and clipboard, which I under-weighted first time round.

If someone asks for it, there is no principled objection. Build it derive-and-discard, storing only what createFromUri would have produced from the same mnemonic.

Everything else in the comment above stands: the formats share nothing, mnemonic portability is verified against real CLI wallets, and the export direction remains the gap that can actually lose funds.

Correcting reason (1) in the comment above — it was overstated, and the record should not carry it as written. ## What the upstream extension actually stores Checked rather than assumed: - **`encodePair` receives `{ publicKey, secretKey }` only.** The encrypted blob is `PAIR_HDR ‖ secretKey ‖ PAIR_DIV ‖ publicKey`. There is no slot for a mnemonic. - **`accountsCreateSuri` passes meta `{ genesisHash, name }`.** For a root account the suri *is* the mnemonic, and it is dropped once `addUri` has derived from it. - Nothing in `extension-base/src/stores/` persists a seed or mnemonic. One thing worth knowing while in here: `pairToJson` places `meta` **outside** the encrypted field, so meta is plaintext at rest. `derivationCreate` stores `suri` in meta for derived accounts — a path like `//0`, not a mnemonic, but it is worth remembering that meta is not protected. ## Where the argument was wrong I wrote "it would import the mnemonic into extension storage" as though that were a property of CLI-file import. It is not. An importer can derive the key and discard the mnemonic, which is precisely what the mnemonic-paste path does today. That reason described an implementation I had assumed rather than a constraint of the format. The narrower point that does hold: an ML-DSA secret key compromises **one account**, while a mnemonic regenerates the **whole tree** — every index, both schemes, and the wormhole tree at `m/44'/189189189'/…`. So a stored mnemonic would be the same password guarding a much larger prize. That is a reason to build an importer carefully, not a reason to refuse one. ## Revised recommendation — same conclusion, weaker grounds Still: do not implement CLI-format import **yet**. But on cost and stability, not on principle: 1. `encryption_version: 2` with empty `kyber_ciphertext`/`kyber_public_key` — an ML-KEM mode planned or optional. Implementing against a format mid-flight means tracking it. 2. It buys **convenience, not capability**: the mnemonic path already reaches the same end state. That convenience is real, though — it spares the user handling a mnemonic through screen and clipboard, which I under-weighted first time round. If someone asks for it, there is no principled objection. Build it derive-and-discard, storing only what `createFromUri` would have produced from the same mnemonic. Everything else in the comment above stands: the formats share nothing, mnemonic portability is verified against real CLI wallets, and the export direction remains the gap that can actually lose funds.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/common#3