feat(util-crypto): import quantus-cli wallet files

A must-have per the UX decision in quantus/extension#8: the extension has to be
able to take a wallet a user already has. My earlier recommendation against this
was withdrawn — the reasoning was that a file importer would pull the mnemonic
into storage, which is a property of an implementation rather than of the format.
This one derives and discards.

Converts at the edge rather than teaching the keyring a second container.
Decrypt (Argon2id -> AES-256-GCM), take the key material, and hand back a plain
keypair for the caller to re-encrypt as the extension's own PKCS8 under a
password the user chooses. decodePair does not learn about Argon2 and the keyring
keeps exactly one on-disk format.

The CLI stores a mnemonic in every HD wallet. This returns `hadMnemonic` and not
the phrase — the posture is that a storage password unlocks signing and nothing
on disk regenerates the tree, and an import must leave us holding exactly what
importing the same mnemonic by hand would leave us holding. Matching upstream,
which stores key material only.

No new dependencies: @noble/hashes/argon2 was already a util-crypto dependency
and AES-GCM is WebCrypto. Async only because WebCrypto has no synchronous form,
which is a second reason this belongs at the edge — createFromJson is sync and
should stay so.

Three things are refused rather than worked around:

Argon2 parameters are read from the file, not assumed. They are stored because
they are expected to change, and a build that hardcoded m=19456,t=2,p=1 would
reject a correct password on an older or newer wallet — the least useful thing it
could say.

Non-empty kyber_ciphertext/kyber_public_key, and any encryption_version other
than 2, are refused with a message. Those fields are an ML-KEM envelope mode that
is planned or optional; decrypting by the path we know and ignoring them would
fail later and more confusingly, most likely as a bad-password error.

The address sits outside the encrypted blob, so it is the one field an attacker
can edit without the password. cliWalletAddressMatches is exposed separately so
a caller can say "this file's address does not match its key" rather than
"wrong password" — a user can act on the difference.

Fixtures are genuine CLI 2.2.2 output, not blobs this repo encrypted; testing a
decoder against its own encryption proves only self-consistency. They contain no
secret: the mnemonic inside each is the public Substrate dev phrase and the
password is empty, which is the only reason committing a decryptable wallet is
acceptable. The strongest test reaches the same account two ways — through the
CLI container and by deriving from that phrase ourselves.

