From 7fa26252f00347c347b0833cc4dab41f448db015 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 15 Sep 2026 13:23:58 +0300 Subject: [PATCH] feat: choose the signing context from specVersion, and return sig||pk for raw bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- packages/extension-base/package.json | 1 + .../src/background/RequestBytesSign.ts | 27 +++- .../background/RequestExtrinsicSign.spec.ts | 128 ++++++++++++++++++ .../src/background/RequestExtrinsicSign.ts | 39 +++++- yarn.lock | 3 +- 5 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 packages/extension-base/src/background/RequestExtrinsicSign.spec.ts diff --git a/packages/extension-base/package.json b/packages/extension-base/package.json index 6d842cfb..db019c44 100644 --- a/packages/extension-base/package.json +++ b/packages/extension-base/package.json @@ -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" diff --git a/packages/extension-base/src/background/RequestBytesSign.ts b/packages/extension-base/src/background/RequestBytesSign.ts index 1f9c5a24..59973c8d 100644 --- a/packages/extension-base/src/background/RequestBytesSign.ts +++ b/packages/extension-base/src/background/RequestBytesSign.ts @@ -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 `` 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 } ) ) }; diff --git a/packages/extension-base/src/background/RequestExtrinsicSign.spec.ts b/packages/extension-base/src/background/RequestExtrinsicSign.spec.ts new file mode 100644 index 00000000..995058b4 --- /dev/null +++ b/packages/extension-base/src/background/RequestExtrinsicSign.spec.ts @@ -0,0 +1,128 @@ +// Copyright 2019-2026 @polkadot/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +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); + }); + }); +}); diff --git a/packages/extension-base/src/background/RequestExtrinsicSign.ts b/packages/extension-base/src/background/RequestExtrinsicSign.ts index a36f00d0..f5d42c4e 100644 --- a/packages/extension-base/src/background/RequestExtrinsicSign.ts +++ b/packages/extension-base/src/background/RequestExtrinsicSign.ts @@ -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 })) + }; } } diff --git a/yarn.lock b/yarn.lock index 5cee0432..257dc7d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: