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

The counterpart to the importer, and the thing that stops it being a one-way
door. Without it an extension backup is readable only by polkadot-js-format
tooling, so a user who brought a wallet in could not take it back out.

The assumption that blocked this — that we cannot write the CLI format because we
do not store a mnemonic — turns out to be false. The format has a null-mnemonic
shape and the CLI writes one itself for every raw-seed wallet; crystal_alice,
which `quantus developer create-test-wallets` produces, has exactly that
alongside `derivation_path: "m/"`.

Verified against quantus-cli 2.2.2 rather than inferred. Exports of both schemes
were written into ~/.quantus/wallets and the CLI listed them with the correct
address, scheme and human checkphrase, showed them under `wallet view`, and
answered `export --format mnemonic` with "Mnemonic phrase is not available for
this wallet" — which is a correct statement about the file, and only reachable by
deriving the Argon2 key, opening the AES-GCM box and parsing the payload. Those
probes were removed; the committed test round-trips through our own importer,
since CI has no CLI.

What a null-mnemonic export costs the user is exactly one thing, and it is not
spending: the CLI cannot re-export a phrase from it or derive further accounts.
That is the honest consequence of never storing a phrase we showed once, and it
is the same position a raw-seed CLI wallet is already in.

Argon2 parameters match the CLI's rather than being raised. They travel in the
file and the CLI reads them, so in principle anything works — but "in principle"
is load-bearing there, and a wallet a slightly older CLI cannot open is worse
than a conservatively tuned KDF. Salt and nonce are fresh per export.

metadata.exported_by marks the file, so a CLI maintainer reading a bug report can
tell where it came from without inferring from shape.

