Skip to main content

vector_core/
signer.rs

1//! Polymorphic signer — local key vault vs. NIP-46 remote bunker.
2//!
3//! Vector supports two signer modes per account:
4//!
5//! - **Local** — the user's nsec lives in `MY_SECRET_KEY` (GuardedKey vault)
6//!   on this device. Signing is local; the key materialises in plaintext only
7//!   for microseconds per operation.
8//! - **Bunker** — the user's nsec lives on a remote NIP-46 signer (Amber,
9//!   nsec.app, ...). Vector holds only a *client keypair* (in `MY_SECRET_KEY`)
10//!   used to RPC the bunker. Every signing request takes a round-trip; the
11//!   user's identity key never touches this device.
12//!
13//! The discriminator is persisted in the per-account settings DB
14//! (`signer_type` key) and materialised into the `SIGNER_KIND` atomic at
15//! login. Hot paths read the atomic; cold paths read the DB directly.
16//!
17//! Storage layout for bunker accounts (see `db::settings`):
18//! - `signer_type = "bunker"`
19//! - `bunker_url`  = the `bunker://<remote_pubkey>?relay=...&secret=...`
20//!   string, encrypted-at-rest if the account uses pin/pass encryption (same
21//!   path as `pkey`).
22//! - `bunker_remote_pubkey` = the signer's pubkey, plaintext (routing only).
23//! - `pkey` = the NIP-46 client keypair (encrypted-at-rest under the same
24//!   path as local accounts). Reusing the existing vault avoids a second
25//!   GuardedKey slot; see the "Client-keypair storage note" section below.
26
27use std::sync::atomic::{AtomicU8, Ordering};
28use std::sync::{LazyLock, RwLock};
29use std::time::Duration;
30
31use nostr_sdk::prelude::*;
32use nostr_connect::prelude::{AuthUrlHandler, NostrConnect, NostrConnectUri};
33
34// ============================================================================
35// SignerError + VectorSigner — the capability-trait bundle
36// ============================================================================
37
38/// Boxed future returned by every async signer capability.
39///
40/// nostr 0.45.0 inlined this shape into its trait signatures and stopped
41/// exporting an alias, so Vector owns the name.
42pub type BoxedFuture<'a, T> =
43    std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
44
45/// Error from any signing backend.
46///
47/// nostr 0.45 deleted `NostrSigner` and split it into per-capability traits,
48/// each carrying its own associated `Error`. Vector normalises all four onto
49/// this one type so the polymorphic seam stays a single trait bound and callers
50/// keep one error to handle.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SignerError(String);
53
54impl SignerError {
55    /// Wrap a backend error (bunker RPC, NIP-55 IPC, local crypto).
56    #[inline]
57    pub fn backend<E>(e: E) -> Self
58    where
59        E: core::fmt::Display,
60    {
61        Self(e.to_string())
62    }
63}
64
65impl core::fmt::Display for SignerError {
66    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
67        f.write_str(&self.0)
68    }
69}
70
71impl core::error::Error for SignerError {}
72
73impl From<&str> for SignerError {
74    #[inline]
75    fn from(s: &str) -> Self {
76        Self(s.to_string())
77    }
78}
79
80impl From<String> for SignerError {
81    #[inline]
82    fn from(s: String) -> Self {
83        Self(s)
84    }
85}
86
87/// Every capability Vector's polymorphic signing paths need, in one bound.
88///
89/// Pinning `Error = SignerError` is what makes the bundle usable as a single
90/// bound; it's viable because the concrete-`Keys` paths have their own
91/// non-generic overloads (`build_list_event` vs `build_list_event_signed`), so
92/// bare `Keys` is never passed here.
93pub trait VectorSigner:
94    AsyncGetPublicKey<Error = SignerError>
95    + AsyncSignEvent<Error = SignerError>
96    + AsyncNip04<Error = SignerError>
97    + AsyncNip44<Error = SignerError>
98{
99}
100
101impl<T> VectorSigner for T
102where
103    T: ?Sized
104        + AsyncGetPublicKey<Error = SignerError>
105        + AsyncSignEvent<Error = SignerError>
106        + AsyncNip04<Error = SignerError>
107        + AsyncNip44<Error = SignerError>,
108{
109}
110
111// ============================================================================
112// ActiveSigner — the session's signer, resolved on demand
113// ============================================================================
114
115/// The signer for the active session.
116///
117/// nostr 0.45 removed `ClientBuilder::signer` / `Client::signer`: events are
118/// built and signed outside the client now, so Vector owns this dispatch.
119///
120/// A concrete enum rather than `Arc<dyn ...>` on purpose. 0.45 ships no blanket
121/// capability impls for `Arc<T>`, and the orphan rule forbids adding them, so a
122/// trait object would force every call site to deref. This also keeps dispatch
123/// static.
124///
125/// Deliberately NOT cached in a static: [`active_signer`] rebuilds it per call
126/// from state that is already swap-managed (`SIGNER_KIND`, `BUNKER_SIGNER`,
127/// `MY_PUBLIC_KEY`). A cached signer would be one more per-account global to
128/// invalidate on `reset_session`, and a stale one signs the new account's events
129/// under the old identity.
130#[derive(Debug, Clone)]
131pub enum ActiveSigner {
132    /// Local key from the GuardedKey vault.
133    Local(crate::crypto::GuardedSigner),
134    /// Remote NIP-46 bunker, with reachability reporting.
135    Bunker(WatchedBunkerSigner),
136    /// On-device NIP-55 signer app reached over Android IPC.
137    Nip55(crate::nip55::Nip55Signer),
138    /// Raw keys — headless/CLI consumers and tests, which have a vault key but
139    /// no notion of signer modes.
140    Keys(Keys),
141}
142
143macro_rules! dispatch {
144    ($self:ident, $method:ident $(, $arg:expr)*) => {
145        match $self {
146            ActiveSigner::Local(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
147            ActiveSigner::Bunker(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
148            ActiveSigner::Nip55(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
149            ActiveSigner::Keys(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
150        }
151    };
152}
153
154impl AsyncGetPublicKey for ActiveSigner {
155    type Error = SignerError;
156
157    fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, Self::Error>> {
158        Box::pin(async move { dispatch!(self, get_public_key_async) })
159    }
160}
161
162impl AsyncSignEvent for ActiveSigner {
163    type Error = SignerError;
164
165    fn sign_event_async(&self, unsigned: UnsignedEvent) -> BoxedFuture<'_, Result<Event, Self::Error>> {
166        Box::pin(async move { dispatch!(self, sign_event_async, unsigned) })
167    }
168}
169
170impl AsyncNip04 for ActiveSigner {
171    type Error = SignerError;
172
173    fn nip04_encrypt_async<'a>(
174        &'a self,
175        public_key: &'a PublicKey,
176        content: &'a str,
177    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
178        Box::pin(async move { dispatch!(self, nip04_encrypt_async, public_key, content) })
179    }
180
181    fn nip04_decrypt_async<'a>(
182        &'a self,
183        public_key: &'a PublicKey,
184        encrypted_content: &'a str,
185    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
186        Box::pin(async move { dispatch!(self, nip04_decrypt_async, public_key, encrypted_content) })
187    }
188}
189
190impl AsyncNip44 for ActiveSigner {
191    type Error = SignerError;
192
193    fn nip44_encrypt_async<'a>(
194        &'a self,
195        public_key: &'a PublicKey,
196        content: &'a str,
197    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
198        Box::pin(async move { dispatch!(self, nip44_encrypt_async, public_key, content) })
199    }
200
201    fn nip44_decrypt_async<'a>(
202        &'a self,
203        public_key: &'a PublicKey,
204        payload: &'a str,
205    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
206        Box::pin(async move { dispatch!(self, nip44_decrypt_async, public_key, payload) })
207    }
208}
209
210/// Test-only signer override consulted by [`active_signer`].
211#[cfg(test)]
212static TEST_SIGNER: LazyLock<RwLock<Option<ActiveSigner>>> =
213    LazyLock::new(|| RwLock::new(None));
214
215/// Install (or clear) the test signer override.
216#[cfg(test)]
217pub(crate) fn set_test_signer(signer: Option<ActiveSigner>) {
218    if let Ok(mut g) = TEST_SIGNER.write() {
219        *g = signer;
220    }
221}
222
223/// Resolve the active session's signer.
224///
225/// Fails CLOSED on identity mismatch: a remote-signer account's vault holds its
226/// *client* keypair, whose pubkey is not the identity. Signing with it emits
227/// wrong-identity events that self-reject on every reader (AuthorMismatch), so
228/// erroring here surfaces the misconfiguration instead of a silently
229/// undeliverable send.
230pub fn active_signer() -> Result<ActiveSigner, String> {
231    // Tests model a remote-signer account (identity signable, local vault empty),
232    // which production resolves from `BUNKER_SIGNER`. There's no such handle to
233    // fabricate in-process, so tests inject the signer directly.
234    #[cfg(test)]
235    if let Some(s) = TEST_SIGNER.read().ok().and_then(|g| g.clone()) {
236        return Ok(s);
237    }
238    match signer_kind() {
239        SignerKind::Bunker => {
240            let inner = bunker_signer()
241                .ok_or("bunker account has no live signer (not yet connected)")?;
242            Ok(ActiveSigner::Bunker(WatchedBunkerSigner::new(inner)))
243        }
244        SignerKind::Nip55 => {
245            let pk = crate::state::my_public_key().ok_or("no active identity")?;
246            Ok(ActiveSigner::Nip55(crate::nip55::Nip55Signer::new(pk)))
247        }
248        SignerKind::Local => {
249            let keys = crate::state::MY_SECRET_KEY
250                .to_keys()
251                .ok_or("no signer available (no local key)")?;
252            if let Some(pk) = crate::state::my_public_key() {
253                if keys.public_key() != pk {
254                    return Err("local key does not match the active identity (remote-signer account with no live signer)".to_string());
255                }
256                return Ok(ActiveSigner::Local(crate::crypto::GuardedSigner::new(pk)));
257            }
258            // No bound identity: headless/CLI consumers and tests.
259            Ok(ActiveSigner::Keys(keys))
260        }
261    }
262}
263
264// ============================================================================
265// SignerKind — discriminator
266// ============================================================================
267
268/// Which signer backs the active account.
269#[derive(Copy, Clone, Debug, Eq, PartialEq)]
270#[repr(u8)]
271pub enum SignerKind {
272    /// The user's nsec lives in `MY_SECRET_KEY` on this device.
273    Local = 0,
274    /// The user's nsec lives on a remote NIP-46 signer; we only hold the
275    /// client keypair used to RPC it.
276    Bunker = 1,
277    /// The user's nsec lives in an on-device NIP-55 signer app (Amber) reached
278    /// over local Android IPC. Nothing secret is stored on this device at all
279    /// (not even a client keypair). Android-only.
280    Nip55 = 2,
281}
282
283impl SignerKind {
284    /// Persisted form used by the per-account settings KV.
285    #[inline]
286    pub fn as_setting_str(self) -> &'static str {
287        match self {
288            SignerKind::Local => "local",
289            SignerKind::Bunker => "bunker",
290            SignerKind::Nip55 => "nip55",
291        }
292    }
293
294    /// Parse from the on-disk setting string. Unknown values fall back to
295    /// `Local` so an upgrade path from pre-NIP-46 accounts (which have no
296    /// `signer_type` row) is the obvious default.
297    #[inline]
298    pub fn from_setting_str(s: &str) -> Self {
299        match s {
300            "bunker" => SignerKind::Bunker,
301            "nip55" => SignerKind::Nip55,
302            _ => SignerKind::Local,
303        }
304    }
305}
306
307static SIGNER_KIND: AtomicU8 = AtomicU8::new(SignerKind::Local as u8);
308
309/// The signer kind for the active session. Cheap to read; backed by an atomic.
310#[inline]
311pub fn signer_kind() -> SignerKind {
312    match SIGNER_KIND.load(Ordering::Acquire) {
313        1 => SignerKind::Bunker,
314        2 => SignerKind::Nip55,
315        _ => SignerKind::Local,
316    }
317}
318
319/// Install the signer kind for the active session. Call at login after the
320/// settings row has been read, and on swap before any signing work runs.
321#[inline]
322pub fn set_signer_kind(kind: SignerKind) {
323    SIGNER_KIND.store(kind as u8, Ordering::Release);
324}
325
326/// `true` iff the active account signs via a remote NIP-46 bunker. Hot-path
327/// helper for code that needs to branch on signer mode (e.g. parallelising
328/// gift-wrap signing harder when each call pays a round-trip).
329///
330/// Reserve this for genuinely NIP-46-relay-specific logic. For "we don't hold
331/// the identity key on this device" gates (key export refusal, keyless-account
332/// feature availability) use `is_keyless()` instead — a NIP-55 account is
333/// keyless but not a bunker.
334#[inline]
335pub fn is_bunker() -> bool {
336    signer_kind() == SignerKind::Bunker
337}
338
339/// `true` iff the identity key is NOT held on this device — i.e. any remote /
340/// external signer (NIP-46 bunker or NIP-55 Amber). The local `MY_SECRET_KEY`
341/// vault does not hold the signing identity for these accounts, so anything
342/// that reads or exports the raw nsec must gate on this and route through
343/// `client.signer()` instead.
344#[inline]
345pub fn is_keyless() -> bool {
346    signer_kind() != SignerKind::Local
347}
348
349// ============================================================================
350// Client-keypair storage note
351// ============================================================================
352//
353// The NIP-46 client keypair (used to RPC the bunker — not the user's
354// identity) lives in the existing `MY_SECRET_KEY` vault for bunker accounts.
355// This is intentional: every existing call site that loads "the active
356// signing key" gets the client key, which is what the NIP-46 layer wants for
357// its RPC envelope. For events the *user* sends, the path goes through
358// `client.signer()` → NostrConnect, which tunnels to the bunker — so user
359// events are signed by the user's identity, RPC envelopes by the client key.
360//
361// This avoids needing a second GuardedKey vault and the slot-coordination
362// problem that comes with it. The trade-off: bunker accounts share the same
363// memory-protection footprint as local accounts (the user's identity isn't
364// on this device at all).
365
366// ============================================================================
367// BUNKER_SIGNER — live NostrConnect handle
368// ============================================================================
369
370/// Active `NostrConnect` handle. `None` for local-signer sessions.
371///
372/// `NostrConnect` is internally `Arc`-counted (relay pool, OnceCell-backed
373/// remote pubkey cache), so cloning it for per-call use is cheap. The lock is
374/// only held briefly to snapshot the inner value.
375pub static BUNKER_SIGNER: LazyLock<RwLock<Option<NostrConnect>>> =
376    LazyLock::new(|| RwLock::new(None));
377
378/// Snapshot the active bunker handle. Returns `None` for local-signer sessions.
379#[inline]
380pub fn bunker_signer() -> Option<NostrConnect> {
381    BUNKER_SIGNER.read().ok().and_then(|g| g.as_ref().cloned())
382}
383
384/// Install the bunker handle for the active session. Replaces any prior handle
385/// without shutting it down — callers swapping should `take_bunker_signer()`
386/// first and `.shutdown().await` the old one to drain its relay pool cleanly.
387#[inline]
388pub fn set_bunker_signer(signer: NostrConnect) {
389    if let Ok(mut g) = BUNKER_SIGNER.write() {
390        *g = Some(signer);
391    }
392}
393
394/// Atomically remove the bunker handle. Used by session teardown so the
395/// caller can `.shutdown()` it without racing readers.
396#[inline]
397pub fn take_bunker_signer() -> Option<NostrConnect> {
398    BUNKER_SIGNER.write().ok().and_then(|mut g| g.take())
399}
400
401// ============================================================================
402// Construction helpers
403// ============================================================================
404
405/// Parse a `bunker://` URL and return the relay URLs it lists. Used by the
406/// Settings UI to render "Connected via <relay>" without re-bootstrapping.
407/// Returns an empty Vec on any parse failure — the caller treats this as a
408/// display-only signal and renders a generic fallback instead of erroring.
409pub fn parse_bunker_relays(bunker_url: &str) -> Vec<String> {
410    match NostrConnectUri::parse(bunker_url) {
411        Ok(NostrConnectUri::Bunker { relays, .. }) => {
412            relays.into_iter().map(|r| r.to_string()).collect()
413        }
414        _ => Vec::new(),
415    }
416}
417
418/// Inspect a `bunker://` URL without bootstrapping: returns the remote
419/// signer's pubkey (hex). Used by login flows to check whether a re-submitted
420/// URL points at the same bunker as the active session (idempotent re-login)
421/// versus a different bunker (which requires logout first). Cheap — no
422/// network.
423pub fn parse_bunker_remote_pubkey(bunker_url: &str) -> Result<String, String> {
424    let uri = NostrConnectUri::parse(bunker_url)
425        .map_err(|e| format!("Invalid bunker URL: {}", e))?;
426    match uri {
427        NostrConnectUri::Bunker { remote_signer_public_key, .. } => {
428            // Force lowercase. `to_hex()` already returns lowercase per
429            // nostr-sdk, but normalising here lets callers compare hex
430            // forms with `==` without worrying about a future upstream
431            // shift to mixed-case.
432            Ok(remote_signer_public_key.to_hex().to_ascii_lowercase())
433        }
434        // Client-initiated URIs aren't supported as login entry points in v1;
435        // they're for the reverse direction (we hand a URL to the signer).
436        NostrConnectUri::Client { .. } => {
437            Err("Client-initiated URIs not supported here; use a bunker:// URL".into())
438        }
439    }
440}
441
442// ============================================================================
443// Vector app identity — surfaced to remote signers via NIP-46 metadata
444// ============================================================================
445
446/// Application name shown to the user by the remote signer when approving the
447/// connection (e.g. on Amber's pairing screen).
448pub const VECTOR_APP_NAME: &str = "Vector";
449
450/// Marketing site — surfaced as the signer's "More info" link.
451pub const VECTOR_APP_URL: &str = "https://vectorapp.io";
452
453/// Icon shown by the signer alongside the app name. PNG, served from the
454/// public GitHub mirror so the URL stays valid even if vectorapp.io changes
455/// its asset layout. Signers cache by URL, so a stable target avoids
456/// re-fetches on every pairing.
457pub const VECTOR_APP_ICON: &str = "https://raw.githubusercontent.com/VectorPrivacy/Vector/master/src-tauri/icons/icon.png";
458
459/// NIP-46 permission scope Vector requests on client-initiated pairings.
460///
461/// Sent as the `perms=` query parameter on `nostrconnect://` URIs. Signer apps
462/// that honour it (Amber, nsec.app) surface this list on their pairing screen
463/// and refuse RPC calls outside the granted scope. Vector intentionally never
464/// requests `get_private_key`: the whole point of a Remote Signer is that the
465/// identity nsec stays on the signer device, so allowing extraction would
466/// defeat the threat model. Adding a method here is an explicit policy
467/// decision; signer apps that don't enforce `perms` server-side still benefit
468/// from a smaller surface in their pairing UI.
469pub const VECTOR_NIP46_PERMS: &[&str] = &[
470    "get_public_key",
471    "sign_event",
472    "nip04_encrypt",
473    "nip04_decrypt",
474    "nip44_encrypt",
475    "nip44_decrypt",
476];
477
478/// Build the NIP-46 metadata payload Vector advertises in client-initiated
479/// `nostrconnect://` URIs. The signer reads this to render the approval
480/// prompt — name and icon are the bits the user actually sees.
481pub fn vector_metadata() -> NostrConnectMetadata {
482    let mut md = NostrConnectMetadata::new(VECTOR_APP_NAME);
483    if let Ok(url) = Url::parse(VECTOR_APP_URL) {
484        md = md.url(url);
485    }
486    if let Ok(icon) = Url::parse(VECTOR_APP_ICON) {
487        md = md.icons(vec![icon]);
488    }
489    md
490}
491
492/// Build a client-initiated `nostrconnect://` URI. The user copies this URL
493/// into their signer app (or scans the QR rendering of it); the signer
494/// initiates the connection back to the listed relays.
495///
496/// Multi-relay by design — single-relay connect URIs are a centralisation
497/// trap: if that one relay goes down, the user can't reconnect to their own
498/// account. Pass the live trusted-relay list from `state::TRUSTED_RELAYS`.
499pub fn build_nostrconnect_uri(
500    client_pubkey: PublicKey,
501    relays: Vec<RelayUrl>,
502) -> NostrConnectUri {
503    NostrConnectUri::Client {
504        public_key: client_pubkey,
505        relays,
506        metadata: vector_metadata(),
507        secret: random_connect_secret(),
508    }
509}
510
511/// Fresh NIP-46 pairing secret. The signer echoes it in the `connect` response;
512/// a mismatch means someone else answered, so it must be unguessable per session.
513fn random_connect_secret() -> String {
514    use ::rand::RngCore;
515    let mut bytes = [0u8; 16];
516    ::rand::rngs::OsRng.fill_bytes(&mut bytes);
517    bytes.iter().map(|b| format!("{b:02x}")).collect()
518}
519
520/// Build a `NostrConnect` for a client-initiated session — generates the
521/// `nostrconnect://` URI from the client keys + relays + Vector metadata,
522/// constructs the underlying `NostrConnect` with the Vector auth-URL handler
523/// already attached, and returns both for the caller to (a) display the URI
524/// to the user (QR + copy button) and (b) install the signer.
525///
526/// Note: doesn't bootstrap. The caller is expected to install the returned
527/// `NostrConnect` in `BUNKER_SIGNER` and await `get_public_key()` to wait
528/// for the signer's connect response.
529pub fn build_nostrconnect_session(
530    client_keys: Keys,
531    relays: Vec<RelayUrl>,
532    timeout: Duration,
533) -> Result<(NostrConnect, String), String> {
534    let uri = build_nostrconnect_uri(client_keys.public_key(), relays);
535    // Append the NIP-46 `perms=` scope. nostr-sdk's `Display` impl doesn't
536    // write it, so the SDK-built URI is fine to hand back to `NostrConnect`
537    // (which doesn't read perms locally), while the signer app on the other
538    // side parses the appended query param to render its pairing screen.
539    //
540    // The NIP-46 `secret` IS emitted now (0.45 requires it, and its response
541    // parser accepts both a spec-compliant secret echo and Amber's bare `"ack"`),
542    // so spoof detection costs nothing in interop.
543    let mut uri_string = uri.to_string();
544    let perms = VECTOR_NIP46_PERMS.join(",");
545    if !perms.is_empty() {
546        uri_string.push_str("&perms=");
547        uri_string.push_str(&perms);
548    }
549    let mut nc = NostrConnect::new(uri, client_keys, timeout, None)
550        .map_err(|e| format!("Bunker init failed: {}", e))?;
551    nc.auth_url_handler(VectorAuthUrlHandler);
552    Ok((nc, uri_string))
553}
554
555/// Build a `NostrConnect` from a `bunker://` URL + client keypair. Doesn't
556/// connect yet — `NostrConnect` bootstraps lazily on the first signing call.
557/// Use `prewarm()` if you want the connection up before the user's first send.
558///
559/// `timeout` bounds each RPC round-trip. 60s is the upstream example; we
560/// expose it so chat-send paths can tighten this for snappier failure surfacing.
561pub fn build_bunker_signer(
562    bunker_url: &str,
563    client_keys: Keys,
564    timeout: Duration,
565) -> Result<NostrConnect, String> {
566    let uri = NostrConnectUri::parse(bunker_url)
567        .map_err(|e| format!("Invalid bunker URL: {}", e))?;
568    NostrConnect::new(uri, client_keys, timeout, None)
569        .map_err(|e| format!("Bunker init failed: {}", e))
570}
571
572/// Force a bunker bootstrap and discover the user's identity pubkey.
573///
574/// The signer's *device* pubkey (returned by `bunker_uri()`) is NOT the user
575/// identity for signers like Amber — bypassing this RPC produces events
576/// signed under the wrong key. In Amber's "Manually approve each" mode this
577/// prompts the user once during initial pairing.
578pub async fn prewarm_bunker(signer: &NostrConnect) -> Result<PublicKey, String> {
579    signer
580        .get_public_key_async()
581        .await
582        .map_err(|e| format!("Bunker prewarm failed: {}", e))
583}
584
585// ============================================================================
586// BunkerConnectionState — observable connection lifecycle
587// ============================================================================
588
589/// Observable state of the bunker connection. The atomic backs hot-path reads
590/// (e.g. send paths checking "is it safe to issue a sign call?"); state changes
591/// also fan out to the frontend via `EventEmitter` so the UI can show a banner.
592#[derive(Copy, Clone, Debug, Eq, PartialEq)]
593#[repr(u8)]
594pub enum BunkerConnectionState {
595    /// No active bunker session. Either we're on a local account, or we're
596    /// between login and the first successful bootstrap.
597    Idle = 0,
598    /// Currently bootstrapping (relay connect + remote-pubkey discovery).
599    Connecting = 1,
600    /// Bunker is reachable; signing calls should succeed.
601    Online = 2,
602    /// Bunker is unreachable. Hot-path sends will fail fast; the next signing
603    /// call will retry the underlying NostrConnect path which may reconnect.
604    Offline = 3,
605}
606
607impl BunkerConnectionState {
608    /// User-facing label, mirrored to the frontend in `bunker_state` events.
609    pub fn as_label(self) -> &'static str {
610        match self {
611            BunkerConnectionState::Idle => "idle",
612            BunkerConnectionState::Connecting => "connecting",
613            BunkerConnectionState::Online => "online",
614            BunkerConnectionState::Offline => "offline",
615        }
616    }
617}
618
619static BUNKER_STATE: AtomicU8 = AtomicU8::new(BunkerConnectionState::Idle as u8);
620
621/// Read the live bunker connection state. Backed by an atomic; cheap to call.
622#[inline]
623pub fn bunker_state() -> BunkerConnectionState {
624    match BUNKER_STATE.load(Ordering::Acquire) {
625        1 => BunkerConnectionState::Connecting,
626        2 => BunkerConnectionState::Online,
627        3 => BunkerConnectionState::Offline,
628        _ => BunkerConnectionState::Idle,
629    }
630}
631
632/// Install a new bunker state and fan out a `bunker_state` event to the
633/// frontend. No-op if the state didn't change — avoids spamming the UI with
634/// duplicate transitions when a signing call confirms what's already known.
635pub fn set_bunker_state(new_state: BunkerConnectionState) {
636    let prev = BUNKER_STATE.swap(new_state as u8, Ordering::AcqRel);
637    if prev == new_state as u8 {
638        return;
639    }
640    crate::traits::emit_event_json(
641        "bunker_state",
642        serde_json::json!({ "state": new_state.as_label() }),
643    );
644}
645
646// ============================================================================
647// WatchedBunkerSigner — wrap NostrConnect with bunker_state observability
648// ============================================================================
649//
650// Every signing operation (sign_event, nip44_encrypt, nip04_*) flows through
651// this adapter when a bunker account is active. On success we flip
652// `BUNKER_STATE` to Online; on error we flip to Offline. The frontend's
653// `bunker_state` listener picks up the transition and surfaces a banner /
654// toast so the user knows when their signer becomes unreachable mid-session.
655//
656// State flips are deduplicated by `set_bunker_state` (same-value writes are
657// no-ops), so the per-call overhead is just one atomic load.
658
659/// `VectorSigner` wrapper that emits `BunkerConnectionState` transitions on
660/// every signing outcome. The inner `NostrConnect` is cheaply clonable
661/// (internally Arc'd), so this is also Clone.
662///
663/// Captures a `SessionGuard` at construction; state flips after `reset_session`
664/// are no-ops to avoid leaking signer-state events across an account swap (an
665/// in-flight signing call resolving after the new account is installed would
666/// otherwise emit `bunker_state: offline` against a local-account session).
667#[derive(Debug, Clone)]
668pub struct WatchedBunkerSigner {
669    inner: NostrConnect,
670    session: crate::state::SessionGuard,
671}
672
673impl WatchedBunkerSigner {
674    pub fn new(inner: NostrConnect) -> Self {
675        Self { inner, session: crate::state::SessionGuard::capture() }
676    }
677
678    /// Flip state only when the captured session is still active.
679    #[inline]
680    fn flip(&self, state: BunkerConnectionState) {
681        if self.session.is_valid() {
682            set_bunker_state(state);
683        }
684    }
685
686    /// Test-only view onto the captured guard so a test can assert the
687    /// wrapper is bound to the session generation at construction.
688    #[cfg(test)]
689    pub(crate) fn session_generation_for_test(&self) -> u64 {
690        self.session.generation()
691    }
692}
693
694impl WatchedBunkerSigner {
695    /// Record the reachability implied by an outcome and normalise the bunker's
696    /// error into `SignerError`.
697    #[inline]
698    fn watch<T, E>(&self, res: Result<T, E>) -> Result<T, SignerError>
699    where
700        E: core::fmt::Display,
701    {
702        match res {
703            Ok(v) => {
704                self.flip(BunkerConnectionState::Online);
705                Ok(v)
706            }
707            Err(e) => {
708                self.flip(BunkerConnectionState::Offline);
709                Err(SignerError::backend(e))
710            }
711        }
712    }
713}
714
715impl AsyncGetPublicKey for WatchedBunkerSigner {
716    type Error = SignerError;
717
718    fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, Self::Error>> {
719        Box::pin(async move { self.watch(self.inner.get_public_key_async().await) })
720    }
721}
722
723impl AsyncSignEvent for WatchedBunkerSigner {
724    type Error = SignerError;
725
726    fn sign_event_async(&self, unsigned: UnsignedEvent) -> BoxedFuture<'_, Result<Event, Self::Error>> {
727        Box::pin(async move { self.watch(self.inner.sign_event_async(unsigned).await) })
728    }
729}
730
731impl AsyncNip04 for WatchedBunkerSigner {
732    type Error = SignerError;
733
734    fn nip04_encrypt_async<'a>(
735        &'a self,
736        public_key: &'a PublicKey,
737        content: &'a str,
738    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
739        Box::pin(async move { self.watch(self.inner.nip04_encrypt_async(public_key, content).await) })
740    }
741
742    fn nip04_decrypt_async<'a>(
743        &'a self,
744        public_key: &'a PublicKey,
745        encrypted_content: &'a str,
746    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
747        Box::pin(async move {
748            self.watch(self.inner.nip04_decrypt_async(public_key, encrypted_content).await)
749        })
750    }
751}
752
753impl AsyncNip44 for WatchedBunkerSigner {
754    type Error = SignerError;
755
756    fn nip44_encrypt_async<'a>(
757        &'a self,
758        public_key: &'a PublicKey,
759        content: &'a str,
760    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
761        Box::pin(async move { self.watch(self.inner.nip44_encrypt_async(public_key, content).await) })
762    }
763
764    fn nip44_decrypt_async<'a>(
765        &'a self,
766        public_key: &'a PublicKey,
767        payload: &'a str,
768    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
769        Box::pin(async move { self.watch(self.inner.nip44_decrypt_async(public_key, payload).await) })
770    }
771}
772
773// ============================================================================
774// VectorAuthUrlHandler — bridge bunker permission prompts to the frontend
775// ============================================================================
776//
777// NIP-46 signers occasionally need user approval (e.g. signing an event kind
778// the user hasn't yet granted blanket permission for). Amber and nsec.app
779// respond with an `auth_url` the user must visit; on completion the signing
780// retry succeeds. This handler emits the URL to the frontend so the UI can
781// show a "Open signer" prompt — we deliberately don't auto-open a browser
782// from the core because (a) the core doesn't own the platform-specific
783// browser-open path, and (b) frontends may prefer in-app webview.
784
785/// Auth-URL handler that forwards bunker prompts to the frontend via the
786/// `EventEmitter` trait. The frontend receives a `bunker_auth_url` event and
787/// is responsible for opening the URL (in-app webview, system browser, ...).
788#[derive(Debug, Clone, Default)]
789pub struct VectorAuthUrlHandler;
790
791impl AuthUrlHandler for VectorAuthUrlHandler {
792    fn on_auth_url<'a>(&'a self, auth_url: Url) -> BoxedFuture<'a, std::result::Result<(), nostr_connect::error::Error>> {
793        Box::pin(async move {
794            crate::traits::emit_event_json(
795                "bunker_auth_url",
796                serde_json::json!({ "url": auth_url.to_string() }),
797            );
798            Ok(())
799        })
800    }
801}
802
803// ============================================================================
804// attempt_bunker_login — end-to-end: build → prewarm → install
805// ============================================================================
806
807/// Build a `NostrConnect`, attach the Vector auth-URL handler, bootstrap it,
808/// and install it as the active bunker signer. Returns the discovered remote
809/// signer pubkey on success.
810///
811/// Emits `bunker_state` transitions: Connecting → Online (on success) or
812/// Connecting → Offline (on failure). The caller is expected to update the
813/// account-level discriminator (`signer_kind`) separately — this helper deals
814/// only with the live connection.
815pub async fn attempt_bunker_login(
816    bunker_url: &str,
817    client_keys: Keys,
818    timeout: Duration,
819) -> Result<PublicKey, String> {
820    set_bunker_state(BunkerConnectionState::Connecting);
821
822    let mut nc = match build_bunker_signer(bunker_url, client_keys, timeout) {
823        Ok(nc) => nc,
824        Err(e) => {
825            set_bunker_state(BunkerConnectionState::Offline);
826            return Err(e);
827        }
828    };
829    nc.auth_url_handler(VectorAuthUrlHandler);
830
831    match prewarm_bunker(&nc).await {
832        Ok(remote_pk) => {
833            // If a prior NostrConnect is already installed (retry-after-blip
834            // path), take it out and shut it down on a background task so
835            // its relay pool drains cleanly. Without this, repeated calls
836            // leak Arc'd RelayPool handles fighting for connection slots.
837            //
838            if let Some(old) = take_bunker_signer() {
839                tokio::spawn(async move { let _ = old.shutdown().await; });
840            }
841            set_bunker_signer(nc);
842            set_bunker_state(BunkerConnectionState::Online);
843            Ok(remote_pk)
844        }
845        Err(e) => {
846            // The just-built `nc`'s Drop will release its half-opened relay
847            // connections asynchronously; we don't need a shutdown call here
848            // because we never installed it as the active signer.
849            set_bunker_state(BunkerConnectionState::Offline);
850            Err(e)
851        }
852    }
853}
854
855// ============================================================================
856// Teardown
857// ============================================================================
858
859/// Clear all bunker-specific state. Called by `reset_session()` so a swap
860/// from bunker → local (or between two bunker accounts) leaves no stale
861/// keying material, relay-pool handles, or stale connection-state observed
862/// by the frontend. The caller is responsible for `.shutdown().await`-ing
863/// the returned signer outside the lock.
864pub fn drain_bunker_state() -> Option<NostrConnect> {
865    // Resets SIGNER_KIND for EVERY signer kind, not just bunker — `reset_session`
866    // calls this unconditionally, so a NIP-55 (or any) → local swap lands the
867    // discriminator back at Local before the next account's login re-reads its
868    // own signer_type. Do NOT make this bunker-conditional or is_keyless() gets
869    // stuck across swaps.
870    set_signer_kind(SignerKind::Local);
871    set_bunker_state(BunkerConnectionState::Idle);
872    take_bunker_signer()
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878
879    #[test]
880    fn setting_roundtrip() {
881        assert_eq!(SignerKind::from_setting_str("local"), SignerKind::Local);
882        assert_eq!(SignerKind::from_setting_str("bunker"), SignerKind::Bunker);
883        assert_eq!(SignerKind::from_setting_str("nip55"), SignerKind::Nip55);
884        assert_eq!(SignerKind::Local.as_setting_str(), "local");
885        assert_eq!(SignerKind::Bunker.as_setting_str(), "bunker");
886        assert_eq!(SignerKind::Nip55.as_setting_str(), "nip55");
887        // Unknown values fall back to Local — upgrade path for pre-NIP-46 rows.
888        assert_eq!(SignerKind::from_setting_str(""), SignerKind::Local);
889        assert_eq!(SignerKind::from_setting_str("garbage"), SignerKind::Local);
890    }
891
892    // SIGNER_KIND + BUNKER_SIGNER + BUNKER_STATE are process-wide atomics /
893    // locks. Cargo runs `#[test]` functions in parallel, so any pair of
894    // tests that mutate the same global races and produces flaky failures.
895    // Bundled into one test function so the sequence is deterministic —
896    // mirrors `session_helpers_round_trip_and_clear` in state.rs which
897    // does the same for `MY_PUBLIC_KEY` / `PENDING_INVITE`.
898    #[test]
899    fn atomic_state_round_trips_and_drains() {
900        // Defensive cleanup: a previous test panic could have left a non-
901        // default value behind.
902        set_signer_kind(SignerKind::Local);
903        set_bunker_state(BunkerConnectionState::Idle);
904
905        // atomic kind roundtrip
906        set_signer_kind(SignerKind::Bunker);
907        assert_eq!(signer_kind(), SignerKind::Bunker);
908        assert!(is_bunker());
909        assert!(is_keyless());
910        set_signer_kind(SignerKind::Local);
911        assert_eq!(signer_kind(), SignerKind::Local);
912        assert!(!is_bunker());
913        assert!(!is_keyless());
914
915        // NIP-55 is keyless but NOT a bunker — the two gates must not conflate.
916        set_signer_kind(SignerKind::Nip55);
917        assert_eq!(signer_kind(), SignerKind::Nip55);
918        assert!(!is_bunker());
919        assert!(is_keyless());
920        set_signer_kind(SignerKind::Local);
921
922        // drain resets discriminator + state and returns the (absent) signer
923        set_signer_kind(SignerKind::Bunker);
924        set_bunker_state(BunkerConnectionState::Online);
925        let drained = drain_bunker_state();
926        assert!(drained.is_none());
927        assert_eq!(signer_kind(), SignerKind::Local);
928        assert_eq!(bunker_state(), BunkerConnectionState::Idle);
929
930        // drain is idempotent — running again on already-cleared state is
931        // safe (no panic, no spurious event), and leaves things clean.
932        let drained_again = drain_bunker_state();
933        assert!(drained_again.is_none());
934        assert_eq!(signer_kind(), SignerKind::Local);
935        assert_eq!(bunker_state(), BunkerConnectionState::Idle);
936    }
937
938    #[test]
939    fn bunker_state_label_covers_all_variants() {
940        // Whenever a new BunkerConnectionState is added, this test forces a
941        // matching label so the frontend's `bunker_state` listener never sees
942        // an unlabelled discriminant.
943        assert_eq!(BunkerConnectionState::Idle.as_label(), "idle");
944        assert_eq!(BunkerConnectionState::Connecting.as_label(), "connecting");
945        assert_eq!(BunkerConnectionState::Online.as_label(), "online");
946        assert_eq!(BunkerConnectionState::Offline.as_label(), "offline");
947    }
948
949    #[test]
950    fn parse_bunker_relays_returns_relays_from_bunker_uri() {
951        let signer_keys = Keys::generate();
952        let r1 = RelayUrl::parse("wss://relay1.example").unwrap();
953        let r2 = RelayUrl::parse("wss://relay2.example").unwrap();
954        let uri = NostrConnectUri::Bunker {
955            remote_signer_public_key: signer_keys.public_key(),
956            relays: vec![r1.clone(), r2.clone()],
957            secret: None,
958        };
959        let relays = parse_bunker_relays(&uri.to_string());
960        assert_eq!(relays.len(), 2);
961        assert!(relays.iter().any(|r| r.contains("relay1.example")));
962        assert!(relays.iter().any(|r| r.contains("relay2.example")));
963    }
964
965    #[test]
966    fn parse_bunker_relays_returns_empty_on_invalid_input() {
967        // Display-only signal; never panics, never errors. Bad input collapses
968        // to "no relays known" so the Security panel falls back to "unknown"
969        // instead of crashing.
970        assert!(parse_bunker_relays("").is_empty());
971        assert!(parse_bunker_relays("not a url").is_empty());
972        assert!(parse_bunker_relays("http://example.com").is_empty());
973
974        // Client-initiated URIs also return empty — they're not the bunker
975        // form we want to surface relays for.
976        let client_keys = Keys::generate();
977        let relay = RelayUrl::parse("wss://relay.example").unwrap();
978        let client_uri = build_nostrconnect_uri(client_keys.public_key(), vec![relay]);
979        assert!(parse_bunker_relays(&client_uri.to_string()).is_empty(),
980            "client URI must not surface as a bunker relay list");
981    }
982
983    #[test]
984    fn parse_bunker_remote_pubkey_invalid_url() {
985        assert!(parse_bunker_remote_pubkey("not a url").is_err());
986        assert!(parse_bunker_remote_pubkey("").is_err());
987        assert!(parse_bunker_remote_pubkey("http://example.com").is_err());
988    }
989
990    #[test]
991    fn parse_bunker_remote_pubkey_rejects_client_uri() {
992        // A client-initiated `nostrconnect://` URI is not a login entry point;
993        // accepting it would let a hostile clipboard string register an
994        // attacker-controlled client pubkey as "the remote signer".
995        let client_keys = Keys::generate();
996        let relay = RelayUrl::parse("wss://relay.example").unwrap();
997        let uri = build_nostrconnect_uri(client_keys.public_key(), vec![relay]);
998        let err = parse_bunker_remote_pubkey(&uri.to_string())
999            .expect_err("client URI must be rejected");
1000        assert!(err.contains("Client-initiated"), "unexpected error: {}", err);
1001    }
1002
1003    #[test]
1004    fn parse_bunker_remote_pubkey_normalizes_lowercase() {
1005        // Build a valid bunker URI with a known pubkey and verify the parse
1006        // result is forced to lowercase regardless of upstream casing choice.
1007        let signer_keys = Keys::generate();
1008        let relay = RelayUrl::parse("wss://relay.example").unwrap();
1009        let uri = NostrConnectUri::Bunker {
1010            remote_signer_public_key: signer_keys.public_key(),
1011            relays: vec![relay],
1012            secret: None,
1013        };
1014        let parsed = parse_bunker_remote_pubkey(&uri.to_string())
1015            .expect("valid bunker URI");
1016        assert_eq!(parsed, signer_keys.public_key().to_hex().to_ascii_lowercase());
1017        assert_eq!(parsed, parsed.to_ascii_lowercase(),
1018            "callers may compare with == — output must already be lowercase");
1019    }
1020
1021    #[test]
1022    fn vector_metadata_carries_app_name_and_icon() {
1023        let md = vector_metadata();
1024        let json = serde_json::to_string(&md).expect("metadata serializes");
1025        assert!(json.contains(VECTOR_APP_NAME),
1026            "metadata must include app name for the signer's approval prompt; got {}", json);
1027        assert!(json.contains("vectorapp.io"),
1028            "metadata must reference the app URL for the signer's 'More info' link");
1029    }
1030
1031    #[test]
1032    fn nip46_perms_list_excludes_get_private_key() {
1033        // The whole point of a Remote Signer is keeping the identity nsec on
1034        // the signer device. Adding `get_private_key` to the requested perms
1035        // would invite the signer to expose it back to Vector and defeat the
1036        // threat model. This test fails loudly if a future edit re-adds it.
1037        for perm in VECTOR_NIP46_PERMS {
1038            assert!(!perm.contains("get_private_key"),
1039                "VECTOR_NIP46_PERMS must never include get_private_key (found: {})", perm);
1040            assert!(!perm.contains("private_key"),
1041                "perm string looks dangerous: {}", perm);
1042        }
1043    }
1044
1045    #[test]
1046    fn build_nostrconnect_session_appends_perms_query_param() {
1047        let client_keys = Keys::generate();
1048        let relay = RelayUrl::parse("wss://relay.example").unwrap();
1049        let (_nc, uri) = build_nostrconnect_session(
1050            client_keys,
1051            vec![relay],
1052            std::time::Duration::from_secs(1),
1053        ).expect("session builds");
1054        assert!(uri.contains("perms="),
1055            "URI must carry perms query param so signers can scope the pairing; got: {}", uri);
1056        // Every permission we DO ask for must appear in the URI.
1057        for perm in VECTOR_NIP46_PERMS {
1058            assert!(uri.contains(perm),
1059                "URI missing permission '{}': {}", perm, uri);
1060        }
1061        // And get_private_key must NOT.
1062        assert!(!uri.contains("get_private_key"),
1063            "URI must never request get_private_key: {}", uri);
1064    }
1065
1066    #[test]
1067    fn build_nostrconnect_session_rejects_empty_uri() {
1068        // `build_nostrconnect_session` is the QR-flow entry. Constructing one
1069        // with zero relays would produce a URI that no signer can connect
1070        // back to — caller-side check is in `start_nostrconnect_session`, but
1071        // this is a sanity test that NostrConnect itself does not silently
1072        // accept an empty relay list at the URI level.
1073        let client_keys = Keys::generate();
1074        let session = build_nostrconnect_session(
1075            client_keys,
1076            vec![],
1077            std::time::Duration::from_secs(1),
1078        );
1079        // We don't assert pass/fail — different upstream versions may treat
1080        // empty relays differently — only that we don't panic.
1081        let _ = session;
1082    }
1083
1084    // Combined into one #[test] to serialise mutation of process-wide globals
1085    // (SESSION_GENERATION, BUNKER_STATE, BUNKER_SIGNER). See the rationale on
1086    // `atomic_state_round_trips_and_drains` above.
1087    #[test]
1088    fn watched_signer_session_gate_and_state_transitions() {
1089        use crate::state::{bump_session_generation, current_session_generation};
1090
1091        // Build a real NostrConnect so we can wrap it. We never call any of
1092        // its async methods (those would require a relay) — only the inner
1093        // wrapper's session-guard semantics are under test.
1094        let client_keys = Keys::generate();
1095        let relay = RelayUrl::parse("wss://relay.example").unwrap();
1096        let signer_keys = Keys::generate();
1097        let uri = NostrConnectUri::Bunker {
1098            remote_signer_public_key: signer_keys.public_key(),
1099            relays: vec![relay],
1100            secret: None,
1101        };
1102        let nc = NostrConnect::new(
1103            uri,
1104            client_keys,
1105            std::time::Duration::from_secs(1),
1106            None,
1107        ).expect("NostrConnect builds");
1108
1109        let gen_before = current_session_generation();
1110        let watched = WatchedBunkerSigner::new(nc);
1111        assert_eq!(watched.session_generation_for_test(), gen_before,
1112            "WatchedBunkerSigner must capture the live session generation at construction");
1113
1114        // Pre-swap: flip emits because the captured guard matches.
1115        set_bunker_state(BunkerConnectionState::Idle);
1116        watched.flip(BunkerConnectionState::Online);
1117        assert_eq!(bunker_state(), BunkerConnectionState::Online,
1118            "flip with valid session must update bunker_state");
1119
1120        // Simulate a session swap (logout / account swap). The captured
1121        // guard goes stale; subsequent flips must be ignored so a leftover
1122        // in-flight signing call from the previous account can't leak
1123        // bunker_state changes into the new session.
1124        bump_session_generation();
1125        set_bunker_state(BunkerConnectionState::Online);
1126        watched.flip(BunkerConnectionState::Offline);
1127        assert_eq!(bunker_state(), BunkerConnectionState::Online,
1128            "flip with stale session must be a no-op");
1129
1130        // Cleanup so subsequent test runs / siblings see a sane state.
1131        set_bunker_state(BunkerConnectionState::Idle);
1132    }
1133}