Skip to main content

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}