Skip to content

Commit 1bfb46e

Browse files
authored
feat(keys): derive ML-DSA-65 keys from BIP-39 seed phrases (#224)
Adopts the SLIP-0010 ML-DSA extension from satoshilabs/slips#1968 (also QIP-0002 and the Lattice HD Wallets construction): master node HMAC-SHA512(key = "ML-DSA-65 seed", data = BIP-39 seed), standard hardened-only child steps, and the 32-byte node secret used as the FIPS 204 seed xi. parseSeedPhrase now takes an options object ({ path, keyType }) in addition to the existing path-string form. The SLIP-0010 code moves to utils/hd.ts, parametrized by curve salt, and is verified against the official ed25519 vectors and the slips#1968 ML-DSA-65 vectors (which are validated against the NIST ACVP ML-DSA-keyGen-FIPS204 KATs). Path parsing now accepts bare "m" (master node) and rejects indexes >= 2^31, which previously wrapped silently.
1 parent b62fc1b commit 1bfb46e

7 files changed

Lines changed: 462 additions & 78 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"near-kit": minor
3+
---
4+
5+
Add ML-DSA-65 key derivation from BIP-39 seed phrases. `parseSeedPhrase` now accepts an options object with a `keyType` of `"ed25519"` (default) or `"ml-dsa-65"`, deriving post-quantum keys via the SLIP-0010 construction from satoshilabs/slips#1968 (master node `HMAC-SHA512(key = "ML-DSA-65 seed", data = BIP-39 seed)`, hardened-only children, node secret used as the FIPS 204 seed ξ). Validated against the slips#1968 test vectors.

docs/in-depth/key-management.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,22 @@ await pqNear.transaction("alice.near").transfer("bob.near", "1 NEAR").send()
168168

169169
For a key generated by `MlDsa65KeyPair`, the serialized secret key is the 32-byte seed (`ml-dsa-65:<base58 seed>`). `parseKey` also accepts the 4032-byte raw expanded secret key that nearcore / near-cli write to credential files (`ml-dsa-65:<base58 raw key>`), so existing credentials load too. Either form round-trips. The public key is 1952 bytes and signatures are 3309 bytes.
170170

171+
#### Deriving from a seed phrase
172+
173+
`parseSeedPhrase` can derive an ML-DSA-65 key from a BIP-39 mnemonic, so a post-quantum key is recoverable from the same phrase a wallet already backs up:
174+
175+
```typescript
176+
import { parseSeedPhrase } from "near-kit"
177+
178+
const pqKey = parseSeedPhrase("word1 word2 ... word12", {
179+
keyType: "ml-dsa-65",
180+
path: "m/44'/397'/0'", // default; bump the index for more keys
181+
})
182+
console.log(pqKey.publicKey.toString()) // ml-dsa-65:...
183+
```
184+
185+
Derivation follows the SLIP-0010 extension proposed in [satoshilabs/slips#1968](https://github.com/satoshilabs/slips/pull/1968): the master node is `HMAC-SHA512(key = "ML-DSA-65 seed", data = BIP-39 seed)`, children use the standard SLIP-0010 hardened-only step, and the derived 32-byte node secret is the FIPS 204 seed ξ fed to ML-DSA key generation. Because the master salt differs from ed25519's, the ML-DSA-65 key derived from a phrase is unrelated to the ed25519 key derived from that same phrase.
186+
171187
<Note title="On-chain key handles">
172188
On-chain, an ML-DSA-65 access key is stored as a 32-byte hash, so `view_access_key_list` returns it as `ml-dsa-65-hash:...`, **not** the full key. Parse that form with `parseMlDsa65Handle()` for display and comparison — it is a read-only handle and cannot be used to sign or as an `addKey` public key (the full key is not recoverable from it).
173189
</Note>

packages/near-kit/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export {
117117
isValidPublicKey,
118118
MlDsa65KeyPair,
119119
type MlDsa65PublicKeyHandle,
120+
type ParseSeedPhraseOptions,
120121
type PrivateKey,
121122
parseAmount,
122123
parseGas,

packages/near-kit/src/utils/hd.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* SLIP-0010 hardened-only hierarchical key derivation.
3+
*
4+
* Implements the HMAC-SHA512 derivation chain from
5+
* {@link https://github.com/satoshilabs/slips/blob/master/slip-0010.md | SLIP-0010}
6+
* for curves without public-key derivation: every path segment must be
7+
* hardened, and the derived 32-byte node secret is used directly as key
8+
* material (an ed25519 private key, or an ML-DSA-65 FIPS 204 seed ξ).
9+
*
10+
* The ML-DSA-65 variant uses the `"ML-DSA-65 seed"` master salt from
11+
* {@link https://github.com/satoshilabs/slips/pull/1968 | satoshilabs/slips#1968},
12+
* the same construction adopted by Quantus (QIP-0002) and proven secure in the
13+
* Lattice HD Wallets paper (https://eprint.iacr.org/2026/380).
14+
*/
15+
16+
import { hmac } from "@noble/hashes/hmac.js"
17+
import { sha512 } from "@noble/hashes/sha2.js"
18+
import { InvalidKeyError } from "../errors/index.js"
19+
20+
/** SLIP-0010 master-node HMAC key for the ed25519 curve. */
21+
export const ED25519_CURVE_SALT = "ed25519 seed"
22+
/** Master-node HMAC key for ML-DSA-65, per satoshilabs/slips#1968. */
23+
export const ML_DSA_65_CURVE_SALT = "ML-DSA-65 seed"
24+
25+
const HARDENED_OFFSET = 0x80000000
26+
27+
/** A SLIP-0010 node: 32-byte secret (I_L) and 32-byte chain code (I_R). */
28+
export interface Slip10Node {
29+
key: Uint8Array
30+
chainCode: Uint8Array
31+
}
32+
33+
/**
34+
* SLIP-0010 master node: `I = HMAC-SHA512(key = salt, data = seed)`.
35+
* @internal
36+
*/
37+
function masterNodeFromSeed(salt: string, seed: Uint8Array): Slip10Node {
38+
const I = hmac(sha512, new TextEncoder().encode(salt), seed)
39+
return {
40+
key: I.slice(0, 32),
41+
chainCode: I.slice(32),
42+
}
43+
}
44+
45+
/**
46+
* SLIP-0010 hardened child step:
47+
* `I = HMAC-SHA512(key = c_par, data = 0x00 || k_par || ser32(index))`.
48+
* @internal
49+
*/
50+
function deriveChild(parent: Slip10Node, index: number): Slip10Node {
51+
const data = new Uint8Array(37)
52+
data[0] = 0
53+
data.set(parent.key, 1)
54+
const view = new DataView(data.buffer)
55+
view.setUint32(33, index, false) // big-endian
56+
57+
const I = hmac(sha512, parent.chainCode, data)
58+
return {
59+
key: I.slice(0, 32),
60+
chainCode: I.slice(32),
61+
}
62+
}
63+
64+
/**
65+
* Derive a SLIP-0010 node from a BIP-39 seed along a hardened BIP-32 path.
66+
*
67+
* @param salt - Master-node HMAC key selecting the scheme
68+
* ({@link ED25519_CURVE_SALT} or {@link ML_DSA_65_CURVE_SALT}).
69+
* @param seed - BIP-39 seed (typically 64 bytes from `mnemonicToSeedSync`).
70+
* @param path - Path like `"m/44'/397'/0'"`. Every segment must be hardened
71+
* (`'` suffix); `"m"` alone derives the master node.
72+
* @returns The derived node; `key` is the 32-byte secret (ed25519 private key
73+
* or ML-DSA-65 seed ξ).
74+
*/
75+
export function slip10DerivePath(
76+
salt: string,
77+
seed: Uint8Array,
78+
path: string,
79+
): Slip10Node {
80+
if (!/^m(\/\d+')*$/.test(path)) {
81+
throw new InvalidKeyError(
82+
`Invalid derivation path: ${path}. Must be hardened (e.g., m/44'/397'/0')`,
83+
)
84+
}
85+
86+
let node = masterNodeFromSeed(salt, seed)
87+
88+
const segments = path
89+
.split("/")
90+
.slice(1) // Remove 'm'
91+
.map((s) => Number.parseInt(s.replace("'", ""), 10))
92+
93+
for (const segment of segments) {
94+
if (segment >= HARDENED_OFFSET) {
95+
throw new InvalidKeyError(
96+
`Invalid derivation path: ${path}. Index ${segment} out of range`,
97+
)
98+
}
99+
node = deriveChild(node, segment + HARDENED_OFFSET)
100+
}
101+
102+
return node
103+
}

packages/near-kit/src/utils/key.ts

Lines changed: 50 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { ed25519 } from "@noble/curves/ed25519.js"
22
import { secp256k1 } from "@noble/curves/secp256k1.js"
3-
import { hmac } from "@noble/hashes/hmac.js"
4-
import { sha512 } from "@noble/hashes/sha2.js"
53
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js"
64
import { base58, base64 } from "@scure/base"
75
import * as bip39 from "@scure/bip39"
@@ -26,6 +24,11 @@ import {
2624
type SignMessageParams,
2725
} from "../core/types.js"
2826
import { InvalidKeyError } from "../errors/index.js"
27+
import {
28+
ED25519_CURVE_SALT,
29+
ML_DSA_65_CURVE_SALT,
30+
slip10DerivePath,
31+
} from "./hd.js"
2932
import { serializeNep413Message } from "./nep413.js"
3033

3134
/**
@@ -484,98 +487,58 @@ export function generateSeedPhrase(
484487
}
485488

486489
/**
487-
* SLIP-0010 master key derivation for ed25519
488-
* Uses 'ed25519 seed' as the HMAC key per SLIP-0010 specification
489-
* @internal
490-
*/
491-
function getMasterKeyFromSeed(seed: Uint8Array): {
492-
key: Uint8Array
493-
chainCode: Uint8Array
494-
} {
495-
const ED25519_SEED = new TextEncoder().encode("ed25519 seed")
496-
const I = hmac(sha512, ED25519_SEED, seed)
497-
return {
498-
key: I.slice(0, 32),
499-
chainCode: I.slice(32),
500-
}
501-
}
502-
503-
/**
504-
* SLIP-0010 child key derivation for ed25519
505-
* Only supports hardened derivation (index >= 0x80000000)
506-
* @internal
507-
*/
508-
function deriveChild(
509-
parentKey: Uint8Array,
510-
parentChainCode: Uint8Array,
511-
index: number,
512-
): { key: Uint8Array; chainCode: Uint8Array } {
513-
// Build data: 0x00 || parent_key || index (big-endian)
514-
const data = new Uint8Array(37)
515-
data[0] = 0
516-
data.set(parentKey, 1)
517-
const view = new DataView(data.buffer)
518-
view.setUint32(33, index, false) // big-endian
519-
520-
const I = hmac(sha512, parentChainCode, data)
521-
return {
522-
key: I.slice(0, 32),
523-
chainCode: I.slice(32),
524-
}
525-
}
526-
527-
/**
528-
* Parse derivation path and derive key using SLIP-0010 for ed25519
529-
* @internal
490+
* Options for {@link parseSeedPhrase}.
530491
*/
531-
function derivePath(path: string, seed: Uint8Array): Uint8Array {
532-
const HARDENED_OFFSET = 0x80000000
533-
534-
// Validate path format
535-
if (!/^m(\/\d+')+$/.test(path)) {
536-
throw new InvalidKeyError(
537-
`Invalid derivation path: ${path}. Must be hardened (e.g., m/44'/397'/0')`,
538-
)
539-
}
540-
541-
// Get master key
542-
let { key, chainCode } = getMasterKeyFromSeed(seed)
543-
544-
// Parse and apply each path segment
545-
const segments = path
546-
.split("/")
547-
.slice(1) // Remove 'm'
548-
.map((s) => Number.parseInt(s.replace("'", ""), 10))
549-
550-
for (const segment of segments) {
551-
const result = deriveChild(key, chainCode, segment + HARDENED_OFFSET)
552-
key = result.key
553-
chainCode = result.chainCode
554-
}
555-
556-
return key
492+
export interface ParseSeedPhraseOptions {
493+
/**
494+
* BIP-32 derivation path. Every segment must be hardened.
495+
* Defaults to `"m/44'/397'/0'"` (NEAR's coin type).
496+
*/
497+
path?: string
498+
/**
499+
* Signature scheme of the derived key. Defaults to `"ed25519"`.
500+
*
501+
* `"ml-dsa-65"` derives a post-quantum ML-DSA-65 (FIPS 204) key using the
502+
* SLIP-0010 construction from satoshilabs/slips#1968: the master node is
503+
* `HMAC-SHA512(key = "ML-DSA-65 seed", data = BIP-39 seed)` and the derived
504+
* 32-byte node secret is the FIPS 204 seed ξ fed to ML-DSA key generation.
505+
*/
506+
keyType?: "ed25519" | "ml-dsa-65"
557507
}
558508

559509
/**
560-
* Parse a BIP39 seed phrase to derive a key pair using SLIP-0010 for ed25519.
510+
* Parse a BIP39 seed phrase to derive a key pair using SLIP-0010.
561511
*
562-
* This uses the correct 'ed25519 seed' HMAC key per SLIP-0010 specification,
563-
* which is compatible with NEAR CLI and wallet-generated seed phrases.
512+
* For ed25519 this uses the 'ed25519 seed' HMAC key per SLIP-0010
513+
* specification, which is compatible with NEAR CLI and wallet-generated seed
514+
* phrases. For ML-DSA-65 it uses the 'ML-DSA-65 seed' HMAC key per
515+
* satoshilabs/slips#1968, and the derived 32-byte node secret is the FIPS 204
516+
* seed ξ. The same phrase yields unrelated ed25519 and ML-DSA-65 keys.
564517
*
565518
* @param phrase - BIP39 seed phrase (12-24 words)
566-
* @param path - Derivation path (defaults to "m/44'/397'/0'" for NEAR)
519+
* @param pathOrOptions - Derivation path string (defaults to "m/44'/397'/0'"
520+
* for NEAR), or a {@link ParseSeedPhraseOptions} object to also pick the key
521+
* type
567522
* @returns KeyPair instance
568523
*
569524
* @example
570525
* ```typescript
571526
* const keyPair = parseSeedPhrase("word1 word2 ... word12")
572527
* console.log(keyPair.publicKey.toString()) // ed25519:...
528+
*
529+
* const pqKeyPair = parseSeedPhrase("word1 word2 ... word12", {
530+
* keyType: "ml-dsa-65",
531+
* })
532+
* console.log(pqKeyPair.publicKey.toString()) // ml-dsa-65:...
573533
* ```
574534
*/
575535
export function parseSeedPhrase(
576536
phrase: string,
577-
path: string = "m/44'/397'/0'",
537+
pathOrOptions: string | ParseSeedPhraseOptions = {},
578538
): KeyPair {
539+
const { path = "m/44'/397'/0'", keyType = "ed25519" } =
540+
typeof pathOrOptions === "string" ? { path: pathOrOptions } : pathOrOptions
541+
579542
// Normalize the seed phrase (trim, lowercase, single spaces)
580543
const normalizedPhrase = phrase
581544
.trim()
@@ -591,8 +554,17 @@ export function parseSeedPhrase(
591554
// Convert mnemonic to seed (64 bytes)
592555
const seed = bip39.mnemonicToSeedSync(normalizedPhrase)
593556

594-
// Derive key using SLIP-0010 for ed25519
595-
const privateKey = derivePath(path, seed)
557+
if (keyType === "ml-dsa-65") {
558+
// The derived node secret is the FIPS 204 seed ξ; MlDsa65KeyPair expands
559+
// it via ML-DSA.KeyGen.
560+
const { key } = slip10DerivePath(ML_DSA_65_CURVE_SALT, seed, path)
561+
return new MlDsa65KeyPair(key)
562+
}
563+
if (keyType !== "ed25519") {
564+
throw new InvalidKeyError(`Unsupported key type: ${keyType}`)
565+
}
566+
567+
const { key: privateKey } = slip10DerivePath(ED25519_CURVE_SALT, seed, path)
596568

597569
// Get the ed25519 public key from private key
598570
const publicKey = ed25519.getPublicKey(privateKey)

0 commit comments

Comments
 (0)