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:
rob thijssen
2026-09-15 08:56:20 +03:00
parent 506b77351c
commit f7dc72e32c
6 changed files with 229 additions and 3 deletions

View File

@@ -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 {

View 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');
});
});

View File

@@ -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');
}

View File

@@ -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';

View File

@@ -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));
}

View 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}`);
}