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:
co-authored by
Claude Opus 5
parent
c616a0d949
commit
2bffbc62bc
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user