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}