Import quantus-cli wallet files #7

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

A must-have, per the UX decision in quantus/extension#8. Reverses the recommendation in #3, where the reasoning against it was withdrawn.

What a CLI wallet looks like

~/.quantus/wallets/<name>.json, from quantus-cli 2.2.2:

{ "name": "…", "address": "qz…", "wallet_type": "hot",
  "encrypted_data": [ …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": "…" }

Argon2id → AES-256-GCM, wrapping JSON:

{ "name": "…",
  "keypair": { "public_key": [  ], "private_key": [  ], "scheme": "ml-dsa-65" },
  "mnemonic": "…",
  "derivation_path": "m/44'/189189'/0'/0'/1'",
  "metadata": {  } }

Read the Argon2 parameters from argon2_params rather than hardcoding them. They are in the file because they are expected to change, and a hardcoded m=19456,t=2,p=1 would fail to open older or newer wallets with a wrong-password error rather than a useful one.

Convert at the edge

Decrypt, take keypair.public_key / private_key / scheme, and hand the pair to keyring.addPair re-encrypted as the extension's own PKCS8 under a password the user chooses. The keyring stays single-format — this is not a second container inside it, and decodePair does not learn about Argon2.

Derive and discard the mnemonic. The CLI stores one; we do not, and the UX decision is explicit that a storage password unlocks signing and nothing regenerates the tree from disk. Importing a CLI wallet must leave us holding exactly what importing the same mnemonic by hand would leave us holding — no more.

The scheme field maps directly: ml-dsa-65dilithium65, ml-dsa-87dilithium87, which is already SCHEME_NAME in @quantus/crypto.

No new dependencies

  • Argon2id@noble/hashes/argon2, already a util-crypto dependency
  • AES-256-GCM — WebCrypto (crypto.subtle), available in an MV3 service worker and in node

WebCrypto is async, which is why this belongs at the import edge rather than inside createFromJson: that path is synchronous and should stay so. An import is a one-shot user action that can await.

A real CLI wallet has already been decrypted through exactly this pair of primitives, so the approach is confirmed rather than assumed.

Verify, do not trust

The file carries an address alongside the encrypted key. Check that the imported public key hashes to it, and refuse the import if not — the same reasoning as the tampered-address check in #3. A file whose address does not match its key would otherwise produce an account displaying an address it cannot sign for.

The fields that are empty today

kyber_ciphertext and kyber_public_key are present but empty, and encryption_version is 2. That is an ML-KEM envelope mode either planned or optional. Refuse a file with non-empty kyber fields, clearly, rather than ignoring them and decrypting by the path we know — a wallet encrypted to an ML-KEM key is not one we can open, and silently treating it as if it were would fail confusingly at best.

Treat an unrecognised encryption_version the same way.

Acceptance

  • a quantus-cli wallet imports and produces the address the file claims
  • the imported account signs, and the signature verifies against that address
  • both schemes
  • a wallet with a password imports with that password; a wrong one fails clearly
  • the mnemonic is not present in extension storage afterwards
  • non-empty kyber fields, or an unknown encryption_version, are refused with a message saying why
  • a file whose address disagrees with its key is refused
A must-have, per the UX decision in quantus/extension#8. Reverses the recommendation in #3, where the reasoning against it was withdrawn. ## What a CLI wallet looks like `~/.quantus/wallets/<name>.json`, from `quantus-cli` 2.2.2: ```json { "name": "…", "address": "qz…", "wallet_type": "hot", "encrypted_data": [ …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": "…" } ``` Argon2id → AES-256-GCM, wrapping JSON: ```json { "name": "…", "keypair": { "public_key": [ … ], "private_key": [ … ], "scheme": "ml-dsa-65" }, "mnemonic": "…", "derivation_path": "m/44'/189189'/0'/0'/1'", "metadata": { … } } ``` Read the Argon2 parameters from `argon2_params` rather than hardcoding them. They are in the file because they are expected to change, and a hardcoded `m=19456,t=2,p=1` would fail to open older or newer wallets with a wrong-password error rather than a useful one. ## Convert at the edge Decrypt, take `keypair.public_key` / `private_key` / `scheme`, and hand the pair to `keyring.addPair` re-encrypted as the extension's own PKCS8 under a password the user chooses. **The keyring stays single-format** — this is not a second container inside it, and `decodePair` does not learn about Argon2. **Derive and discard the mnemonic.** The CLI stores one; we do not, and the UX decision is explicit that a storage password unlocks signing and nothing regenerates the tree from disk. Importing a CLI wallet must leave us holding exactly what importing the same mnemonic by hand would leave us holding — no more. The `scheme` field maps directly: `ml-dsa-65` → `dilithium65`, `ml-dsa-87` → `dilithium87`, which is already `SCHEME_NAME` in `@quantus/crypto`. ## No new dependencies - **Argon2id** — `@noble/hashes/argon2`, already a `util-crypto` dependency - **AES-256-GCM** — WebCrypto (`crypto.subtle`), available in an MV3 service worker and in node WebCrypto is async, which is why this belongs at the import edge rather than inside `createFromJson`: that path is synchronous and should stay so. An import is a one-shot user action that can await. A real CLI wallet has already been decrypted through exactly this pair of primitives, so the approach is confirmed rather than assumed. ## Verify, do not trust The file carries an `address` alongside the encrypted key. Check that the imported public key hashes to it, and refuse the import if not — the same reasoning as the tampered-address check in #3. A file whose address does not match its key would otherwise produce an account displaying an address it cannot sign for. ## The fields that are empty today `kyber_ciphertext` and `kyber_public_key` are present but empty, and `encryption_version` is 2. That is an ML-KEM envelope mode either planned or optional. **Refuse a file with non-empty kyber fields**, clearly, rather than ignoring them and decrypting by the path we know — a wallet encrypted to an ML-KEM key is not one we can open, and silently treating it as if it were would fail confusingly at best. Treat an unrecognised `encryption_version` the same way. ## Acceptance - [ ] a `quantus-cli` wallet imports and produces the address the file claims - [ ] the imported account signs, and the signature verifies against that address - [ ] both schemes - [ ] a wallet with a password imports with that password; a wrong one fails clearly - [ ] the mnemonic is not present in extension storage afterwards - [ ] non-empty kyber fields, or an unknown `encryption_version`, are refused with a message saying why - [ ] a file whose `address` disagrees with its key is refused
Author
Owner

Checked against main (7f23d0a60). The library side is done, but nothing uses it yet, and it has one gap of its own.

Done (e55352aa4, 74e1e0cc6):

  • Argon2 parameters are read from the file.
  • Non-empty ML-KEM fields and unknown versions are refused.
  • A wrong password gives a clear error.
  • Both schemes import and reproduce the file's address, and match our own derivation.
  • The mnemonic is never returned.

Remaining:

  1. The import doesn't check the address itself. dilithiumFromCliWallet returns a key without checking it against the file's address. That check is a separate helper, cliWalletAddressMatches, which a caller has to remember to call, and the comment near cliWallet.ts:194 wrongly implies it is already done. Refuse a mismatch inside the import.
  2. No extension import flow. Nothing in quantus/extension calls dilithiumFromCliWallet, so a user cannot import a quantus-cli wallet file today. It needs:
    • a flow that decrypts the file, checks the address, and adds the pair under a password the user chooses (a standalone account, per quantus/extension#14);
    • an end-to-end test covering signing and the mnemonic's absence from storage.

Correction: earlier today I listed this issue as done. It isn't.

Checked against `main` (`7f23d0a60`). The library side is done, but nothing uses it yet, and it has one gap of its own. **Done** (`e55352aa4`, `74e1e0cc6`): - Argon2 parameters are read from the file. - Non-empty ML-KEM fields and unknown versions are refused. - A wrong password gives a clear error. - Both schemes import and reproduce the file's address, and match our own derivation. - The mnemonic is never returned. **Remaining:** 1. **The import doesn't check the address itself.** `dilithiumFromCliWallet` returns a key without checking it against the file's address. That check is a separate helper, `cliWalletAddressMatches`, which a caller has to remember to call, and the comment near `cliWallet.ts:194` wrongly implies it is already done. Refuse a mismatch inside the import. 2. **No extension import flow.** Nothing in quantus/extension calls `dilithiumFromCliWallet`, so a user cannot import a `quantus-cli` wallet file today. It needs: - a flow that decrypts the file, checks the address, and adds the pair under a password the user chooses (a standalone account, per quantus/extension#14); - an end-to-end test covering signing and the mnemonic's absence from storage. **Correction:** earlier today I listed this issue as done. It isn't.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/common#7