Epic: a Quantus-capable browser extension that signs real extrinsics #1

Open
opened 2026-09-10 10:11:00 +00:00 by grenade · 0 comments
Owner

Tracking issue for post-quantum support across this fork and the five upstream forks it depends on. Everything below is scoped as an issue on the repo that owns the code.

Done means: a user installs this extension, creates or restores a Quantus account from a mnemonic, sees it at SS58 prefix 189, and approves a balances.transfer_keep_alive from a dapp that the node accepts and includes in a block. That is #7.

What Quantus actually changes

Only four things differ from stock Substrate. Everything else — extrinsic format v4 signed (0x84), MultiAddress::Id, a standard TxExtension tuple, metadata from an unmodified vendored frame-metadata — is upstream-identical.

Substrate Quantus
Signature MultiSignature, 64/65 B DilithiumSignatureScheme enum — Dilithium87 = variant byte 0, Dilithium65 = variant byte 1; payload is sig ‖ pubkey as a fixed array, no compact length
AccountId is the 32-byte public key Poseidon2-over-Goldilocks(pubkey)one-way, so the public key must ride along inside every signature
Derivation sr25519 junctions (//hard, /soft) BIP39 → SLIP-0010-style HMAC-SHA512, master key "Dilithium seed", hardened-only, path m/44'/189189'/<account>'/0'/<0 for 87 | 1 for 65>'
Signing raw sign over the payload FIPS-204 pure ML-DSA under context "QUANTUS_EXTRINSIC" (spec ≥ 148; empty context before that)

Sizes, which several issues turn on:

public key secret key signature sig ‖ pk variant byte
ML-DSA-65 1952 4032 3309 5261 1
ML-DSA-87 2592 4896 4627 7219 0

SS58 prefix is 189. ML-DSA-65 is the scheme new accounts use; ML-DSA-87 is legacy (accounts created before the scheme was recorded).

Sources of truth, in preference order — do not re-derive any of this from prose:

  • chain:primitives/dilithium-crypto/src/scheme_macro.rs — key generation, signing, IdentifyAccount, the sig ‖ pk layout
  • chain:primitives/dilithium-crypto/src/signing_context.rs — the context string
  • chain:runtime/src/lib.rsSignature, Address, TxExtension, UncheckedExtrinsic
  • quantus-apps:quantus_sdk — a working non-Rust implementation of all of this against a forked polkadart. lib/src/resonance_extrinsic_payload.dart, lib/src/extensions/dilithium_scheme_extension.dart and rust/src/api/crypto.rs are the closest thing to a reference port that exists. Read them before writing anything.

Decode from metadata, never from a name

Standing rule, learned elsewhere and paid for elsewhere. Metadata v14+ carries a complete scale-info registry describing the structure of every call, event and storage item, and state_getMetadata at a block hash makes the node run Metadata_metadata against the runtime wasm in that block's state — the runtime is the oracle and the node executes it for us. blackbeard.observer's blackbeard-core/src/runtime.rs header records why the alternative failed:

An earlier attempt at this problem (qsafe.af) generated pallet and event index tables from the runtime and then hand-wrote a SCALE reader for every event's fields. It worked and it was miserable … transactionVersion went 2 → 3 → 6 across four runtime upgrades, and every one of those is an extrinsic-format change that silently breaks a decoder written against the previous one.

Quantus is a moving target. Wherever a choice exists between reading a type from metadata and pinning it by name, read it from metadata — and cache metadata by spec_version, not by block hash, so decoding survives pruning. This is the deep reason papi needs one patch where polkadot-js needs a fork chain.

The work

quantus/wasm — the crypto itself, compiled from the chain's own crates. Critical path: nothing downstream is testable until it ships something callable.

  • quantus/wasm#1 — ML-DSA-65/87, Poseidon2 and HD derivation in @polkadot/wasm-crypto
  • quantus/wasm#2 — golden vectors proving byte-for-byte agreement with the chain

quantus/common@polkadot/keyring and @polkadot/util-crypto. The bulk of the work.

  • quantus/common#1 — open the KeypairType union; register prefix 189 in @polkadot/networks
  • quantus/common#2createPair: the ML-DSA arms, and address ≠ public key
  • quantus/common#3backup and restore is impossible as the format stands; land before any build lets a user create an account
  • quantus/common#4 — BIP44 path derivation in place of suri junctions
  • quantus/common#5 — thread the FIPS-204 signing context through pair.sign()

quantus/ui@polkadot/ui-keyring.

  • quantus/ui#1restoreAccount cannot rebuild a public key from an address; same problem as common#3, solve it there first

quantus/papi-console — the test console. Independent of the polkadot-js stack entirely, which is what makes it worth having.

quantus/api@polkadot/types. Needed for polkadot-js/apps and the wider @polkadot/api ecosystem, but not for signing a first real extrinsic.

  • quantus/api#1 — the ExtrinsicSignature type; note the comment ruling out the signedTransaction workaround

quantus/extension (here)

  • #2 — build against the forks
  • #3 — account creation, derivation, default keypair type
  • #4 — signing context selection; sig ‖ pk for raw bytes
  • #5 — retire Ledger and QR (independent, can land first)
  • #6 — addresses and metadata at prefix 189
  • #7end to end: three tiers of proof, ending in a real transfer in a real block

Standing convention for all six forks

We are not trying to upstream this. Post-quantum PRs to polkadot-js/Parity have been attempted and did not land, and the working assumption is that they never will — this fork only starts mattering to anyone else if and when non-PQ Substrate becomes a victim of a cryptographically relevant quantum computer.

That has a concrete consequence, and it is the opposite of the usual advice: do not contort changes to look upstreamable, but do keep them mechanically rebasable. Prefer additive changes over rewrites, keep Quantus branches in switch/map lookups next to the existing arms rather than restructuring them, and leave the upstream code path byte-identical where a Quantus account is not involved. When we pull a new upstream tag, a git rebase should be boring. Each fork's default branch is main and tracks upstream's master (papi-console is already main); upstream tags are pushed and can be rebased onto.

papi-console is the exception worth stating: it is AGPL-3.0 where the rest are Apache-2.0/MIT, and our change there should stay a pnpm patch rather than a source edit, precisely so that rebasing stays trivial.

Order

  1. extension#5 — Ledger/QR removal, depends on nothing
  2. wasm#1 + wasm#2 together — without vectors, everything below tests that it agrees with itself
  3. common#1, then common#2, #3, #4, #5
  4. extension#7 tier 1 — a node script that submits, before any browser is involved. This is the earliest point at which the chain confirms we got the crypto right
  5. ui#1 and extension#2, #3, #4, #6 in parallel
  6. extension#7 tier 2 — the harness dapp; the everyday loop from here on
  7. papi-console#1, #3, then extension#7 tier 3
  8. api#1, then polkadot-js/apps for breadth

Note what moved: signing a first real extrinsic no longer waits on api#1. papi assembles Quantus extrinsics correctly today, so the console gets there first and the @polkadot/api work becomes about ecosystem reach rather than about proving the thing works.

Tracking issue for post-quantum support across this fork and the five upstream forks it depends on. Everything below is scoped as an issue on the repo that owns the code. **Done means:** a user installs this extension, creates or restores a Quantus account from a mnemonic, sees it at SS58 prefix 189, and approves a `balances.transfer_keep_alive` from a dapp that the node accepts and includes in a block. That is #7. ## What Quantus actually changes Only four things differ from stock Substrate. Everything else — extrinsic format v4 signed (`0x84`), `MultiAddress::Id`, a standard `TxExtension` tuple, metadata from an *unmodified* vendored `frame-metadata` — is upstream-identical. | | Substrate | Quantus | |---|---|---| | Signature | `MultiSignature`, 64/65 B | `DilithiumSignatureScheme` enum — `Dilithium87` = variant byte `0`, `Dilithium65` = variant byte `1`; payload is `sig ‖ pubkey` as a **fixed** array, no compact length | | AccountId | *is* the 32-byte public key | `Poseidon2-over-Goldilocks(pubkey)` — **one-way**, so the public key must ride along inside every signature | | Derivation | sr25519 junctions (`//hard`, `/soft`) | BIP39 → SLIP-0010-style HMAC-SHA512, master key `"Dilithium seed"`, **hardened-only**, path `m/44'/189189'/<account>'/0'/<0 for 87 \| 1 for 65>'` | | Signing | raw sign over the payload | FIPS-204 pure ML-DSA under context `"QUANTUS_EXTRINSIC"` (spec ≥ 148; empty context before that) | Sizes, which several issues turn on: | | public key | secret key | signature | `sig ‖ pk` | variant byte | |---|---|---|---|---|---| | ML-DSA-65 | 1952 | 4032 | 3309 | **5261** | 1 | | ML-DSA-87 | 2592 | 4896 | 4627 | **7219** | 0 | SS58 prefix is **189**. ML-DSA-65 is the scheme new accounts use; ML-DSA-87 is legacy (accounts created before the scheme was recorded). **Sources of truth**, in preference order — do not re-derive any of this from prose: - `chain:primitives/dilithium-crypto/src/scheme_macro.rs` — key generation, signing, `IdentifyAccount`, the `sig ‖ pk` layout - `chain:primitives/dilithium-crypto/src/signing_context.rs` — the context string - `chain:runtime/src/lib.rs` — `Signature`, `Address`, `TxExtension`, `UncheckedExtrinsic` - `quantus-apps:quantus_sdk` — a **working non-Rust implementation** of all of this against a forked polkadart. `lib/src/resonance_extrinsic_payload.dart`, `lib/src/extensions/dilithium_scheme_extension.dart` and `rust/src/api/crypto.rs` are the closest thing to a reference port that exists. Read them before writing anything. ## Decode from metadata, never from a name Standing rule, learned elsewhere and paid for elsewhere. Metadata v14+ carries a complete `scale-info` registry describing the *structure* of every call, event and storage item, and `state_getMetadata` at a block hash makes the node run `Metadata_metadata` against the runtime wasm in that block's state — the runtime is the oracle and the node executes it for us. blackbeard.observer's `blackbeard-core/src/runtime.rs` header records why the alternative failed: > An earlier attempt at this problem (`qsafe.af`) generated pallet and event *index* tables from the runtime and then hand-wrote a SCALE reader for every event's fields. It worked and it was miserable … `transactionVersion` went 2 → 3 → 6 across four runtime upgrades, and every one of those is an extrinsic-format change that silently breaks a decoder written against the previous one. Quantus is a moving target. Wherever a choice exists between reading a type from metadata and pinning it by name, read it from metadata — and cache metadata by `spec_version`, not by block hash, so decoding survives pruning. This is the deep reason papi needs one patch where polkadot-js needs a fork chain. ## The work **[quantus/wasm](../../../wasm)** — the crypto itself, compiled from the chain's own crates. Critical path: nothing downstream is testable until it ships something callable. - quantus/wasm#1 — ML-DSA-65/87, Poseidon2 and HD derivation in `@polkadot/wasm-crypto` - quantus/wasm#2 — golden vectors proving byte-for-byte agreement with the chain **[quantus/common](../../../common)** — `@polkadot/keyring` and `@polkadot/util-crypto`. The bulk of the work. - quantus/common#1 — open the `KeypairType` union; register prefix 189 in `@polkadot/networks` - quantus/common#2 — `createPair`: the ML-DSA arms, and address ≠ public key - quantus/common#3 — **backup and restore is impossible as the format stands**; land before any build lets a user create an account - quantus/common#4 — BIP44 path derivation in place of suri junctions - quantus/common#5 — thread the FIPS-204 signing context through `pair.sign()` **[quantus/ui](../../../ui)** — `@polkadot/ui-keyring`. - quantus/ui#1 — `restoreAccount` cannot rebuild a public key from an address; same problem as common#3, solve it there first **[quantus/papi-console](../../../papi-console)** — the test console. Independent of the polkadot-js stack entirely, which is what makes it worth having. - quantus/papi-console#1 — one variant-name whitelist rejects Quantus; a two-line pnpm patch - quantus/papi-console#2 — audit the rest for hardcoded signature/address assumptions - quantus/papi-console#3 — chain config, and whether to deploy **[quantus/api](../../../api)** — `@polkadot/types`. Needed for polkadot-js/apps and the wider `@polkadot/api` ecosystem, but **not** for signing a first real extrinsic. - quantus/api#1 — the `ExtrinsicSignature` type; note the comment ruling out the `signedTransaction` workaround **quantus/extension** (here) - #2 — build against the forks - #3 — account creation, derivation, default keypair type - #4 — signing context selection; `sig ‖ pk` for raw bytes - #5 — retire Ledger and QR (independent, can land first) - #6 — addresses and metadata at prefix 189 - #7 — **end to end: three tiers of proof, ending in a real transfer in a real block** ## Standing convention for all six forks We are **not** trying to upstream this. Post-quantum PRs to polkadot-js/Parity have been attempted and did not land, and the working assumption is that they never will — this fork only starts mattering to anyone else if and when non-PQ Substrate becomes a victim of a cryptographically relevant quantum computer. That has a concrete consequence, and it is the opposite of the usual advice: **do not contort changes to look upstreamable, but do keep them mechanically rebasable.** Prefer additive changes over rewrites, keep Quantus branches in `switch`/map lookups next to the existing arms rather than restructuring them, and leave the upstream code path byte-identical where a Quantus account is not involved. When we pull a new upstream tag, a `git rebase` should be boring. Each fork's default branch is `main` and tracks upstream's `master` (papi-console is already `main`); upstream tags are pushed and can be rebased onto. papi-console is the exception worth stating: it is AGPL-3.0 where the rest are Apache-2.0/MIT, and our change there should stay a pnpm patch rather than a source edit, precisely so that rebasing stays trivial. ## Order 1. **extension#5** — Ledger/QR removal, depends on nothing 2. **wasm#1 + wasm#2** together — without vectors, everything below tests that it agrees with itself 3. **common#1**, then **common#2, #3, #4, #5** 4. **extension#7 tier 1** — a node script that submits, before any browser is involved. This is the earliest point at which the chain confirms we got the crypto right 5. **ui#1** and **extension#2, #3, #4, #6** in parallel 6. **extension#7 tier 2** — the harness dapp; the everyday loop from here on 7. **papi-console#1, #3**, then **extension#7 tier 3** 8. **api#1**, then polkadot-js/apps for breadth Note what moved: signing a first real extrinsic no longer waits on api#1. papi assembles Quantus extrinsics correctly today, so the console gets there first and the `@polkadot/api` work becomes about ecosystem reach rather than about proving the thing works.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/extension#1