diff --git a/package.json b/package.json
index b2bad2037..4df61bfb6 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"@types/node": "^22.7.5"
},
"resolutions": {
+ "@quantus/crypto": "link:../wasm/packages/quantus-crypto/build",
"typescript": "^5.5.4"
}
}
diff --git a/packages/keyring/package.json b/packages/keyring/package.json
index ad68fe5be..d996a2a73 100644
--- a/packages/keyring/package.json
+++ b/packages/keyring/package.json
@@ -23,6 +23,7 @@
"dependencies": {
"@polkadot/util": "14.0.3",
"@polkadot/util-crypto": "14.0.3",
+ "@quantus/crypto": "^0.1.0",
"tslib": "^2.8.0"
},
"peerDependencies": {
diff --git a/packages/keyring/src/keyring.ts b/packages/keyring/src/keyring.ts
index a1d8145d0..168e6f0e9 100644
--- a/packages/keyring/src/keyring.ts
+++ b/packages/keyring/src/keyring.ts
@@ -5,13 +5,25 @@ 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 { assertSubstrateType, base64Decode, decodeAddress, ed25519PairFromSeed as ed25519FromSeed, encodeAddress, ethereumEncode, hdEthereum, keyExtractSuri, keyFromPath, mnemonicToLegacySeed, mnemonicToMiniSecret, secp256k1PairFromSeed as secp256k1FromSeed, sr25519PairFromSeed as sr25519FromSeed } from '@polkadot/util-crypto';
+import { base64Decode, decodeAddress, dilithiumPairFromSeed, ed25519PairFromSeed as ed25519FromSeed, encodeAddress, ethereumEncode, hdEthereum, 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';
import { Pairs } from './pairs.js';
+/**
+ * Every keypair type this keyring will construct.
+ *
+ * Was two separate inline arrays, both of which a widened `KeypairType` silently
+ * walked past — the union is compile-time and these checks are not. Named once so
+ * the next change to the union has one place to look, and so the error message
+ * cannot drift from the check.
+ */
+const KEYPAIR_TYPES: KeypairType[] = ['dilithium65', 'dilithium87', 'ecdsa', 'ed25519', 'ethereum', 'sr25519'];
+
const PairFromSeed = {
+ dilithium65: (seed: Uint8Array): Keypair => dilithiumPairFromSeed(seed, 'dilithium65'),
+ dilithium87: (seed: Uint8Array): Keypair => dilithiumPairFromSeed(seed, 'dilithium87'),
ecdsa: (seed: Uint8Array): Keypair => secp256k1FromSeed(seed),
ed25519: (seed: Uint8Array): Keypair => ed25519FromSeed(seed),
ethereum: (seed: Uint8Array): Keypair => secp256k1FromSeed(seed),
@@ -50,8 +62,11 @@ export class Keyring implements KeyringInstance {
constructor (options: KeyringOptions = {}) {
options.type = options.type || 'ed25519';
- if (!['ecdsa', 'ethereum', 'ed25519', 'sr25519'].includes(options.type || 'undefined')) {
- throw new Error(`Expected a keyring type of either 'ed25519', 'sr25519', 'ethereum' or 'ecdsa', found '${options.type || 'unknown'}`);
+ // A runtime whitelist behind the KeypairType union, so widening the type
+ // alone was not enough — the compiler cannot see this one, and it fails at
+ // construction rather than at use.
+ if (!KEYPAIR_TYPES.includes(options.type || ('undefined' as KeypairType))) {
+ throw new Error(`Expected a keyring type of one of ${KEYPAIR_TYPES.map((t) => `'${t}'`).join(', ')}, found '${options.type || 'unknown'}'`);
}
this.#pairs = new Pairs();
@@ -144,7 +159,7 @@ export class Keyring implements KeyringInstance {
*/
public addFromSeed (seed: Uint8Array, meta: KeyringPair$Meta = {}, type: KeypairType = this.type): KeyringPair {
return this.addPair(
- createPair({ toSS58: this.encodeAddress, type }, PairFromSeed[assertSubstrateType(type, 'Deriving a pair from a seed')](seed), meta, null)
+ createPair({ toSS58: this.encodeAddress, type }, PairFromSeed[type](seed), meta, null)
);
}
@@ -175,7 +190,11 @@ export class Keyring implements KeyringInstance {
? [type]
: type;
- if (!['ed25519', 'sr25519', 'ecdsa', 'ethereum'].includes(cryptoType)) {
+ // Same list, second copy — this one guards JSON coming in from a file the
+ // user chose, so it is the guard that will matter most once quantus/common#6
+ // narrows the union and imports of quantum-unsafe keys have to be refused
+ // with a message explaining why rather than "unknown crypto type".
+ if (!KEYPAIR_TYPES.includes(cryptoType as KeypairType)) {
throw new Error(`Unknown crypto type ${cryptoType}`);
}
@@ -232,9 +251,9 @@ export class Keyring implements KeyringInstance {
const derived = type === 'ethereum'
? isPhraseHex
- ? PairFromSeed[assertSubstrateType(type, 'Deriving a pair from a seed')](seed) // for eth, if the private key is provided as suri, it must be derived only once
+ ? PairFromSeed[type](seed) // for eth, if the private key is provided as suri, it must be derived only once
: hdEthereum(seed, derivePath.substring(1))
- : keyFromPath(PairFromSeed[assertSubstrateType(type, 'Deriving a pair from a seed')](seed), path, type);
+ : keyFromPath(PairFromSeed[type](seed), path, type);
return createPair({ toSS58: this.encodeAddress, type }, derived, meta, null);
}
diff --git a/packages/keyring/src/pair/dilithium.spec.ts b/packages/keyring/src/pair/dilithium.spec.ts
new file mode 100644
index 000000000..6e779a39a
--- /dev/null
+++ b/packages/keyring/src/pair/dilithium.spec.ts
@@ -0,0 +1,100 @@
+// Copyright 2017-2026 @polkadot/keyring authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+///
+
+import { contextForSpec, Scheme, sizes } from '@quantus/crypto';
+
+import { dilithiumVerify } from '@polkadot/util-crypto';
+
+import { Keyring } from '../keyring.js';
+
+// crystal_alice, from the chain's dev genesis: a raw 32-byte seed straight into
+// ML-DSA-87 keygen, no HD derivation. The expected address is what
+// `quantus developer create-test-wallets` prints — an independent implementation,
+// not this one.
+const ALICE_ADDRESS = 'qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7';
+
+const MESSAGE = new Uint8Array([0x74, 0x68, 0x65, 0x20, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64]);
+
+describe('dilithium pairs', (): void => {
+ const keyring = new Keyring({ ss58Format: 189, type: 'dilithium87' });
+ const alice = keyring.addFromSeed(new Uint8Array(32), { name: 'crystal_alice' }, 'dilithium87');
+ const context = contextForSpec(148);
+ const s87 = sizes(Scheme.MlDsa87);
+ const s65 = sizes(Scheme.MlDsa65);
+
+ it('derives the address quantus-cli derives', (): void => {
+ expect(alice.address).toEqual(ALICE_ADDRESS);
+ });
+
+ // The assumption this whole fork exists to break. Everywhere else in this
+ // package `addressRaw` and `publicKey` are the same 32 bytes.
+ it('has an addressRaw that is not the publicKey', (): void => {
+ expect(alice.addressRaw.length).toEqual(32);
+ expect(alice.publicKey.length).toEqual(s87.publicKey);
+ });
+
+ it('signs to the wire form the runtime reads', (): void => {
+ const signature = alice.sign(MESSAGE, { context });
+
+ expect(signature.length).toEqual(s87.signatureWithPublicKey);
+ });
+
+ // `withType` prepends the signature enum's variant index. For the curve types
+ // that enum is Substrate's MultiSignature; here it is the runtime's
+ // DilithiumSignatureScheme, reached by the same mechanism.
+ it('prepends the chain variant byte with withType', (): void => {
+ const signed87 = alice.sign(MESSAGE, { context, withType: true });
+ const k65 = keyring.addFromSeed(new Uint8Array(32).fill(9), {}, 'dilithium65');
+ const signed65 = k65.sign(MESSAGE, { context, withType: true });
+
+ expect(signed87[0]).toEqual(0);
+ expect(signed87.length).toEqual(s87.signatureWithPublicKey + 1);
+ expect(signed65[0]).toEqual(1);
+ expect(signed65.length).toEqual(s65.signatureWithPublicKey + 1);
+ });
+
+ it('verifies against the account id', (): void => {
+ const signature = alice.sign(MESSAGE, { context });
+
+ expect(dilithiumVerify(MESSAGE, signature, alice.addressRaw, 'dilithium87', context)).toEqual(true);
+ });
+
+ // The spec-148 boundary. A signature made under the wrong context is
+ // cryptographically valid and rejected by the chain, so this is the only place
+ // it can be caught locally.
+ it('does not verify under the wrong context', (): void => {
+ const signature = alice.sign(MESSAGE, { context });
+
+ expect(dilithiumVerify(MESSAGE, signature, alice.addressRaw, 'dilithium87', contextForSpec(147))).toEqual(false);
+ });
+
+ // Verification checks the embedded public key hashes to the account being
+ // verified against, not merely that the signature is valid for *some* key.
+ it('does not verify another account signature', (): void => {
+ const bob = keyring.addFromSeed(new Uint8Array(32).fill(1), {}, 'dilithium87');
+ const signature = bob.sign(MESSAGE, { context });
+
+ expect(dilithiumVerify(MESSAGE, signature, alice.addressRaw, 'dilithium87', context)).toEqual(false);
+ });
+
+ it('refuses to sign without a context', (): void => {
+ expect(() => alice.sign(MESSAGE)).toThrow(/signing context is required/);
+ });
+
+ // There is no ML-DSA VRF, and faking one from an ordinary signature would
+ // produce output that looks like a VRF proof and cannot be verified as one.
+ it('refuses to VRF sign', (): void => {
+ expect(() => alice.vrfSign(MESSAGE)).toThrow(/not available for dilithium87/);
+ });
+
+ // The classical paths must be untouched — this is what a careless rebase
+ // breaks, and it would break silently.
+ it('leaves sr25519 unchanged', (): void => {
+ const sr = new Keyring({ type: 'sr25519' }).addFromUri('//Alice');
+
+ expect(sr.address).toEqual('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY');
+ expect(sr.sign(MESSAGE).length).toEqual(64);
+ });
+});
diff --git a/packages/keyring/src/pair/index.ts b/packages/keyring/src/pair/index.ts
index 46592f48b..690ce459f 100644
--- a/packages/keyring/src/pair/index.ts
+++ b/packages/keyring/src/pair/index.ts
@@ -6,7 +6,7 @@ import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta, SignOptions } fro
import type { PairInfo } from './types.js';
import { objectSpread, u8aConcat, u8aEmpty, u8aEq, u8aToHex, u8aToU8a } from '@polkadot/util';
-import { assertSubstrateType, blake2AsU8a, ed25519PairFromSeed as ed25519FromSeed, ed25519Sign, ethereumEncode, keccakAsU8a, keyExtractPath, keyFromPath, secp256k1Compress, secp256k1Expand, secp256k1PairFromSeed as secp256k1FromSeed, secp256k1Sign, signatureVerify, sr25519PairFromSeed as sr25519FromSeed, sr25519Sign, sr25519VrfSign, sr25519VrfVerify } from '@polkadot/util-crypto';
+import { blake2AsU8a, dilithiumAccountFromPublic, dilithiumPairFromSeed, dilithiumSign, ed25519PairFromSeed as ed25519FromSeed, ed25519Sign, ethereumEncode, isDilithium, keccakAsU8a, keyExtractPath, keyFromPath, secp256k1Compress, secp256k1Expand, secp256k1PairFromSeed as secp256k1FromSeed, secp256k1Sign, signatureVerify, sr25519PairFromSeed as sr25519FromSeed, sr25519Sign, sr25519VrfSign, sr25519VrfVerify } from '@polkadot/util-crypto';
import { decodePair } from './decode.js';
import { encodePair } from './encode.js';
@@ -20,27 +20,70 @@ interface Setup {
const SIG_TYPE_NONE = new Uint8Array();
const TYPE_FROM_SEED = {
+ dilithium65: (seed: Uint8Array) => dilithiumPairFromSeed(seed, 'dilithium65'),
+ dilithium87: (seed: Uint8Array) => dilithiumPairFromSeed(seed, 'dilithium87'),
ecdsa: secp256k1FromSeed,
ed25519: ed25519FromSeed,
ethereum: secp256k1FromSeed,
sr25519: sr25519FromSeed
};
+// For the curve types these index Substrate's `MultiSignature`. For the ML-DSA
+// types they index the Quantus runtime's `DilithiumSignatureScheme`, where
+// `Dilithium87` is variant 0 and `Dilithium65` is variant 1 — a different enum
+// that happens to be reached by the same `withType` mechanism, so no special
+// casing is needed anywhere upstream of here.
const TYPE_PREFIX = {
+ dilithium65: new Uint8Array([1]),
+ dilithium87: new Uint8Array([0]),
ecdsa: new Uint8Array([2]),
ed25519: new Uint8Array([0]),
ethereum: new Uint8Array([2]),
sr25519: new Uint8Array([1])
};
-const TYPE_SIGNATURE = {
- ecdsa: (m: Uint8Array, p: Partial) => secp256k1Sign(m, p, 'blake2'),
- ed25519: ed25519Sign,
- ethereum: (m: Uint8Array, p: Partial) => secp256k1Sign(m, p, 'keccak'),
- sr25519: sr25519Sign
+// The ML-DSA arms take a context; the curve arms ignore the extra argument. The
+// signature is written out because TypeScript would otherwise infer the narrowest
+// common shape — the two-argument curve arms — and reject passing a context at all.
+type SignFn = (message: Uint8Array, pair: Partial, context?: Uint8Array) => Uint8Array;
+
+const TYPE_SIGNATURE: Record = {
+ dilithium65: (m, p, ctx) => dilithiumSign(m, p, 'dilithium65', requireContext(ctx)),
+ dilithium87: (m, p, ctx) => dilithiumSign(m, p, 'dilithium87', requireContext(ctx)),
+ ecdsa: (m, p) => secp256k1Sign(m, p, 'blake2'),
+ // Wrapped rather than passed directly: ed25519Sign and sr25519Sign take a
+ // third argument of their own (`onlyJs`), and letting it line up with the
+ // context slot would silently reinterpret a Uint8Array as a boolean.
+ ed25519: (m, p) => ed25519Sign(m, p),
+ ethereum: (m, p) => secp256k1Sign(m, p, 'keccak'),
+ sr25519: (m, p) => sr25519Sign(m, p)
};
+/**
+ * A post-quantum pair will not sign without being told which context to use.
+ *
+ * See `SignOptions.context`. The alternative — defaulting to the extrinsic
+ * context — produces a valid signature that a spec-147 chain rejects, with
+ * nothing local able to explain why.
+ */
+function requireContext (context?: Uint8Array): Uint8Array {
+ if (!context) {
+ throw new Error('A signing context is required for post-quantum keypairs; pass options.context (see contextForSpec)');
+ }
+
+ return context;
+}
+
+// Every curve arm here is identity or a cheap re-encoding, because on Substrate
+// the address *is* the public key. The ML-DSA arms are the exception that the
+// rest of this file has to be read in light of: 1952 or 2592 bytes in, 32 out,
+// and no way back. `ecdsa` already hints at the shape — it hashes when given an
+// uncompressed key — but it stays reversible in the sense that matters, since
+// the signature carries a recoverable key. ML-DSA does not, which is why its
+// signatures carry the public key explicitly.
const TYPE_ADDRESS = {
+ dilithium65: dilithiumAccountFromPublic,
+ dilithium87: dilithiumAccountFromPublic,
ecdsa: (p: Uint8Array) => p.length > 32 ? blake2AsU8a(p) : p,
ed25519: (p: Uint8Array) => p,
ethereum: (p: Uint8Array) => p.length === 20 ? p : keccakAsU8a(secp256k1Expand(p)),
@@ -94,7 +137,7 @@ export function createPair ({ toSS58, type }: Setup, { publicKey, secretKey }: P
publicKey = decoded.publicKey;
secretKey = decoded.secretKey;
} else {
- const pair = TYPE_FROM_SEED[assertSubstrateType(type, 'Deriving a pair from a seed')](decoded.secretKey);
+ const pair = TYPE_FROM_SEED[type](decoded.secretKey);
publicKey = pair.publicKey;
secretKey = pair.secretKey;
@@ -111,7 +154,7 @@ export function createPair ({ toSS58, type }: Setup, { publicKey, secretKey }: P
};
const encodeAddress = (): string => {
- const raw = TYPE_ADDRESS[assertSubstrateType(type, 'Deriving an address')](publicKey);
+ const raw = TYPE_ADDRESS[type](publicKey);
return type === 'ethereum'
? ethereumEncode(raw)
@@ -123,7 +166,7 @@ export function createPair ({ toSS58, type }: Setup, { publicKey, secretKey }: P
return encodeAddress();
},
get addressRaw (): Uint8Array {
- const raw = TYPE_ADDRESS[assertSubstrateType(type, 'Deriving an address')](publicKey);
+ const raw = TYPE_ADDRESS[type](publicKey);
return type === 'ethereum'
? raw.slice(-20)
@@ -171,9 +214,9 @@ export function createPair ({ toSS58, type }: Setup, { publicKey, secretKey }: P
return u8aConcat(
options.withType
- ? TYPE_PREFIX[assertSubstrateType(type, 'Signing')]
+ ? TYPE_PREFIX[type]
: SIG_TYPE_NONE,
- TYPE_SIGNATURE[assertSubstrateType(type, 'Signing')](u8aToU8a(message), { publicKey, secretKey })
+ TYPE_SIGNATURE[type](u8aToU8a(message), { publicKey, secretKey }, options.context)
);
},
toJson: (passphrase?: string): KeyringPair$Json => {
@@ -192,7 +235,7 @@ export function createPair ({ toSS58, type }: Setup, { publicKey, secretKey }: P
return decodePkcs8(passphrase);
},
verify: (message: string | Uint8Array, signature: string | Uint8Array, signerPublic: string | Uint8Array): boolean => {
- return signatureVerify(message, signature, TYPE_ADDRESS[assertSubstrateType(type, 'Verifying')](u8aToU8a(signerPublic))).isValid;
+ return signatureVerify(message, signature, TYPE_ADDRESS[type](u8aToU8a(signerPublic))).isValid;
},
vrfSign: (message: string | Uint8Array, context?: string | Uint8Array, extra?: string | Uint8Array): Uint8Array => {
if (isLocked(secretKey)) {
@@ -203,7 +246,16 @@ export function createPair ({ toSS58, type }: Setup, { publicKey, secretKey }: P
return sr25519VrfSign(message, { secretKey }, context, extra);
}
- const proof = TYPE_SIGNATURE[assertSubstrateType(type, 'Signing')](u8aToU8a(message), { publicKey, secretKey });
+ // There is no ML-DSA VRF. The construction below fakes one out of an
+ // ordinary signature, which is sound only because the underlying schemes
+ // are deterministic and unique — properties ML-DSA's signature does not
+ // have in the form this needs. Refuse rather than produce something that
+ // looks like a VRF output and is not verifiable as one.
+ if (isDilithium(type)) {
+ throw new Error(`VRF signing is not available for ${type}`);
+ }
+
+ const proof = TYPE_SIGNATURE[type](u8aToU8a(message), { publicKey, secretKey });
return u8aConcat(vrfHash(proof, context, extra), proof);
},
@@ -212,7 +264,7 @@ export function createPair ({ toSS58, type }: Setup, { publicKey, secretKey }: P
return sr25519VrfVerify(message, vrfResult, publicKey, context, extra);
}
- const result = signatureVerify(message, u8aConcat(TYPE_PREFIX[assertSubstrateType(type, 'VRF verification')], vrfResult.subarray(32)), TYPE_ADDRESS[assertSubstrateType(type, 'Verifying')](u8aToU8a(signerPublic)));
+ const result = signatureVerify(message, u8aConcat(TYPE_PREFIX[type], vrfResult.subarray(32)), TYPE_ADDRESS[type](u8aToU8a(signerPublic)));
return result.isValid && u8aEq(vrfResult.subarray(0, 32), vrfHash(vrfResult.subarray(32), context, extra));
}
diff --git a/packages/keyring/src/types.ts b/packages/keyring/src/types.ts
index 2c6d81bb1..a0314d822 100644
--- a/packages/keyring/src/types.ts
+++ b/packages/keyring/src/types.ts
@@ -72,6 +72,17 @@ export interface KeyringPair$Json extends EncryptedJson {
}
export interface SignOptions {
+ /**
+ * The FIPS 204 domain-separation context, for post-quantum pairs.
+ *
+ * Mandatory for `dilithium65`/`dilithium87` and ignored by every other type.
+ * There is deliberately no default: Quantus extrinsics on spec >= 148 are
+ * verified under `QUANTUS_EXTRINSIC` and earlier ones under the empty context,
+ * and a signature made under the wrong one is cryptographically valid, rejected
+ * by the chain, and locally indistinguishable from a correct one. Only the
+ * caller knows the spec version, so only the caller can answer.
+ */
+ context?: Uint8Array;
/** Create a MultiSignature-compatible output with an indicator type */
withType?: boolean;
}
diff --git a/packages/util-crypto/package.json b/packages/util-crypto/package.json
index 2c7715fd3..6ecefc50c 100644
--- a/packages/util-crypto/package.json
+++ b/packages/util-crypto/package.json
@@ -35,6 +35,7 @@
"@polkadot/wasm-util": "^7.5.3",
"@polkadot/x-bigint": "14.0.3",
"@polkadot/x-randomvalues": "14.0.3",
+ "@quantus/crypto": "^0.1.0",
"@scure/base": "^1.1.7",
"@scure/sr25519": "^0.2.0",
"tslib": "^2.8.0"
diff --git a/packages/util-crypto/src/bundle.ts b/packages/util-crypto/src/bundle.ts
index 7a118ab7d..20567a50a 100644
--- a/packages/util-crypto/src/bundle.ts
+++ b/packages/util-crypto/src/bundle.ts
@@ -14,6 +14,7 @@ export * from './base64/index.js';
export * from './blake2/index.js';
export * from './crypto.js';
export * from './dilithium.js';
+export * from './dilithium/index.js';
export * from './ed25519/index.js';
export * from './ethereum/index.js';
export * from './hd/index.js';
diff --git a/packages/util-crypto/src/dilithium/account.ts b/packages/util-crypto/src/dilithium/account.ts
new file mode 100644
index 000000000..a6911c4c9
--- /dev/null
+++ b/packages/util-crypto/src/dilithium/account.ts
@@ -0,0 +1,17 @@
+// Copyright 2017-2026 @polkadot/util-crypto authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { accountFromPublicKey } from '@quantus/crypto';
+
+/**
+ * The 32-byte account id for an ML-DSA public key — Poseidon2 over Goldilocks.
+ *
+ * This is the step with no Substrate equivalent. There an `AccountId32` *is* the
+ * public key; here it is a one-way hash of one, which is why an ML-DSA signature
+ * has to carry its public key inside itself and why nothing can recover a key
+ * from an address. Every "the address and the publicKey are isomorphic"
+ * assumption in this codebase is false for these types.
+ */
+export function dilithiumAccountFromPublic (publicKey: Uint8Array): Uint8Array {
+ return accountFromPublicKey(publicKey);
+}
diff --git a/packages/util-crypto/src/dilithium/index.ts b/packages/util-crypto/src/dilithium/index.ts
new file mode 100644
index 000000000..93acdc786
--- /dev/null
+++ b/packages/util-crypto/src/dilithium/index.ts
@@ -0,0 +1,8 @@
+// Copyright 2017-2026 @polkadot/util-crypto authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+export { dilithiumAccountFromPublic } from './account.js';
+export { dilithiumPairFromSeed } from './pair.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
new file mode 100644
index 000000000..c5e5b06de
--- /dev/null
+++ b/packages/util-crypto/src/dilithium/pair.ts
@@ -0,0 +1,21 @@
+// Copyright 2017-2026 @polkadot/util-crypto authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { DilithiumType } from '../dilithium.js';
+import type { Keypair } from '../types.js';
+
+import { keypairFromSeed } from '@quantus/crypto';
+
+import { dilithiumSchemeFor } from './scheme.js';
+
+/**
+ * Create an ML-DSA keypair from 32 bytes of entropy.
+ *
+ * FIPS 204 `ML-DSA.KeyGen_internal` with no Quantus-specific step. The parameter
+ * set is absorbed into the seed expansion, so the same 32 bytes give independent
+ * keys per scheme — which is why the dev-genesis accounts (`[0u8; 32]` and
+ * friends) and HD-derived accounts share this one entry point.
+ */
+export function dilithiumPairFromSeed (seed: Uint8Array, type: DilithiumType): Keypair {
+ return keypairFromSeed(seed, dilithiumSchemeFor(type));
+}
diff --git a/packages/util-crypto/src/dilithium/scheme.ts b/packages/util-crypto/src/dilithium/scheme.ts
new file mode 100644
index 000000000..fa5045f71
--- /dev/null
+++ b/packages/util-crypto/src/dilithium/scheme.ts
@@ -0,0 +1,32 @@
+// Copyright 2017-2026 @polkadot/util-crypto authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { Sizes } from '@quantus/crypto';
+import type { DilithiumType } from '../dilithium.js';
+
+import { Scheme, sizes } from '@quantus/crypto';
+
+/**
+ * The `@quantus/crypto` scheme selector for a keyring type.
+ *
+ * The mapping is the identity on the chain's signature-enum variant index:
+ * `Dilithium87` is variant 0 and `Dilithium65` is variant 1, and that byte is
+ * what a signed extrinsic carries. Nothing here invents a numbering.
+ */
+export function dilithiumSchemeFor (type: DilithiumType): Scheme {
+ return type === 'dilithium65'
+ ? Scheme.MlDsa65
+ : Scheme.MlDsa87;
+}
+
+/**
+ * Key and signature lengths for a keyring type, read from the crate.
+ *
+ * Not written down here on purpose. These are consensus-critical — the runtime
+ * decodes a fixed-size array with no compact length prefix — so a constant that
+ * drifted from the crate would re-frame every byte after the signature while
+ * looking entirely healthy.
+ */
+export function dilithiumSizes (type: DilithiumType): Sizes {
+ return sizes(dilithiumSchemeFor(type));
+}
diff --git a/packages/util-crypto/src/dilithium/sign.ts b/packages/util-crypto/src/dilithium/sign.ts
new file mode 100644
index 000000000..625a77cd9
--- /dev/null
+++ b/packages/util-crypto/src/dilithium/sign.ts
@@ -0,0 +1,38 @@
+// Copyright 2017-2026 @polkadot/util-crypto authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { DilithiumType } from '../dilithium.js';
+import type { Keypair } from '../types.js';
+
+import { sign, signatureWithPublicKey } from '@quantus/crypto';
+
+import { u8aToU8a } from '@polkadot/util';
+
+import { dilithiumSchemeFor } from './scheme.js';
+
+/**
+ * Sign a message with an ML-DSA keypair under a FIPS 204 context.
+ *
+ * Returns `signature ‖ publicKey`, which is what the runtime's
+ * `DilithiumSignatureScheme` carries — as a fixed-size array with **no** compact
+ * length prefix. The public key travels inside the signature because the account
+ * id is a one-way hash of it, so a verifier holding only an address cannot
+ * recover the key to check anything.
+ *
+ * `context` is mandatory and has no default. Extrinsics on spec >= 148 are
+ * verified under `QUANTUS_EXTRINSIC` and earlier ones under the empty context;
+ * a signature made under the wrong one is cryptographically valid, rejected by
+ * the chain, and indistinguishable from a correct one without asking a node.
+ * Defaulting would turn that into a silent failure at the worst possible moment,
+ * so the caller — which is the only party that knows the spec version — states it.
+ */
+export function dilithiumSign (message: string | Uint8Array, { publicKey, secretKey }: Partial, type: DilithiumType, context: Uint8Array): Uint8Array {
+ if (!publicKey || !secretKey) {
+ throw new Error('Expected a valid keypair to sign with');
+ }
+
+ const scheme = dilithiumSchemeFor(type);
+ const signature = sign(u8aToU8a(message), { publicKey, secretKey }, context, scheme);
+
+ return signatureWithPublicKey(signature, publicKey);
+}
diff --git a/packages/util-crypto/src/dilithium/verify.ts b/packages/util-crypto/src/dilithium/verify.ts
new file mode 100644
index 000000000..90b20ad97
--- /dev/null
+++ b/packages/util-crypto/src/dilithium/verify.ts
@@ -0,0 +1,48 @@
+// Copyright 2017-2026 @polkadot/util-crypto authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { DilithiumType } from '../dilithium.js';
+
+import { sizes, verify } from '@quantus/crypto';
+
+import { u8aToU8a } from '@polkadot/util';
+
+import { dilithiumAccountFromPublic } from './account.js';
+import { dilithiumSchemeFor } from './scheme.js';
+
+/**
+ * Verify a `signature ‖ publicKey` blob against an account id.
+ *
+ * This mirrors the runtime's `Verify::verify` rather than inventing a scheme:
+ * split the blob, hash the embedded public key with Poseidon2, check it equals
+ * the account being verified against, and only then check the signature. Both
+ * halves matter. Skipping the account check would accept a valid signature from
+ * *some* key rather than *the* key, which is the whole of the security property.
+ *
+ * `account` is the 32-byte account id — not a public key. For every other scheme
+ * in this package those are the same bytes; here they are not.
+ */
+export function dilithiumVerify (message: string | Uint8Array, sigWithPublic: Uint8Array, account: Uint8Array, type: DilithiumType, context: Uint8Array): boolean {
+ const scheme = dilithiumSchemeFor(type);
+ const { publicKey: publicKeyLen, signature: signatureLen } = sizes(scheme);
+
+ if (sigWithPublic.length !== signatureLen + publicKeyLen) {
+ return false;
+ }
+
+ const signature = sigWithPublic.subarray(0, signatureLen);
+ const publicKey = sigWithPublic.subarray(signatureLen);
+ const derived = dilithiumAccountFromPublic(publicKey);
+
+ if (derived.length !== account.length) {
+ return false;
+ }
+
+ for (let i = 0; i < derived.length; i++) {
+ if (derived[i] !== account[i]) {
+ return false;
+ }
+ }
+
+ return verify(u8aToU8a(message), signature, publicKey, context, scheme);
+}
diff --git a/yarn.lock b/yarn.lock
index e61ab7502..8d144779d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1623,6 +1623,7 @@ __metadata:
dependencies:
"@polkadot/util": "npm:14.0.3"
"@polkadot/util-crypto": "npm:14.0.3"
+ "@quantus/crypto": "npm:^0.1.0"
tslib: "npm:^2.8.0"
peerDependencies:
"@polkadot/util": 14.0.3
@@ -1653,6 +1654,7 @@ __metadata:
"@polkadot/wasm-util": "npm:^7.5.3"
"@polkadot/x-bigint": "npm:14.0.3"
"@polkadot/x-randomvalues": "npm:14.0.3"
+ "@quantus/crypto": "npm:^0.1.0"
"@scure/base": "npm:^1.1.7"
"@scure/sr25519": "npm:^0.2.0"
tslib: "npm:^2.8.0"
@@ -1837,6 +1839,12 @@ __metadata:
languageName: unknown
linkType: soft
+"@quantus/crypto@link:../wasm/packages/quantus-crypto/build::locator=root-workspace-0b6124%40workspace%3A.":
+ version: 0.0.0-use.local
+ resolution: "@quantus/crypto@link:../wasm/packages/quantus-crypto/build::locator=root-workspace-0b6124%40workspace%3A."
+ languageName: node
+ linkType: soft
+
"@react-native-community/cli-clean@npm:12.3.0":
version: 12.3.0
resolution: "@react-native-community/cli-clean@npm:12.3.0"