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
@@ -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"
+1
View File
@@ -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';
@@ -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);
}
@@ -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';
@@ -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));
}
@@ -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));
}
@@ -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<Keypair>, 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);
}
@@ -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);
}