Refs quantus/common#8

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 10:33:20 +03:00
co-authored by Claude Opus 5
parent e55352aa4e
commit 74e1e0cc6a
3 changed files with 196 additions and 3 deletions
@@ -11,7 +11,7 @@ import path from 'node:path';
import { u8aToHex } from '@polkadot/util';
import { encodeAddress } from '../address/encode.js';
import { cliWalletAddressMatches, dilithiumFromCliWallet } from './cliWallet.js';
import { cliWalletAddressMatches, dilithiumFromCliWallet, dilithiumToCliWallet } from './cliWallet.js';
import { dilithiumPairFromMnemonic } from './pair.js';
const FIXTURES = path.join(process.cwd(), 'packages/util-crypto/src/dilithium/fixtures');
@@ -122,4 +122,82 @@ describe('quantus-cli wallet import', (): void => {
expect(cliWalletAddressMatches(a.accountId, a.accountId)).toEqual(true);
expect(cliWalletAddressMatches(a.accountId, b.accountId)).toEqual(false);
});
// Export is what stops import being a one-way door. The mnemonic is always
// null, which is a shape the CLI writes itself for raw-seed wallets and opens
// normally — verified against quantus-cli 2.2.2 by hand, since CI has no CLI.
describe('export', (): void => {
it('round-trips a keypair back through import', async (): Promise<void> => {
const source = await dilithiumFromCliWallet(load(CASE_65.file));
const exported = await dilithiumToCliWallet({
derivationPath: CASE_65.path,
keypair: source.keypair,
name: 'round tripped',
type: 'dilithium65'
}, 'a password');
const reimported = await dilithiumFromCliWallet(exported, 'a password');
expect(u8aToHex(reimported.keypair.publicKey)).toEqual(u8aToHex(source.keypair.publicKey));
expect(u8aToHex(reimported.keypair.secretKey)).toEqual(u8aToHex(source.keypair.secretKey));
expect(reimported.type).toEqual('dilithium65');
expect(reimported.derivationPath).toEqual(CASE_65.path);
});
it('writes the address the key actually hashes to', async (): Promise<void> => {
const source = await dilithiumFromCliWallet(load(CASE_87.file));
const exported = await dilithiumToCliWallet({
keypair: source.keypair, name: 'addressed', type: 'dilithium87'
});
expect(exported.address).toEqual(load(CASE_87.file).address);
});
it('carries no mnemonic', async (): Promise<void> => {
const source = await dilithiumFromCliWallet(load(CASE_65.file));
const exported = await dilithiumToCliWallet({ keypair: source.keypair, name: 'x', type: 'dilithium65' });
const reimported = await dilithiumFromCliWallet(exported);
expect(source.hadMnemonic).toEqual(true);
expect(reimported.hadMnemonic).toEqual(false);
});
// A raw-seed account has no path, and the CLI records `m/` for that rather
// than omitting the field.
it('records m/ for an account with no derivation path', async (): Promise<void> => {
const source = await dilithiumFromCliWallet(load(CASE_65.file));
const exported = await dilithiumToCliWallet({ keypair: source.keypair, name: 'raw', type: 'dilithium65' });
const reimported = await dilithiumFromCliWallet(exported);
expect(reimported.derivationPath).toEqual('m/');
});
it('uses a fresh salt and nonce each time', async (): Promise<void> => {
const source = await dilithiumFromCliWallet(load(CASE_65.file));
const input = { keypair: source.keypair, name: 'x', type: 'dilithium65' as const };
const a = await dilithiumToCliWallet(input);
const b = await dilithiumToCliWallet(input);
expect(a.argon2_salt).not.toEqual(b.argon2_salt);
expect(a.aes_nonce).not.toEqual(b.aes_nonce);
expect(a.encrypted_data).not.toEqual(b.encrypted_data);
});
it('writes a container this build declares it supports', async (): Promise<void> => {
const source = await dilithiumFromCliWallet(load(CASE_65.file));
const exported = await dilithiumToCliWallet({ keypair: source.keypair, name: 'x', type: 'dilithium65' });
expect(exported.encryption_version).toEqual(2);
expect(exported.kyber_ciphertext).toEqual([]);
expect(exported.kyber_public_key).toEqual([]);
expect(exported.argon2_params.startsWith('$argon2id$v=19$m=')).toEqual(true);
expect(exported.argon2_salt.length).toEqual(16);
expect(exported.aes_nonce.length).toEqual(12);
});
it('refuses key material that does not match the scheme', async (): Promise<void> => {
const source = await dilithiumFromCliWallet(load(CASE_65.file));
await expect(dilithiumToCliWallet({ keypair: source.keypair, name: 'x', type: 'dilithium87' })).rejects.toThrow(/Key lengths do not match/);
});
});
});
@@ -8,6 +8,8 @@ import { argon2id } from '@noble/hashes/argon2';
import { u8aEq } from '@polkadot/util';
import { encodeAddress } from '../address/encode.js';
import { base64Encode } from '../base64/index.js';
import { dilithiumAccountFromPublic } from './account.js';
import { dilithiumSizes } from './scheme.js';
@@ -46,6 +48,35 @@ const SCHEME_TO_TYPE: Record<string, DilithiumType> = {
'ml-dsa-87': 'dilithium87'
};
const TYPE_TO_SCHEME: Record<DilithiumType, string> = {
dilithium65: 'ml-dsa-65',
dilithium87: 'ml-dsa-87'
};
/**
* Argon2 parameters written on export.
*
* Deliberately the same values `quantus-cli` 2.2.2 uses rather than something
* stronger. The parameters travel in the file and the CLI reads them, so in
* principle anything works — but "in principle" is doing load-bearing work
* there, and a wallet that a slightly older CLI cannot open is a worse outcome
* than one whose KDF is tuned conservatively. Raise them when there is a reason
* to believe every CLI in circulation honours the PHC string.
*/
const EXPORT_ARGON2: Argon2Params = { m: 19456, p: 1, t: 2 };
/**
* SS58 prefix for the `address` field of an exported wallet.
*
* Fixed rather than a parameter: this is the Quantus CLI's format, the CLI only
* ever deals with Quantus, and an address at any other prefix would be one it
* could not match to the key inside.
*/
const QUANTUS_PREFIX = 189;
/** What the CLI records for a wallet with no derivation — a raw seed. */
const NO_DERIVATION = 'm/';
interface Argon2Params {
m: number;
p: number;
@@ -187,3 +218,87 @@ export async function dilithiumFromCliWallet (wallet: QuantusCliWallet, password
export function cliWalletAddressMatches (accountId: Uint8Array, decodedAddress: Uint8Array): boolean {
return u8aEq(accountId, decodedAddress);
}
export interface QuantusCliExportInput {
/** The derivation path, where known. Omit for a raw-seed account. */
derivationPath?: string | null;
keypair: Keypair;
name: string;
type: DilithiumType;
}
/**
* Write a `quantus-cli` wallet file.
*
* The mnemonic is **always null**. That is not a limitation worked around: the
* CLI's format has a null-mnemonic shape and writes one itself for every
* raw-seed wallet, and a wallet in that shape lists, opens and spends normally.
* What it cannot do is `export --format mnemonic` or derive further accounts —
* the honest consequence of the extension never storing a phrase it showed once.
*
* Without this, importing a CLI wallet (see `dilithiumFromCliWallet`) is a
* one-way door, and an extension backup is readable only by polkadot-js-format
* tooling.
*/
export async function dilithiumToCliWallet ({ derivationPath, keypair, name, type }: QuantusCliExportInput, password = ''): Promise<QuantusCliWallet> {
const sizes = dilithiumSizes(type);
if (keypair.publicKey.length !== sizes.publicKey || keypair.secretKey.length !== sizes.secretKey) {
throw new Error(`Key lengths do not match ${type}: expected ${sizes.publicKey}/${sizes.secretKey}, found ${keypair.publicKey.length}/${keypair.secretKey.length}`);
}
const payload = JSON.stringify({
derivation_path: derivationPath || NO_DERIVATION,
keypair: {
private_key: Array.from(keypair.secretKey),
public_key: Array.from(keypair.publicKey),
scheme: TYPE_TO_SCHEME[type]
},
metadata: {
algorithm: TYPE_TO_SCHEME[type].toUpperCase(),
// So a CLI maintainer reading a bug report can tell where the file came
// from without having to guess from its shape.
exported_by: 'quantus-extension',
version: '1.0.0'
},
mnemonic: null,
name
});
const salt = crypto.getRandomValues(new Uint8Array(16));
const nonce = crypto.getRandomValues(new Uint8Array(12));
const key = argon2id(
new TextEncoder().encode(password),
salt,
{ dkLen: 32, m: EXPORT_ARGON2.m, p: EXPORT_ARGON2.p, t: EXPORT_ARGON2.t }
);
const aesKey = await crypto.subtle.importKey('raw', key, 'AES-GCM', false, ['encrypt']);
const encrypted = new Uint8Array(
await crypto.subtle.encrypt(
{ iv: nonce, name: 'AES-GCM' },
aesKey,
new TextEncoder().encode(payload)
)
);
return {
address: encodeAddress(dilithiumAccountFromPublic(keypair.publicKey), QUANTUS_PREFIX),
aes_nonce: Array.from(nonce),
// The salt goes into the PHC string as well as its own field, because that
// is what the CLI writes and a reader may take it from either.
argon2_params: `$argon2id$v=19$m=${EXPORT_ARGON2.m},t=${EXPORT_ARGON2.t},p=${EXPORT_ARGON2.p}$${base64Unpadded(salt)}`,
argon2_salt: Array.from(salt),
created_at: new Date().toISOString(),
encrypted_data: Array.from(encrypted),
encryption_version: SUPPORTED_VERSION,
kyber_ciphertext: [],
kyber_public_key: [],
name,
wallet_type: 'hot'
};
}
/** Unpadded standard base64, as the PHC string format requires. */
function base64Unpadded (data: Uint8Array): string {
return base64Encode(data).replace(/=+$/, '');
}
+2 -2
View File
@@ -2,8 +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 type { QuantusCliExportInput, QuantusCliImport, QuantusCliWallet } from './cliWallet.js';
export { cliWalletAddressMatches, dilithiumFromCliWallet, dilithiumToCliWallet } from './cliWallet.js';
export { dilithiumPairFromMnemonic, dilithiumPairFromSeed } from './pair.js';
export { dilithiumPath, dilithiumPathFromSuri } from './path.js';
export { dilithiumSchemeFor, dilithiumSizes } from './scheme.js';