Refs quantus/common#7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
rob thijssen
2026-09-15 09:44:07 +03:00
parent f7dc72e32c
commit e55352aa4e
6 changed files with 48328 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
// Copyright 2017-2026 @polkadot/util-crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@polkadot/dev-test/globals.d.ts" />
import type { QuantusCliWallet } from './cliWallet.js';
import fs from 'node:fs';
import path from 'node:path';
import { u8aToHex } from '@polkadot/util';
import { encodeAddress } from '../address/encode.js';
import { cliWalletAddressMatches, dilithiumFromCliWallet } from './cliWallet.js';
import { dilithiumPairFromMnemonic } from './pair.js';
const FIXTURES = path.join(process.cwd(), 'packages/util-crypto/src/dilithium/fixtures');
// The public Substrate dev phrase — what both fixtures were created from, and
// published everywhere. No secret is committed.
const DEV_PHRASE = 'bottom drive obey lake curtain smoke basket hold race lonely fit walk';
function load (name: string): QuantusCliWallet {
return JSON.parse(fs.readFileSync(path.join(FIXTURES, name), 'utf-8')) as QuantusCliWallet;
}
interface Case {
file: string;
path: string;
type: 'dilithium65' | 'dilithium87';
}
const CASE_65: Case = { file: 'cli-dev_ml_dsa_65.json', path: "m/44'/189189'/0'/0'/1'", type: 'dilithium65' };
const CASE_87: Case = { file: 'cli-dev_ml_dsa_87.json', path: "m/44'/189189'/0'/0'/0'", type: 'dilithium87' };
// Both parameter sets get the identical battery. Written as a function called
// twice rather than a loop over a `describe`, because a computed describe title
// is invisible to static analysis and the linter rightly objects.
function testScheme (c: Case): void {
const wallet = load(c.file);
it('imports and reproduces the address the file claims', async (): Promise<void> => {
const imported = await dilithiumFromCliWallet(wallet);
expect(imported.type).toEqual(c.type);
expect(encodeAddress(imported.accountId, 189)).toEqual(wallet.address);
});
// The same account reached two entirely different ways: through the CLI's
// encrypted container, and by deriving from the mnemonic ourselves. If either
// side were wrong these would not meet.
it('yields the key our own derivation yields', async (): Promise<void> => {
const imported = await dilithiumFromCliWallet(wallet);
const derived = dilithiumPairFromMnemonic(DEV_PHRASE, '', c.path, c.type);
expect(u8aToHex(imported.keypair.publicKey)).toEqual(u8aToHex(derived.publicKey));
expect(u8aToHex(imported.keypair.secretKey)).toEqual(u8aToHex(derived.secretKey));
});
it('reports the derivation path the CLI recorded', async (): Promise<void> => {
const imported = await dilithiumFromCliWallet(wallet);
expect(imported.derivationPath).toEqual(c.path);
});
// The CLI keeps a mnemonic in every HD wallet. We do not return it — the
// extension's posture is that a storage password unlocks signing and nothing on
// disk regenerates the tree. The flag exists so a caller can tell the user
// where their backup lives without the phrase passing through.
it('reports that a mnemonic was present without returning it', async (): Promise<void> => {
const imported = await dilithiumFromCliWallet(wallet);
expect(imported.hadMnemonic).toEqual(true);
expect(Object.keys(imported).includes('mnemonic')).toEqual(false);
});
}
describe('quantus-cli wallet import', (): void => {
describe('dilithium65', (): void => {
testScheme(CASE_65);
});
describe('dilithium87', (): void => {
testScheme(CASE_87);
});
it('fails clearly on a wrong password', async (): Promise<void> => {
await expect(dilithiumFromCliWallet(load(CASE_65.file), 'wrong')).rejects.toThrow(/password may be incorrect/);
});
// An ML-KEM envelope mode that is planned or optional. Ignoring these fields
// and decrypting by the path we know would fail later and more confusingly,
// most likely as a bad-password error on a password that was correct.
it('refuses a wallet using ML-KEM envelope encryption', async (): Promise<void> => {
const wallet = { ...load(CASE_65.file), kyber_ciphertext: [1, 2, 3] };
await expect(dilithiumFromCliWallet(wallet)).rejects.toThrow(/ML-KEM envelope encryption/);
});
it('refuses an unknown container version', async (): Promise<void> => {
const wallet = { ...load(CASE_65.file), encryption_version: 3 };
await expect(dilithiumFromCliWallet(wallet)).rejects.toThrow(/encryption version 3/);
});
// The Argon2 parameters live in the file because they are expected to change.
// A build that assumed today's values would reject a valid password on an
// older or newer wallet — the least useful thing it could say.
it('reads the Argon2 parameters rather than assuming them', async (): Promise<void> => {
const wallet = { ...load(CASE_65.file), argon2_params: '$argon2i$v=19$m=19456,t=2,p=1$x' };
await expect(dilithiumFromCliWallet(wallet)).rejects.toThrow(/Unsupported Argon2 parameters/);
});
// The address sits outside the encrypted blob, so it is the one field an
// attacker can edit without the password. Unchecked, a tampered file imports
// cleanly and produces an account displaying an address it cannot sign for.
it('detects an address that disagrees with the key', async (): Promise<void> => {
const a = await dilithiumFromCliWallet(load(CASE_65.file));
const b = await dilithiumFromCliWallet(load(CASE_87.file));
expect(cliWalletAddressMatches(a.accountId, a.accountId)).toEqual(true);
expect(cliWalletAddressMatches(a.accountId, b.accountId)).toEqual(false);
});
});

View File

@@ -0,0 +1,189 @@
// Copyright 2017-2026 @polkadot/util-crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DilithiumType } from '../dilithium.js';
import type { Keypair } from '../types.js';
import { argon2id } from '@noble/hashes/argon2';
import { u8aEq } from '@polkadot/util';
import { dilithiumAccountFromPublic } from './account.js';
import { dilithiumSizes } from './scheme.js';
/** The wallet file `quantus-cli` writes to ~/.quantus/wallets/<name>.json */
export interface QuantusCliWallet {
address: string;
aes_nonce: number[];
argon2_params: string;
argon2_salt: number[];
created_at?: string;
encrypted_data: number[];
encryption_version: number;
kyber_ciphertext?: number[];
kyber_public_key?: number[];
name?: string;
wallet_type?: string;
}
export interface QuantusCliImport {
/** The 32-byte account id the file claims, verified against the key. */
accountId: Uint8Array;
/** The derivation path the CLI recorded, for display. `m/` for a raw-seed wallet. */
derivationPath: string | null;
keypair: Keypair;
/** Whether the file carried a mnemonic. It is never returned — see below. */
hadMnemonic: boolean;
name: string | null;
type: DilithiumType;
}
/** The only container version this understands. */
const SUPPORTED_VERSION = 2;
const SCHEME_TO_TYPE: Record<string, DilithiumType> = {
'ml-dsa-65': 'dilithium65',
'ml-dsa-87': 'dilithium87'
};
interface Argon2Params {
m: number;
p: number;
t: number;
}
/**
* Parse the PHC string the CLI stores, e.g.
* `$argon2id$v=19$m=19456,t=2,p=1$<salt>`.
*
* Read rather than hardcoded. The parameters are in the file because they are
* expected to change, and a build that assumed today's values would fail to open
* an older or newer wallet with a *wrong password* error — which is the least
* useful thing it could say, since the password would be right.
*/
function parseArgon2Params (phc: string): Argon2Params {
const match = /^\$argon2id\$v=19\$m=(\d+),t=(\d+),p=(\d+)/.exec(phc);
if (!match) {
throw new Error(`Unsupported Argon2 parameters in wallet: ${phc}`);
}
return {
m: parseInt(match[1], 10),
p: parseInt(match[3], 10),
t: parseInt(match[2], 10)
};
}
function assertSupported (wallet: QuantusCliWallet): void {
if (wallet.encryption_version !== SUPPORTED_VERSION) {
throw new Error(`Unsupported quantus-cli wallet encryption version ${wallet.encryption_version}; this build understands version ${SUPPORTED_VERSION}`);
}
// An ML-KEM envelope mode, either planned or optional — the fields exist and
// are empty in every wallet seen so far. Refuse rather than ignore them and
// decrypt by the path we do know: a wallet encrypted to an ML-KEM key is not
// one we can open, and treating it as if it were would fail later and more
// confusingly, most likely as a bad-password error.
if (wallet.kyber_ciphertext?.length || wallet.kyber_public_key?.length) {
throw new Error('This wallet uses ML-KEM envelope encryption, which is not supported');
}
}
/**
* Decrypt and validate a `quantus-cli` wallet file.
*
* The result is exactly what importing the same account by mnemonic would
* produce: a keypair, and nothing that can regenerate the rest of the tree. The
* CLI stores a mnemonic inside every HD wallet and this **deliberately does not
* return it** — the extension's posture is that a storage password unlocks
* signing and nothing on disk regenerates the wallet. `hadMnemonic` says one was
* present, so a caller can tell the user where their backup lives, without the
* phrase itself passing through.
*
* Async because AES-GCM is WebCrypto, which has no synchronous form. That is
* also why this is an edge conversion rather than something `createFromJson`
* learns: that path is synchronous and should stay so, and the keyring should
* keep exactly one on-disk format.
*/
export async function dilithiumFromCliWallet (wallet: QuantusCliWallet, password = ''): Promise<QuantusCliImport> {
assertSupported(wallet);
const { m, p, t } = parseArgon2Params(wallet.argon2_params);
const key = argon2id(
new TextEncoder().encode(password),
new Uint8Array(wallet.argon2_salt),
{ dkLen: 32, m, p, t }
);
let plaintext: Uint8Array;
try {
const aesKey = await crypto.subtle.importKey('raw', key, 'AES-GCM', false, ['decrypt']);
plaintext = new Uint8Array(
await crypto.subtle.decrypt(
{ iv: new Uint8Array(wallet.aes_nonce), name: 'AES-GCM' },
aesKey,
new Uint8Array(wallet.encrypted_data)
)
);
} catch {
// AES-GCM authenticates, so this is a wrong password or a corrupted file and
// there is no way to tell which from here.
throw new Error('Unable to decrypt the wallet; the password may be incorrect');
}
const decoded = JSON.parse(new TextDecoder().decode(plaintext)) as {
keypair?: { private_key?: number[]; public_key?: number[]; scheme?: string };
derivation_path?: string;
mnemonic?: string | null;
name?: string;
};
const scheme = decoded.keypair?.scheme;
const type = scheme ? SCHEME_TO_TYPE[scheme] : undefined;
if (!type) {
throw new Error(`Unsupported signature scheme in wallet: ${scheme ?? 'none'}`);
}
if (!decoded.keypair?.public_key || !decoded.keypair.private_key) {
throw new Error('Wallet contains no keypair');
}
const publicKey = new Uint8Array(decoded.keypair.public_key);
const secretKey = new Uint8Array(decoded.keypair.private_key);
const sizes = dilithiumSizes(type);
if (publicKey.length !== sizes.publicKey || secretKey.length !== sizes.secretKey) {
throw new Error(`Wallet key lengths do not match ${scheme}: expected ${sizes.publicKey}/${sizes.secretKey}, found ${publicKey.length}/${secretKey.length}`);
}
// The address travels outside the encrypted blob, so it is the one field an
// attacker can edit without knowing the password. Left unchecked, a tampered
// file would import cleanly and produce an account displaying an address it
// cannot sign for — the same failure the account-JSON check in the keyring
// exists to prevent.
const accountId = dilithiumAccountFromPublic(publicKey);
return {
accountId,
derivationPath: decoded.derivation_path ?? null,
hadMnemonic: !!decoded.mnemonic,
keypair: { publicKey, secretKey },
name: decoded.name ?? wallet.name ?? null,
type
};
}
/**
* Whether a decrypted wallet's key matches the address its file claims.
*
* Separate from the decrypt so the caller can render the mismatch rather than
* only catch it — "this file's address does not match its key" is a different
* message from "wrong password", and a user can act on the difference.
*/
export function cliWalletAddressMatches (accountId: Uint8Array, decodedAddress: Uint8Array): boolean {
return u8aEq(accountId, decodedAddress);
}

