From f7dc72e32c4308fc2d4e7c9b411a64048b0ad6cd Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 15 Sep 2026 08:56:20 +0300 Subject: [PATCH] feat(keyring,util-crypto): BIP44 derivation for ML-DSA, in place of junctions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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'/'/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), // 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) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- packages/keyring/src/keyring.ts | 29 ++++- .../keyring/src/pair/dilithiumDerive.spec.ts | 104 ++++++++++++++++++ packages/keyring/src/pair/index.ts | 7 ++ packages/util-crypto/src/dilithium/index.ts | 3 +- packages/util-crypto/src/dilithium/pair.ts | 16 ++- packages/util-crypto/src/dilithium/path.ts | 73 ++++++++++++ 6 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 packages/keyring/src/pair/dilithiumDerive.spec.ts create mode 100644 packages/util-crypto/src/dilithium/path.ts diff --git a/packages/keyring/src/keyring.ts b/packages/keyring/src/keyring.ts index 3aaf1d8a6..e3e33ea0f 100644 --- a/packages/keyring/src/keyring.ts +++ b/packages/keyring/src/keyring.ts @@ -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 { diff --git a/packages/keyring/src/pair/dilithiumDerive.spec.ts b/packages/keyring/src/pair/dilithiumDerive.spec.ts new file mode 100644 index 000000000..785e5e845 --- /dev/null +++ b/packages/keyring/src/pair/dilithiumDerive.spec.ts @@ -0,0 +1,104 @@ +// Copyright 2017-2026 @polkadot/keyring authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +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 --scheme ` 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'); + }); +}); diff --git a/packages/keyring/src/pair/index.ts b/packages/keyring/src/pair/index.ts index caeac857b..c4a32f8fc 100644 --- a/packages/keyring/src/pair/index.ts +++ b/packages/keyring/src/pair/index.ts @@ -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'); } diff --git a/packages/util-crypto/src/dilithium/index.ts b/packages/util-crypto/src/dilithium/index.ts index 93acdc786..226c05688 100644 --- a/packages/util-crypto/src/dilithium/index.ts +++ b/packages/util-crypto/src/dilithium/index.ts @@ -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'; diff --git a/packages/util-crypto/src/dilithium/pair.ts b/packages/util-crypto/src/dilithium/pair.ts index c5e5b06de..75977f8dd 100644 --- a/packages/util-crypto/src/dilithium/pair.ts +++ b/packages/util-crypto/src/dilithium/pair.ts @@ -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)); +} diff --git a/packages/util-crypto/src/dilithium/path.ts b/packages/util-crypto/src/dilithium/path.ts new file mode 100644 index 000000000..b51d12cdb --- /dev/null +++ b/packages/util-crypto/src/dilithium/path.ts @@ -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'/'/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 + * - `//` → 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 // 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}`); +}