#[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;
pub const NSEC_HRP: &str = "nsec";
pub const NPUB_HRP: &str = "npub";
#[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 {
#[inline]
#[must_use]
pub const fn nsec(&self) -> &Zeroizing<String> {
&self.nsec
}
#[inline]
#[must_use]
pub fn npub(&self) -> &str {
self.inner.address()
}
#[inline]
#[must_use]
pub const fn as_derived_account(&self) -> &DerivedAccount {
&self.inner
}
#[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
}
}
#[derive(Debug)]
pub struct Deriver<'a> {
wallet: &'a Wallet,
}
impl<'a> Deriver<'a> {
#[inline]
#[must_use]
pub const fn new(wallet: &'a Wallet) -> Self {
Self { wallet }
}
#[inline]
pub fn derive(&self, index: u32) -> Result<NostrAccount, DeriveError> {
self.derive_at(&format!("m/44'/1237'/{index}'/0/0"))
}
pub fn derive_at(&self, path: &str) -> Result<NostrAccount, DeriveError> {
let key = self.wallet.derive_secp256k1(path)?;
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;
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::*;
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";
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}"
);
}
#[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);
assert_eq!(a.address(), TV1_NPUB);
}
#[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);
}
#[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(),
);
}
}