Skip to main content

nula_core/
signer.rs

1//! `NostrSigner` trait — the universal signing interface used by every
2//! higher-level crate.
3//!
4//! Implementations vary widely:
5//!
6//! - in-process [`Keys`] (default for client and relay tooling),
7//! - NIP-07 browser extensions,
8//! - NIP-46 remote signers / bunkers,
9//! - hardware bunkers behind RPC.
10//!
11//! All of them eventually answer the same two questions: "what is your
12//! public key?" and "please sign this unsigned event". The trait is
13//! deliberately object-safe (`dyn NostrSigner`) so consumers can store an
14//! `Arc<dyn NostrSigner>` in their state without committing to a concrete
15//! signer at construction time.
16//!
17//! # Why `Pin<Box<dyn Future + Send>>` instead of `async fn`
18//!
19//! `async fn` in traits is stable since Rust 1.75, but the resulting
20//! return-position-impl-trait makes the trait *not* `dyn`-safe on stable.
21//! Higher-level crates need `Arc<dyn NostrSigner>` (relay pools, gossip
22//! planners, multi-account UIs); a `dyn`-unsafe trait would force every
23//! consumer to either (a) pick a concrete signer at construction time,
24//! or (b) pull in a third-party adapter such as `trait_variant`.
25//!
26//! Boxing the future is the idiomatic stable workaround and the same
27//! choice the `tokio` / `futures` ecosystem uses for object-safe async
28//! traits. The single allocation per call is negligible compared to the
29//! Schnorr signature itself, and impls that already produce a boxed
30//! future (NIP-46 RPC, browser extensions) pay no extra cost.
31
32use std::error::Error as StdError;
33use std::fmt;
34use std::future::Future;
35use std::pin::Pin;
36use std::sync::Arc;
37
38use thiserror::Error as ThisError;
39
40use crate::event::{Event, UnsignedEvent, UnsignedEventError};
41use crate::key::{Keys, PublicKey};
42
43/// A type-erased `Future` returned by [`NostrSigner`] methods.
44///
45/// On every non-wasm target the future is `Send` so consumers can move
46/// signer calls across `tokio::spawn` boundaries. On `wasm32` the `Send`
47/// bound is dropped: NIP-07 browser signers return `!Send` `JsFuture`s
48/// (the same target split [`crate::boxed::BoxFuture`] makes).
49///
50/// Synchronous signers can wrap their work with [`std::future::ready`].
51#[cfg(not(target_arch = "wasm32"))]
52pub type SignerFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
53
54/// A type-erased `Future` returned by [`NostrSigner`] methods. On
55/// `wasm32` the `Send` bound is dropped because NIP-07 browser signers
56/// return `!Send` `JsFuture`s.
57#[cfg(target_arch = "wasm32")]
58pub type SignerFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
59
60/// Box and pin an `async` block as a [`SignerFuture`].
61///
62/// Convenience wrapper so signer impls can write
63///
64/// ```ignore
65/// fn get_public_key(&self) -> SignerFuture<'_, Result<PublicKey, SignerError>> {
66///     boxed_signer_future(async { Ok(*self.public_key()) })
67/// }
68/// ```
69///
70/// instead of repeating `Box::pin(async move { ... })` at every call
71/// site. The function exists in `nula_core` so downstream crates do not
72/// have to depend on `futures-util` or rewrite the boilerplate.
73///
74/// The `Send` bound on `future` follows the same target split as
75/// [`SignerFuture`]: required off-wasm, dropped on `wasm32`.
76#[cfg(not(target_arch = "wasm32"))]
77pub fn boxed_signer_future<'a, F, T>(future: F) -> SignerFuture<'a, T>
78where
79    F: Future<Output = T> + Send + 'a,
80{
81    Box::pin(future)
82}
83
84/// Box and pin an `async` block as a [`SignerFuture`] (wasm32: no `Send`
85/// bound, since browser signer futures are `!Send`).
86#[cfg(target_arch = "wasm32")]
87pub fn boxed_signer_future<'a, F, T>(future: F) -> SignerFuture<'a, T>
88where
89    F: Future<Output = T> + 'a,
90{
91    Box::pin(future)
92}
93
94/// Errors raised by a [`NostrSigner`].
95#[derive(Debug, ThisError)]
96#[non_exhaustive]
97pub enum SignerError {
98    /// The signer's public key did not match the unsigned event author.
99    #[error(transparent)]
100    AuthorMismatch(#[from] UnsignedEventError),
101    /// The remote signer rejected the request (e.g. user denied a NIP-46
102    /// prompt, NIP-07 returned `null`, …).
103    ///
104    /// `code` carries the machine-readable NIP-46 error string when the
105    /// backend supplies one (`"user_rejected"`, `"timeout"`, etc.); leave
106    /// it `None` for backends without a structured error channel.
107    #[error(
108        "signer rejected the request{}: {message}",
109        code.as_ref().map_or_else(String::new, |c| format!(" (code = {c})"))
110    )]
111    Rejected {
112        /// Human-readable explanation, suitable for display.
113        message: String,
114        /// Machine-readable error code when supplied by the backend.
115        code: Option<String>,
116    },
117    /// The signer could not communicate with its backend.
118    #[error("signer backend failure: {0}")]
119    Backend(Box<dyn StdError + Send + Sync>),
120    /// The signer does not implement the requested operation.
121    #[error("signer does not support `{0}`")]
122    Unsupported(&'static str),
123}
124
125impl SignerError {
126    /// Wrap an arbitrary error as a backend failure.
127    pub fn backend<E>(err: E) -> Self
128    where
129        E: StdError + Send + Sync + 'static,
130    {
131        Self::Backend(Box::new(err))
132    }
133
134    /// Convenience constructor for [`SignerError::Rejected`] without a
135    /// structured backend code (NIP-07, sandbox signers, etc.).
136    pub fn rejected<S>(message: S) -> Self
137    where
138        S: Into<String>,
139    {
140        Self::Rejected {
141            message: message.into(),
142            code: None,
143        }
144    }
145
146    /// Convenience constructor for [`SignerError::Rejected`] with a
147    /// machine-readable code (typically the NIP-46 `error` string).
148    pub fn rejected_with_code<S, C>(message: S, code: C) -> Self
149    where
150        S: Into<String>,
151        C: Into<String>,
152    {
153        Self::Rejected {
154            message: message.into(),
155            code: Some(code.into()),
156        }
157    }
158}
159
160/// Universal signer trait.
161///
162/// Object-safe by design: every method returns a [`SignerFuture`]. The
163/// trait covers two responsibility levels:
164///
165/// 1. **Mandatory**: [`Self::get_public_key`] and [`Self::sign_event`].
166///    Every signer can answer these — that's the whole point of a
167///    signer.
168/// 2. **Optional encryption capabilities** (NIP-04 / NIP-44 v2). The
169///    four `*_encrypt` / `*_decrypt` methods carry default
170///    implementations that return [`SignerError::Unsupported`].
171///    Concrete signers override them when the underlying backend can
172///    perform the operation:
173///
174///    | Signer        | NIP-04 | NIP-44 v2 |
175///    |---------------|:------:|:---------:|
176///    | [`Keys`]      |   ✅   |    ✅     |
177///    | NIP-07        |   ✅   |    ✅     |
178///    | NIP-46        |   ✅   |    ✅     |
179///    | Hardware-only |   ❌   |    ❌     |
180///
181/// The opt-out style keeps the trait `dyn`-safe (capability supertraits
182/// would require dynamic downcasts) and gives downstream code a single
183/// import path instead of `where S: NostrSigner + Nip04Cipher + Nip44Cipher`.
184pub trait NostrSigner: fmt::Debug + Send + Sync {
185    /// Return the signer's public key.
186    ///
187    /// The method is named `get_public_key` (rather than `public_key`) so it
188    /// never shadows the inherent accessor on concrete keypair types like
189    /// [`Keys`].
190    fn get_public_key(&self) -> SignerFuture<'_, Result<PublicKey, SignerError>>;
191
192    /// Sign an [`UnsignedEvent`] and return the resulting [`Event`].
193    ///
194    /// Implementations must reject events whose `pubkey` does not match the
195    /// signer's own public key.
196    fn sign_event(&self, unsigned: UnsignedEvent) -> SignerFuture<'_, Result<Event, SignerError>>;
197
198    /// NIP-04 (legacy) encrypt to `peer`.
199    ///
200    /// # Errors
201    ///
202    /// Default impl returns [`SignerError::Unsupported`]; override in
203    /// signers that can produce NIP-04 ciphertexts.
204    fn nip04_encrypt<'a>(
205        &'a self,
206        _peer: &'a PublicKey,
207        _plaintext: &'a str,
208    ) -> SignerFuture<'a, Result<String, SignerError>> {
209        boxed_signer_future(async { Err(SignerError::Unsupported("nip04_encrypt")) })
210    }
211
212    /// NIP-04 (legacy) decrypt from `peer`.
213    ///
214    /// # Errors
215    ///
216    /// See [`Self::nip04_encrypt`].
217    fn nip04_decrypt<'a>(
218        &'a self,
219        _peer: &'a PublicKey,
220        _ciphertext: &'a str,
221    ) -> SignerFuture<'a, Result<String, SignerError>> {
222        boxed_signer_future(async { Err(SignerError::Unsupported("nip04_decrypt")) })
223    }
224
225    /// NIP-44 v2 encrypt to `peer`.
226    ///
227    /// # Errors
228    ///
229    /// Default impl returns [`SignerError::Unsupported`]; override in
230    /// signers that can produce NIP-44 ciphertexts.
231    fn nip44_encrypt<'a>(
232        &'a self,
233        _peer: &'a PublicKey,
234        _plaintext: &'a str,
235    ) -> SignerFuture<'a, Result<String, SignerError>> {
236        boxed_signer_future(async { Err(SignerError::Unsupported("nip44_encrypt")) })
237    }
238
239    /// NIP-44 v2 decrypt from `peer`.
240    ///
241    /// # Errors
242    ///
243    /// See [`Self::nip44_encrypt`].
244    fn nip44_decrypt<'a>(
245        &'a self,
246        _peer: &'a PublicKey,
247        _payload: &'a str,
248    ) -> SignerFuture<'a, Result<String, SignerError>> {
249        boxed_signer_future(async { Err(SignerError::Unsupported("nip44_decrypt")) })
250    }
251}
252
253impl NostrSigner for Keys {
254    fn get_public_key(&self) -> SignerFuture<'_, Result<PublicKey, SignerError>> {
255        let key = *self.public_key();
256        boxed_signer_future(async move { Ok(key) })
257    }
258
259    fn sign_event(&self, unsigned: UnsignedEvent) -> SignerFuture<'_, Result<Event, SignerError>> {
260        boxed_signer_future(async move {
261            let event = unsigned.sign_with_keys(self)?;
262            Ok(event)
263        })
264    }
265
266    #[cfg(feature = "nip04")]
267    fn nip04_encrypt<'a>(
268        &'a self,
269        peer: &'a PublicKey,
270        plaintext: &'a str,
271    ) -> SignerFuture<'a, Result<String, SignerError>> {
272        boxed_signer_future(async move {
273            crate::nips::nip04::encrypt(self.secret_key(), peer, plaintext)
274                .map_err(SignerError::backend)
275        })
276    }
277
278    #[cfg(feature = "nip04")]
279    fn nip04_decrypt<'a>(
280        &'a self,
281        peer: &'a PublicKey,
282        ciphertext: &'a str,
283    ) -> SignerFuture<'a, Result<String, SignerError>> {
284        boxed_signer_future(async move {
285            crate::nips::nip04::decrypt(self.secret_key(), peer, ciphertext)
286                .map_err(SignerError::backend)
287        })
288    }
289
290    #[cfg(feature = "nip44")]
291    fn nip44_encrypt<'a>(
292        &'a self,
293        peer: &'a PublicKey,
294        plaintext: &'a str,
295    ) -> SignerFuture<'a, Result<String, SignerError>> {
296        boxed_signer_future(async move {
297            crate::nips::nip44::encrypt(self.secret_key(), peer, plaintext)
298                .map_err(SignerError::backend)
299        })
300    }
301
302    #[cfg(feature = "nip44")]
303    fn nip44_decrypt<'a>(
304        &'a self,
305        peer: &'a PublicKey,
306        payload: &'a str,
307    ) -> SignerFuture<'a, Result<String, SignerError>> {
308        boxed_signer_future(async move {
309            crate::nips::nip44::decrypt(self.secret_key(), peer, payload)
310                .map_err(SignerError::backend)
311        })
312    }
313}
314
315impl<S> NostrSigner for Arc<S>
316where
317    S: NostrSigner + ?Sized,
318{
319    fn get_public_key(&self) -> SignerFuture<'_, Result<PublicKey, SignerError>> {
320        (**self).get_public_key()
321    }
322
323    fn sign_event(&self, unsigned: UnsignedEvent) -> SignerFuture<'_, Result<Event, SignerError>> {
324        (**self).sign_event(unsigned)
325    }
326
327    fn nip04_encrypt<'a>(
328        &'a self,
329        peer: &'a PublicKey,
330        plaintext: &'a str,
331    ) -> SignerFuture<'a, Result<String, SignerError>> {
332        (**self).nip04_encrypt(peer, plaintext)
333    }
334
335    fn nip04_decrypt<'a>(
336        &'a self,
337        peer: &'a PublicKey,
338        ciphertext: &'a str,
339    ) -> SignerFuture<'a, Result<String, SignerError>> {
340        (**self).nip04_decrypt(peer, ciphertext)
341    }
342
343    fn nip44_encrypt<'a>(
344        &'a self,
345        peer: &'a PublicKey,
346        plaintext: &'a str,
347    ) -> SignerFuture<'a, Result<String, SignerError>> {
348        (**self).nip44_encrypt(peer, plaintext)
349    }
350
351    fn nip44_decrypt<'a>(
352        &'a self,
353        peer: &'a PublicKey,
354        payload: &'a str,
355    ) -> SignerFuture<'a, Result<String, SignerError>> {
356        (**self).nip44_decrypt(peer, payload)
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::event::EventBuilder;
364    use crate::types::Timestamp;
365
366    fn fixture_keys() -> Keys {
367        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
368    }
369
370    fn block_on<F: Future>(f: F) -> F::Output {
371        // Smallest possible executor: poll once, panic if pending.
372        use std::pin::pin;
373        use std::task::{Context, Poll, Waker};
374
375        let waker = Waker::noop();
376        let mut cx = Context::from_waker(waker);
377        let mut fut = pin!(f);
378        match fut.as_mut().poll(&mut cx) {
379            Poll::Ready(out) => out,
380            Poll::Pending => unreachable!("test futures must be synchronous"),
381        }
382    }
383
384    #[test]
385    fn keys_implement_signer() {
386        let keys = fixture_keys();
387        let pk = block_on(keys.get_public_key()).unwrap();
388        assert_eq!(pk, *keys.public_key());
389    }
390
391    #[test]
392    fn keys_sign_event() {
393        let keys = fixture_keys();
394        let unsigned = EventBuilder::text_note("hi")
395            .created_at(Timestamp::from_secs(1))
396            .build_unsigned(*keys.public_key())
397            .unwrap();
398        let event = block_on(keys.sign_event(unsigned)).unwrap();
399        event.verify().unwrap();
400    }
401
402    #[test]
403    fn arc_signer_dispatches() {
404        let keys: Arc<dyn NostrSigner> = Arc::new(fixture_keys());
405        let pk = block_on(keys.get_public_key()).unwrap();
406        assert_eq!(pk.to_byte_array().len(), 32);
407    }
408
409    #[test]
410    fn rejected_error_carries_reason() {
411        let err = SignerError::rejected("user denied");
412        let s = err.to_string();
413        assert!(s.contains("user denied"));
414        assert!(!s.contains("code"), "no structured code → no code suffix");
415    }
416
417    #[test]
418    fn rejected_with_code_surfaces_machine_readable_code() {
419        let err = SignerError::rejected_with_code("user denied", "user_rejected");
420        let s = err.to_string();
421        assert!(s.contains("user denied"));
422        assert!(
423            s.contains("code = user_rejected"),
424            "expected code suffix in: {s}",
425        );
426    }
427
428    #[test]
429    fn backend_error_round_trip() {
430        let inner = std::io::Error::other("oops");
431        let err = SignerError::backend(inner);
432        assert!(err.to_string().contains("oops"));
433    }
434
435    /// Minimal sign-only signer that opts out of every encryption
436    /// capability. Used to exercise the default `Unsupported` return
437    /// values on the trait, independent of which NIP feature flags
438    /// are enabled in this build.
439    #[derive(Debug)]
440    struct SignOnlySigner(Keys);
441
442    impl NostrSigner for SignOnlySigner {
443        fn get_public_key(&self) -> SignerFuture<'_, Result<PublicKey, SignerError>> {
444            self.0.get_public_key()
445        }
446
447        fn sign_event(
448            &self,
449            unsigned: UnsignedEvent,
450        ) -> SignerFuture<'_, Result<Event, SignerError>> {
451            self.0.sign_event(unsigned)
452        }
453        // No encryption overrides — every `nipNN_*` method falls back
454        // to the trait default that returns `SignerError::Unsupported`.
455    }
456
457    #[test]
458    fn default_encryption_methods_return_unsupported() {
459        // Hardware-only signers cannot encrypt; the trait's default
460        // impls must surface that as a structured error rather than a
461        // panic, regardless of build features.
462        let alice = SignOnlySigner(fixture_keys());
463        let bob_pk =
464            *Keys::parse("0000000000000000000000000000000000000000000000000000000000000007")
465                .unwrap()
466                .public_key();
467
468        let cases: [(&str, SignerFuture<'_, _>); 4] = [
469            ("nip04_encrypt", alice.nip04_encrypt(&bob_pk, "hi")),
470            ("nip04_decrypt", alice.nip04_decrypt(&bob_pk, "")),
471            ("nip44_encrypt", alice.nip44_encrypt(&bob_pk, "hi")),
472            ("nip44_decrypt", alice.nip44_decrypt(&bob_pk, "")),
473        ];
474        for (label, fut) in cases {
475            let err = block_on(fut).unwrap_err();
476            assert!(
477                matches!(err, SignerError::Unsupported(name) if name == label),
478                "expected Unsupported({label}), got {err:?}",
479            );
480        }
481    }
482
483    #[cfg(feature = "nip04")]
484    #[test]
485    fn keys_nip04_round_trip_through_signer_trait() {
486        let alice = fixture_keys();
487        let bob = Keys::parse("0000000000000000000000000000000000000000000000000000000000000007")
488            .unwrap();
489        let payload = block_on(alice.nip04_encrypt(bob.public_key(), "legacy hi")).unwrap();
490        let recovered = block_on(bob.nip04_decrypt(alice.public_key(), &payload)).unwrap();
491        assert_eq!(recovered, "legacy hi");
492    }
493
494    #[cfg(feature = "nip44")]
495    #[test]
496    fn keys_nip44_round_trip_through_signer_trait() {
497        // Two-party round trip using the `NostrSigner` trait surface.
498        // This proves the trait wires the underlying `nips::nip44`
499        // helpers correctly without leaking the secret key.
500        let alice = fixture_keys();
501        let bob = Keys::parse("0000000000000000000000000000000000000000000000000000000000000007")
502            .unwrap();
503        let payload = block_on(alice.nip44_encrypt(bob.public_key(), "secret")).unwrap();
504        let recovered = block_on(bob.nip44_decrypt(alice.public_key(), &payload)).unwrap();
505        assert_eq!(recovered, "secret");
506    }
507
508    #[cfg(feature = "nip44")]
509    #[test]
510    fn arc_dyn_signer_forwards_nip44_methods() {
511        // Object-safe path: `Arc<dyn NostrSigner>` must transparently
512        // delegate every encryption method to the wrapped signer.
513        let alice: Arc<dyn NostrSigner> = Arc::new(fixture_keys());
514        let bob = Keys::parse("0000000000000000000000000000000000000000000000000000000000000007")
515            .unwrap();
516        let payload = block_on(alice.nip44_encrypt(bob.public_key(), "via dyn")).unwrap();
517        let recovered =
518            block_on(bob.nip44_decrypt(&block_on(alice.get_public_key()).unwrap(), &payload))
519                .unwrap();
520        assert_eq!(recovered, "via dyn");
521    }
522}