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:
co-authored by
Claude Opus 5
parent
e55352aa4e
commit
74e1e0cc6a
@@ -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,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';
|
||||
|
||||
Reference in New Issue
Block a user