feat: choose the signing context from specVersion, and return sig||pk for raw bytes
Extrinsic signing no longer goes through ExtrinsicPayload.sign(pair). That helper
calls pair.sign(encoded, { withType: true }) and has nowhere to put a signing
context — and an ML-DSA pair refuses to sign without one, so before this the
extension simply threw.
The two lines it would have run are reproduced here with the context added:
encode the payload, BLAKE2b-256 it if it exceeds 256 bytes (Substrate's own rule
from unchecked_extrinsic.rs, which signer and runtime must apply identically),
then sign. Forking @polkadot/types to thread an option through was the
alternative, and is a far larger commitment for the same result.
The context comes from the payload's specVersion, because that is the only place
it can come from: spec >= 148 verifies under QUANTUS_EXTRINSIC, earlier specs
under the empty context, and only the caller knows which runtime it is talking
to. Getting it wrong produces a signature that is cryptographically valid,
rejected by the chain, and indistinguishable from a correct one without asking a
node — which is why both directions are pinned by tests rather than assumed.
Raw bytes use the **empty** context, deliberately not the extrinsic one. A
signature made for a dapp login must never be replayable as a transfer, and the
context is bound into the signature itself. u8aWrapBytes stays, but it is not
what provides that guarantee — it is polkadot-js's own <Bytes> wrapping, kept so
a verifier written against that convention still sees what it expects. For ML-DSA
the context is authoritative: it cannot be stripped or forgotten by a verifier
the way an in-band wrapper can.
No Quantus-specific bytes context is invented. Nothing else in the ecosystem
defines one — the chain and the SDK name only QUANTUS_EXTRINSIC — and making one
up would produce signatures no other Quantus tool could verify.
Raw signing returns sig||pk, which is not a convenience: the account id is a
one-way Poseidon2 hash of the public key, so a verifier holding only an address
cannot recover the key. The key has to travel with the signature. Pinned by a
test that verifies against the signer's address and nothing else.
79 tests pass; typecheck, lint and build:chrome clean.
Refs quantus/extension#4, 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:
@@ -34,6 +34,7 @@
|
||||
"@polkadot/ui-settings": "^3.16.7",
|
||||
"@polkadot/util": "^14.0.3",
|
||||
"@polkadot/util-crypto": "^14.0.3",
|
||||
"@quantus/crypto": "^0.1.1",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"tslib": "^2.8.1"
|
||||
|
||||
@@ -9,6 +9,26 @@ import type { RequestSignBytes } from './types.js';
|
||||
|
||||
import { u8aToHex, u8aWrapBytes } from '@polkadot/util';
|
||||
|
||||
/**
|
||||
* Raw-bytes signing uses the **empty** FIPS 204 context, not the extrinsic one.
|
||||
*
|
||||
* That separation is the point. A signature made here must never be replayable
|
||||
* as an extrinsic, and the context is bound into the signature itself, so a
|
||||
* blob signed for a dapp login cannot be presented to the chain as a transfer.
|
||||
*
|
||||
* `u8aWrapBytes` is kept as well, but it is not what provides that guarantee —
|
||||
* it is polkadot-js's own `<Bytes>…</Bytes>` wrapping, inherited so that a
|
||||
* verifier written against the polkadot-js convention still sees what it
|
||||
* expects. For ML-DSA the **context is authoritative**: it cannot be stripped or
|
||||
* forgotten by a verifier the way an in-band wrapper can.
|
||||
*
|
||||
* There is deliberately no Quantus-specific bytes context. Nothing else in the
|
||||
* ecosystem defines one — the chain and the SDK name only QUANTUS_EXTRINSIC —
|
||||
* and inventing one here would produce signatures no other Quantus tool could
|
||||
* verify.
|
||||
*/
|
||||
const BYTES_CONTEXT = new Uint8Array();
|
||||
|
||||
export default class RequestBytesSign implements RequestSignBytes {
|
||||
public readonly channel = 'bytes' as const;
|
||||
public readonly payload: SignerPayloadRaw;
|
||||
@@ -18,10 +38,15 @@ export default class RequestBytesSign implements RequestSignBytes {
|
||||
}
|
||||
|
||||
sign (_registry: TypeRegistry, pair: KeyringPair): { signature: HexString } {
|
||||
// For an ML-DSA pair this returns `signature ‖ publicKey`, which is not a
|
||||
// convenience: the account id is a one-way Poseidon2 hash of the public key,
|
||||
// so a verifier holding only an address cannot recover the key to check
|
||||
// anything. The key has to travel with the signature.
|
||||
return {
|
||||
signature: u8aToHex(
|
||||
pair.sign(
|
||||
u8aWrapBytes(this.payload.data)
|
||||
u8aWrapBytes(this.payload.data),
|
||||
{ context: BYTES_CONTEXT }
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@polkadot/dev-test/globals.d.ts" />
|
||||
|
||||
import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
|
||||
|
||||
import { contextForSpec, Scheme, sizes } from '@quantus/crypto';
|
||||
|
||||
import { Keyring } from '@polkadot/keyring';
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
import { hexToU8a, u8aWrapBytes } from '@polkadot/util';
|
||||
import { dilithiumVerify } from '@polkadot/util-crypto';
|
||||
|
||||
import RequestBytesSign from './RequestBytesSign.js';
|
||||
import RequestExtrinsicSign from './RequestExtrinsicSign.js';
|
||||
|
||||
const GENESIS = '0xfb5487c0be6ae4ade2d41d16e50465129861636c2b8d61fa94d7a19631626fba';
|
||||
|
||||
// A balances.transfer_keep_alive-shaped call. The bytes do not need to decode
|
||||
// against real metadata for a signature test — what is signed is the payload
|
||||
// envelope, and the registry only has to encode it.
|
||||
const METHOD = '0x0403001cbd2d43530a44705ad088af313e18f80b53ef16b36177cd4b77b846f2a5f07c0700e40b5402';
|
||||
|
||||
function payloadFor (specVersion: `0x${string}`): SignerPayloadJSON {
|
||||
return {
|
||||
address: 'qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6',
|
||||
blockHash: GENESIS,
|
||||
blockNumber: '0x00000000',
|
||||
era: '0x0000',
|
||||
genesisHash: GENESIS,
|
||||
method: METHOD,
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion,
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000001',
|
||||
version: 4
|
||||
};
|
||||
}
|
||||
|
||||
describe('signing with an ML-DSA pair', (): void => {
|
||||
const registry = new TypeRegistry();
|
||||
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium65' });
|
||||
const pair = keyring.createFromUri('bottom drive obey lake curtain smoke basket hold race lonely fit walk', {}, 'dilithium65');
|
||||
const s65 = sizes(Scheme.MlDsa65);
|
||||
|
||||
const signExtrinsic = (specVersion: `0x${string}`) => {
|
||||
const payload = payloadFor(specVersion);
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
|
||||
return hexToU8a(new RequestExtrinsicSign(payload).sign(registry, pair).signature);
|
||||
};
|
||||
|
||||
// The whole point of the change. ExtrinsicPayload.sign(pair) has nowhere to put
|
||||
// a context, and an ML-DSA pair refuses to sign without one — so before this,
|
||||
// signing an extrinsic threw.
|
||||
it('signs an extrinsic at all', (): void => {
|
||||
expect(signExtrinsic('0x00000094').length).toEqual(s65.signatureWithPublicKey + 1);
|
||||
});
|
||||
|
||||
// withType prepends the runtime's DilithiumSignatureScheme variant index, and
|
||||
// the body is sig ‖ pk as a fixed array — no compact length prefix.
|
||||
it('produces the wire shape the runtime reads', (): void => {
|
||||
const signed = signExtrinsic('0x00000094');
|
||||
|
||||
expect(signed[0]).toEqual(1); // Dilithium65
|
||||
expect(signed.length).toEqual(s65.signatureWithPublicKey + 1);
|
||||
});
|
||||
|
||||
// spec 148 = 0x94. The context is chosen from the payload, and getting it
|
||||
// wrong is the failure that cannot be detected locally — a valid signature the
|
||||
// chain rejects.
|
||||
it('signs spec >= 148 under QUANTUS_EXTRINSIC', (): void => {
|
||||
const signed = signExtrinsic('0x00000094');
|
||||
const payload = payloadFor('0x00000094');
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
|
||||
const encoded = registry.createType('ExtrinsicPayload', payload, { version: payload.version }).toU8a({ method: true });
|
||||
|
||||
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(148))).toEqual(true);
|
||||
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(147))).toEqual(false);
|
||||
});
|
||||
|
||||
it('signs an earlier spec under the empty context', (): void => {
|
||||
const signed = signExtrinsic('0x00000093'); // spec 147
|
||||
const payload = payloadFor('0x00000093');
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
|
||||
const encoded = registry.createType('ExtrinsicPayload', payload, { version: payload.version }).toU8a({ method: true });
|
||||
|
||||
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(147))).toEqual(true);
|
||||
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(148))).toEqual(false);
|
||||
});
|
||||
|
||||
describe('raw bytes', (): void => {
|
||||
const raw: SignerPayloadRaw = {
|
||||
address: 'qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6',
|
||||
data: '0x68656c6c6f',
|
||||
type: 'bytes'
|
||||
};
|
||||
|
||||
it('returns sig || pk, with no variant byte', (): void => {
|
||||
const signature = hexToU8a(new RequestBytesSign(raw).sign(registry, pair).signature);
|
||||
|
||||
expect(signature.length).toEqual(s65.signatureWithPublicKey);
|
||||
});
|
||||
|
||||
// Without the public key riding along, nothing could verify this: the account
|
||||
// id is a one-way hash, so an address alone cannot produce the key.
|
||||
it('verifies against the signer address', (): void => {
|
||||
const signature = hexToU8a(new RequestBytesSign(raw).sign(registry, pair).signature);
|
||||
|
||||
expect(dilithiumVerify(u8aWrapBytes(raw.data), signature, pair.addressRaw, 'dilithium65', new Uint8Array())).toEqual(true);
|
||||
});
|
||||
|
||||
// The separation that stops a dapp-login signature being replayed as a
|
||||
// transfer.
|
||||
it('is not valid under the extrinsic context', (): void => {
|
||||
const signature = hexToU8a(new RequestBytesSign(raw).sign(registry, pair).signature);
|
||||
|
||||
expect(dilithiumVerify(u8aWrapBytes(raw.data), signature, pair.addressRaw, 'dilithium65', contextForSpec(148))).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,18 @@ import type { SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { RequestSignExtrinsic } from './types.js';
|
||||
|
||||
import { contextForSpec } from '@quantus/crypto';
|
||||
|
||||
import { hexToNumber, u8aToHex } from '@polkadot/util';
|
||||
import { blake2AsU8a } from '@polkadot/util-crypto';
|
||||
|
||||
/**
|
||||
* Substrate's own rule, from `unchecked_extrinsic.rs`: a signing payload longer
|
||||
* than 256 bytes is signed as its BLAKE2b-256 hash, otherwise as-is. The signer
|
||||
* and the runtime must apply it identically or nothing verifies.
|
||||
*/
|
||||
const HASH_ABOVE = 256;
|
||||
|
||||
export default class RequestExtrinsicSign implements RequestSignExtrinsic {
|
||||
public readonly channel = 'extrinsic' as const;
|
||||
public readonly payload: SignerPayloadJSON;
|
||||
@@ -16,8 +28,29 @@ export default class RequestExtrinsicSign implements RequestSignExtrinsic {
|
||||
}
|
||||
|
||||
sign (registry: TypeRegistry, pair: KeyringPair): { signature: HexString } {
|
||||
return registry
|
||||
.createType('ExtrinsicPayload', this.payload, { version: this.payload.version })
|
||||
.sign(pair);
|
||||
const payload = registry.createType('ExtrinsicPayload', this.payload, { version: this.payload.version });
|
||||
|
||||
// Deliberately not `payload.sign(pair)`. That helper calls
|
||||
// `pair.sign(encoded, { withType: true })` and has nowhere to put a signing
|
||||
// context — and an ML-DSA pair will not sign without one, because the wrong
|
||||
// context yields a signature that is cryptographically valid, rejected by
|
||||
// the chain, and indistinguishable from a correct one without asking a node.
|
||||
//
|
||||
// So the two lines it would have run are reproduced here, with the context
|
||||
// added. Forking @polkadot/types to thread an option through was the
|
||||
// alternative, and is a much larger commitment for the same result.
|
||||
const encoded = payload.toU8a({ method: true });
|
||||
const toSign = encoded.length > HASH_ABOVE
|
||||
? blake2AsU8a(encoded)
|
||||
: encoded;
|
||||
|
||||
// Only the caller knows the runtime version, and it arrives in the payload:
|
||||
// spec >= 148 verifies under QUANTUS_EXTRINSIC, earlier specs under the
|
||||
// empty context. Curve pairs ignore the option entirely.
|
||||
const context = contextForSpec(hexToNumber(this.payload.specVersion));
|
||||
|
||||
return {
|
||||
signature: u8aToHex(pair.sign(toSign, { context, withType: true }))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,6 +982,7 @@ __metadata:
|
||||
"@polkadot/ui-settings": "npm:^3.16.7"
|
||||
"@polkadot/util": "npm:^14.0.3"
|
||||
"@polkadot/util-crypto": "npm:^14.0.3"
|
||||
"@quantus/crypto": "npm:^0.1.1"
|
||||
eventemitter3: "npm:^5.0.1"
|
||||
rxjs: "npm:^7.8.1"
|
||||
tslib: "npm:^2.8.1"
|
||||
@@ -1601,7 +1602,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@quantus/crypto@npm:^0.1.0":
|
||||
"@quantus/crypto@npm:^0.1.0, @quantus/crypto@npm:^0.1.1":
|
||||
version: 0.1.1
|
||||
resolution: "@quantus/crypto@npm:0.1.1::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcrypto%2F-%2F0.1.1%2Fcrypto-0.1.1.tgz"
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user