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;