Skip to main content

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}