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:
rob thijssen
2026-09-15 13:23:58 +03:00
parent ac08676ea7
commit 7fa26252f0
5 changed files with 193 additions and 5 deletions

View File

@@ -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"

View File

@@ -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 }
)
)
};

View File

@@ -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);
});
});
});

View File

@@ -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 }))
};
}
}

View File

@@ -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: