Exclude quantum-unsafe keypair types, rather than adding ML-DSA alongside them #6

Closed
opened 2026-09-10 15:28:45 +00:00 by grenade · 2 comments
Owner

A policy change that inverts part of #1 and lands with #2. Recorded here rather than buried in a commit because it changes what several other issues are for.

The decision

our deployments should gate on chains using pq algos. so where the enum previously excluded mldsa, we'll fork to exclude ecdsa and other pq unsafe algos.

KeypairType becomes 'dilithium65' | 'dilithium87'. Not ML-DSA added to ed25519/sr25519/ecdsa/ethereum — ML-DSA instead of them.

Why this is the better posture

#1 opened the union, which made the extension able to hold post-quantum keys. It also left it able to hold quantum-vulnerable ones, in a tool whose entire reason for existing is that those are not safe. A wallet that will happily generate an sr25519 account is a wallet that invites someone to store funds under a key Shor's algorithm recovers, while displaying the same reassuring branding around it.

It also shrinks the fork. assertSubstrateType, added in #1 to express "no post-quantum answer here yet", stops being needed the moment there is no non-post-quantum answer either — the TYPE_* maps get ML-DSA arms and nothing else, and every narrowing call site disappears rather than being rewritten.

Two levels, and which one this is

Type-level exclusionKeypairType carries only the ML-DSA types, so the keyring cannot construct, import or sign with anything else. The primitives (ed25519Sign, sr25519PairFromSeed, secp256k1…) stay in util-crypto as exported functions, unreferenced by the keyring.

Ripping out the primitives — deleting util-crypto/src/{ed25519,sr25519,secp256k1,ethereum}, some 48 source files plus specs.

Do the first. The security property comes entirely from the first: nothing can reach those functions through the keyring once the type is gone. The second is a very large diff with a permanent rebase cost, and it buys defence against a caller who imports ed25519Sign directly — which a TypeScript type never prevented anyway, so the real answer there is a runtime guard at the data-entry points, not a deletion.

Runtime guards are the part that matters and must not be skipped:

  • keyring.createFromJson must reject a classical encoding.content[1] with a message saying why, not throw a type error or, worse, construct something
  • keyring.addFromJson / ui-keyring.restoreAccount inherit that
  • the message should say the key is quantum-unsafe and cannot be held here, because "unknown crypto type ed25519" reads like a bug in our software rather than a deliberate refusal

Sequencing

This cannot land before #2. Removing the classical types from the TYPE_* maps while the ML-DSA arms are unimplemented leaves the keyring with no valid arms at all — it would build and be inert. The order is: #2 wires @quantus/crypto into the maps, then the classical arms and the classical types go in the same change.

Already done

The network half shipped on quantus-keypair-types (commit c616a0d94): availableNetworks and selectableNetworks now exclude any chain whose standardAccount is a discrete-log scheme, via isQuantumSafe().

allNetworks is deliberately not filtered. Gating what we offer must not gate what we can read — a pasted Polkadot address has to render as a Polkadot address so it can be recognised and refused. A wallet that cannot decode an address it will not accept can only say "invalid", which reads as a typo rather than a warning.

Five upstream tests asserted the shape of a 150-entry list and were rewritten to assert the rule that displaced them, including an explicit test that non-PQ chains stay decodable — that distinction is the design, not an accident.

Knock-on

  • quantus/common#4 — derivation. With no classical types left, keyFromPath and the whole junction mechanism have no caller. Consider deleting the call rather than making it throw.
  • quantus/extension#3canDerive.ts's ['ed25519','sr25519','ecdsa','ethereum'] whitelist becomes empty of anything reachable; it should list the ML-DSA types or go.
  • quantus/extension#5 — Ledger removal stops being a product decision and becomes a type-level consequence: no Ledger device speaks ML-DSA.
  • quantus/ui#1restoreAccount defaults cryptoType to 'ed25519' when the JSON does not say. That default is now unrepresentable and must become a refusal.