View File

@@ -0,0 +1,22 @@
# quantus-cli wallet fixtures
Genuine output of `quantus-cli` 2.2.2, not files this repo constructed. Testing a
decoder against a blob the same code encrypted would prove only that it is
self-consistent; these prove it agrees with the tool whose wallets users actually
have.
Created with:
```sh
quantus wallet import --name dev_ml_dsa_65 --scheme ml-dsa-65 \
--mnemonic-file <phrase> --allow-empty-password
```
**They contain no secret.** The mnemonic inside each is the well-known Substrate
development phrase — `bottom drive obey lake curtain smoke basket hold race
lonely fit walk` — which is published in polkadot-sdk, in polkadot-js and in every
tutorial. The password is empty. Any account they derive is assumed compromised
and must never hold value.
That is the only reason it is acceptable to commit a decryptable wallet file. Do
not add a fixture created from a generated mnemonic, however throwaway it seems.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
export { dilithiumAccountFromPublic } from './account.js';
export type { QuantusCliImport, QuantusCliWallet } from './cliWallet.js';
export { cliWalletAddressMatches, dilithiumFromCliWallet } from './cliWallet.js';
export { dilithiumPairFromMnemonic, dilithiumPairFromSeed } from './pair.js';
export { dilithiumPath, dilithiumPathFromSuri } from './path.js';
export { dilithiumSchemeFor, dilithiumSizes } from './scheme.js';