Skip to main content

signer_nostr/
lib.rs

1//! Nostr transaction signer built on BIP-340 Schnorr over secp256k1.
2//!
3//! Implements the signing side of the Nostr protocol
4//! ([NIP-01](https://nips.nostr.com/1)) with
5//! [NIP-19](https://nips.nostr.com/19) bech32 key encoding (`nsec` for
6//! private keys, `npub` for x-only public keys).
7//!
8//! **Key derivation from a mnemonic is handled by `kobe-nostr`
9//! ([NIP-06](https://nips.nostr.com/6)) — this crate is signing only.**
10//!
11//! # Examples
12//!
13//! ```
14//! use signer_nostr::{Sign as _, Signer};
15//!
16//! // NIP-06 test vector 1.
17//! let signer = Signer::from_hex(
18//!     "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a",
19//! )
20//! .unwrap();
21//! assert!(signer.address().starts_with("npub1"));
22//!
23//! // Sign a NIP-01 event id (32-byte SHA-256 of the canonical serialization).
24//! let event_id = [0u8; 32];
25//! let out = signer.sign_hash(&event_id).unwrap();
26//! assert_eq!(out.to_bytes().len(), 64); // BIP-340 Schnorr
27//! ```
28
29#![cfg_attr(not(feature = "std"), no_std)]
30
31extern crate alloc;
32
33use alloc::string::String;
34use alloc::vec::Vec;
35
36use bech32::{Bech32, Hrp};
37use sha2::{Digest as _, Sha256};
38pub use signer_primitives::{self, Sign, SignError, SignMessage, SignOutput};
39use signer_primitives::{SchnorrSigner, delegate_schnorr_ctors};
40use zeroize::Zeroizing;
41
42/// NIP-19 human-readable part for private keys.
43pub const NSEC_HRP: &str = "nsec";
44/// NIP-19 human-readable part for public keys.
45pub const NPUB_HRP: &str = "npub";
46/// NIP-19 human-readable part for event ids.
47pub const NOTE_HRP: &str = "note";
48
49/// Nostr transaction signer.
50///
51/// Newtype over BIP-340 [`SchnorrSigner`]. The inner key is zeroized on drop.
52#[derive(Debug)]
53pub struct Signer(SchnorrSigner);
54
55impl Signer {
56    delegate_schnorr_ctors!();
57
58    /// Create from a NIP-19 `nsec1…` bech32-encoded private key.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`SignError::InvalidKey`] if the string is malformed, has the
63    /// wrong human-readable part, or decodes to bytes that are not a valid
64    /// secp256k1 scalar.
65    pub fn from_nsec(nsec: &str) -> Result<Self, SignError> {
66        let bytes = decode_bech32_32(nsec, NSEC_HRP)?;
67        Self::from_bytes(&bytes)
68    }
69
70    /// NIP-19 `npub1…` bech32-encoded x-only public key.
71    ///
72    /// This is the canonical on-wire address format for a Nostr account.
73    #[must_use]
74    pub fn address(&self) -> String {
75        encode_bech32(NPUB_HRP, &self.0.xonly_public_key())
76    }
77
78    /// Alias for [`address`](Self::address).
79    #[must_use]
80    pub fn npub(&self) -> String {
81        self.address()
82    }
83
84    /// NIP-19 `nsec1…` bech32-encoded private key (zeroized on drop).
85    ///
86    /// Handle with the same care as the raw private key.
87    #[must_use]
88    pub fn nsec(&self) -> Zeroizing<String> {
89        Zeroizing::new(encode_bech32(NSEC_HRP, &self.0.to_bytes()))
90    }
91
92    /// 32-byte x-only public key (NIP-01 wire format).
93    #[must_use]
94    pub fn public_key_bytes(&self) -> Vec<u8> {
95        self.0.xonly_public_key().to_vec()
96    }
97
98    /// Hex-encoded 32-byte x-only public key (64 lowercase characters).
99    #[must_use]
100    pub fn public_key_hex(&self) -> String {
101        self.0.xonly_public_key_hex()
102    }
103
104    /// Verify a signature against a message with this signer's public key.
105    ///
106    /// Primarily intended for round-trip testing.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`SignError::InvalidSignature`] on verification failure.
111    pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), SignError> {
112        self.0.verify(message, signature)
113    }
114
115    /// Sign a serialized NIP-01 event: computes SHA-256 then Schnorr-signs.
116    ///
117    /// `serialized_event` is the UTF-8 JSON-serialized form of
118    /// `[0, pubkey, created_at, kind, tags, content]` as specified in
119    /// NIP-01. The returned 64-byte Schnorr signature is valid for the
120    /// event whose id equals `sha256(serialized_event)`.
121    ///
122    /// # Errors
123    ///
124    /// Returns [`SignError::SigningFailed`] if the underlying Schnorr
125    /// primitive fails (practically unreachable for well-formed inputs).
126    pub fn sign_transaction(&self, serialized_event: &[u8]) -> Result<SignOutput, SignError> {
127        let event_id: [u8; 32] = Sha256::digest(serialized_event).into();
128        self.sign_hash(&event_id)
129    }
130}
131
132impl Sign for Signer {
133    type Error = SignError;
134
135    /// Sign a NIP-01 event id (32-byte SHA-256 of the canonical serialization).
136    ///
137    /// This is the canonical Nostr signing entry point: callers serialize the
138    /// event per NIP-01 §"Events and signatures", compute its SHA-256, and
139    /// pass the resulting 32 bytes here.
140    ///
141    /// Returns a 64-byte BIP-340 Schnorr signature with the signer's x-only
142    /// public key attached.
143    fn sign_hash(&self, event_id: &[u8; 32]) -> Result<SignOutput, SignError> {
144        self.0.sign_prehash(event_id)
145    }
146}
147
148impl SignMessage for Signer {
149    /// **Framing**: raw BIP-340 Schnorr over the message bytes — no prefix,
150    /// no hashing. Nostr has no canonical "signed message" envelope (unlike
151    /// EIP-191 / BIP-137 / TRON prefix).
152    ///
153    /// For on-protocol Nostr events, always prefer:
154    ///
155    /// - [`Sign::sign_hash`] with the NIP-01 `event.id`
156    ///   (32-byte SHA-256 of the canonical serialization), or
157    /// - [`Signer::sign_transaction`] with the serialized event JSON — it
158    ///   computes `sha256(event)` for you.
159    ///
160    /// Use this method only for bespoke off-protocol challenges where both
161    /// signer and verifier agree on the exact input bytes.
162    fn sign_message(&self, message: &[u8]) -> Result<SignOutput, SignError> {
163        self.0.sign(message)
164    }
165}
166
167#[cfg(feature = "kobe")]
168impl Signer {
169    /// Create from a [`kobe_nostr::DerivedAccount`].
170    ///
171    /// Uses the raw 32-byte private key directly (no hex or bech32
172    /// round-trip).
173    ///
174    /// # Errors
175    ///
176    /// Returns [`SignError::InvalidKey`] if the derived bytes are not a
177    /// valid secp256k1 scalar.
178    pub fn from_derived(account: &kobe_nostr::DerivedAccount) -> Result<Self, SignError> {
179        Self::from_bytes(account.private_key_bytes())
180    }
181}
182
183fn encode_bech32(hrp: &str, data: &[u8]) -> String {
184    let hrp = Hrp::parse_unchecked(hrp);
185    #[allow(
186        clippy::expect_used,
187        reason = "HRP is a validated compile-time constant and data length is bounded"
188    )]
189    {
190        bech32::encode::<Bech32>(hrp, data).expect("bech32 encoding over known-good input")
191    }
192}
193
194fn decode_bech32_32(s: &str, expected_hrp: &str) -> Result<[u8; 32], SignError> {
195    let (hrp, data) = bech32::decode(s)
196        .map_err(|e| SignError::InvalidKey(alloc::format!("nip-19 bech32: {e}")))?;
197    if hrp.as_str() != expected_hrp {
198        return Err(SignError::InvalidKey(alloc::format!(
199            "nip-19 bech32: expected HRP `{expected_hrp}`, got `{}`",
200            hrp.as_str()
201        )));
202    }
203    data.try_into().map_err(|v: Vec<u8>| {
204        SignError::InvalidKey(alloc::format!(
205            "nip-19 bech32: expected 32 bytes, got {}",
206            v.len()
207        ))
208    })
209}
210
211#[cfg(test)]
212mod tests;