A policy change that inverts part of #1 and lands with #2. Recorded here rather than buried in a commit because it changes what several other issues are for. ## The decision > our deployments should gate on chains using pq algos. so where the enum previously excluded mldsa, we'll fork to exclude ecdsa and other pq unsafe algos. `KeypairType` becomes `'dilithium65' | 'dilithium87'`. Not ML-DSA *added to* ed25519/sr25519/ecdsa/ethereum — ML-DSA *instead of* them. ## Why this is the better posture #1 opened the union, which made the extension able to hold post-quantum keys. It also left it able to hold quantum-vulnerable ones, in a tool whose entire reason for existing is that those are not safe. A wallet that will happily generate an sr25519 account is a wallet that invites someone to store funds under a key Shor's algorithm recovers, while displaying the same reassuring branding around it. It also shrinks the fork. `assertSubstrateType`, added in #1 to express "no post-quantum answer here yet", stops being needed the moment there is no non-post-quantum answer either — the `TYPE_*` maps get ML-DSA arms and nothing else, and every narrowing call site disappears rather than being rewritten. ## Two levels, and which one this is **Type-level exclusion** — `KeypairType` carries only the ML-DSA types, so the keyring cannot construct, import or sign with anything else. The primitives (`ed25519Sign`, `sr25519PairFromSeed`, `secp256k1…`) stay in `util-crypto` as exported functions, unreferenced by the keyring. **Ripping out the primitives** — deleting `util-crypto/src/{ed25519,sr25519,secp256k1,ethereum}`, some 48 source files plus specs. Do the first. The security property comes entirely from the first: nothing can reach those functions through the keyring once the type is gone. The second is a very large diff with a permanent rebase cost, and it buys defence against a caller who imports `ed25519Sign` directly — which a TypeScript type never prevented anyway, so the real answer there is a runtime guard at the data-entry points, not a deletion. Runtime guards are the part that matters and must not be skipped: - `keyring.createFromJson` must **reject** a classical `encoding.content[1]` with a message saying why, not throw a type error or, worse, construct something - `keyring.addFromJson` / `ui-keyring.restoreAccount` inherit that - the message should say the key is quantum-unsafe and cannot be held here, because "unknown crypto type ed25519" reads like a bug in our software rather than a deliberate refusal ## Sequencing **This cannot land before #2.** Removing the classical types from the `TYPE_*` maps while the ML-DSA arms are unimplemented leaves the keyring with no valid arms at all — it would build and be inert. The order is: #2 wires `@quantus/crypto` into the maps, then the classical arms and the classical types go in the same change. ## Already done The network half shipped on `quantus-keypair-types` (commit `c616a0d94`): `availableNetworks` and `selectableNetworks` now exclude any chain whose `standardAccount` is a discrete-log scheme, via `isQuantumSafe()`. `allNetworks` is deliberately **not** filtered. Gating what we offer must not gate what we can read — a pasted Polkadot address has to render as a Polkadot address so it can be recognised and refused. A wallet that cannot decode an address it will not accept can only say "invalid", which reads as a typo rather than a warning. Five upstream tests asserted the shape of a 150-entry list and were rewritten to assert the rule that displaced them, including an explicit test that non-PQ chains stay decodable — that distinction is the design, not an accident. ## Knock-on - **quantus/common#4** — derivation. With no classical types left, `keyFromPath` and the whole junction mechanism have no caller. Consider deleting the call rather than making it throw. - **quantus/extension#3** — `canDerive.ts`'s `['ed25519','sr25519','ecdsa','ethereum']` whitelist becomes empty of anything reachable; it should list the ML-DSA types or go. - **quantus/extension#5** — Ledger removal stops being a product decision and becomes a type-level consequence: no Ledger device speaks ML-DSA. - **quantus/ui#1** — `restoreAccount` defaults `cryptoType` to `'ed25519'` when the JSON does not say. That default is now unrepresentable and must become a refusal.
Author
Owner

Deferred, deliberately. Both halves of this — the type narrowing and the primitive deletion — wait until post-quantum signing is proven end to end.

i'd be inclined to rip out the non-pq support but it doesn't feel like the first thing we should do since it's bound to slow down the 'prove we can do pq in these forks' story chasing compilation issues dealing with the removed code/support.

Why the ordering matters more than it looks

The work this issue describes produces no post-quantum capability whatsoever. Every hour of it is spent fixing code that has nothing to do with ML-DSA — 30 spec files, the hw-ledger packages, whatever else falls out — while the question that actually carries risk stays unanswered.

And that question is genuinely open. quantus/wasm#1 proved the crypto agrees with the chain, but nothing has yet proved that a signature built through the keyring, wrapped by @polkadot/types, and submitted to a node is accepted. The failure modes there are the nasty ones: a valid signature under the wrong FIPS-204 context, or a framing error that re-frames every byte after the signature. Those are found by quantus/extension#7 tier 1, and nothing about deleting sr25519 brings them forward.

Worse, doing the removal first would blur the diagnosis. If the first real extrinsic is rejected, the useful question is "is our ML-DSA wrong, or our extrinsic assembly wrong". A branch that has simultaneously torn out half of util-crypto adds a third candidate that is pure noise.

Revised order

  1. quantus/common#2 — ML-DSA arms alongside the classical ones. The union stays as #1 left it, the build stays green, no churn.
  2. #3, #4, quantus/ui#1, then quantus/extension#7 tier 1 — a real extrinsic in a real block.
  3. Then this issue: narrow KeypairType, delete util-crypto/src/{ed25519,sr25519,secp256k1,ethereum}, and add the runtime refusals at the JSON entry points.

The intent is unchanged — the forks end up post-quantum only, primitives deleted rather than merely unreachable. What changed is that it happens once there is something working to protect, rather than as a prerequisite to having one.

Consequence for #1's scaffolding

assertSubstrateType now has a clearer job than "not implemented yet": it is the seam that lets the two schemes coexist through the proving phase. Each call site is replaced by a real ML-DSA arm in #2, and the helper itself is deleted here, when the classical arms go. Its remaining call sites are the progress bar.

