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