keyring: account backup and restore is impossible for Quantus as the format stands #3
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.
decodePairreads a fixed-length public key.packages/keyring/src/pair/defaults.ts:encodePairwritesPAIR_HDR ‖ secretKey ‖ PAIR_DIV ‖ publicKey, which is length-agnostic and happens to work.decodePairthen looks forPAIR_DIVat offset16 + SEC_LENGTH, falls back to16 + SEED_LENGTH, and throws if neither matches. An ML-DSA secret is 4032 or 4896 bytes, so neither offset matches and restore fails withInvalid 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.
createFromJsonreconstructs the public key from the address.packages/keyring/src/keyring.ts, with the assumption stated outright in a comment:For Quantus the address is a Poseidon2 hash. There is no inverse. The public key is in the file —
encodePairput 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:
createFromJsonreturns a locked pair, and callers readpair.addressoff it before any password exists. The pair therefore has to carry its account id as data rather than compute it, at least untildecodePkcs8runs and the real public key arrives. Whatever shape that takes increatePairneeds to be settled here and mirrored in quantus/ui#1, which has the identical problem inui-keyring.restoreAccount.The same function also whitelists crypto types and throws on anything else:
Compatibility with the wallets that already exist
Quantus keys are already stored by
quantus-cliand by the mobile wallet, both of which record aschemefield (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.contentalready 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
Done, on
quantus-keypair-types(506b77351). 7 new specs; 3060 tests pass repo-wide.Both breakages, and a third that was hiding behind them
decodePairnow takes an optionalsecretLength. 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.createFromJsonno longer invents a public key from the address.PairInfogains an optionalaccountId, and the ordering problem this issue identified is exactly what it solves: a restored pair is locked, callers readpair.addressoff it long before a password appears, and for ML-DSA there is nothing to compute that from untildecodePkcs8opens the blob. So the account id is carried as data and cleared once the real key arrives.Third one, found on the way:
decodePkcs8decided "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
decodePkcs8checks 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
addressfield 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
The compatibility question is still open
This issue asked whether the format can round-trip with
quantus-cliand the mobile wallet, both of which record aschemefield. I have not checked, and nothing here depends on the answer — the format used is upstream's own, withencoding.content[1]carryingdilithium65/dilithium87as 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.
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-cliactually writesDecrypted a real wallet from
~/.quantus/wallets/(CLI 2.2.2) to check rather than infer. The outer container: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:
Two details worth having:
schemevalues areml-dsa-65/ml-dsa-87— exactly whatSCHEME_NAMEin@quantus/cryptoalready uses, so that mapping was rightWhy it does not matter
The mnemonic is right there, and
quantus wallet export --format mnemonichands 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:hd_ml_dsa_65//0hd_ml_dsa_87//0crystal_aliceNote the third: dev wallets store
derivation_path: "m/"and no mnemonic, so they move as a hex seed — which is whycreateFromUritaking a raw seed underived (quantus/common#4) matters beyond the genesis accounts.Recommendation: do not implement CLI-format import
It is buildable —
@noble/hasheshas Argon2id and WebCrypto has AES-GCM — but:encryption_version: 2, withkyber_ciphertextandkyber_public_keypresent 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.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-clicannot 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.
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:
encodePairreceives{ publicKey, secretKey }only. The encrypted blob isPAIR_HDR ‖ secretKey ‖ PAIR_DIV ‖ publicKey. There is no slot for a mnemonic.accountsCreateSuripasses meta{ genesisHash, name }. For a root account the suri is the mnemonic, and it is dropped onceaddUrihas derived from it.extension-base/src/stores/persists a seed or mnemonic.One thing worth knowing while in here:
pairToJsonplacesmetaoutside the encrypted field, so meta is plaintext at rest.derivationCreatestoressuriin 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:
encryption_version: 2with emptykyber_ciphertext/kyber_public_key— an ML-KEM mode planned or optional. Implementing against a format mid-flight means tracking it.If someone asks for it, there is no principled objection. Build it derive-and-discard, storing only what
createFromUriwould 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.