keyring: teach createPair the ML-DSA arms, and stop assuming address == public key #2

Closed
opened 2026-09-10 10:12:19 +00:00 by grenade · 1 comment
Owner

Depends on #1. Context in quantus/extension#1.

There is no plugin point

packages/keyring/src/pair/index.ts holds four module-private const maps and a createPair that closes over them:

const TYPE_FROM_SEED = { ecdsa, ed25519, ethereum, sr25519 };
const TYPE_PREFIX    = { ed25519: [0], sr25519: [1], ecdsa: [2], ethereum: [2] };
const TYPE_SIGNATURE = {  };
const TYPE_ADDRESS   = { ed25519: (p) => p, sr25519: (p) => p,  };

Nothing is injectable, which is the whole reason this repo is a fork rather than a wrapper. Add arms; do not restructure the maps (see the rebasability convention in quantus/extension#1).

TYPE_PREFIX is already the right shape

A happy accident worth not breaking: pair.sign(msg, { withType: true }) prepends TYPE_PREFIX[type], and the chain's DilithiumSignatureScheme variant byte is exactly that — dilithium87: [0], dilithium65: [1]. With TYPE_SIGNATURE returning sig ‖ pk, the bytes @polkadot/types hands to a dapp are already the correct SCALE encoding of the runtime's signature enum, with no special-casing anywhere upstream of here.

TYPE_ADDRESS is the interesting one

Every existing arm is either identity or a cheap re-encoding, because on Substrate the address is the public key. For Quantus it is Poseidon2(publicKey) — 1952 or 2592 bytes in, 32 bytes out, one-way.

That breaks an invariant createPair currently relies on without stating it: a locked pair can still report its address, because the public key is in memory even when the secret is not. For Quantus that still holds — the public key is enough to compute the address — but the moment the public key isn't available, it does not. Which is exactly the situation #3 is about, and the two issues should be designed together: createPair will likely need to accept a precomputed account id for pairs restored from JSON.

Also

  • decodePkcs8 branches on decoded.secretKey.length === 64 to decide "secret key" vs "seed". A 4032- or 4896-byte ML-DSA secret takes the wrong branch and gets fed to TYPE_FROM_SEED as if it were entropy. Make the check type-aware rather than length-magic.
  • derive() is #4.
  • pair.sign() needs a signing context — #5.

Acceptance

  • a pair created from a known mnemonic reports the SS58 address that quantus-cli reports for it
  • pair.sign(payload, { withType: true }) produces bytes that the runtime's Verify::verify accepts
  • the sr25519/ed25519/ecdsa/ethereum paths are byte-identical to upstream — pin this with a test, it is the thing a careless rebase will quietly break
Depends on #1. Context in quantus/extension#1. ## There is no plugin point `packages/keyring/src/pair/index.ts` holds four module-private const maps and a `createPair` that closes over them: ```js const TYPE_FROM_SEED = { ecdsa, ed25519, ethereum, sr25519 }; const TYPE_PREFIX = { ed25519: [0], sr25519: [1], ecdsa: [2], ethereum: [2] }; const TYPE_SIGNATURE = { … }; const TYPE_ADDRESS = { ed25519: (p) => p, sr25519: (p) => p, … }; ``` Nothing is injectable, which is the whole reason this repo is a fork rather than a wrapper. Add arms; do not restructure the maps (see the rebasability convention in quantus/extension#1). ## `TYPE_PREFIX` is already the right shape A happy accident worth not breaking: `pair.sign(msg, { withType: true })` prepends `TYPE_PREFIX[type]`, and the chain's `DilithiumSignatureScheme` variant byte is exactly that — `dilithium87: [0]`, `dilithium65: [1]`. With `TYPE_SIGNATURE` returning `sig ‖ pk`, the bytes `@polkadot/types` hands to a dapp are already the correct SCALE encoding of the runtime's signature enum, with no special-casing anywhere upstream of here. ## `TYPE_ADDRESS` is the interesting one Every existing arm is either identity or a cheap re-encoding, because on Substrate the address *is* the public key. For Quantus it is `Poseidon2(publicKey)` — 1952 or 2592 bytes in, 32 bytes out, one-way. That breaks an invariant `createPair` currently relies on without stating it: **a locked pair can still report its address**, because the public key is in memory even when the secret is not. For Quantus that still holds — the public key is enough to compute the address — but the moment the public key *isn't* available, it does not. Which is exactly the situation #3 is about, and the two issues should be designed together: `createPair` will likely need to accept a precomputed account id for pairs restored from JSON. ## Also - `decodePkcs8` branches on `decoded.secretKey.length === 64` to decide "secret key" vs "seed". A 4032- or 4896-byte ML-DSA secret takes the wrong branch and gets fed to `TYPE_FROM_SEED` as if it were entropy. Make the check type-aware rather than length-magic. - `derive()` is #4. - `pair.sign()` needs a signing context — #5. ## Acceptance - [ ] a pair created from a known mnemonic reports the SS58 address that `quantus-cli` reports for it - [ ] `pair.sign(payload, { withType: true })` produces bytes that the runtime's `Verify::verify` accepts - [ ] the sr25519/ed25519/ecdsa/ethereum paths are byte-identical to upstream — pin this with a test, it is the thing a careless rebase will quietly break
Author
Owner

Verified against main (7f23d0a60), closing.

  • ML-DSA in createPair: landed in 2bffbc62b and 506b77351.
  • Addresses match quantus-cli: for a known mnemonic, both schemes (dilithiumDerive.spec.ts); the raw-seed crystal_alice is checked in dilithium.spec.ts.
  • Signature layout: sign(…, { withType: true }) carries the chain's variant byte and length, and verifies with dilithiumVerify. Chain acceptance was then shown through quantus/extension#7 tier 1.
  • Curve schemes unchanged: upstream's pair/index.spec.ts is untouched, the curve sign functions are wrapped so a context can't reach them, and sr25519 is pinned.
  • Also fixed:
    • decodePkcs8 now decides by key type rather than by key length.
    • Pairs restored from JSON carry an account id, checked against the real key on unlock.

The classical arms themselves go in quantus/common#6.

Verified against `main` (`7f23d0a60`), closing. - **ML-DSA in `createPair`:** landed in `2bffbc62b` and `506b77351`. - **Addresses match `quantus-cli`:** for a known mnemonic, both schemes (`dilithiumDerive.spec.ts`); the raw-seed crystal_alice is checked in `dilithium.spec.ts`. - **Signature layout:** `sign(…, { withType: true })` carries the chain's variant byte and length, and verifies with `dilithiumVerify`. Chain acceptance was then shown through quantus/extension#7 tier 1. - **Curve schemes unchanged:** upstream's `pair/index.spec.ts` is untouched, the curve sign functions are wrapped so a context can't reach them, and sr25519 is pinned. - **Also fixed:** - `decodePkcs8` now decides by key type rather than by key length. - Pairs restored from JSON carry an account id, checked against the real key on unlock. The classical arms themselves go in quantus/common#6.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/common#2