From 2bffbc62bcbb9ca9cf488977e03e8b2bb138cb2b Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Thu, 10 Sep 2026 18:43:25 +0300 Subject: [PATCH] feat(keyring,util-crypto): ML-DSA arms for createPair, alongside the curve ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- package.json | 1 + packages/keyring/package.json | 1 + packages/keyring/src/keyring.ts | 33 ++++-- packages/keyring/src/pair/dilithium.spec.ts | 100 ++++++++++++++++++ packages/keyring/src/pair/index.ts | 80 +++++++++++--- packages/keyring/src/types.ts | 11 ++ packages/util-crypto/package.json | 1 + packages/util-crypto/src/bundle.ts | 1 + packages/util-crypto/src/dilithium/account.ts | 17 +++ packages/util-crypto/src/dilithium/index.ts | 8 ++ packages/util-crypto/src/dilithium/pair.ts | 21 ++++ packages/util-crypto/src/dilithium/scheme.ts | 32 ++++++ packages/util-crypto/src/dilithium/sign.ts | 38 +++++++ packages/util-crypto/src/dilithium/verify.ts | 48 +++++++++ yarn.lock | 8 ++ 15 files changed, 379 insertions(+), 21 deletions(-) create mode 100644 packages/keyring/src/pair/dilithium.spec.ts create mode 100644 packages/util-crypto/src/dilithium/account.ts create mode 100644 packages/util-crypto/src/dilithium/index.ts create mode 100644 packages/util-crypto/src/dilithium/pair.ts create mode 100644 packages/util-crypto/src/dilithium/scheme.ts create mode 100644 packages/util-crypto/src/dilithium/sign.ts create mode 100644 packages/util-crypto/src/dilithium/verify.ts 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"