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