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