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