Deferred, deliberately. Both halves of this — the type narrowing *and* the primitive deletion — wait until post-quantum signing is proven end to end. > i'd be inclined to rip out the non-pq support but it doesn't feel like the first thing we should do since it's bound to slow down the 'prove we can do pq in these forks' story chasing compilation issues dealing with the removed code/support. ## Why the ordering matters more than it looks The work this issue describes produces no post-quantum capability whatsoever. Every hour of it is spent fixing code that has nothing to do with ML-DSA — 30 spec files, the `hw-ledger` packages, whatever else falls out — while the question that actually carries risk stays unanswered. And that question is genuinely open. quantus/wasm#1 proved the *crypto* agrees with the chain, but nothing has yet proved that a signature built through the keyring, wrapped by `@polkadot/types`, and submitted to a node is accepted. The failure modes there are the nasty ones: a valid signature under the wrong FIPS-204 context, or a framing error that re-frames every byte after the signature. Those are found by quantus/extension#7 tier 1, and nothing about deleting sr25519 brings them forward. Worse, doing the removal first would blur the diagnosis. If the first real extrinsic is rejected, the useful question is "is our ML-DSA wrong, or our extrinsic assembly wrong". A branch that has simultaneously torn out half of `util-crypto` adds a third candidate that is pure noise. ## Revised order 1. **quantus/common#2** — ML-DSA arms **alongside** the classical ones. The union stays as #1 left it, the build stays green, no churn. 2. #3, #4, quantus/ui#1, then quantus/extension#7 tier 1 — a real extrinsic in a real block. 3. **Then this issue**: narrow `KeypairType`, delete `util-crypto/src/{ed25519,sr25519,secp256k1,ethereum}`, and add the runtime refusals at the JSON entry points. The intent is unchanged — the forks end up post-quantum only, primitives deleted rather than merely unreachable. What changed is that it happens once there is something working to protect, rather than as a prerequisite to having one. ## Consequence for #1's scaffolding `assertSubstrateType` now has a clearer job than "not implemented yet": it is the seam that lets the two schemes coexist through the proving phase. Each call site is replaced by a real ML-DSA arm in #2, and the helper itself is deleted here, when the classical arms go. Its remaining call sites are the progress bar.
Author
Owner

Done. Merged to main in all three repos, fast-forwarded from pq-only:

  • common 0480bfcbc: KeypairType = 'dilithium65' | 'dilithium87'. The classical primitives are deleted: ed25519, sr25519, secp256k1, signature, the ethereum and ledger HD paths, hdkd and mnemonicToMiniSecret. keyring.createFromJson and createFromUri refuse classical types with "<type> keys are not quantum-safe and cannot be held here…". Published as 14.0.3-quantus.3.
  • ui 160d97c1: ui-keyring.restoreAccount refuses version-0 JSON and classical types. Published as 3.16.7-quantus.3.
  • extension 29033e8b: depends on both. The derive-from-parent flow, extension-compat-metamask and the Ethereum address branches are removed. The restore screen now shows the keyring's refusal reason instead of "Invalid Json file".

Verification:

  • Checked in Firefox: loading an ed25519 backup is refused with the not-quantum-safe message.
  • Checked in Firefox: creating a wallet still shows its ML-DSA-65, ML-DSA-87 and wormhole addresses.
  • Not tested in a browser: a batch file with a classical key. The code refuses it at Restore with the same message.
  • Tests: common 2747, ui 47, extension 103, all passing. The extension's enzyme .spec.tsx files still don't run; see earlier comments.

Address decoding is untouched, so a pasted Polkadot address still renders and can be recognised.

Done. Merged to `main` in all three repos, fast-forwarded from `pq-only`: - **common** `0480bfcbc`: `KeypairType = 'dilithium65' | 'dilithium87'`. The classical primitives are deleted: `ed25519`, `sr25519`, `secp256k1`, `signature`, the ethereum and ledger HD paths, `hdkd` and `mnemonicToMiniSecret`. `keyring.createFromJson` and `createFromUri` refuse classical types with "`<type>` keys are not quantum-safe and cannot be held here…". Published as `14.0.3-quantus.3`. - **ui** `160d97c1`: `ui-keyring.restoreAccount` refuses version-0 JSON and classical types. Published as `3.16.7-quantus.3`. - **extension** `29033e8b`: depends on both. The derive-from-parent flow, `extension-compat-metamask` and the Ethereum address branches are removed. The restore screen now shows the keyring's refusal reason instead of "Invalid Json file". Verification: - Checked in Firefox: loading an ed25519 backup is refused with the not-quantum-safe message. - Checked in Firefox: creating a wallet still shows its ML-DSA-65, ML-DSA-87 and wormhole addresses. - Not tested in a browser: a batch file with a classical key. The code refuses it at Restore with the same message. - Tests: common 2747, ui 47, extension 103, all passing. The extension's enzyme `.spec.tsx` files still don't run; see earlier comments. Address *decoding* is untouched, so a pasted Polkadot address still renders and can be recognised.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/common#6