keyring: replace suri junction derivation with the Quantus BIP44 path for ML-DSA pairs #4

Closed
opened 2026-09-10 10:13:05 +00:00 by grenade · 2 comments
Owner

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

Lattice keys have no soft derivation

createFromUri parses a suri into { phrase, password, path, derivePath } and runs keyFromPath(pair, path, type), which walks sr25519/ed25519 junctions — //hard and /soft. Neither concept exists for ML-DSA. The chain says so directly, in Pair::derive:

Pair::derive() is not supported for Dilithium.
Use qp_rusty_crystals_hdwallet::derive_key_from_mnemonic() for HD key derivation.

and the hdwallet crate rejects any unhardened path outright, with a test pinning that contract (non_hardened_path_rejected). The README's reasoning is that lattice keys are not publicly derivable, so an unhardened level cannot mean what BIP-32 implies.

What replaces it

m/44'/189189'/<account>'/0'/<0 for ML-DSA-87 | 1 for ML-DSA-65>'

The account index sits at the third level and the trailing hardened index carries the scheme — an unusual layout, but it is what quantus-cli and the mobile wallet both already use (quantus-apps:quantus_sdk/lib/src/services/hd_wallet_service.dart, pathForIndex). Do not improve on it; anything else means addresses that no other Quantus tool can find.

There is a second tree at m/44'/189189189'/… for wormhole addresses. Out of scope here — note it only so nobody assumes the coin type is a typo.

Two things to decide

  1. createFromUri seeding. The non-ethereum branch calls mnemonicToMiniSecret, which is the Substrate variant. Quantus needs the 64-byte BIP39 legacy seed, then the "Dilithium seed" HMAC chain. Wrong seeding here yields a valid-looking key for an account nobody owns.
  2. Suri syntax. //0 reads naturally as "account 0" but collides with the hard-junction meaning, and createFromUri also silently substitutes DEV_PHRASE for a suri starting with //. Either accept a full m/44'/… path as the derive part, or map a small index syntax onto pathForIndex — but make an unsupported junction an error, never a silently different key. Whatever is chosen has to match what the extension's derive UI sends (quantus/extension#3).

Acceptance

  • derived keys match the golden vectors from quantus/wasm#2
  • account index n from a given mnemonic matches what quantus-cli derives for the same mnemonic and index
  • a soft junction against an ML-DSA pair throws
  • derive() on an already-derived pair does something defensible, or refuses — the extension's "derive from account" flow depends on the answer
Depends on #1. Context in quantus/extension#1. ## Lattice keys have no soft derivation `createFromUri` parses a suri into `{ phrase, password, path, derivePath }` and runs `keyFromPath(pair, path, type)`, which walks sr25519/ed25519 junctions — `//hard` and `/soft`. Neither concept exists for ML-DSA. The chain says so directly, in `Pair::derive`: ``` Pair::derive() is not supported for Dilithium. Use qp_rusty_crystals_hdwallet::derive_key_from_mnemonic() for HD key derivation. ``` and the hdwallet crate rejects any unhardened path outright, with a test pinning that contract (`non_hardened_path_rejected`). The README's reasoning is that lattice keys are not publicly derivable, so an unhardened level cannot mean what BIP-32 implies. ## What replaces it `m/44'/189189'/<account>'/0'/<0 for ML-DSA-87 | 1 for ML-DSA-65>'` The account index sits at the third level and the trailing hardened index carries the *scheme* — an unusual layout, but it is what `quantus-cli` and the mobile wallet both already use (`quantus-apps:quantus_sdk/lib/src/services/hd_wallet_service.dart`, `pathForIndex`). Do not improve on it; anything else means addresses that no other Quantus tool can find. There is a second tree at `m/44'/189189189'/…` for wormhole addresses. Out of scope here — note it only so nobody assumes the coin type is a typo. ## Two things to decide 1. **`createFromUri` seeding.** The non-ethereum branch calls `mnemonicToMiniSecret`, which is the Substrate variant. Quantus needs the 64-byte BIP39 legacy seed, then the `"Dilithium seed"` HMAC chain. Wrong seeding here yields a valid-looking key for an account nobody owns. 2. **Suri syntax.** `//0` reads naturally as "account 0" but collides with the hard-junction meaning, and `createFromUri` also silently substitutes `DEV_PHRASE` for a suri starting with `//`. Either accept a full `m/44'/…` path as the derive part, or map a small index syntax onto `pathForIndex` — but make an unsupported junction an **error**, never a silently different key. Whatever is chosen has to match what the extension's derive UI sends (quantus/extension#3). ## Acceptance - [ ] derived keys match the golden vectors from quantus/wasm#2 - [ ] account index *n* from a given mnemonic matches what `quantus-cli` derives for the same mnemonic and index - [ ] a soft junction against an ML-DSA pair throws - [ ] `derive()` on an already-derived pair does something defensible, or refuses — the extension's "derive from account" flow depends on the answer
Author
Owner

Done, on quantus-keypair-types (f7dc72e32). 12 specs.

Suri syntax, settled

This issue left the choice open. What landed:

form meaning
(nothing) account 0 — what quantus-cli defaults to
//<n> account index n
//m/44'/189189'/… that path verbatim, for anything the index form cannot express
anything else throws

//Alice throws, /0 throws, and an unhardened level inside an explicit path throws. Quietly reinterpreting any of them would hand back an address no other Quantus tool derives and no seed phrase obviously recovers — which is the failure this issue asked to avoid, and it is worth more than the convenience of accepting the input.

quantus/extension#3 should send //<n>, or an index it turns into one.

Seeding, which is the part that fails silently

The ML-DSA branch sits before the existing seeding, not after. That existing code calls mnemonicToMiniSecret, Substrate's variant and the default reach in this file; using it would produce a perfectly well-formed key for an account nobody owns, with nothing to indicate anything went wrong. The ML-DSA path goes mnemonic → 64-byte BIP39 seed → HMAC-SHA512 keyed with "Dilithium seed", via the hdwallet crate.

Raw seeds

Taken underived, since that is how the dev-genesis accounts are defined — 0x0000…00 gives crystal_alice. Combining a raw seed with a derivation path is refused rather than guessed: whether such a seed is the master or already derived is unanswerable, and picking one silently would be the same class of mistake as the seeding above.

derive() on an existing pair

The last question here — "does something defensible, or refuses". It refuses, with its own message rather than keyFromPath's generic one, because the reason is specific and actionable: a child would have to come from this pair's key material, and ML-DSA keys are not derivable from one another at all. The Quantus tree derives each account from the mnemonic independently. So the error names createFromUri and the mnemonic.

That makes quantus/extension#3's canDerive decision easy: derive-from-account cannot work, and the UI should offer an account index at creation instead.

Acceptance

  • derived keys match quantus-cli for both schemes from the public dev phrase
  • account index n is distinct per index; explicit paths agree with the index form
  • soft junctions, named junctions and unhardened levels all throw
  • derive() refuses, with a message saying what to do instead
  • sr25519 junction derivation unchanged (//Alice, //Bob pinned)

Not done: golden vectors from quantus/wasm#2 — these fixtures are CLI-derived, which is an independent implementation but not the crate's own published vectors.

Done, on `quantus-keypair-types` (`f7dc72e32`). 12 specs. ## Suri syntax, settled This issue left the choice open. What landed: | form | meaning | |---|---| | *(nothing)* | account 0 — what `quantus-cli` defaults to | | `//<n>` | account index *n* | | `//m/44'/189189'/…` | that path verbatim, for anything the index form cannot express | | anything else | **throws** | `//Alice` throws, `/0` throws, and an unhardened level inside an explicit path throws. Quietly reinterpreting any of them would hand back an address no other Quantus tool derives and no seed phrase obviously recovers — which is the failure this issue asked to avoid, and it is worth more than the convenience of accepting the input. quantus/extension#3 should send `//<n>`, or an index it turns into one. ## Seeding, which is the part that fails silently The ML-DSA branch sits **before** the existing seeding, not after. That existing code calls `mnemonicToMiniSecret`, Substrate's variant and the default reach in this file; using it would produce a perfectly well-formed key for an account nobody owns, with nothing to indicate anything went wrong. The ML-DSA path goes mnemonic → 64-byte BIP39 seed → HMAC-SHA512 keyed with `"Dilithium seed"`, via the hdwallet crate. ## Raw seeds Taken underived, since that is how the dev-genesis accounts are defined — `0x0000…00` gives crystal_alice. Combining a raw seed with a derivation path is **refused** rather than guessed: whether such a seed is the master or already derived is unanswerable, and picking one silently would be the same class of mistake as the seeding above. ## `derive()` on an existing pair The last question here — "does something defensible, or refuses". It refuses, with its own message rather than `keyFromPath`'s generic one, because the reason is specific and actionable: a child would have to come from this pair's key material, and **ML-DSA keys are not derivable from one another at all**. The Quantus tree derives each account from the mnemonic independently. So the error names `createFromUri` and the mnemonic. That makes quantus/extension#3's `canDerive` decision easy: derive-from-account cannot work, and the UI should offer an account index at creation instead. ## Acceptance - [x] derived keys match `quantus-cli` for both schemes from the public dev phrase - [x] account index *n* is distinct per index; explicit paths agree with the index form - [x] soft junctions, named junctions and unhardened levels all throw - [x] `derive()` refuses, with a message saying what to do instead - [x] sr25519 junction derivation unchanged (`//Alice`, `//Bob` pinned) Not done: golden vectors from quantus/wasm#2 — these fixtures are CLI-derived, which is an independent implementation but not the crate's own published vectors.
Author
Owner

Closing. The goal is met; the one unmet criterion moves to quantus/wasm#2.

Met on main (f7dc72e32):

  • keys match quantus-cli for a given mnemonic and index (dilithiumDerive.spec.ts);
  • soft, named and unhardened derivations throw;
  • derive() on an existing pair refuses with a message that says why.

In the extension, account indices are added from a wallet's menu (quantus/extension#14), using the same //<n>m/44'/189189'/n'/0'/scheme' path.

Moved: "matches the hdwallet crate's own golden vectors" needs the shared fixture that quantus/wasm#2 has not produced yet. It is tracked there (item 5) rather than keeping this open.

Closing. The goal is met; the one unmet criterion moves to quantus/wasm#2. **Met on `main` (`f7dc72e32`):** - keys match `quantus-cli` for a given mnemonic and index (`dilithiumDerive.spec.ts`); - soft, named and unhardened derivations throw; - `derive()` on an existing pair refuses with a message that says why. In the extension, account indices are added from a wallet's menu (quantus/extension#14), using the same `//<n>` → `m/44'/189189'/n'/0'/scheme'` path. **Moved:** "matches the hdwallet crate's own golden vectors" needs the shared fixture that quantus/wasm#2 has not produced yet. It is tracked there (item 5) rather than keeping this open.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/common#4