kobe-nostr 3.4.0

Nostr wallet for Kobe
Documentation
//! Nostr key derivation from a unified wallet.
//!
//! Implements [NIP-06](https://nips.nostr.com/6) — BIP-32 secp256k1 derivation
//! at path `m/44'/1237'/{account}'/0/0` — and emits [NIP-19](https://nips.nostr.com/19)
//! bech32 entities (`nsec` for the private key, `npub` for the x-only public key).

#[cfg(feature = "alloc")]
use alloc::{format, string::String};
use core::ops::Deref;

use bech32::{Bech32, Hrp};
use kobe_primitives::{Derive, DeriveError, DerivedAccount, DerivedPublicKey, Wallet};
use zeroize::Zeroizing;

/// NIP-19 human-readable part for secret keys.
pub const NSEC_HRP: &str = "nsec";
/// NIP-19 human-readable part for public keys.
pub const NPUB_HRP: &str = "npub";

/// A Nostr-specific derived account — [`DerivedAccount`] plus NIP-19 `nsec`.
///
/// Wraps the unified [`DerivedAccount`] (path, 32-byte private key, 32-byte
/// x-only public key, `npub1…` address) and adds the NIP-19 `nsec1…` bech32
/// encoding of the private key, zeroized on drop.
///
/// Implements `Deref<Target = DerivedAccount>`, so all shared accessors
/// (`address()`, `public_key_bytes()`, etc.) are available directly.
#[derive(Clone)]
pub struct NostrAccount {
    inner: DerivedAccount,
    nsec: Zeroizing<String>,
}

impl core::fmt::Debug for NostrAccount {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.debug_struct("NostrAccount")
            .field("inner", &self.inner)
            .field("nsec", &"[REDACTED]")
            .finish()
    }
}

impl NostrAccount {
    /// NIP-19 `nsec1…` bech32 encoding of the 32-byte private key, zeroized on drop.
    #[inline]
    #[must_use]
    pub const fn nsec(&self) -> &Zeroizing<String> {
        &self.nsec
    }

    /// NIP-19 `npub1…` bech32 encoding of the x-only public key.
    ///
    /// Alias for [`DerivedAccount::address`] (inherited through `Deref`).
    #[inline]
    #[must_use]
    pub fn npub(&self) -> &str {
        self.inner.address()
    }

    /// The underlying unified [`DerivedAccount`].
    #[inline]
    #[must_use]
    pub const fn as_derived_account(&self) -> &DerivedAccount {
        &self.inner
    }

    /// Consume and yield the underlying [`DerivedAccount`], dropping the
    /// Nostr-specific `nsec` field.
    #[inline]
    #[must_use]
    pub fn into_derived_account(self) -> DerivedAccount {
        self.inner
    }
}

impl Deref for NostrAccount {
    type Target = DerivedAccount;

    #[inline]
    fn deref(&self) -> &Self::Target {
        &self.inner
    }
}

impl From<NostrAccount> for DerivedAccount {
    #[inline]
    fn from(account: NostrAccount) -> Self {
        account.inner
    }
}

/// Nostr address deriver from a unified wallet seed.
///
/// Follows NIP-06 with BIP-32 path `m/44'/1237'/{account}'/0/0`.
#[derive(Debug)]
pub struct Deriver<'a> {
    /// Wallet seed reference.
    wallet: &'a Wallet,
}

impl<'a> Deriver<'a> {
    /// Create a new Nostr deriver from a wallet.
    #[inline]
    #[must_use]
    pub const fn new(wallet: &'a Wallet) -> Self {
        Self { wallet }
    }

    /// Derive a Nostr account at the given NIP-06 `account` index.
    ///
    /// `index` maps to the hardened **account** level of the BIP-32
    /// path (`m/44'/1237'/{index}'/0/0`), not the final address level.
    ///
    /// # Errors
    ///
    /// Returns an error if key derivation or bech32 encoding fails.
    #[inline]
    pub fn derive(&self, index: u32) -> Result<NostrAccount, DeriveError> {
        self.derive_at(&format!("m/44'/1237'/{index}'/0/0"))
    }

    /// Derive a Nostr account at an arbitrary BIP-32 path.
    ///
    /// # Errors
    ///
    /// Returns an error if the path is invalid or derivation fails.
    pub fn derive_at(&self, path: &str) -> Result<NostrAccount, DeriveError> {
        let key = self.wallet.derive_secp256k1(path)?;

        // NIP-19 / BIP-340: the x-only public key is the last 32 bytes of the
        // 33-byte compressed secp256k1 pubkey (the leading 0x02/0x03 parity byte
        // is dropped).
        let compressed = key.compressed_pubkey();
        let mut xonly = [0u8; 32];
        xonly.copy_from_slice(compressed.get(1..).ok_or_else(|| {
            DeriveError::Crypto(String::from(
                "nostr: compressed pubkey shorter than 33 bytes",
            ))
        })?);

        let npub_hrp = Hrp::parse(NPUB_HRP)
            .map_err(|e| DeriveError::AddressEncoding(format!("nostr: invalid npub HRP: {e}")))?;
        let npub = bech32::encode::<Bech32>(npub_hrp, &xonly)
            .map_err(|e| DeriveError::AddressEncoding(format!("nostr npub encoding: {e}")))?;

        let nsec_hrp = Hrp::parse(NSEC_HRP)
            .map_err(|e| DeriveError::AddressEncoding(format!("nostr: invalid nsec HRP: {e}")))?;
        let sk_bytes = key.private_key_bytes();
        let nsec = bech32::encode::<Bech32>(nsec_hrp, sk_bytes.as_slice())
            .map_err(|e| DeriveError::AddressEncoding(format!("nostr nsec encoding: {e}")))?;

        let inner = DerivedAccount::new(
            String::from(path),
            sk_bytes,
            DerivedPublicKey::Secp256k1XOnly(xonly),
            npub,
        );

        Ok(NostrAccount {
            inner,
            nsec: Zeroizing::new(nsec),
        })
    }
}

