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
@@ -41,6 +41,7 @@
"@types/node": "^22.7.5" "@types/node": "^22.7.5"
}, },
"resolutions": { "resolutions": {
"@quantus/crypto": "link:../wasm/packages/quantus-crypto/build",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }
} }
+1
View File
@@ -23,6 +23,7 @@
"dependencies": { "dependencies": {
"@polkadot/util": "14.0.3", "@polkadot/util": "14.0.3",
"@polkadot/util-crypto": "14.0.3", "@polkadot/util-crypto": "14.0.3",
"@quantus/crypto": "^0.1.0",
"tslib": "^2.8.0" "tslib": "^2.8.0"
}, },
"peerDependencies": { "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 type { KeyringInstance, KeyringOptions, KeyringPair, KeyringPair$Json, KeyringPair$Meta } from './types.js';
import { hexToU8a, isHex, stringToU8a } from '@polkadot/util'; 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 { createPair } from './pair/index.js';
import { DEV_PHRASE } from './defaults.js'; import { DEV_PHRASE } from './defaults.js';
import { Pairs } from './pairs.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 = { const PairFromSeed = {
dilithium65: (seed: Uint8Array): Keypair => dilithiumPairFromSeed(seed, 'dilithium65'),
dilithium87: (seed: Uint8Array): Keypair => dilithiumPairFromSeed(seed, 'dilithium87'),
ecdsa: (seed: Uint8Array): Keypair => secp256k1FromSeed(seed), ecdsa: (seed: Uint8Array): Keypair => secp256k1FromSeed(seed),
ed25519: (seed: Uint8Array): Keypair => ed25519FromSeed(seed), ed25519: (seed: Uint8Array): Keypair => ed25519FromSeed(seed),
ethereum: (seed: Uint8Array): Keypair => secp256k1FromSeed(seed), ethereum: (seed: Uint8Array): Keypair => secp256k1FromSeed(seed),
@@ -50,8 +62,11 @@ export class Keyring implements KeyringInstance {
constructor (options: KeyringOptions = {}) { constructor (options: KeyringOptions = {}) {
options.type = options.type || 'ed25519'; options.type = options.type || 'ed25519';
if (!['ecdsa', 'ethereum', 'ed25519', 'sr25519'].includes(options.type || 'undefined')) { // A runtime whitelist behind the KeypairType union, so widening the type
throw new Error(`Expected a keyring type of either 'ed25519', 'sr25519', 'ethereum' or 'ecdsa', found '${options.type || 'unknown'}`); // 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(); 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 { public addFromSeed (seed: Uint8Array, meta: KeyringPair$Meta = {}, type: KeypairType = this.type): KeyringPair {
return this.addPair( 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]
: 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}`); throw new Error(`Unknown crypto type ${cryptoType}`);
} }
@@ -232,9 +251,9 @@ export class Keyring implements KeyringInstance {
const derived = type === 'ethereum' const derived = type === 'ethereum'
? isPhraseHex ? 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)) : 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); 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 type { PairInfo } from './types.js';
import { objectSpread, u8aConcat, u8aEmpty, u8aEq, u8aToHex, u8aToU8a } from '@polkadot/util'; 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 { decodePair } from './decode.js';
import { encodePair } from './encode.js'; import { encodePair } from './encode.js';
@@ -20,27 +20,70 @@ interface Setup {
const SIG_TYPE_NONE = new Uint8Array(); const SIG_TYPE_NONE = new Uint8Array();
const TYPE_FROM_SEED = { const TYPE_FROM_SEED = {
dilithium65: (seed: Uint8Array) => dilithiumPairFromSeed(seed, 'dilithium65'),
dilithium87: (seed: Uint8Array) => dilithiumPairFromSeed(seed, 'dilithium87'),
ecdsa: secp256k1FromSeed, ecdsa: secp256k1FromSeed,
ed25519: ed25519FromSeed, ed25519: ed25519FromSeed,
ethereum: secp256k1FromSeed, ethereum: secp256k1FromSeed,
sr25519: sr25519FromSeed 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 = { const TYPE_PREFIX = {
dilithium65: new Uint8Array([1]),
dilithium87: new Uint8Array([0]),
ecdsa: new Uint8Array([2]), ecdsa: new Uint8Array([2]),
ed25519: new Uint8Array([0]), ed25519: new Uint8Array([0]),
ethereum: new Uint8Array([2]), ethereum: new Uint8Array([2]),
sr25519: new Uint8Array([1]) sr25519: new Uint8Array([1])
}; };
const TYPE_SIGNATURE = { // The ML-DSA arms take a context; the curve arms ignore the extra argument. The
ecdsa: (m: Uint8Array, p: Partial<Keypair>) => secp256k1Sign(m, p, 'blake2'), // signature is written out because TypeScript would otherwise infer the narrowest
ed25519: ed25519Sign, // common shape — the two-argument curve arms — and reject passing a context at all.
ethereum: (m: Uint8Array, p: Partial<Keypair>) => secp256k1Sign(m, p, 'keccak'), type SignFn = (message: Uint8Array, pair: Partial<Keypair>, context?: Uint8Array) => Uint8Array;
sr25519: sr25519Sign
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 = { const TYPE_ADDRESS = {
dilithium65: dilithiumAccountFromPublic,
dilithium87: dilithiumAccountFromPublic,
ecdsa: (p: Uint8Array) => p.length > 32 ? blake2AsU8a(p) : p, ecdsa: (p: Uint8Array) => p.length > 32 ? blake2AsU8a(p) : p,
ed25519: (p: Uint8Array) => p, ed25519: (p: Uint8Array) => p,
ethereum: (p: Uint8Array) => p.length === 20 ? p : keccakAsU8a(secp256k1Expand(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; publicKey = decoded.publicKey;
secretKey = decoded.secretKey; secretKey = decoded.secretKey;
} else { } 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; publicKey = pair.publicKey;
secretKey = pair.secretKey; secretKey = pair.secretKey;
@@ -111,7 +154,7 @@ export function createPair ({ toSS58, type }: Setup, { publicKey, secretKey }: P
}; };
const encodeAddress = (): string => { const encodeAddress = (): string => {
const raw = TYPE_ADDRESS[assertSubstrateType(type, 'Deriving an address')](publicKey); const raw = TYPE_ADDRESS[type](publicKey);
return type === 'ethereum' return type === 'ethereum'
? ethereumEncode(raw) ? ethereumEncode(raw)
@@ -123,7 +166,7 @@ export function createPair ({ toSS58, type }: Setup, { publicKey, secretKey }: P
return encodeAddress(); return encodeAddress();
}, },
get addressRaw (): Uint8Array { get addressRaw (): Uint8Array {
const raw = TYPE_ADDRESS[assertSubstrateType(type, 'Deriving an address')](publicKey); const raw = TYPE_ADDRESS[type](publicKey);
return type === 'ethereum' return type === 'ethereum'
? raw.slice(-20) ? raw.slice(-20)
@@ -171,9 +214,9 @@ export function createPair ({ toSS58, type }: Setup, { publicKey, secretKey }: P
return u8aConcat( return u8aConcat(
options.withType options.withType
? TYPE_PREFIX[assertSubstrateType(type, 'Signing')] ? TYPE_PREFIX[type]
: SIG_TYPE_NONE, : 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 => { toJson: (passphrase?: string): KeyringPair$Json => {
@@ -192,7 +235,7 @@ export function createPair ({ toSS58, type }: Setup, { publicKey, secretKey }: P
return decodePkcs8(passphrase); return decodePkcs8(passphrase);
}, },
verify: (message: string | Uint8Array, signature: string | Uint8Array, signerPublic: string | Uint8Array): boolean => { 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 => { vrfSign: (message: string | Uint8Array, context?: string | Uint8Array, extra?: string | Uint8Array): Uint8Array => {
if (isLocked(secretKey)) { if (isLocked(secretKey)) {
@@ -203,7 +246,16 @@ export function createPair ({ toSS58, type }: Setup, { publicKey, secretKey }: P
return sr25519VrfSign(message, { secretKey }, context, extra); 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); 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); 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)); 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 { 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 */ /** Create a MultiSignature-compatible output with an indicator type */
withType?: boolean; withType?: boolean;
} }
+1
View File
@@ -35,6 +35,7 @@
"@polkadot/wasm-util": "^7.5.3", "@polkadot/wasm-util": "^7.5.3",
"@polkadot/x-bigint": "14.0.3", "@polkadot/x-bigint": "14.0.3",
"@polkadot/x-randomvalues": "14.0.3", "@polkadot/x-randomvalues": "14.0.3",
"@quantus/crypto": "^0.1.0",
"@scure/base": "^1.1.7", "@scure/base": "^1.1.7",
"@scure/sr25519": "^0.2.0", "@scure/sr25519": "^0.2.0",
"tslib": "^2.8.0" "tslib": "^2.8.0"
+1
View File
@@ -14,6 +14,7 @@ export * from './base64/index.js';
export * from './blake2/index.js'; export * from './blake2/index.js';
export * from './crypto.js'; export * from './crypto.js';
export * from './dilithium.js'; export * from './dilithium.js';
export * from './dilithium/index.js';
export * from './ed25519/index.js'; export * from './ed25519/index.js';
export * from './ethereum/index.js'; export * from './ethereum/index.js';
export * from './hd/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);
}
+8
View File
@@ -1623,6 +1623,7 @@ __metadata:
dependencies: dependencies:
"@polkadot/util": "npm:14.0.3" "@polkadot/util": "npm:14.0.3"
"@polkadot/util-crypto": "npm:14.0.3" "@polkadot/util-crypto": "npm:14.0.3"
"@quantus/crypto": "npm:^0.1.0"
tslib: "npm:^2.8.0" tslib: "npm:^2.8.0"
peerDependencies: peerDependencies:
"@polkadot/util": 14.0.3 "@polkadot/util": 14.0.3
@@ -1653,6 +1654,7 @@ __metadata:
"@polkadot/wasm-util": "npm:^7.5.3" "@polkadot/wasm-util": "npm:^7.5.3"
"@polkadot/x-bigint": "npm:14.0.3" "@polkadot/x-bigint": "npm:14.0.3"
"@polkadot/x-randomvalues": "npm:14.0.3" "@polkadot/x-randomvalues": "npm:14.0.3"
"@quantus/crypto": "npm:^0.1.0"
"@scure/base": "npm:^1.1.7" "@scure/base": "npm:^1.1.7"
"@scure/sr25519": "npm:^0.2.0" "@scure/sr25519": "npm:^0.2.0"
tslib: "npm:^2.8.0" tslib: "npm:^2.8.0"
@@ -1837,6 +1839,12 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft 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": "@react-native-community/cli-clean@npm:12.3.0":
version: 12.3.0 version: 12.3.0
resolution: "@react-native-community/cli-clean@npm:12.3.0" resolution: "@react-native-community/cli-clean@npm:12.3.0"