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