feat(keyring,util-crypto): ML-DSA arms for createPair, alongside the curve ones

Wires @quantus/crypto into the four TYPE_* maps, so a keyring pair of type
dilithium65/dilithium87 can be created, addressed, signed with and verified.
The curve arms are untouched: quantus/common#6 removes them, but only once
post-quantum signing is proven end to end, so that a rejected first extrinsic has
two suspects rather than three.

util-crypto gains thin dilithium/ wrappers so keyring composes primitives the way
it already does for ed25519, rather than reaching into @quantus/crypto directly.

TYPE_ADDRESS is the interesting arm. Every other entry is identity or a cheap
re-encoding because on Substrate the address *is* the public key; ML-DSA takes
1952 or 2592 bytes to 32 with no way back. dilithiumVerify therefore mirrors the
runtime's Verify::verify rather than inventing anything: split sig||pk, hash the
embedded public key, check it equals the account being verified against, and only
then check the signature. Skipping the account check would accept a valid
signature from *some* key rather than *the* key, which is the whole property.

TYPE_PREFIX gets 0 for dilithium87 and 1 for dilithium65. For the curve types
those index Substrate's MultiSignature; here they index the runtime's
DilithiumSignatureScheme — a different enum reached by the same withType
mechanism, so nothing upstream of createPair needs to know.

Signing takes a mandatory context, which is quantus/common#5 arriving early
because the two are inseparable: TYPE_SIGNATURE cannot have an ML-DSA arm without
one. There is deliberately no default. Extrinsics on spec >= 148 verify under
QUANTUS_EXTRINSIC and earlier ones under the empty context, and the wrong choice
is a valid signature the chain rejects, indistinguishable locally. Only the
caller knows the spec version.

The curve sign functions are wrapped rather than passed by reference: 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.

VRF signing refuses for ML-DSA. The construction fakes a VRF from an ordinary
signature, which is sound only given properties ML-DSA's signature does not have;
producing something that looks like a VRF proof and cannot be verified as one is
worse than refusing.

Two runtime whitelists sat behind the KeypairType union and silently rejected the
new types — the Keyring constructor and createFromJson. The compiler cannot see
those, and the first failed at construction with a message listing four types.
Both now use one named KEYPAIR_TYPES so the next change to the union has a single
place to look and the message cannot drift from the check.

Verified against quantus-cli, not against ourselves: keyring.addFromSeed with 32
zero bytes yields crystal_alice's address, signs to the runtime's wire length,
verifies under the extrinsic context and fails under the spec-147 one, and
another account's signature is rejected for it.

Refs quantus/common#2, quantus/common#5

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-10 18:43:25 +03:00
co-authored by Claude Opus 5
parent c616a0d949
commit 2bffbc62bc
15 changed files with 379 additions and 21 deletions
+1
View File
@@ -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": {
+26 -7
View File
@@ -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);
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2017-2026 @polkadot/keyring authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@polkadot/dev-test/globals.d.ts" />
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);
});
});
+66 -14
View File
@@ -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<Keypair>) => secp256k1Sign(m, p, 'blake2'),
ed25519: ed25519Sign,
ethereum: (m: Uint8Array, p: Partial<Keypair>) => 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<Keypair>, context?: Uint8Array) => Uint8Array;
const TYPE_SIGNATURE: Record<KeypairType, SignFn> = {
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));
}
+11
View File
@@ -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;
}