Signing: choose the context from specVersion, and return sig ‖ pk for raw bytes #4

Closed
opened 2026-09-10 10:14:49 +00:00 by grenade · 2 comments
Owner

Part of #1. Depends on quantus/common#5.

Extrinsic signing

packages/extension-base/src/background/RequestExtrinsicSign.ts is three lines:

sign (registry: TypeRegistry, pair: KeyringPair): { signature: HexString } {
  return registry
    .createType('ExtrinsicPayload', this.payload, { version: this.payload.version })
    .sign(pair);
}

Everything under that is already correct for Quantus — ExtrinsicPayload.sign blake2-hashes the payload when it exceeds 256 bytes, exactly as unchecked_extrinsic.rs does, and prepends the type byte because MultiSignature is an Enum. What is missing is the signing context.

The context depends on the runtime: spec ≥ 148 signs under "QUANTUS_EXTRINSIC", earlier specs under the empty context. this.payload.specVersion carries it, so this class is the right place to make the choice — but the context then has to travel through ExtrinsicPayload.sign(pair) down to pair.sign(), and that call passes no options today. Getting the plumbing right, or working around it, is the substance of this issue.

Signing under the wrong context produces a perfectly valid signature that the chain rejects, with nothing local able to tell the difference. Do not guess a default — see quantus/common#5.

Raw bytes signing

RequestBytesSign.ts returns a bare signature:

signature: u8aToHex(pair.sign(u8aWrapBytes(this.payload.data)))

For Quantus that is unverifiable by anyone. The account id is a hash of the public key, so a verifier given only an address and a signature cannot recover the key to check it — which is precisely why the chain's own signature type carries sig ‖ pk. Raw signing must do the same.

That is an interface change visible to dapps, so decide and document what a Quantus signRaw returns: whether it carries the variant byte as well, and how a verifier is expected to use it. Note that u8aWrapBytes wrapping and the ML-DSA context are two different domain-separation mechanisms doing overlapping jobs — state which one is authoritative rather than applying both by accident.

Upstream recently reworked this area (e66446ed, "derive raw vs extrinsic signing from the request channel"), so rebase before starting.

Acceptance

  • a signed balances.transfer_keep_alive is accepted by a node running spec ≥ 148
  • the same payload signed with the empty context is rejected — prove the context is actually reaching the signer
  • a signRaw result verifies against the signer's address without the caller needing the public key from elsewhere
Part of #1. Depends on quantus/common#5. ## Extrinsic signing `packages/extension-base/src/background/RequestExtrinsicSign.ts` is three lines: ```ts sign (registry: TypeRegistry, pair: KeyringPair): { signature: HexString } { return registry .createType('ExtrinsicPayload', this.payload, { version: this.payload.version }) .sign(pair); } ``` Everything under that is already correct for Quantus — `ExtrinsicPayload.sign` blake2-hashes the payload when it exceeds 256 bytes, exactly as `unchecked_extrinsic.rs` does, and prepends the type byte because `MultiSignature` is an `Enum`. What is missing is the **signing context**. The context depends on the runtime: spec ≥ 148 signs under `"QUANTUS_EXTRINSIC"`, earlier specs under the empty context. `this.payload.specVersion` carries it, so this class is the right place to make the choice — but the context then has to travel through `ExtrinsicPayload.sign(pair)` down to `pair.sign()`, and that call passes no options today. Getting the plumbing right, or working around it, is the substance of this issue. Signing under the wrong context produces a perfectly valid signature that the chain rejects, with nothing local able to tell the difference. Do not guess a default — see quantus/common#5. ## Raw bytes signing `RequestBytesSign.ts` returns a bare signature: ```ts signature: u8aToHex(pair.sign(u8aWrapBytes(this.payload.data))) ``` For Quantus that is unverifiable by anyone. The account id is a hash of the public key, so a verifier given only an address and a signature cannot recover the key to check it — which is precisely why the chain's own signature type carries `sig ‖ pk`. Raw signing must do the same. That is an interface change visible to dapps, so decide and document what a Quantus `signRaw` returns: whether it carries the variant byte as well, and how a verifier is expected to use it. Note that `u8aWrapBytes` wrapping and the ML-DSA context are two different domain-separation mechanisms doing overlapping jobs — state which one is authoritative rather than applying both by accident. Upstream recently reworked this area (`e66446ed`, *"derive raw vs extrinsic signing from the request channel"*), so rebase before starting. ## Acceptance - [ ] a signed `balances.transfer_keep_alive` is accepted by a node running spec ≥ 148 - [ ] the same payload signed with the empty context is **rejected** — prove the context is actually reaching the signer - [ ] a `signRaw` result verifies against the signer's address without the caller needing the public key from elsewhere
Author
Owner

Done, on quantus-accounts (7fa26252). 79 tests pass; typecheck, lint and build:chrome clean.

The plumbing question this issue posed

the context then has to travel through ExtrinsicPayload.sign(pair) down to pair.sign(), and that call passes no options today. Getting the plumbing right, or working around it, is the substance of this issue.

Answer: don't use that helper. It is two lines, and they are reproduced here with the context added —

const encoded = payload.toU8a({ method: true });
const toSign = encoded.length > 256 ? blake2AsU8a(encoded) : encoded;

return { signature: u8aToHex(pair.sign(toSign, { context, withType: true })) };

Forking @polkadot/types to thread an option through was the alternative, and is a much larger commitment for the same result. The 256-byte BLAKE2b rule is Substrate's own, from unchecked_extrinsic.rs; signer and runtime must apply it identically or nothing verifies, so it is spelled out rather than inherited.

