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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user