Skip to main content

kobe_nostr/
deriver.rs

1//! Nostr key derivation from a unified wallet.
2//!
3//! Implements [NIP-06](https://nips.nostr.com/6) — BIP-32 secp256k1 derivation
4//! at path `m/44'/1237'/{account}'/0/0` — and emits [NIP-19](https://nips.nostr.com/19)
5//! bech32 entities (`nsec` for the private key, `npub` for the x-only public key).
6
7#[cfg(feature = "alloc")]
8use alloc::{format, string::String};
9use core::ops::Deref;
10
11use bech32::{Bech32, Hrp};
12use kobe_primitives::{Derive, DeriveError, DerivedAccount, DerivedPublicKey, Wallet};
13use zeroize::Zeroizing;
14
15/// NIP-19 human-readable part for secret keys.
16pub const NSEC_HRP: &str = "nsec";
17/// NIP-19 human-readable part for public keys.
18pub const NPUB_HRP: &str = "npub";
19
20/// A Nostr-specific derived account — [`DerivedAccount`] plus NIP-19 `nsec`.
21///
22/// Wraps the unified [`DerivedAccount`] (path, 32-byte private key, 32-byte
23/// x-only public key, `npub1…` address) and adds the NIP-19 `nsec1…` bech32
24/// encoding of the private key, zeroized on drop.
25///
26/// Implements `Deref<Target = DerivedAccount>`, so all shared accessors
27/// (`address()`, `public_key_bytes()`, etc.) are available directly.
28#[derive(Clone)]
29pub struct NostrAccount {
30    inner: DerivedAccount,
31    nsec: Zeroizing<String>,
32}
33
34impl core::fmt::Debug for NostrAccount {
35    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
36        f.debug_struct("NostrAccount")
37            .field("inner", &self.inner)
38            .field("nsec", &"[REDACTED]")
39            .finish()
40    }
41}
42
43impl NostrAccount {
44    /// NIP-19 `nsec1…` bech32 encoding of the 32-byte private key, zeroized on drop.
45    #[inline]
46    #[must_use]
47    pub const fn nsec(&self) -> &Zeroizing<String> {
48        &self.nsec
49    }
50
51    /// NIP-19 `npub1…` bech32 encoding of the x-only public key.
52    ///
53    /// Alias for [`DerivedAccount::address`] (inherited through `Deref`).
54    #[inline]
55    #[must_use]
56    pub fn npub(&self) -> &str {
57        self.inner.address()
58    }
59
60    /// The underlying unified [`DerivedAccount`].
61    #[inline]
62    #[must_use]
63    pub const fn as_derived_account(&self) -> &DerivedAccount {
64        &self.inner
65    }
66
67    /// Consume and yield the underlying [`DerivedAccount`], dropping the
68    /// Nostr-specific `nsec` field.
69    #[inline]
70    #[must_use]
71    pub fn into_derived_account(self) -> DerivedAccount {
72        self.inner
73    }
74}
75
76impl Deref for NostrAccount {
77    type Target = DerivedAccount;
78
79    #[inline]
80    fn deref(&self) -> &Self::Target {
81        &self.inner
82    }
83}
84
85impl From<NostrAccount> for DerivedAccount {
86    #[inline]
87    fn from(account: NostrAccount) -> Self {
88        account.inner
89    }
90}
91
92/// Nostr address deriver from a unified wallet seed.
93///
94/// Follows NIP-06 with BIP-32 path `m/44'/1237'/{account}'/0/0`.
95#[derive(Debug)]
96pub struct Deriver<'a> {
97    /// Wallet seed reference.
98    wallet: &'a Wallet,
99}
100
101impl<'a> Deriver<'a> {
102    /// Create a new Nostr deriver from a wallet.
103    #[inline]
104    #[must_use]
105    pub const fn new(wallet: &'a Wallet) -> Self {
106        Self { wallet }
107    }
108
109    /// Derive a Nostr account at the given NIP-06 `account` index.
110    ///
111    /// `index` maps to the hardened **account** level of the BIP-32
112    /// path (`m/44'/1237'/{index}'/0/0`), not the final address level.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if key derivation or bech32 encoding fails.
117    #[inline]
118    pub fn derive(&self, index: u32) -> Result<NostrAccount, DeriveError> {
119        self.derive_at(&format!("m/44'/1237'/{index}'/0/0"))
120    }
121
122    /// Derive a Nostr account at an arbitrary BIP-32 path.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if the path is invalid or derivation fails.
127    pub fn derive_at(&self, path: &str) -> Result<NostrAccount, DeriveError> {
128        let key = self.wallet.derive_secp256k1(path)?;
129
130        // NIP-19 / BIP-340: the x-only public key is the last 32 bytes of the
131        // 33-byte compressed secp256k1 pubkey (the leading 0x02/0x03 parity byte
132        // is dropped).
133        let compressed = key.compressed_pubkey();
134        let mut xonly = [0u8; 32];
135        xonly.copy_from_slice(compressed.get(1..).ok_or_else(|| {
136            DeriveError::Crypto(String::from(
137                "nostr: compressed pubkey shorter than 33 bytes",
138            ))
139        })?);
140
141        let npub_hrp = Hrp::parse(NPUB_HRP)
142            .map_err(|e| DeriveError::AddressEncoding(format!("nostr: invalid npub HRP: {e}")))?;
143        let npub = bech32::encode::<Bech32>(npub_hrp, &xonly)
144            .map_err(|e| DeriveError::AddressEncoding(format!("nostr npub encoding: {e}")))?;
145
146        let nsec_hrp = Hrp::parse(NSEC_HRP)
147            .map_err(|e| DeriveError::AddressEncoding(format!("nostr: invalid nsec HRP: {e}")))?;
148        let sk_bytes = key.private_key_bytes();
149        let nsec = bech32::encode::<Bech32>(nsec_hrp, sk_bytes.as_slice())
150            .map_err(|e| DeriveError::AddressEncoding(format!("nostr nsec encoding: {e}")))?;
151
152        let inner = DerivedAccount::new(
153            String::from(path),
154            sk_bytes,
155            DerivedPublicKey::Secp256k1XOnly(xonly),
156            npub,
157        );
158
159        Ok(NostrAccount {
160            inner,
161            nsec: Zeroizing::new(nsec),
162        })
163    }
164}
165
166impl Derive for Deriver<'_> {
167    type Account = NostrAccount;
168    type Error = DeriveError;
169
170    /// Derive a Nostr account at the given NIP-06 `account` index.
171    ///
172    /// The returned [`NostrAccount`] wraps a [`DerivedAccount`] plus the
173    /// NIP-19 `nsec` bech32 encoding; `Deref` / `AsRef<DerivedAccount>`
174    /// expose the unified view.
175    fn derive(&self, index: u32) -> Result<NostrAccount, DeriveError> {
176        Deriver::derive(self, index)
177    }
178
179    fn derive_path(&self, path: &str) -> Result<NostrAccount, DeriveError> {
180        self.derive_at(path)
181    }
182}
183
184impl AsRef<DerivedAccount> for NostrAccount {
185    #[inline]
186    fn as_ref(&self) -> &DerivedAccount {
187        &self.inner
188    }
189}
190
191#[cfg(test)]
192#[allow(clippy::indexing_slicing, reason = "test assertions")]
193mod tests {
194    use kobe_primitives::DeriveExt;
195
196    use super::*;
197
198    /// NIP-06 test vector 1 from the official
199    /// <https://github.com/nostr-protocol/nips/blob/master/06.md> spec.
200    const TV1_MNEMONIC: &str =
201        "leader monkey parrot ring guide accident before fence cannon height naive bean";
202    const TV1_PRIV_HEX: &str = "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a";
203    const TV1_NSEC: &str = "nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp";
204    const TV1_PUB_HEX: &str = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917";
205    const TV1_NPUB: &str = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu";
206
207    /// NIP-06 test vector 2 (24 words) from the same spec.
208    const TV2_MNEMONIC: &str = "what bleak badge arrange retreat wolf trade produce cricket blur garlic valid proud rude strong choose busy staff weather area salt hollow arm fade";
209    const TV2_PRIV_HEX: &str = "c15d739894c81a2fcfd3a2df85a0d2c0dbc47a280d092799f144d73d7ae78add";
210    const TV2_NSEC: &str = "nsec1c9wh8xy5eqdzln7n5t0ctgxjcrdug73gp5yj0x03gntn67h83twssdfhel";
211    const TV2_PUB_HEX: &str = "d41b22899549e1f3d335a31002cfd382174006e166d3e658e3a5eecdb6463573";
212    const TV2_NPUB: &str = "npub16sdj9zv4f8sl85e45vgq9n7nsgt5qphpvmf7vk8r5hhvmdjxx4es8rq74h";
213
214    fn wallet(mnemonic: &str) -> Wallet {
215        Wallet::from_mnemonic(mnemonic, None).unwrap()
216    }
217
218    #[test]
219    fn debug_redacts_nsec() {
220        let a = Deriver::new(&wallet(TV1_MNEMONIC)).derive(0).unwrap();
221        let dbg = format!("{a:?}");
222        assert!(dbg.contains("[REDACTED]"));
223        assert!(!dbg.contains(TV1_NSEC), "Debug must not leak nsec: {dbg}");
224        assert!(
225            !dbg.contains(TV1_PRIV_HEX),
226            "Debug must not leak private key hex: {dbg}"
227        );
228    }
229
230    /// Official NIP-06 test vector 1 — full 4-way lock
231    /// (path / private key / public key / npub / nsec).
232    #[test]
233    fn kat_nip06_vector1() {
234        let a = Deriver::new(&wallet(TV1_MNEMONIC)).derive(0).unwrap();
235        assert_eq!(a.path(), "m/44'/1237'/0'/0/0");
236        assert_eq!(a.private_key_hex().as_str(), TV1_PRIV_HEX);
237        assert_eq!(a.public_key_hex(), TV1_PUB_HEX);
238        assert_eq!(a.npub(), TV1_NPUB);
239        assert_eq!(a.nsec().as_str(), TV1_NSEC);
240        // `address()` is the canonical NIP-19 representation of the pubkey.
241        assert_eq!(a.address(), TV1_NPUB);
242    }
243
244    /// Official NIP-06 test vector 2 (24-word mnemonic) — stresses the
245    /// BIP-39 PBKDF2 + SLIP-10 path on a longer seed entropy.
246    #[test]
247    fn kat_nip06_vector2() {
248        let a = Deriver::new(&wallet(TV2_MNEMONIC)).derive(0).unwrap();
249        assert_eq!(a.path(), "m/44'/1237'/0'/0/0");
250        assert_eq!(a.private_key_hex().as_str(), TV2_PRIV_HEX);
251        assert_eq!(a.public_key_hex(), TV2_PUB_HEX);
252        assert_eq!(a.npub(), TV2_NPUB);
253        assert_eq!(a.nsec().as_str(), TV2_NSEC);
254    }
255
256    /// `derive_many` from [`DeriveExt`] must agree with scalar `derive` for
257    /// every index and preserve the NIP-19 `npub` emitted by
258    /// [`NostrAccount`].
259    #[test]
260    fn derive_many_matches_individual() {
261        let w = wallet(TV1_MNEMONIC);
262        let d = Deriver::new(&w);
263        let batch = d.derive_many(0, 3).unwrap();
264        let single: Vec<NostrAccount> = (0..3).map(|i| d.derive(i).unwrap()).collect();
265        for i in 0..3 {
266            assert_eq!(batch[i].address(), single[i].address());
267            assert_eq!(batch[i].path(), single[i].path());
268            assert_eq!(batch[i].npub(), single[i].npub());
269            assert_eq!(batch[i].nsec().as_str(), single[i].nsec().as_str());
270        }
271    }
272
273    #[test]
274    fn passphrase_changes_derivation() {
275        let w = Wallet::from_mnemonic(TV1_MNEMONIC, Some("TREZOR")).unwrap();
276        assert_ne!(
277            Deriver::new(&wallet(TV1_MNEMONIC))
278                .derive(0)
279                .unwrap()
280                .address(),
281            Deriver::new(&w).derive(0).unwrap().address(),
282        );
283    }
284}