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// SignerKind — discriminator
36// ============================================================================
37
38/// Which signer backs the active account.
39#[derive(Copy, Clone, Debug, Eq, PartialEq)]
40#[repr(u8)]
41pub enum SignerKind {
42    /// The user's nsec lives in `MY_SECRET_KEY` on this device.
43    Local = 0,
44    /// The user's nsec lives on a remote NIP-46 signer; we only hold the
45    /// client keypair used to RPC it.
46    Bunker = 1,
47}
48
49impl SignerKind {
50    /// Persisted form used by the per-account settings KV.
51    #[inline]
52    pub fn as_setting_str(self) -> &'static str {
53        match self {
54            SignerKind::Local => "local",
55            SignerKind::Bunker => "bunker",
56        }
57    }
58
59    /// Parse from the on-disk setting string. Unknown values fall back to
60    /// `Local` so an upgrade path from pre-NIP-46 accounts (which have no
61    /// `signer_type` row) is the obvious default.
62    #[inline]
63    pub fn from_setting_str(s: &str) -> Self {
64        match s {
65            "bunker" => SignerKind::Bunker,
66            _ => SignerKind::Local,
67        }
68    }
69}
70
71static SIGNER_KIND: AtomicU8 = AtomicU8::new(SignerKind::Local as u8);
72
73/// The signer kind for the active session. Cheap to read; backed by an atomic.
74#[inline]
75pub fn signer_kind() -> SignerKind {
76    match SIGNER_KIND.load(Ordering::Acquire) {
77        1 => SignerKind::Bunker,
78        _ => SignerKind::Local,
79    }
80}
81
82/// Install the signer kind for the active session. Call at login after the
83/// settings row has been read, and on swap before any signing work runs.
84#[inline]
85pub fn set_signer_kind(kind: SignerKind) {
86    SIGNER_KIND.store(kind as u8, Ordering::Release);
87}
88
89/// `true` iff the active account signs via a remote NIP-46 bunker. Hot-path
90/// helper for code that needs to branch on signer mode (e.g. parallelising
91/// gift-wrap signing harder when each call pays a round-trip).
92#[inline]
93pub fn is_bunker() -> bool {
94    signer_kind() == SignerKind::Bunker
95}
96
97// ============================================================================
98// Client-keypair storage note
99// ============================================================================
100//
101// The NIP-46 client keypair (used to RPC the bunker — not the user's
102// identity) lives in the existing `MY_SECRET_KEY` vault for bunker accounts.
103// This is intentional: every existing call site that loads "the active
104// signing key" gets the client key, which is what the NIP-46 layer wants for
105// its RPC envelope. For events the *user* sends, the path goes through
106// `client.signer()` → NostrConnect, which tunnels to the bunker — so user
107// events are signed by the user's identity, RPC envelopes by the client key.
108//
109// This avoids needing a second GuardedKey vault and the slot-coordination
110// problem that comes with it. The trade-off: bunker accounts share the same
111// memory-protection footprint as local accounts (the user's identity isn't
112// on this device at all).
113
114// ============================================================================
115// BUNKER_SIGNER — live NostrConnect handle
116// ============================================================================
117
118/// Active `NostrConnect` handle. `None` for local-signer sessions.
119///
120/// `NostrConnect` is internally `Arc`-counted (relay pool, OnceCell-backed
121/// remote pubkey cache), so cloning it for per-call use is cheap. The lock is
122/// only held briefly to snapshot the inner value.
123pub static BUNKER_SIGNER: LazyLock<RwLock<Option<NostrConnect>>> =
124    LazyLock::new(|| RwLock::new(None));
125
126/// Snapshot the active bunker handle. Returns `None` for local-signer sessions.
127#[inline]
128pub fn bunker_signer() -> Option<NostrConnect> {
129    BUNKER_SIGNER.read().ok().and_then(|g| g.as_ref().cloned())
130}
131
132/// Install the bunker handle for the active session. Replaces any prior handle
133/// without shutting it down — callers swapping should `take_bunker_signer()`
134/// first and `.shutdown().await` the old one to drain its relay pool cleanly.
135#[inline]
136pub fn set_bunker_signer(signer: NostrConnect) {
137    if let Ok(mut g) = BUNKER_SIGNER.write() {
138        *g = Some(signer);
139    }
140}
141
142/// Atomically remove the bunker handle. Used by session teardown so the
143/// caller can `.shutdown()` it without racing readers.
144#[inline]
145pub fn take_bunker_signer() -> Option<NostrConnect> {
146    BUNKER_SIGNER.write().ok().and_then(|mut g| g.take())
147}
148
149// ============================================================================
150// Construction helpers
151// ============================================================================
152
153/// Parse a `bunker://` URL and return the relay URLs it lists. Used by the
154/// Settings UI to render "Connected via <relay>" without re-bootstrapping.
155/// Returns an empty Vec on any parse failure — the caller treats this as a
156/// display-only signal and renders a generic fallback instead of erroring.
157pub fn parse_bunker_relays(bunker_url: &str) -> Vec<String> {
158    match NostrConnectURI::parse(bunker_url) {
159        Ok(NostrConnectURI::Bunker { relays, .. }) => {
160            relays.into_iter().map(|r| r.to_string()).collect()
161        }
162        _ => Vec::new(),
163    }
164}
165
166/// Inspect a `bunker://` URL without bootstrapping: returns the remote
167/// signer's pubkey (hex). Used by login flows to check whether a re-submitted
168/// URL points at the same bunker as the active session (idempotent re-login)
169/// versus a different bunker (which requires logout first). Cheap — no
170/// network.
171pub fn parse_bunker_remote_pubkey(bunker_url: &str) -> Result<String, String> {
172    let uri = NostrConnectURI::parse(bunker_url)
173        .map_err(|e| format!("Invalid bunker URL: {}", e))?;
174    match uri {
175        NostrConnectURI::Bunker { remote_signer_public_key, .. } => {
176            // Force lowercase. `to_hex()` already returns lowercase per
177            // nostr-sdk, but normalising here lets callers compare hex
178            // forms with `==` without worrying about a future upstream
179            // shift to mixed-case.
180            Ok(remote_signer_public_key.to_hex().to_ascii_lowercase())
181        }
182        // Client-initiated URIs aren't supported as login entry points in v1;
183        // they're for the reverse direction (we hand a URL to the signer).
184        NostrConnectURI::Client { .. } => {
185            Err("Client-initiated URIs not supported here; use a bunker:// URL".into())
186        }
187    }
188}
189
190// ============================================================================
191// Vector app identity — surfaced to remote signers via NIP-46 metadata
192// ============================================================================
193
194/// Application name shown to the user by the remote signer when approving the
195/// connection (e.g. on Amber's pairing screen).
196pub const VECTOR_APP_NAME: &str = "Vector";
197
198/// Marketing site — surfaced as the signer's "More info" link.
199pub const VECTOR_APP_URL: &str = "https://vectorapp.io";
200
201/// Icon shown by the signer alongside the app name. PNG, served from the
202/// public GitHub mirror so the URL stays valid even if vectorapp.io changes
203/// its asset layout. Signers cache by URL, so a stable target avoids
204/// re-fetches on every pairing.
205pub const VECTOR_APP_ICON: &str = "https://raw.githubusercontent.com/VectorPrivacy/Vector/master/src-tauri/icons/icon.png";
206
207/// NIP-46 permission scope Vector requests on client-initiated pairings.
208///
209/// Sent as the `perms=` query parameter on `nostrconnect://` URIs. Signer apps
210/// that honour it (Amber, nsec.app) surface this list on their pairing screen
211/// and refuse RPC calls outside the granted scope. Vector intentionally never
212/// requests `get_private_key`: the whole point of a Remote Signer is that the
213/// identity nsec stays on the signer device, so allowing extraction would
214/// defeat the threat model. Adding a method here is an explicit policy
215/// decision; signer apps that don't enforce `perms` server-side still benefit
216/// from a smaller surface in their pairing UI.
217pub const VECTOR_NIP46_PERMS: &[&str] = &[
218    "get_public_key",
219    "sign_event",
220    "nip04_encrypt",
221    "nip04_decrypt",
222    "nip44_encrypt",
223    "nip44_decrypt",
224];
225
226/// Build the NIP-46 metadata payload Vector advertises in client-initiated
227/// `nostrconnect://` URIs. The signer reads this to render the approval
228/// prompt — name and icon are the bits the user actually sees.
229pub fn vector_metadata() -> NostrConnectMetadata {
230    let mut md = NostrConnectMetadata::new(VECTOR_APP_NAME);
231    if let Ok(url) = Url::parse(VECTOR_APP_URL) {
232        md = md.url(url);
233    }
234    if let Ok(icon) = Url::parse(VECTOR_APP_ICON) {
235        md = md.icons(vec![icon]);
236    }
237    md
238}
239
240/// Build a client-initiated `nostrconnect://` URI. The user copies this URL
241/// into their signer app (or scans the QR rendering of it); the signer
242/// initiates the connection back to the listed relays.
243///
244/// Multi-relay by design — single-relay connect URIs are a centralisation
245/// trap: if that one relay goes down, the user can't reconnect to their own
246/// account. Pass the live trusted-relay list from `state::TRUSTED_RELAYS`.
247pub fn build_nostrconnect_uri(
248    client_pubkey: PublicKey,
249    relays: Vec<RelayUrl>,
250) -> NostrConnectURI {
251    NostrConnectURI::Client {
252        public_key: client_pubkey,
253        relays,
254        metadata: vector_metadata(),
255    }
256}
257
258/// Build a `NostrConnect` for a client-initiated session — generates the
259/// `nostrconnect://` URI from the client keys + relays + Vector metadata,
260/// constructs the underlying `NostrConnect` with the Vector auth-URL handler
261/// already attached, and returns both for the caller to (a) display the URI
262/// to the user (QR + copy button) and (b) install the signer.
263///
264/// Note: doesn't bootstrap. The caller is expected to install the returned
265/// `NostrConnect` in `BUNKER_SIGNER` and await `get_public_key()` to wait
266/// for the signer's connect response.
267pub fn build_nostrconnect_session(
268    client_keys: Keys,
269    relays: Vec<RelayUrl>,
270    timeout: Duration,
271) -> Result<(NostrConnect, String), String> {
272    let uri = build_nostrconnect_uri(client_keys.public_key, relays);
273    // Append the NIP-46 `perms=` scope. nostr-sdk's `Display` impl doesn't
274    // write it, so the SDK-built URI is fine to hand back to `NostrConnect`
275    // (which doesn't read perms locally), while the signer app on the other
276    // side parses the appended query param to render its pairing screen.
277    //
278    // NIP-46 also defines a `secret=` query param the signer should echo
279    // in its connect response for spoof detection. Not emitted: current
280    // signers (Amber) return only `"ack"`, and the SDK's response parser
281    // accepts only `"ack"` — a secret round-trip would short-circuit at
282    // both ends. Revisit when ecosystem support lands.
283    let mut uri_string = uri.to_string();
284    let perms = VECTOR_NIP46_PERMS.join(",");
285    if !perms.is_empty() {
286        uri_string.push_str("&perms=");
287        uri_string.push_str(&perms);
288    }
289    let mut nc = NostrConnect::new(uri, client_keys, timeout, None)
290        .map_err(|e| format!("Bunker init failed: {}", e))?;
291    nc.auth_url_handler(VectorAuthUrlHandler);
292    Ok((nc, uri_string))
293}
294
295/// Build a `NostrConnect` from a `bunker://` URL + client keypair. Doesn't
296/// connect yet — `NostrConnect` bootstraps lazily on the first signing call.
297/// Use `prewarm()` if you want the connection up before the user's first send.
298///
299/// `timeout` bounds each RPC round-trip. 60s is the upstream example; we
300/// expose it so chat-send paths can tighten this for snappier failure surfacing.
301pub fn build_bunker_signer(
302    bunker_url: &str,
303    client_keys: Keys,
304    timeout: Duration,
305) -> Result<NostrConnect, String> {
306    let uri = NostrConnectURI::parse(bunker_url)
307        .map_err(|e| format!("Invalid bunker URL: {}", e))?;
308    NostrConnect::new(uri, client_keys, timeout, None)
309        .map_err(|e| format!("Bunker init failed: {}", e))
310}
311
312/// Force a bunker bootstrap and discover the user's identity pubkey.
313///
314/// The signer's *device* pubkey (returned by `bunker_uri()`) is NOT the user
315/// identity for signers like Amber — bypassing this RPC produces events
316/// signed under the wrong key. In Amber's "Manually approve each" mode this
317/// prompts the user once during initial pairing.
318pub async fn prewarm_bunker(signer: &NostrConnect) -> Result<PublicKey, String> {
319    signer
320        .get_public_key()
321        .await
322        .map_err(|e| format!("Bunker prewarm failed: {}", e))
323}
324
325// ============================================================================
326// BunkerConnectionState — observable connection lifecycle
327// ============================================================================
328
329/// Observable state of the bunker connection. The atomic backs hot-path reads
330/// (e.g. send paths checking "is it safe to issue a sign call?"); state changes
331/// also fan out to the frontend via `EventEmitter` so the UI can show a banner.
332#[derive(Copy, Clone, Debug, Eq, PartialEq)]
333#[repr(u8)]
334pub enum BunkerConnectionState {
335    /// No active bunker session. Either we're on a local account, or we're
336    /// between login and the first successful bootstrap.
337    Idle = 0,
338    /// Currently bootstrapping (relay connect + remote-pubkey discovery).
339    Connecting = 1,
340    /// Bunker is reachable; signing calls should succeed.
341    Online = 2,
342    /// Bunker is unreachable. Hot-path sends will fail fast; the next signing
343    /// call will retry the underlying NostrConnect path which may reconnect.
344    Offline = 3,
345}
346
347impl BunkerConnectionState {
348    /// User-facing label, mirrored to the frontend in `bunker_state` events.
349    pub fn as_label(self) -> &'static str {
350        match self {
351            BunkerConnectionState::Idle => "idle",
352            BunkerConnectionState::Connecting => "connecting",
353            BunkerConnectionState::Online => "online",
354            BunkerConnectionState::Offline => "offline",
355        }
356    }
357}
358
359static BUNKER_STATE: AtomicU8 = AtomicU8::new(BunkerConnectionState::Idle as u8);
360
361/// Read the live bunker connection state. Backed by an atomic; cheap to call.
362#[inline]
363pub fn bunker_state() -> BunkerConnectionState {
364    match BUNKER_STATE.load(Ordering::Acquire) {
365        1 => BunkerConnectionState::Connecting,
366        2 => BunkerConnectionState::Online,
367        3 => BunkerConnectionState::Offline,
368        _ => BunkerConnectionState::Idle,
369    }
370}
371
372/// Install a new bunker state and fan out a `bunker_state` event to the
373/// frontend. No-op if the state didn't change — avoids spamming the UI with
374/// duplicate transitions when a signing call confirms what's already known.
375pub fn set_bunker_state(new_state: BunkerConnectionState) {
376    let prev = BUNKER_STATE.swap(new_state as u8, Ordering::AcqRel);
377    if prev == new_state as u8 {
378        return;
379    }
380    crate::traits::emit_event_json(
381        "bunker_state",
382        serde_json::json!({ "state": new_state.as_label() }),
383    );
384}
385
386// ============================================================================
387// WatchedBunkerSigner — wrap NostrConnect with bunker_state observability
388// ============================================================================
389//
390// Every signing operation (sign_event, nip44_encrypt, nip04_*) flows through
391// this adapter when a bunker account is active. On success we flip
392// `BUNKER_STATE` to Online; on error we flip to Offline. The frontend's
393// `bunker_state` listener picks up the transition and surfaces a banner /
394// toast so the user knows when their signer becomes unreachable mid-session.
395//
396// State flips are deduplicated by `set_bunker_state` (same-value writes are
397// no-ops), so the per-call overhead is just one atomic load.
398
399/// `NostrSigner` wrapper that emits `BunkerConnectionState` transitions on
400/// every signing outcome. The inner `NostrConnect` is cheaply clonable
401/// (internally Arc'd), so this is also Clone.
402///
403/// Captures a `SessionGuard` at construction; state flips after `reset_session`
404/// are no-ops to avoid leaking signer-state events across an account swap (an
405/// in-flight signing call resolving after the new account is installed would
406/// otherwise emit `bunker_state: offline` against a local-account session).
407#[derive(Debug, Clone)]
408pub struct WatchedBunkerSigner {
409    inner: NostrConnect,
410    session: crate::state::SessionGuard,
411}
412
413impl WatchedBunkerSigner {
414    pub fn new(inner: NostrConnect) -> Self {
415        Self { inner, session: crate::state::SessionGuard::capture() }
416    }
417
418    /// Flip state only when the captured session is still active.
419    #[inline]
420    fn flip(&self, state: BunkerConnectionState) {
421        if self.session.is_valid() {
422            set_bunker_state(state);
423        }
424    }
425
426    /// Test-only view onto the captured guard so a test can assert the
427    /// wrapper is bound to the session generation at construction.
428    #[cfg(test)]
429    pub(crate) fn session_generation_for_test(&self) -> u64 {
430        self.session.generation()
431    }
432}
433
434impl NostrSigner for WatchedBunkerSigner {
435    fn backend(&self) -> SignerBackend<'_> {
436        self.inner.backend()
437    }
438
439    fn get_public_key<'a>(&'a self) -> BoxedFuture<'a, Result<PublicKey, SignerError>> {
440        Box::pin(async move {
441            match self.inner.get_public_key().await {
442                Ok(pk) => {
443                    self.flip(BunkerConnectionState::Online);
444                    Ok(pk)
445                }
446                Err(e) => {
447                    self.flip(BunkerConnectionState::Offline);
448                    Err(e)
449                }
450            }
451        })
452    }
453
454    fn sign_event<'a>(&'a self, unsigned: UnsignedEvent) -> BoxedFuture<'a, Result<Event, SignerError>> {
455        Box::pin(async move {
456            match self.inner.sign_event(unsigned).await {
457                Ok(event) => {
458                    self.flip(BunkerConnectionState::Online);
459                    Ok(event)
460                }
461                Err(e) => {
462                    self.flip(BunkerConnectionState::Offline);
463                    Err(e)
464                }
465            }
466        })
467    }
468
469    fn nip04_encrypt<'a>(
470        &'a self,
471        public_key: &'a PublicKey,
472        content: &'a str,
473    ) -> BoxedFuture<'a, Result<String, SignerError>> {
474        Box::pin(async move {
475            match self.inner.nip04_encrypt(public_key, content).await {
476                Ok(s) => { self.flip(BunkerConnectionState::Online); Ok(s) }
477                Err(e) => { self.flip(BunkerConnectionState::Offline); Err(e) }
478            }
479        })
480    }
481
482    fn nip04_decrypt<'a>(
483        &'a self,
484        public_key: &'a PublicKey,
485        content: &'a str,
486    ) -> BoxedFuture<'a, Result<String, SignerError>> {
487        Box::pin(async move {
488            match self.inner.nip04_decrypt(public_key, content).await {
489                Ok(s) => { self.flip(BunkerConnectionState::Online); Ok(s) }
490                Err(e) => { self.flip(BunkerConnectionState::Offline); Err(e) }
491            }
492        })
493    }
494
495    fn nip44_encrypt<'a>(
496        &'a self,
497        public_key: &'a PublicKey,
498        content: &'a str,
499    ) -> BoxedFuture<'a, Result<String, SignerError>> {
500        Box::pin(async move {
501            match self.inner.nip44_encrypt(public_key, content).await {
502                Ok(s) => { self.flip(BunkerConnectionState::Online); Ok(s) }
503                Err(e) => { self.flip(BunkerConnectionState::Offline); Err(e) }
504            }
505        })
506    }
507
508    fn nip44_decrypt<'a>(
509        &'a self,
510        public_key: &'a PublicKey,
511        content: &'a str,
512    ) -> BoxedFuture<'a, Result<String, SignerError>> {
513        Box::pin(async move {
514            match self.inner.nip44_decrypt(public_key, content).await {
515                Ok(s) => { self.flip(BunkerConnectionState::Online); Ok(s) }
516                Err(e) => { self.flip(BunkerConnectionState::Offline); Err(e) }
517            }
518        })
519    }
520}
521
522// ============================================================================
523// VectorAuthUrlHandler — bridge bunker permission prompts to the frontend
524// ============================================================================
525//
526// NIP-46 signers occasionally need user approval (e.g. signing an event kind
527// the user hasn't yet granted blanket permission for). Amber and nsec.app
528// respond with an `auth_url` the user must visit; on completion the signing
529// retry succeeds. This handler emits the URL to the frontend so the UI can
530// show a "Open signer" prompt — we deliberately don't auto-open a browser
531// from the core because (a) the core doesn't own the platform-specific
532// browser-open path, and (b) frontends may prefer in-app webview.
533
534/// Auth-URL handler that forwards bunker prompts to the frontend via the
535/// `EventEmitter` trait. The frontend receives a `bunker_auth_url` event and
536/// is responsible for opening the URL (in-app webview, system browser, ...).
537#[derive(Debug, Clone, Default)]
538pub struct VectorAuthUrlHandler;
539
540impl AuthUrlHandler for VectorAuthUrlHandler {
541    fn on_auth_url<'a>(&'a self, auth_url: Url) -> BoxedFuture<'a, Result<()>> {
542        Box::pin(async move {
543            crate::traits::emit_event_json(
544                "bunker_auth_url",
545                serde_json::json!({ "url": auth_url.to_string() }),
546            );
547            Ok(())
548        })
549    }
550}
551
552// ============================================================================
553// attempt_bunker_login — end-to-end: build → prewarm → install
554// ============================================================================
555
556/// Build a `NostrConnect`, attach the Vector auth-URL handler, bootstrap it,
557/// and install it as the active bunker signer. Returns the discovered remote
558/// signer pubkey on success.
559///
560/// Emits `bunker_state` transitions: Connecting → Online (on success) or
561/// Connecting → Offline (on failure). The caller is expected to update the
562/// account-level discriminator (`signer_kind`) separately — this helper deals
563/// only with the live connection.
564pub async fn attempt_bunker_login(
565    bunker_url: &str,
566    client_keys: Keys,
567    timeout: Duration,
568) -> Result<PublicKey, String> {
569    set_bunker_state(BunkerConnectionState::Connecting);
570
571    let mut nc = match build_bunker_signer(bunker_url, client_keys, timeout) {
572        Ok(nc) => nc,
573        Err(e) => {
574            set_bunker_state(BunkerConnectionState::Offline);
575            return Err(e);
576        }
577    };
578    nc.auth_url_handler(VectorAuthUrlHandler);
579
580    match prewarm_bunker(&nc).await {
581        Ok(remote_pk) => {
582            // If a prior NostrConnect is already installed (retry-after-blip
583            // path), take it out and shut it down on a background task so
584            // its relay pool drains cleanly. Without this, repeated calls
585            // leak Arc'd RelayPool handles fighting for connection slots.
586            //
587            if let Some(old) = take_bunker_signer() {
588                tokio::spawn(async move { let _ = old.shutdown().await; });
589            }
590            set_bunker_signer(nc);
591            set_bunker_state(BunkerConnectionState::Online);
592            Ok(remote_pk)
593        }
594        Err(e) => {
595            // The just-built `nc`'s Drop will release its half-opened relay
596            // connections asynchronously; we don't need a shutdown call here
597            // because we never installed it as the active signer.
598            set_bunker_state(BunkerConnectionState::Offline);
599            Err(e)
600        }
601    }
602}
603
604// ============================================================================
605// Teardown
606// ============================================================================
607
608/// Clear all bunker-specific state. Called by `reset_session()` so a swap
609/// from bunker → local (or between two bunker accounts) leaves no stale
610/// keying material, relay-pool handles, or stale connection-state observed
611/// by the frontend. The caller is responsible for `.shutdown().await`-ing
612/// the returned signer outside the lock.
613pub fn drain_bunker_state() -> Option<NostrConnect> {
614    set_signer_kind(SignerKind::Local);
615    set_bunker_state(BunkerConnectionState::Idle);
616    take_bunker_signer()
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn setting_roundtrip() {
625        assert_eq!(SignerKind::from_setting_str("local"), SignerKind::Local);
626        assert_eq!(SignerKind::from_setting_str("bunker"), SignerKind::Bunker);
627        assert_eq!(SignerKind::Local.as_setting_str(), "local");
628        assert_eq!(SignerKind::Bunker.as_setting_str(), "bunker");
629        // Unknown values fall back to Local — upgrade path for pre-NIP-46 rows.
630        assert_eq!(SignerKind::from_setting_str(""), SignerKind::Local);
631        assert_eq!(SignerKind::from_setting_str("garbage"), SignerKind::Local);
632    }
633
634    // SIGNER_KIND + BUNKER_SIGNER + BUNKER_STATE are process-wide atomics /
635    // locks. Cargo runs `#[test]` functions in parallel, so any pair of
636    // tests that mutate the same global races and produces flaky failures.
637    // Bundled into one test function so the sequence is deterministic —
638    // mirrors `session_helpers_round_trip_and_clear` in state.rs which
639    // does the same for `MY_PUBLIC_KEY` / `PENDING_INVITE`.
640    #[test]
641    fn atomic_state_round_trips_and_drains() {
642        // Defensive cleanup: a previous test panic could have left a non-
643        // default value behind.
644        set_signer_kind(SignerKind::Local);
645        set_bunker_state(BunkerConnectionState::Idle);
646
647        // atomic kind roundtrip
648        set_signer_kind(SignerKind::Bunker);
649        assert_eq!(signer_kind(), SignerKind::Bunker);
650        assert!(is_bunker());
651        set_signer_kind(SignerKind::Local);
652        assert_eq!(signer_kind(), SignerKind::Local);
653        assert!(!is_bunker());
654
655        // drain resets discriminator + state and returns the (absent) signer
656        set_signer_kind(SignerKind::Bunker);
657        set_bunker_state(BunkerConnectionState::Online);
658        let drained = drain_bunker_state();
659        assert!(drained.is_none());
660        assert_eq!(signer_kind(), SignerKind::Local);
661        assert_eq!(bunker_state(), BunkerConnectionState::Idle);
662
663        // drain is idempotent — running again on already-cleared state is
664        // safe (no panic, no spurious event), and leaves things clean.
665        let drained_again = drain_bunker_state();
666        assert!(drained_again.is_none());
667        assert_eq!(signer_kind(), SignerKind::Local);
668        assert_eq!(bunker_state(), BunkerConnectionState::Idle);
669    }
670
671    #[test]
672    fn bunker_state_label_covers_all_variants() {
673        // Whenever a new BunkerConnectionState is added, this test forces a
674        // matching label so the frontend's `bunker_state` listener never sees
675        // an unlabelled discriminant.
676        assert_eq!(BunkerConnectionState::Idle.as_label(), "idle");
677        assert_eq!(BunkerConnectionState::Connecting.as_label(), "connecting");
678        assert_eq!(BunkerConnectionState::Online.as_label(), "online");
679        assert_eq!(BunkerConnectionState::Offline.as_label(), "offline");
680    }
681
682    #[test]
683    fn parse_bunker_relays_returns_relays_from_bunker_uri() {
684        let signer_keys = Keys::generate();
685        let r1 = RelayUrl::parse("wss://relay1.example").unwrap();
686        let r2 = RelayUrl::parse("wss://relay2.example").unwrap();
687        let uri = NostrConnectURI::Bunker {
688            remote_signer_public_key: signer_keys.public_key,
689            relays: vec![r1.clone(), r2.clone()],
690            secret: None,
691        };
692        let relays = parse_bunker_relays(&uri.to_string());
693        assert_eq!(relays.len(), 2);
694        assert!(relays.iter().any(|r| r.contains("relay1.example")));
695        assert!(relays.iter().any(|r| r.contains("relay2.example")));
696    }
697
698    #[test]
699    fn parse_bunker_relays_returns_empty_on_invalid_input() {
700        // Display-only signal; never panics, never errors. Bad input collapses
701        // to "no relays known" so the Security panel falls back to "unknown"
702        // instead of crashing.
703        assert!(parse_bunker_relays("").is_empty());
704        assert!(parse_bunker_relays("not a url").is_empty());
705        assert!(parse_bunker_relays("http://example.com").is_empty());
706
707        // Client-initiated URIs also return empty — they're not the bunker
708        // form we want to surface relays for.
709        let client_keys = Keys::generate();
710        let relay = RelayUrl::parse("wss://relay.example").unwrap();
711        let client_uri = build_nostrconnect_uri(client_keys.public_key, vec![relay]);
712        assert!(parse_bunker_relays(&client_uri.to_string()).is_empty(),
713            "client URI must not surface as a bunker relay list");
714    }
715
716    #[test]
717    fn parse_bunker_remote_pubkey_invalid_url() {
718        assert!(parse_bunker_remote_pubkey("not a url").is_err());
719        assert!(parse_bunker_remote_pubkey("").is_err());
720        assert!(parse_bunker_remote_pubkey("http://example.com").is_err());
721    }
722
723    #[test]
724    fn parse_bunker_remote_pubkey_rejects_client_uri() {
725        // A client-initiated `nostrconnect://` URI is not a login entry point;
726        // accepting it would let a hostile clipboard string register an
727        // attacker-controlled client pubkey as "the remote signer".
728        let client_keys = Keys::generate();
729        let relay = RelayUrl::parse("wss://relay.example").unwrap();
730        let uri = build_nostrconnect_uri(client_keys.public_key, vec![relay]);
731        let err = parse_bunker_remote_pubkey(&uri.to_string())
732            .expect_err("client URI must be rejected");
733        assert!(err.contains("Client-initiated"), "unexpected error: {}", err);
734    }
735
736    #[test]
737    fn parse_bunker_remote_pubkey_normalizes_lowercase() {
738        // Build a valid bunker URI with a known pubkey and verify the parse
739        // result is forced to lowercase regardless of upstream casing choice.
740        let signer_keys = Keys::generate();
741        let relay = RelayUrl::parse("wss://relay.example").unwrap();
742        let uri = NostrConnectURI::Bunker {
743            remote_signer_public_key: signer_keys.public_key,
744            relays: vec![relay],
745            secret: None,
746        };
747        let parsed = parse_bunker_remote_pubkey(&uri.to_string())
748            .expect("valid bunker URI");
749        assert_eq!(parsed, signer_keys.public_key.to_hex().to_ascii_lowercase());
750        assert_eq!(parsed, parsed.to_ascii_lowercase(),
751            "callers may compare with == — output must already be lowercase");
752    }
753
754    #[test]
755    fn vector_metadata_carries_app_name_and_icon() {
756        let md = vector_metadata();
757        let json = serde_json::to_string(&md).expect("metadata serializes");
758        assert!(json.contains(VECTOR_APP_NAME),
759            "metadata must include app name for the signer's approval prompt; got {}", json);
760        assert!(json.contains("vectorapp.io"),
761            "metadata must reference the app URL for the signer's 'More info' link");
762    }
763
764    #[test]
765    fn nip46_perms_list_excludes_get_private_key() {
766        // The whole point of a Remote Signer is keeping the identity nsec on
767        // the signer device. Adding `get_private_key` to the requested perms
768        // would invite the signer to expose it back to Vector and defeat the
769        // threat model. This test fails loudly if a future edit re-adds it.
770        for perm in VECTOR_NIP46_PERMS {
771            assert!(!perm.contains("get_private_key"),
772                "VECTOR_NIP46_PERMS must never include get_private_key (found: {})", perm);
773            assert!(!perm.contains("private_key"),
774                "perm string looks dangerous: {}", perm);
775        }
776    }
777
778    #[test]
779    fn build_nostrconnect_session_appends_perms_query_param() {
780        let client_keys = Keys::generate();
781        let relay = RelayUrl::parse("wss://relay.example").unwrap();
782        let (_nc, uri) = build_nostrconnect_session(
783            client_keys,
784            vec![relay],
785            std::time::Duration::from_secs(1),
786        ).expect("session builds");
787        assert!(uri.contains("perms="),
788            "URI must carry perms query param so signers can scope the pairing; got: {}", uri);
789        // Every permission we DO ask for must appear in the URI.
790        for perm in VECTOR_NIP46_PERMS {
791            assert!(uri.contains(perm),
792                "URI missing permission '{}': {}", perm, uri);
793        }
794        // And get_private_key must NOT.
795        assert!(!uri.contains("get_private_key"),
796            "URI must never request get_private_key: {}", uri);
797    }
798
799    #[test]
800    fn build_nostrconnect_session_rejects_empty_uri() {
801        // `build_nostrconnect_session` is the QR-flow entry. Constructing one
802        // with zero relays would produce a URI that no signer can connect
803        // back to — caller-side check is in `start_nostrconnect_session`, but
804        // this is a sanity test that NostrConnect itself does not silently
805        // accept an empty relay list at the URI level.
806        let client_keys = Keys::generate();
807        let session = build_nostrconnect_session(
808            client_keys,
809            vec![],
810            std::time::Duration::from_secs(1),
811        );
812        // We don't assert pass/fail — different upstream versions may treat
813        // empty relays differently — only that we don't panic.
814        let _ = session;
815    }
816
817    // Combined into one #[test] to serialise mutation of process-wide globals
818    // (SESSION_GENERATION, BUNKER_STATE, BUNKER_SIGNER). See the rationale on
819    // `atomic_state_round_trips_and_drains` above.
820    #[test]
821    fn watched_signer_session_gate_and_state_transitions() {
822        use crate::state::{bump_session_generation, current_session_generation};
823
824        // Build a real NostrConnect so we can wrap it. We never call any of
825        // its async methods (those would require a relay) — only the inner
826        // wrapper's session-guard semantics are under test.
827        let client_keys = Keys::generate();
828        let relay = RelayUrl::parse("wss://relay.example").unwrap();
829        let signer_keys = Keys::generate();
830        let uri = NostrConnectURI::Bunker {
831            remote_signer_public_key: signer_keys.public_key,
832            relays: vec![relay],
833            secret: None,
834        };
835        let nc = NostrConnect::new(
836            uri,
837            client_keys,
838            std::time::Duration::from_secs(1),
839            None,
840        ).expect("NostrConnect builds");
841
842        let gen_before = current_session_generation();
843        let watched = WatchedBunkerSigner::new(nc);
844        assert_eq!(watched.session_generation_for_test(), gen_before,
845            "WatchedBunkerSigner must capture the live session generation at construction");
846
847        // Pre-swap: flip emits because the captured guard matches.
848        set_bunker_state(BunkerConnectionState::Idle);
849        watched.flip(BunkerConnectionState::Online);
850        assert_eq!(bunker_state(), BunkerConnectionState::Online,
851            "flip with valid session must update bunker_state");
852
853        // Simulate a session swap (logout / account swap). The captured
854        // guard goes stale; subsequent flips must be ignored so a leftover
855        // in-flight signing call from the previous account can't leak
856        // bunker_state changes into the new session.
857        bump_session_generation();
858        set_bunker_state(BunkerConnectionState::Online);
859        watched.flip(BunkerConnectionState::Offline);
860        assert_eq!(bunker_state(), BunkerConnectionState::Online,
861            "flip with stale session must be a no-op");
862
863        // Cleanup so subsequent test runs / siblings see a sane state.
864        set_bunker_state(BunkerConnectionState::Idle);
865    }
866}