Before this, the extension did not merely sign wrongly — it threw, because the keyring refuses to sign an ML-DSA pair without a context (quantus/common#5).

Raw bytes

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.

This issue asked which of u8aWrapBytes and the context is authoritative. The context is. u8aWrapBytes stays — it is polkadot-js's <Bytes>…</Bytes> convention, kept so a verifier written against it still sees what it expects — but it is an in-band wrapper a verifier can strip or forget, where the context cannot be.

No Quantus-specific bytes context is invented. Nothing 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 — not a convenience, since the account id is a one-way hash and a verifier holding only an address cannot recover the key.

Acceptance

  • the wire shape is right: variant byte 1 for ML-DSA-65, then sig ‖ pk at the fixed length
  • a payload signed at spec 148 verifies under QUANTUS_EXTRINSIC and fails under the empty context — and the converse at spec 147
  • a signRaw result verifies against the signer's address with nothing else supplied
  • a raw signature does not verify under the extrinsic context

The one criterion this cannot close by itself: "a signed balances.transfer_keep_alive is accepted by a node running spec ≥ 148". Everything here says the bytes are right; only a node can say they are accepted. That is #7 tier 1.

Note on the earlier rebase warning

This issue said to rebase before starting, because upstream reworked the area in e66446ed ("derive raw vs extrinsic signing from the request channel"). That rework is already in the fork's base — Request/index.tsx branches on isExtrinsicRequest(request) rather than on payload shape — so there was nothing to reconcile.

Done, on `quantus-accounts` (`7fa26252`). 79 tests pass; typecheck, lint and `build:chrome` clean. ## The plumbing question this issue posed > the context then has to travel through `ExtrinsicPayload.sign(pair)` down to `pair.sign()`, and that call passes no options today. Getting the plumbing right, or working around it, is the substance of this issue. Answer: **don't use that helper.** It is two lines, and they are reproduced here with the context added — ```ts const encoded = payload.toU8a({ method: true }); const toSign = encoded.length > 256 ? blake2AsU8a(encoded) : encoded; return { signature: u8aToHex(pair.sign(toSign, { context, withType: true })) }; ``` Forking `@polkadot/types` to thread an option through was the alternative, and is a much larger commitment for the same result. The 256-byte BLAKE2b rule is Substrate's own, from `unchecked_extrinsic.rs`; signer and runtime must apply it identically or nothing verifies, so it is spelled out rather than inherited. Before this, the extension did not merely sign wrongly — it **threw**, because the keyring refuses to sign an ML-DSA pair without a context (quantus/common#5). ## Raw bytes 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. This issue asked which of `u8aWrapBytes` and the context is authoritative. **The context is.** `u8aWrapBytes` stays — it is polkadot-js's `<Bytes>…</Bytes>` convention, kept so a verifier written against it still sees what it expects — but it is an in-band wrapper a verifier can strip or forget, where the context cannot be. No Quantus-specific bytes context is invented. Nothing 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` — not a convenience, since the account id is a one-way hash and a verifier holding only an address cannot recover the key. ## Acceptance - [x] the wire shape is right: variant byte `1` for ML-DSA-65, then `sig ‖ pk` at the fixed length - [x] **a payload signed at spec 148 verifies under `QUANTUS_EXTRINSIC` and fails under the empty context** — and the converse at spec 147 - [x] a `signRaw` result verifies against the signer's address with nothing else supplied - [x] a raw signature does **not** verify under the extrinsic context The one criterion this cannot close by itself: *"a signed `balances.transfer_keep_alive` is accepted by a node running spec ≥ 148"*. Everything here says the bytes are right; only a node can say they are accepted. That is #7 tier 1. ## Note on the earlier rebase warning This issue said to rebase before starting, because upstream reworked the area in `e66446ed` ("derive raw vs extrinsic signing from the request channel"). That rework is already in the fork's base — `Request/index.tsx` branches on `isExtrinsicRequest(request)` rather than on payload shape — so there was nothing to reconcile.
Author
Owner

Verified against main, closing.

  • Context from specVersion: RequestExtrinsicSign uses contextForSpec, rebuilt on @quantus/codec in 34759485.
  • Accepted on chain: a transfer_keep_alive signed under the spec-148 context was included on Heisenberg, at block 1050475 (be691499) and again at 1050581 after the extension was rewired (34759485).
  • Rejected without it: the empty context is refused with 1010: bad signature (tier-1 case, 4f759c40), and RequestExtrinsicSign.spec.ts covers it too.
  • Raw bytes: signRaw returns sig ‖ pk under the empty context and verifies from the address alone. The specs also check that it does not verify under the extrinsic context.

The on-chain proof went through the tier-1 script, which uses the same codec, keyring and context. An extension-popup-signed extrinsic seen in a block is still owed, under #7 tier 2 and qapi#1.

Verified against `main`, closing. - **Context from `specVersion`:** `RequestExtrinsicSign` uses `contextForSpec`, rebuilt on `@quantus/codec` in `34759485`. - **Accepted on chain:** a `transfer_keep_alive` signed under the spec-148 context was included on Heisenberg, at block 1050475 (`be691499`) and again at 1050581 after the extension was rewired (`34759485`). - **Rejected without it:** the empty context is refused with `1010: bad signature` (tier-1 case, `4f759c40`), and `RequestExtrinsicSign.spec.ts` covers it too. - **Raw bytes:** `signRaw` returns `sig ‖ pk` under the empty context and verifies from the address alone. The specs also check that it does not verify under the extrinsic context. The on-chain proof went through the tier-1 script, which uses the same codec, keyring and context. An extension-popup-signed extrinsic seen in a block is still owed, under #7 tier 2 and qapi#1.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/extension#4