nula_core/nips/nip06.rs
1//! [NIP-06] Basic key derivation from a mnemonic seed phrase.
2//!
3//! NIP-06 anchors a Nostr identity to a [BIP-39] mnemonic via the
4//! [BIP-32] hierarchical-deterministic derivation path:
5//!
6//! ```text
7//! m / 44' / 1237' / <account>' / <chain_type> / <index>
8//! ```
9//!
10//! - `44'` — BIP-44 purpose constant.
11//! - `1237'` — Nostr's coin type registered in [SLIP-44].
12//! - `account`, `chain_type`, `index` — caller-controlled selectors;
13//! `account = 0`, `chain_type = 0`, `index = 0` is the canonical
14//! "first identity" path and what every interoperable client uses by
15//! default (see the spec test vectors).
16//!
17//! # Pipeline
18//!
19//! 1. [BIP-39] parses the mnemonic into entropy + checksum and runs
20//! PBKDF2-HMAC-SHA512 over the (NFKD) sentence + an optional
21//! passphrase, producing a 512-bit seed.
22//! 2. [BIP-32] derives the master extended private key from that seed
23//! via `HMAC-SHA512(key = "Bitcoin seed", msg = seed)`.
24//! 3. We walk the path above. Hardened steps (`'`) feed only the
25//! parent secret to the HMAC; non-hardened steps feed the
26//! compressed parent public key. The leaf secret is the Nostr
27//! private key.
28//!
29//! The implementation here is **self-contained**: a private
30//! [`mod bip32`] block ships exactly the BIP-32 surface NIP-06 needs
31//! (master derivation + private→private CKD), without pulling in a
32//! full wallet crate.
33//!
34//! # Examples
35//!
36//! ```
37//! # #[cfg(feature = "nip06")] {
38//! use nula_core::nips::nip06;
39//!
40//! // The first vector from the NIP-06 spec.
41//! let keys = nip06::derive_keys(
42//! "leader monkey parrot ring guide accident before fence cannon height naive bean",
43//! None,
44//! )
45//! .unwrap();
46//! assert_eq!(
47//! keys.secret_key().to_hex(),
48//! "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a",
49//! );
50//! # }
51//! ```
52//!
53//! [NIP-06]: https://github.com/nostr-protocol/nips/blob/master/06.md
54//! [BIP-32]: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
55//! [BIP-39]: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki
56//! [SLIP-44]: https://github.com/satoshilabs/slips/blob/master/slip-0044.md
57
58pub use bip39::{Language, Mnemonic};
59use thiserror::Error;
60use zeroize::Zeroize;
61
62use crate::key::{Keys, SecretKey};
63
64/// BIP-44 "purpose" constant (`44'`).
65const PURPOSE: u32 = 44;
66/// SLIP-44 Nostr coin type (`1237'`).
67const COIN_TYPE: u32 = 1237;
68
69/// Number of words a fresh mnemonic should contain.
70///
71/// Each variant corresponds to a different entropy size; 12 words is
72/// the minimum allowed by BIP-39 and 24 the maximum. Most consumer
73/// wallets default to 12; security-conscious deployments prefer 24.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75#[non_exhaustive]
76pub enum WordCount {
77 /// 12 words / 128 bits of entropy.
78 Twelve,
79 /// 15 words / 160 bits of entropy.
80 Fifteen,
81 /// 18 words / 192 bits of entropy.
82 Eighteen,
83 /// 21 words / 224 bits of entropy.
84 TwentyOne,
85 /// 24 words / 256 bits of entropy.
86 TwentyFour,
87}
88
89impl WordCount {
90 /// Decimal length (e.g. `12`).
91 #[must_use]
92 pub const fn as_count(self) -> usize {
93 match self {
94 Self::Twelve => 12,
95 Self::Fifteen => 15,
96 Self::Eighteen => 18,
97 Self::TwentyOne => 21,
98 Self::TwentyFour => 24,
99 }
100 }
101}
102
103/// Errors raised by NIP-06 helpers.
104#[derive(Debug, Error)]
105#[non_exhaustive]
106pub enum Nip06Error {
107 /// The mnemonic could not be parsed (bad checksum, unknown word,
108 /// wrong word count, …).
109 #[error("invalid BIP-39 mnemonic: {0}")]
110 Mnemonic(#[from] bip39::Error),
111 /// The BIP-32 derivation produced an invalid scalar (probability
112 /// ~1/2^127, but the spec mandates we surface it rather than panic).
113 #[error("BIP-32 derivation produced an invalid secp256k1 scalar")]
114 InvalidDerivedKey,
115 /// The OS entropy source failed during fresh-mnemonic generation.
116 #[error(transparent)]
117 Rng(#[from] crate::util::rng::RngError),
118}
119
120/// Generate a fresh English BIP-39 mnemonic of the requested length.
121///
122/// Entropy is drawn from the OS-provided random source via
123/// [`crate::util::rng`].
124///
125/// # Errors
126///
127/// Returns [`Nip06Error::Rng`] when the OS RNG is unavailable.
128pub fn generate_mnemonic(word_count: WordCount) -> Result<Mnemonic, Nip06Error> {
129 // BIP-39 entropy size in bytes: count = (entropy_bits + checksum) / 11,
130 // checksum = entropy_bits / 32. Solving gives entropy_bytes =
131 // count * 11 / 33 * 4.
132 let entropy_bytes = word_count.as_count() * 4 / 3;
133
134 // Fixed-size dispatch keeps the OS-RNG buffer on the stack and
135 // avoids the const-generic gymnastics that a generic
136 // `random_bytes::<N>()` would force on the caller.
137 let mnemonic = match entropy_bytes {
138 16 => Mnemonic::from_entropy_in(Language::English, &fresh::<16>()?)?,
139 20 => Mnemonic::from_entropy_in(Language::English, &fresh::<20>()?)?,
140 24 => Mnemonic::from_entropy_in(Language::English, &fresh::<24>()?)?,
141 28 => Mnemonic::from_entropy_in(Language::English, &fresh::<28>()?)?,
142 32 => Mnemonic::from_entropy_in(Language::English, &fresh::<32>()?)?,
143 // `WordCount` is a closed enum; the match is therefore total
144 // and this arm is unreachable.
145 _ => unreachable!("WordCount only yields 16/20/24/28/32 entropy bytes"),
146 };
147 Ok(mnemonic)
148}
149
150fn fresh<const N: usize>() -> Result<[u8; N], crate::util::rng::RngError> {
151 crate::util::rng::random_bytes::<N>()
152}
153
154/// Derive the canonical Nostr [`Keys`] from a mnemonic.
155///
156/// Walks `m/44'/1237'/0'/0/0`, the path mandated by NIP-06 for the
157/// "first identity" — every NIP-06-compatible client agrees on this
158/// derivation, so a mnemonic round-trips between clients.
159///
160/// `passphrase` is the optional [BIP-39 §Wallet seed]
161/// passphrase ("seed extension"); pass `None` for the empty passphrase
162/// most clients use.
163///
164/// # Errors
165///
166/// See [`Nip06Error`].
167///
168/// [BIP-39 §Wallet seed]: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki#from-mnemonic-to-seed
169pub fn derive_keys(mnemonic: &str, passphrase: Option<&str>) -> Result<Keys, Nip06Error> {
170 derive_keys_advanced(mnemonic, passphrase, 0, 0, 0)
171}
172
173/// Derive [`Keys`] from a mnemonic with a fully custom BIP-32 path.
174///
175/// `account` controls `m/44'/1237'/<account>'`, `chain_type` the next
176/// non-hardened level (typically `0`, sometimes `1` for "change-style"
177/// accounts), and `index` the leaf. Every component fits in
178/// [`u32::MAX / 2`] (the BIP-32 hard cap on indices).
179///
180/// # Errors
181///
182/// Returns [`Nip06Error::Mnemonic`] for a malformed sentence and
183/// [`Nip06Error::InvalidDerivedKey`] if any HMAC step yields a key
184/// outside the secp256k1 group order (probability ~1/2^127).
185pub fn derive_keys_advanced(
186 mnemonic: &str,
187 passphrase: Option<&str>,
188 account: u32,
189 chain_type: u32,
190 index: u32,
191) -> Result<Keys, Nip06Error> {
192 let parsed = Mnemonic::parse_normalized(mnemonic)?;
193 let mut seed = parsed.to_seed_normalized(passphrase.unwrap_or_default());
194
195 let secret_bytes = bip32::derive_nostr_path(&seed, account, chain_type, index)?;
196 seed.zeroize();
197
198 let secret =
199 SecretKey::from_byte_array(secret_bytes).map_err(|_| Nip06Error::InvalidDerivedKey)?;
200 Ok(Keys::from_secret_key(secret))
201}
202
203/// Self-contained BIP-32 helper, scoped private to NIP-06.
204///
205/// The full BIP-32 surface (xpub serialisation, fingerprints, public
206/// CKD, …) would be ~10× this code; NIP-06 only needs master
207/// derivation and the hardened/normal private→private step, so we
208/// inline exactly that.
209//
210// `expect_used` / `unwrap_in_result` are gated at the module level
211// because every `expect` here guards an invariant that is statically
212// proved at the call site:
213//
214// - `<HmacSha512>::new_from_slice` only fails when the key exceeds the
215// underlying hash's block size (128 B for SHA-512). The two call
216// sites pass `b"Bitcoin seed"` (12 B) and a 32-byte chain code,
217// both safely within bounds.
218// - `<[u8; 64]>::split_first_chunk::<32>` is `Some` because 32 ≤ 64.
219//
220// Any new `expect` in this module needs to come with the same kind of
221// proof in a comment, enforced by code review.
222#[allow(
223 clippy::expect_used,
224 clippy::unwrap_in_result,
225 reason = "see module-level comment: every expect guards a statically proved length invariant"
226)]
227mod bip32 {
228 use hmac::digest::KeyInit;
229 use hmac::{Hmac, Mac};
230 use sha2::Sha512;
231 use zeroize::Zeroize;
232
233 use super::{COIN_TYPE, Nip06Error, PURPOSE};
234
235 /// Hard-coded BIP-32 master-derivation salt (`"Bitcoin seed"`).
236 const MASTER_KEY: &[u8] = b"Bitcoin seed";
237 /// Bit set on hardened child indices.
238 const HARDENED_OFFSET: u32 = 0x8000_0000;
239 /// Width of one half of an HMAC-SHA512 output.
240 const HALF: usize = 32;
241
242 type HmacSha512 = Hmac<Sha512>;
243
244 /// Derive `m/44'/COIN_TYPE'/account'/chain_type/index` from `seed`
245 /// and return the leaf 32-byte secret.
246 ///
247 /// `seed` must be 64 bytes (the BIP-39 PBKDF2 output); shorter or
248 /// longer inputs are accepted but produce non-standard master keys.
249 pub(super) fn derive_nostr_path(
250 seed: &[u8; 64],
251 account: u32,
252 chain_type: u32,
253 index: u32,
254 ) -> Result<[u8; 32], Nip06Error> {
255 let (mut k, mut c) = master_key(seed);
256
257 // Walk the 5-level Nostr path. The first three levels are
258 // hardened per BIP-44; the last two are not.
259 for &(idx, hardened) in &[
260 (PURPOSE, true),
261 (COIN_TYPE, true),
262 (account, true),
263 (chain_type, false),
264 (index, false),
265 ] {
266 (k, c) = ckd_priv(&k, &c, idx, hardened)?;
267 }
268
269 // Best-effort wipe the chain code on the way out; the caller
270 // owns `k` (the leaf secret) and is responsible for it.
271 c.zeroize();
272 Ok(k)
273 }
274
275 /// Split a 64-byte HMAC-SHA512 output into `(left, right)` halves.
276 ///
277 /// Statically infallible: 32 ≤ 64. Implemented via
278 /// [`<[u8; 64]>::split_first_chunk`] so the compiler can prove the
279 /// bounds at the type level rather than relying on a runtime
280 /// length check.
281 fn split_halves(bytes: [u8; 64]) -> ([u8; HALF], [u8; HALF]) {
282 let (left, rest) = bytes
283 .split_first_chunk::<HALF>()
284 .expect("32 <= 64, statically");
285 let right: [u8; HALF] = rest.try_into().expect("64 - 32 = 32, statically");
286 (*left, right)
287 }
288
289 /// `HMAC-SHA512(MASTER_KEY, seed)` → `(secret_left || chain_code_right)`.
290 fn master_key(seed: &[u8]) -> ([u8; HALF], [u8; HALF]) {
291 let mut mac = <HmacSha512 as KeyInit>::new_from_slice(MASTER_KEY)
292 .expect("HMAC-SHA512 accepts any key up to its 128-byte block size");
293 mac.update(seed);
294 let bytes: [u8; 64] = mac.finalize().into_bytes().into();
295 split_halves(bytes)
296 }
297
298 /// Private→private child key derivation step.
299 ///
300 /// For hardened indices (`hardened = true`), the HMAC input is
301 /// `0x00 || parent_secret || index`; for non-hardened indices it is
302 /// `compressed_parent_public_key || index`. The output's left half
303 /// is added (mod n) to the parent secret to produce the child
304 /// secret; the right half becomes the new chain code.
305 fn ckd_priv(
306 parent_secret: &[u8; HALF],
307 parent_chain: &[u8; HALF],
308 index: u32,
309 hardened: bool,
310 ) -> Result<([u8; HALF], [u8; HALF]), Nip06Error> {
311 let parent_sk = secp256k1::SecretKey::from_byte_array(*parent_secret)
312 .map_err(|_| Nip06Error::InvalidDerivedKey)?;
313
314 let child_index = if hardened {
315 index | HARDENED_OFFSET
316 } else {
317 index
318 };
319
320 let mut mac = <HmacSha512 as KeyInit>::new_from_slice(parent_chain)
321 .expect("HMAC-SHA512 accepts any key up to its 128-byte block size");
322
323 if hardened {
324 mac.update(&[0x00]);
325 mac.update(parent_secret);
326 } else {
327 // Compressed serialization of the parent public key
328 // (33 bytes: 0x02/0x03 prefix + 32-byte X coordinate).
329 let parent_pk = secp256k1::PublicKey::from_secret_key_global(&parent_sk);
330 mac.update(&parent_pk.serialize());
331 }
332 mac.update(&child_index.to_be_bytes());
333
334 let bytes: [u8; 64] = mac.finalize().into_bytes().into();
335 let (mut left, chain) = split_halves(bytes);
336
337 // child_secret = (left + parent_secret) mod n. `add_tweak`
338 // returns `Err` when the addition lands on `0` mod n; the
339 // probability is ~1/2^127, but BIP-32 mandates we surface it.
340 let scalar =
341 secp256k1::Scalar::from_be_bytes(left).map_err(|_| Nip06Error::InvalidDerivedKey)?;
342 let child_sk = parent_sk
343 .add_tweak(&scalar)
344 .map_err(|_| Nip06Error::InvalidDerivedKey)?;
345
346 let child_bytes = child_sk.secret_bytes();
347 left.zeroize();
348 Ok((child_bytes, chain))
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 /// Test vectors lifted from NIP-06 itself
357 /// (<https://github.com/nostr-protocol/nips/blob/master/06.md#test-vectors>).
358 /// `(mnemonic, expected_hex_secret)` for the canonical
359 /// `m/44'/1237'/0'/0/0` derivation.
360 const SPEC_VECTORS: &[(&str, &str)] = &[
361 (
362 "leader monkey parrot ring guide accident before fence cannon height naive bean",
363 "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a",
364 ),
365 (
366 "what bleak badge arrange retreat wolf trade produce cricket blur garlic valid proud rude strong choose busy staff weather area salt hollow arm fade",
367 "c15d739894c81a2fcfd3a2df85a0d2c0dbc47a280d092799f144d73d7ae78add",
368 ),
369 ];
370
371 #[test]
372 fn spec_vectors_match() {
373 for (sentence, expected) in SPEC_VECTORS {
374 let keys = derive_keys(sentence, None).unwrap();
375 assert_eq!(
376 keys.secret_key().to_hex(),
377 *expected,
378 "mismatch on mnemonic: {sentence}",
379 );
380 }
381 }
382
383 #[test]
384 fn passphrase_changes_derived_key() {
385 // BIP-39 spec: any non-empty passphrase MUST produce a
386 // completely different seed (no plausible collision).
387 let mnemonic = SPEC_VECTORS[0].0;
388 let plain = derive_keys(mnemonic, None).unwrap();
389 let with_pw = derive_keys(mnemonic, Some("nostr")).unwrap();
390 assert_ne!(plain.secret_key().to_hex(), with_pw.secret_key().to_hex());
391 }
392
393 #[test]
394 fn account_changes_derived_key() {
395 // Different `account` levels must produce different keys —
396 // this is the whole point of the BIP-44 hierarchy.
397 let mnemonic = SPEC_VECTORS[0].0;
398 let acct0 = derive_keys_advanced(mnemonic, None, 0, 0, 0).unwrap();
399 let acct1 = derive_keys_advanced(mnemonic, None, 1, 0, 0).unwrap();
400 assert_ne!(acct0.secret_key().to_hex(), acct1.secret_key().to_hex());
401 }
402
403 #[test]
404 fn index_changes_derived_key() {
405 let mnemonic = SPEC_VECTORS[0].0;
406 let i0 = derive_keys_advanced(mnemonic, None, 0, 0, 0).unwrap();
407 let i1 = derive_keys_advanced(mnemonic, None, 0, 0, 1).unwrap();
408 assert_ne!(i0.secret_key().to_hex(), i1.secret_key().to_hex());
409 }
410
411 #[test]
412 fn malformed_mnemonic_rejected() {
413 let err = derive_keys("not a real mnemonic just words here", None).unwrap_err();
414 assert!(matches!(err, Nip06Error::Mnemonic(_)));
415 }
416
417 #[test]
418 fn generate_mnemonic_round_trips_through_derive() {
419 // Generated mnemonics must be parseable and yield a valid
420 // secret key for every supported word count.
421 for &count in &[
422 WordCount::Twelve,
423 WordCount::Fifteen,
424 WordCount::Eighteen,
425 WordCount::TwentyOne,
426 WordCount::TwentyFour,
427 ] {
428 let mnemonic = generate_mnemonic(count).unwrap();
429 assert_eq!(mnemonic.word_count(), count.as_count());
430 let _ = derive_keys(&mnemonic.to_string(), None).unwrap();
431 }
432 }
433
434 #[test]
435 fn surrounding_whitespace_is_tolerated() {
436 // BIP-39 wordlists are case-sensitive ASCII (uppercase variants
437 // are *not* valid words), but `parse_normalized` does collapse
438 // surrounding/internal whitespace and NFKD/NFC differences.
439 // Verify the whitespace half here.
440 let canonical = SPEC_VECTORS[0].0;
441 let padded = format!("\t {canonical} \n");
442 let a = derive_keys(canonical, None).unwrap();
443 let b = derive_keys(&padded, None).unwrap();
444 assert_eq!(a.secret_key().to_hex(), b.secret_key().to_hex());
445 }
446
447 #[test]
448 fn uppercase_mnemonic_is_rejected() {
449 // Pin the contract: BIP-39 demands the canonical lowercase
450 // wordlist. Casing the sentence yields words that are not in
451 // the wordlist and parsing must fail.
452 let err = derive_keys(&SPEC_VECTORS[0].0.to_uppercase(), None).unwrap_err();
453 assert!(matches!(err, Nip06Error::Mnemonic(_)));
454 }
455}