feat(keyring,util-crypto): BIP44 derivation for ML-DSA, in place of junctions
createFromUri now derives ML-DSA pairs from the mnemonic along a hardened BIP44 path rather than from a seed along a junction chain. Lattice keys have no public derivability, so there is no soft junction to emulate and the chain's own Pair::derive refuses for the same reason. The path is m/44'/189189'/<account>'/0'/<0 for 87 | 1 for 65>'. Two things about it are unusual and both are deliberate: the account index sits at the third level rather than the last, and the trailing index carries the scheme rather than an address index. It is what quantus-cli and the mobile wallet already derive, so anything else produces addresses no other Quantus tool can find. The ML-DSA branch sits before the existing seeding, not after, because that seeding is the wrong one for us. mnemonicToMiniSecret is Substrate's variant and the default reach in this file; using it here yields a perfectly well-formed key for an account nobody owns. We need the 64-byte BIP39 seed and the HMAC-SHA512 chain keyed with "Dilithium seed", which is what the hdwallet crate does. Accepted suri forms are nothing (account 0, matching quantus-cli's default), //<n> for an account index, and //m/44'/… for a full path. Everything else throws. That matters more than it looks: the syntax was built for curve junctions, where /foo is soft and //foo hard over arbitrary bytes, and quietly reinterpreting //Alice as something would hand back an address no other tool derives and no seed phrase obviously recovers. A raw hex seed is taken undederived — that is how the dev-genesis accounts are defined — and combining one with a derivation path is refused rather than guessed, since whether such a seed is the master or already derived is unanswerable. pair.derive() refuses with its own message. Not "not implemented" but not possible: a child would have to come from this pair's key material, and ML-DSA keys are not derivable from one another at all. The caller needs the mnemonic, so the error says so rather than being generically unhelpful. Verified against quantus-cli for both schemes from the public Substrate dev phrase, plus index separation, explicit paths agreeing with indices, passphrase sensitivity, and every refusal. Refs quantus/common#4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
@@ -5,7 +5,7 @@ import type { EncryptedJsonEncoding, Keypair, KeypairType } from '@polkadot/util
|
||||
import type { KeyringInstance, KeyringOptions, KeyringPair, KeyringPair$Json, KeyringPair$Meta } from './types.js';
|
||||
|
||||
import { hexToU8a, isHex, stringToU8a } from '@polkadot/util';
|
||||
import { base64Decode, decodeAddress, dilithiumPairFromSeed, ed25519PairFromSeed as ed25519FromSeed, encodeAddress, ethereumEncode, hdEthereum, isDilithium, keyExtractSuri, keyFromPath, mnemonicToLegacySeed, mnemonicToMiniSecret, secp256k1PairFromSeed as secp256k1FromSeed, sr25519PairFromSeed as sr25519FromSeed } from '@polkadot/util-crypto';
|
||||
import { base64Decode, decodeAddress, dilithiumPairFromMnemonic, dilithiumPairFromSeed, dilithiumPathFromSuri, ed25519PairFromSeed as ed25519FromSeed, encodeAddress, ethereumEncode, hdEthereum, isDilithium, keyExtractSuri, keyFromPath, mnemonicToLegacySeed, mnemonicToMiniSecret, secp256k1PairFromSeed as secp256k1FromSeed, sr25519PairFromSeed as sr25519FromSeed } from '@polkadot/util-crypto';
|
||||
|
||||
import { createPair } from './pair/index.js';
|
||||
import { DEV_PHRASE } from './defaults.js';
|
||||
@@ -243,6 +243,33 @@ export class Keyring implements KeyringInstance {
|
||||
let seed: Uint8Array;
|
||||
const isPhraseHex = isHex(phrase, 256);
|
||||
|
||||
// ML-DSA derives from the mnemonic itself along a hardened BIP44 path, not
|
||||
// from a seed along a junction chain — lattice keys have no public
|
||||
// derivability, so there is no soft junction to emulate and the chain's own
|
||||
// Pair::derive refuses for the same reason. Handled before the seeding below
|
||||
// because that seeding (mnemonicToMiniSecret) is the wrong one for us and
|
||||
// would produce a valid key for an account nobody owns.
|
||||
if (isDilithium(type)) {
|
||||
if (isPhraseHex) {
|
||||
// A raw 32-byte seed goes straight into keygen, which is how the
|
||||
// dev-genesis accounts are defined. Combining one with a derivation path
|
||||
// is ambiguous — is the seed the master, or already derived? — so refuse
|
||||
// rather than pick.
|
||||
if (derivePath) {
|
||||
throw new Error('A derivation path cannot be combined with a raw seed for post-quantum pairs');
|
||||
}
|
||||
|
||||
return createPair({ toSS58: this.encodeAddress, type }, PairFromSeed[type](hexToU8a(phrase)), meta, null);
|
||||
}
|
||||
|
||||
return createPair(
|
||||
{ toSS58: this.encodeAddress, type },
|
||||
dilithiumPairFromMnemonic(phrase, password || '', dilithiumPathFromSuri(type, derivePath), type),
|
||||
meta,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
if (isPhraseHex) {
|
||||
seed = hexToU8a(phrase);
|
||||
} else {
|
||||
|
||||
104
packages/keyring/src/pair/dilithiumDerive.spec.ts
Normal file
104
packages/keyring/src/pair/dilithiumDerive.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright 2017-2026 @polkadot/keyring authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@polkadot/dev-test/globals.d.ts" />
|
||||
|
||||
import { dilithiumPath } from '@polkadot/util-crypto';
|
||||
|
||||
import { Keyring } from '../keyring.js';
|
||||
|
||||
// The well-known Substrate development phrase — public by design, so pinning it
|
||||
// commits no secret. Any account it derives is assumed compromised.
|
||||
const DEV_PHRASE = 'bottom drive obey lake curtain smoke basket hold race lonely fit walk';
|
||||
|
||||
// What `quantus wallet import --mnemonic-file <DEV_PHRASE> --scheme <s>` prints.
|
||||
// An independent implementation, not this one.
|
||||
const EXPECT_65 = 'qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6';
|
||||
const EXPECT_87 = 'qzjrYTUnnE5NduTZKxe9dESCMTZg7nTueKM3bwhnkRdD1iYV4';
|
||||
|
||||
// crystal_alice — a raw 32-byte seed straight into keygen, no derivation.
|
||||
const ALICE = 'qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7';
|
||||
|
||||
describe('dilithium derivation', (): void => {
|
||||
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium65' });
|
||||
|
||||
it('builds the paths quantus-cli uses', (): void => {
|
||||
expect(dilithiumPath('dilithium65')).toEqual("m/44'/189189'/0'/0'/1'");
|
||||
expect(dilithiumPath('dilithium87')).toEqual("m/44'/189189'/0'/0'/0'");
|
||||
expect(dilithiumPath('dilithium65', 3)).toEqual("m/44'/189189'/3'/0'/1'");
|
||||
});
|
||||
|
||||
// The whole derivation chain at once: BIP39 to a 64-byte seed (not Substrate's
|
||||
// mnemonicToMiniSecret), the HMAC-SHA512 walk keyed with "Dilithium seed", the
|
||||
// trailing hardened index carrying the scheme, and the Poseidon2 account hash.
|
||||
it('derives what quantus-cli derives, both schemes', (): void => {
|
||||
expect(keyring.createFromUri(DEV_PHRASE, {}, 'dilithium65').address).toEqual(EXPECT_65);
|
||||
expect(keyring.createFromUri(DEV_PHRASE, {}, 'dilithium87').address).toEqual(EXPECT_87);
|
||||
});
|
||||
|
||||
it('defaults to account index 0', (): void => {
|
||||
expect(keyring.createFromUri(`${DEV_PHRASE}//0`, {}, 'dilithium65').address).toEqual(EXPECT_65);
|
||||
});
|
||||
|
||||
it('derives distinct accounts per index', (): void => {
|
||||
const zero = keyring.createFromUri(`${DEV_PHRASE}//0`, {}, 'dilithium65').address;
|
||||
const one = keyring.createFromUri(`${DEV_PHRASE}//1`, {}, 'dilithium65').address;
|
||||
|
||||
expect(zero).not.toEqual(one);
|
||||
});
|
||||
|
||||
it('accepts an explicit hardened path', (): void => {
|
||||
const byIndex = keyring.createFromUri(`${DEV_PHRASE}//2`, {}, 'dilithium65').address;
|
||||
const byPath = keyring.createFromUri(`${DEV_PHRASE}//m/44'/189189'/2'/0'/1'`, {}, 'dilithium65').address;
|
||||
|
||||
expect(byPath).toEqual(byIndex);
|
||||
});
|
||||
|
||||
// The suri syntax was built for curve junctions, where `/foo` is a soft
|
||||
// derivation over arbitrary bytes. Neither form exists for ML-DSA, and quietly
|
||||
// reinterpreting one would hand back an address no other tool derives.
|
||||
it('refuses soft derivation', (): void => {
|
||||
expect(() => keyring.createFromUri(`${DEV_PHRASE}/0`, {}, 'dilithium65')).toThrow(/Soft derivation is not possible/);
|
||||
});
|
||||
|
||||
it('refuses a named junction', (): void => {
|
||||
expect(() => keyring.createFromUri(`${DEV_PHRASE}//Alice`, {}, 'dilithium65')).toThrow(/Unsupported derivation path/);
|
||||
});
|
||||
|
||||
it('refuses an unhardened level in an explicit path', (): void => {
|
||||
expect(() => keyring.createFromUri(`${DEV_PHRASE}//m/44'/189189'/0'/0'/1`, {}, 'dilithium65')).toThrow(/Unhardened derivation/);
|
||||
});
|
||||
|
||||
// A raw seed is already key material. Whether it is the master or something
|
||||
// already derived is unanswerable, so combining it with a path is refused
|
||||
// rather than guessed.
|
||||
it('takes a raw hex seed underived, and refuses to derive from one', (): void => {
|
||||
const seed = `0x${'00'.repeat(32)}`;
|
||||
|
||||
expect(keyring.createFromUri(seed, {}, 'dilithium87').address).toEqual(ALICE);
|
||||
expect(() => keyring.createFromUri(`${seed}//1`, {}, 'dilithium87')).toThrow(/cannot be combined with a raw seed/);
|
||||
});
|
||||
|
||||
it('honours a BIP39 passphrase', (): void => {
|
||||
const plain = keyring.createFromUri(DEV_PHRASE, {}, 'dilithium65').address;
|
||||
const withPass = keyring.createFromUri(`${DEV_PHRASE}///hunter2`, {}, 'dilithium65').address;
|
||||
|
||||
expect(plain).not.toEqual(withPass);
|
||||
});
|
||||
|
||||
// Deriving a child *from a pair* is not merely unimplemented here, it is
|
||||
// impossible: ML-DSA keys are not derivable from one another, and the Quantus
|
||||
// tree derives each account from the mnemonic independently.
|
||||
it('refuses to derive from an existing pair', (): void => {
|
||||
const pair = keyring.createFromUri(DEV_PHRASE, {}, 'dilithium65');
|
||||
|
||||
expect(() => pair.derive('//1')).toThrow(/derive from the mnemonic with createFromUri/);
|
||||
});
|
||||
|
||||
it('leaves sr25519 junction derivation unchanged', (): void => {
|
||||
const sr = new Keyring({ type: 'sr25519' });
|
||||
|
||||
expect(sr.createFromUri('//Alice').address).toEqual('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY');
|
||||
expect(sr.createFromUri('//Bob').address).toEqual('5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty');
|
||||
});
|
||||
});
|
||||
@@ -225,6 +225,13 @@ export function createPair ({ toSS58, type }: Setup, { accountId, publicKey, sec
|
||||
derive: (suri: string, meta?: KeyringPair$Meta): KeyringPair => {
|
||||
if (type === 'ethereum') {
|
||||
throw new Error('Unable to derive on this keypair');
|
||||
} else if (isDilithium(type)) {
|
||||
// Not "not implemented" — not possible. A child here would have to come
|
||||
// from this pair's key material, and ML-DSA keys are not derivable from
|
||||
// one another at all; the Quantus tree derives every account from the
|
||||
// mnemonic independently. So the caller needs the mnemonic, not this
|
||||
// pair, and saying that is more use than a generic refusal.
|
||||
throw new Error(`Unable to derive from an existing ${type} pair; derive from the mnemonic with createFromUri instead`);
|
||||
} else if (isLocked(secretKey)) {
|
||||
throw new Error('Cannot derive on a locked keypair');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export { dilithiumAccountFromPublic } from './account.js';
|
||||
export { dilithiumPairFromSeed } from './pair.js';
|
||||
export { dilithiumPairFromMnemonic, dilithiumPairFromSeed } from './pair.js';
|
||||
export { dilithiumPath, dilithiumPathFromSuri } from './path.js';
|
||||
export { dilithiumSchemeFor, dilithiumSizes } from './scheme.js';
|
||||
export { dilithiumSign } from './sign.js';
|
||||
export { dilithiumVerify } from './verify.js';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { DilithiumType } from '../dilithium.js';
|
||||
import type { Keypair } from '../types.js';
|
||||
|
||||
import { keypairFromSeed } from '@quantus/crypto';
|
||||
import { keypairFromMnemonic, keypairFromSeed } from '@quantus/crypto';
|
||||
|
||||
import { dilithiumSchemeFor } from './scheme.js';
|
||||
|
||||
@@ -19,3 +19,17 @@ import { dilithiumSchemeFor } from './scheme.js';
|
||||
export function dilithiumPairFromSeed (seed: Uint8Array, type: DilithiumType): Keypair {
|
||||
return keypairFromSeed(seed, dilithiumSchemeFor(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ML-DSA keypair from a BIP39 mnemonic at a hardened path.
|
||||
*
|
||||
* The seeding is the part that silently goes wrong. This runs mnemonic → the
|
||||
* 64-byte BIP39 seed → an HMAC-SHA512 chain keyed with the literal string
|
||||
* "Dilithium seed". Substrate's own `mnemonicToMiniSecret` is a different
|
||||
* derivation entirely and is what the rest of this package reaches for by
|
||||
* default; using it here yields a perfectly well-formed key for an account
|
||||
* nobody owns.
|
||||
*/
|
||||
export function dilithiumPairFromMnemonic (mnemonic: string, password: string, path: string, type: DilithiumType): Keypair {
|
||||
return keypairFromMnemonic(mnemonic, password, path, dilithiumSchemeFor(type));
|
||||
}
|
||||
|
||||
73
packages/util-crypto/src/dilithium/path.ts
Normal file
73
packages/util-crypto/src/dilithium/path.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright 2017-2026 @polkadot/util-crypto authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DilithiumType } from '../dilithium.js';
|
||||
|
||||
/** Quantus BIP44 coin type. */
|
||||
const COIN_TYPE = 189189;
|
||||
|
||||
/**
|
||||
* The derivation path for an account index.
|
||||
*
|
||||
* ```text
|
||||
* m/44'/189189'/<account>'/0'/<0 for ML-DSA-87 | 1 for ML-DSA-65>'
|
||||
* ```
|
||||
*
|
||||
* Two things about this are unusual and both are deliberate: the account index
|
||||
* sits at the third level rather than the last, and the trailing index carries
|
||||
* the *scheme* rather than an address index. It is what `quantus-cli` and the
|
||||
* mobile wallet already derive, so anything else produces addresses no other
|
||||
* Quantus tool can find. Do not improve it.
|
||||
*
|
||||
* Every level is hardened. Lattice keys are not publicly derivable, so an
|
||||
* unhardened level cannot mean what BIP-32 implies, and the hdwallet crate
|
||||
* rejects one outright.
|
||||
*/
|
||||
export function dilithiumPath (type: DilithiumType, account = 0): string {
|
||||
if (!Number.isInteger(account) || account < 0) {
|
||||
throw new Error(`Invalid account index: ${account}`);
|
||||
}
|
||||
|
||||
return `m/44'/${COIN_TYPE}'/${account}'/0'/${type === 'dilithium65' ? 1 : 0}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the derivation part of a suri to a Quantus path.
|
||||
*
|
||||
* Accepted:
|
||||
* - nothing → account 0, the default `quantus-cli` uses
|
||||
* - `//<n>` → account index n
|
||||
* - `//m/44'/189189'/…` → that path verbatim, for anything the index form
|
||||
* cannot express (the wormhole tree, say)
|
||||
*
|
||||
* Everything else throws, and that matters more than it looks. The suri syntax
|
||||
* these strings come from was built for curve junctions, where `/foo` is a soft
|
||||
* derivation and `//foo` a hard one over arbitrary bytes. Neither exists for
|
||||
* ML-DSA. Quietly reinterpreting `//Alice` as *something* would hand the user an
|
||||
* address no other tool derives and no seed phrase obviously recovers.
|
||||
*/
|
||||
export function dilithiumPathFromSuri (type: DilithiumType, derivePath: string): string {
|
||||
if (!derivePath) {
|
||||
return dilithiumPath(type);
|
||||
}
|
||||
|
||||
if (!derivePath.startsWith('//')) {
|
||||
throw new Error(`Soft derivation is not possible for ${type}; use //<index> or a full //m/44'/${COIN_TYPE}'/… path`);
|
||||
}
|
||||
|
||||
const rest = derivePath.substring(2);
|
||||
|
||||
if (/^\d+$/.test(rest)) {
|
||||
return dilithiumPath(type, parseInt(rest, 10));
|
||||
}
|
||||
|
||||
if (rest.startsWith('m/')) {
|
||||
if (/\d(?!')(?:\/|$)/.test(rest)) {
|
||||
throw new Error(`Unhardened derivation is not possible for ${type}: ${rest}`);
|
||||
}
|
||||
|
||||
return rest;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported derivation path for ${type}: ${derivePath}`);
|
||||
}
|
||||
Reference in New Issue
Block a user