nostro2_traits/lib.rs
1#![warn(
2 clippy::all,
3 clippy::missing_errors_doc,
4 clippy::style,
5 clippy::pedantic,
6 clippy::nursery
7)]
8//! # `nostro2-traits`
9//!
10//! Signing surface for the `nostro2` ecosystem. Two traits, no data
11//! structures, no curve dependencies — `nostro2` (data), `nostro2-nips`
12//! (protocols), and `nostro2-signer` (impls) all depend on this crate.
13//!
14//! - [`NostrSigner`]: minimum signing surface (sign a 32-byte prehash, expose
15//! the x-only pubkey, generate a fresh signer). Hardware wallets, NIP-46
16//! remote signers, and browser extensions can implement this without ever
17//! exposing key material.
18//! - [`NostrKeypair`]: extends [`NostrSigner`] with raw secret-key export and
19//! ECDH for in-process keypairs.
20//! - [`hex`]: minimal hex encode/decode traits (`Hexable`, `FromHex`).
21
22pub mod bech32;
23pub mod hex;
24use hex::{FromHex as _, Hexable as _};
25
26// ── Private supertrait extensions (auto-implemented via blanket impls) ──
27
28/// Auto-derived bech32 encoding for any signer.
29///
30/// Blanket-implemented for every [`NostrSigner`] — no extra work for
31/// implementors. Used by `nostro2-signer` to provide `npub()` / `nsec()`
32/// without reaching into the `bech32` module directly.
33#[doc(hidden)]
34pub trait SignerBech32: NostrSigner {
35 /// Encode the public key as `npub1…` bech32.
36 ///
37 /// # Errors
38 /// Returns [`bech32::Bech32Error`] if encoding fails.
39 fn to_npub(&self) -> std::result::Result<String, bech32::Bech32Error> {
40 bech32::Bech32Crypto::encode("npub", &self.pubkey_bytes())
41 }
42}
43impl<T: NostrSigner + ?Sized> SignerBech32 for T {}
44
45/// Auto-derived bech32 encoding for any keypair.
46#[doc(hidden)]
47pub trait KeypairBech32: NostrKeypair {
48 /// Encode the secret key as `nsec1…` bech32.
49 ///
50 /// # Errors
51 /// Returns [`bech32::Bech32Error`] if encoding fails.
52 fn to_nsec(&self) -> std::result::Result<String, bech32::Bech32Error> {
53 bech32::Bech32Crypto::encode("nsec", &self.secret_bytes())
54 }
55}
56impl<T: NostrKeypair + ?Sized> KeypairBech32 for T {}
57
58/// Errors returned by signing and key-derivation operations.
59#[derive(Debug)]
60pub enum SignerError {
61 MissingId,
62 MissingSignature,
63 InvalidPublicKey,
64 InvalidSignature,
65 Backend(String),
66}
67
68impl std::fmt::Display for SignerError {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 Self::MissingId => f.write_str("missing id on note"),
72 Self::MissingSignature => f.write_str("missing signature on note"),
73 Self::InvalidPublicKey => f.write_str("invalid public key"),
74 Self::InvalidSignature => f.write_str("invalid signature"),
75 Self::Backend(s) => write!(f, "signing backend error: {s}"),
76 }
77 }
78}
79
80impl std::error::Error for SignerError {}
81
82/// Convenience alias.
83pub type Result<T> = std::result::Result<T, SignerError>;
84
85/// Minimal signing surface: produce a Schnorr signature over a 32-byte
86/// prehash and expose the x-only public key. All hex/bech32 conversions are
87/// default methods on top.
88///
89/// Implementors can be: in-memory keypairs, hardware wallets, NIP-46 remote
90/// signers, browser extensions. The trait does not assume the implementor
91/// holds the raw secret bytes (that's [`NostrKeypair`]) or that fresh keys
92/// can be conjured locally (that's also [`NostrKeypair`]). It is therefore
93/// dyn-compatible — `Box<dyn NostrSigner>` is a valid type.
94pub trait NostrSigner {
95 /// Sign a 32-byte prehash and return the 64-byte Schnorr signature.
96 ///
97 /// # Errors
98 /// Returns an error if the underlying signer rejects the prehash or
99 /// fails for transport/hardware reasons.
100 fn sign_prehash(&self, id: &[u8; 32]) -> Result<[u8; 64]>;
101
102 /// Return the x-only public key as 32 raw bytes.
103 fn pubkey_bytes(&self) -> [u8; 32];
104
105 /// Return the public key as a 64-character lowercase hex string.
106 #[inline]
107 fn public_key(&self) -> String {
108 self.pubkey_bytes().to_hex()
109 }
110}
111
112/// Extended interface for signers that hold raw secret material in process,
113/// adding key export, ECDH, and local key generation.
114///
115/// Remote signers (NIP-46), hardware wallets, and any signer that *cannot*
116/// hand out the secret bytes should implement [`NostrSigner`] only.
117pub trait NostrKeypair: NostrSigner {
118 /// Return the raw 32-byte secret key.
119 fn secret_bytes(&self) -> [u8; 32];
120
121 /// Generate a fresh random keypair.
122 ///
123 /// In-process only — hardware wallets and NIP-46 remote signers cannot
124 /// satisfy this and so do not implement [`NostrKeypair`].
125 fn generate() -> Self
126 where
127 Self: Sized;
128
129 /// Rebuild a keypair from raw 32-byte secret material.
130 ///
131 /// Needed to revive ephemeral keys persisted in protocol state (e.g. the
132 /// NIP-104 double ratchet), where only the secret bytes are stored.
133 ///
134 /// # Errors
135 /// Returns [`SignerError::InvalidSignature`] if the bytes are not a valid
136 /// scalar for the curve.
137 fn from_secret_bytes(bytes: &[u8; 32]) -> Result<Self>
138 where
139 Self: Sized;
140
141 /// Derive the ECDH shared point with a peer's x-only public key. Returns
142 /// the 32-byte X coordinate of the shared point — the same value NIP-04
143 /// and NIP-44 use as their shared-secret seed.
144 ///
145 /// # Errors
146 /// Returns an error if the peer pubkey is not a valid curve point.
147 fn ecdh_x(&self, peer_xonly: &[u8; 32]) -> Result<[u8; 32]>;
148
149 /// Return the raw secret key as a 64-character lowercase hex string.
150 #[inline]
151 fn secret_key(&self) -> String {
152 self.secret_bytes().to_hex()
153 }
154
155 /// Derive the ECDH shared point from a hex-encoded peer x-only pubkey.
156 ///
157 /// # Errors
158 /// Returns an error if the peer pubkey is not 64 hex chars or not a
159 /// valid curve point.
160 fn shared_point(&self, peer_pubkey: &str) -> Result<[u8; 32]> {
161 let mut buf = [0_u8; 32];
162 peer_pubkey
163 .decode_hex_to_slice(&mut buf)
164 .map_err(|_| SignerError::InvalidPublicKey)?;
165 self.ecdh_x(&buf)
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn signer_error_display_all_variants() {
175 let cases = [
176 SignerError::MissingId,
177 SignerError::MissingSignature,
178 SignerError::InvalidPublicKey,
179 SignerError::InvalidSignature,
180 SignerError::Backend("test backend error".into()),
181 ];
182 for err in &cases {
183 let msg = format!("{err}");
184 assert!(!msg.is_empty());
185 }
186 assert!(format!("{}", SignerError::Backend("x".into())).contains('x'));
187 }
188
189 #[test]
190 fn hex_error_display_all_variants() {
191 let cases = [
192 hex::HexError::OddLength,
193 hex::HexError::LengthMismatch,
194 hex::HexError::InvalidChar(b'G'),
195 ];
196 for err in &cases {
197 let msg = format!("{err}");
198 assert!(!msg.is_empty());
199 }
200 assert!(format!("{}", hex::HexError::InvalidChar(b'Z')).contains('Z'));
201 }
202}