impl Derive for Deriver<'_> {
    type Account = NostrAccount;
    type Error = DeriveError;

    /// Derive a Nostr account at the given NIP-06 `account` index.
    ///
    /// The returned [`NostrAccount`] wraps a [`DerivedAccount`] plus the
    /// NIP-19 `nsec` bech32 encoding; `Deref` / `AsRef<DerivedAccount>`
    /// expose the unified view.
    fn derive(&self, index: u32) -> Result<NostrAccount, DeriveError> {
        Deriver::derive(self, index)
    }

    fn derive_path(&self, path: &str) -> Result<NostrAccount, DeriveError> {
        self.derive_at(path)
    }
}

impl AsRef<DerivedAccount> for NostrAccount {
    #[inline]
    fn as_ref(&self) -> &DerivedAccount {
        &self.inner
    }
}

#[cfg(test)]
#[allow(clippy::indexing_slicing, reason = "test assertions")]
mod tests {
    use kobe_primitives::DeriveExt;

    use super::*;

    /// NIP-06 test vector 1 from the official
    /// <https://github.com/nostr-protocol/nips/blob/master/06.md> spec.
    const TV1_MNEMONIC: &str =
        "leader monkey parrot ring guide accident before fence cannon height naive bean";
    const TV1_PRIV_HEX: &str = "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a";
    const TV1_NSEC: &str = "nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp";
    const TV1_PUB_HEX: &str = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917";
    const TV1_NPUB: &str = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu";

    /// NIP-06 test vector 2 (24 words) from the same spec.
    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";
    const TV2_PRIV_HEX: &str = "c15d739894c81a2fcfd3a2df85a0d2c0dbc47a280d092799f144d73d7ae78add";
    const TV2_NSEC: &str = "nsec1c9wh8xy5eqdzln7n5t0ctgxjcrdug73gp5yj0x03gntn67h83twssdfhel";
    const TV2_PUB_HEX: &str = "d41b22899549e1f3d335a31002cfd382174006e166d3e658e3a5eecdb6463573";
    const TV2_NPUB: &str = "npub16sdj9zv4f8sl85e45vgq9n7nsgt5qphpvmf7vk8r5hhvmdjxx4es8rq74h";

    fn wallet(mnemonic: &str) -> Wallet {
        Wallet::from_mnemonic(mnemonic, None).unwrap()
    }

    #[test]
    fn debug_redacts_nsec() {
        let a = Deriver::new(&wallet(TV1_MNEMONIC)).derive(0).unwrap();
        let dbg = format!("{a:?}");
        assert!(dbg.contains("[REDACTED]"));
        assert!(!dbg.contains(TV1_NSEC), "Debug must not leak nsec: {dbg}");
        assert!(
            !dbg.contains(TV1_PRIV_HEX),
            "Debug must not leak private key hex: {dbg}"
        );
    }

    /// Official NIP-06 test vector 1 — full 4-way lock
    /// (path / private key / public key / npub / nsec).
    #[test]
    fn kat_nip06_vector1() {
        let a = Deriver::new(&wallet(TV1_MNEMONIC)).derive(0).unwrap();
        assert_eq!(a.path(), "m/44'/1237'/0'/0/0");
        assert_eq!(a.private_key_hex().as_str(), TV1_PRIV_HEX);
        assert_eq!(a.public_key_hex(), TV1_PUB_HEX);
        assert_eq!(a.npub(), TV1_NPUB);
        assert_eq!(a.nsec().as_str(), TV1_NSEC);
        // `address()` is the canonical NIP-19 representation of the pubkey.
        assert_eq!(a.address(), TV1_NPUB);
    }

    /// Official NIP-06 test vector 2 (24-word mnemonic) — stresses the
    /// BIP-39 PBKDF2 + SLIP-10 path on a longer seed entropy.
    #[test]
    fn kat_nip06_vector2() {
        let a = Deriver::new(&wallet(TV2_MNEMONIC)).derive(0).unwrap();
        assert_eq!(a.path(), "m/44'/1237'/0'/0/0");
        assert_eq!(a.private_key_hex().as_str(), TV2_PRIV_HEX);
        assert_eq!(a.public_key_hex(), TV2_PUB_HEX);
        assert_eq!(a.npub(), TV2_NPUB);
        assert_eq!(a.nsec().as_str(), TV2_NSEC);
    }

    /// `derive_many` from [`DeriveExt`] must agree with scalar `derive` for
    /// every index and preserve the NIP-19 `npub` emitted by
    /// [`NostrAccount`].
    #[test]
    fn derive_many_matches_individual() {
        let w = wallet(TV1_MNEMONIC);
        let d = Deriver::new(&w);
        let batch = d.derive_many(0, 3).unwrap();
        let single: Vec<NostrAccount> = (0..3).map(|i| d.derive(i).unwrap()).collect();
        for i in 0..3 {
            assert_eq!(batch[i].address(), single[i].address());
            assert_eq!(batch[i].path(), single[i].path());
            assert_eq!(batch[i].npub(), single[i].npub());
            assert_eq!(batch[i].nsec().as_str(), single[i].nsec().as_str());
        }
    }

    #[test]
    fn passphrase_changes_derivation() {
        let w = Wallet::from_mnemonic(TV1_MNEMONIC, Some("TREZOR")).unwrap();
        assert_ne!(
            Deriver::new(&wallet(TV1_MNEMONIC))
                .derive(0)
                .unwrap()
                .address(),
            Deriver::new(&w).derive(0).unwrap().address(),
        );
    }
}