Skip to main content

vector_core/
nip55.rs

1//! NIP-55 "offline" signer — an on-device signer app (Amber) reached over
2//! local Android IPC instead of a relay.
3//!
4//! This is the third signer mode alongside `Local` (nsec in the `MY_SECRET_KEY`
5//! vault) and `Bunker` (NIP-46 over relays, see [`crate::signer`]). A NIP-55
6//! account holds **nothing secret on this device** — not even the client
7//! keypair a bunker account keeps. Every signing op is a local IPC hop to the
8//! signer app; no network, works offline.
9//!
10//! Layering:
11//! - [`Nip55Backend`] is a platform hook (mirrors [`crate::traits::EventEmitter`]):
12//!   the Android shell registers the concrete JNI/ContentResolver/Intent impl at
13//!   startup. vector-core stays Tauri- and Android-decoupled. When no backend is
14//!   registered (desktop, CLI, tests), every op returns a clean runtime error —
15//!   never a compile-time platform stub, so a stray shared-call-site reference
16//!   can't break the desktop build.
17//! - [`Nip55Signer`] implements `VectorSigner` on top of the hook, so every
18//!   existing `client.signer()` call site (DM seals, Blossom auth, Concord v2
19//!   identity ops) uses it agnostically with no changes.
20//!
21//! Wire-identity: NIP-55 `sign_event` / `nip44_*` produce byte-identical output
22//! to the local path (same NIP-01 id computation, same NIP-44 conversation key),
23//! which is why an Amber account and a local account share communities without
24//! forking. Fail-closed: a signer returning an event authored by the wrong
25//! identity is rejected here, not silently published.
26
27use std::sync::atomic::{AtomicU8, Ordering};
28use std::sync::{LazyLock, OnceLock};
29
30use nostr_sdk::prelude::*;
31
32use crate::signer::{BoxedFuture, SignerError};
33
34// ============================================================================
35// Nip55Error — hook failure taxonomy
36// ============================================================================
37
38/// Failure modes of a NIP-55 signing operation. The three variants map to
39/// distinct observable states so the UI can tell "reopen and re-grant" apart
40/// from "Amber is gone" apart from "transient hiccup".
41#[derive(Debug, Clone)]
42pub enum Nip55Error {
43    /// Not pre-authorized: the background ContentResolver query returned a
44    /// `rejected`/null result and no foreground Activity was available to
45    /// prompt. Surfaces as [`Nip55State::NeedsAuth`]. MUST NEVER be read as a
46    /// valid empty decrypt — a null cursor is an authorization signal, not data.
47    NotAuthorized,
48    /// No external signer resolvable — Amber uninstalled, or no backend
49    /// registered on this platform. Surfaces as [`Nip55State::Missing`].
50    Missing,
51    /// Any other IPC / parse / transport failure. Transient — does not flip the
52    /// observable state (the signer is presumed still paired).
53    Ipc(String),
54}
55
56impl std::fmt::Display for Nip55Error {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            Nip55Error::NotAuthorized => {
60                write!(f, "external signer is not authorized for this operation")
61            }
62            Nip55Error::Missing => write!(f, "no external signer available"),
63            Nip55Error::Ipc(msg) => write!(f, "external signer IPC error: {msg}"),
64        }
65    }
66}
67
68impl std::error::Error for Nip55Error {}
69
70// ============================================================================
71// Nip55Backend — the platform hook (registered by the Android shell)
72// ============================================================================
73
74/// The Android-side transport for a NIP-55 signer. Implemented in the Tauri
75/// shell (JNI + ContentResolver + Intent-for-result); registered once at
76/// startup via [`set_nip55_backend`].
77///
78/// All methods are blocking (JNI) — [`Nip55Signer`] wraps each in
79/// `spawn_blocking` behind a concurrency semaphore, so implementors may block
80/// freely (including parking on an Activity-result condvar for the foreground
81/// pairing/fallback path).
82///
83/// Hex strings are lowercase x-only pubkey hex. `current_user_hex` is always
84/// the paired identity; passing it on every op lets Amber route to the right
85/// account even if the user has several.
86pub trait Nip55Backend: Send + Sync + 'static {
87    /// Whether an external signer app is installed and resolvable. Cheap; the
88    /// login screen and boot path call it to avoid offering a dead button.
89    fn is_installed(&self) -> Result<bool, Nip55Error>;
90
91    /// Pairing handshake (foreground `get_public_key` Intent). The user picks
92    /// their identity and grants the remembered permission set. Returns
93    /// `(user_pubkey, signer_package)`; the package is pinned for every
94    /// subsequent op. Called ONCE at login — never again while signed in.
95    fn get_public_key_pairing(&self, perms_json: &str) -> Result<(String, String), Nip55Error>;
96
97    /// Fast, silent background ContentResolver op. `method` is the uppercase
98    /// content-authority suffix (`SIGN_EVENT`, `NIP44_DECRYPT`, ...); `data` is
99    /// the payload (event JSON / plaintext / ciphertext); `counterparty` is the
100    /// other party's pubkey hex (empty for `SIGN_EVENT`). Returns a TRI-STATE
101    /// the caller must not collapse (see [`Nip55ResolverOutcome`]).
102    fn resolver_op(
103        &self,
104        method: &str,
105        data: &str,
106        counterparty: &str,
107        current_user: &str,
108    ) -> Nip55ResolverOutcome;
109
110    /// Foreground Intent fallback (may block up to the sign timeout on user approval).
111    /// Returns `(result, event)` — `result` carries sig/ciphertext/plaintext,
112    /// `event` the signed event JSON for `sign_event`. Only invoked when
113    /// [`is_foreground`](Self::is_foreground) is true.
114    fn intent_op(
115        &self,
116        intent_type: &str,
117        data: &str,
118        counterparty: &str,
119        current_user: &str,
120    ) -> Result<(Option<String>, Option<String>), Nip55Error>;
121
122    /// Whether a foreground Activity exists to prompt the user. False in the
123    /// Activity-less background service, where an un-remembered op fails soft.
124    fn is_foreground(&self) -> bool;
125}
126
127/// Outcome of a background ContentResolver op — three distinct meanings the
128/// caller must keep apart. A null/empty cursor is NEVER a valid empty decrypt.
129pub enum Nip55ResolverOutcome {
130    /// Success. `result` = sig/ciphertext/plaintext; `event` = signed JSON.
131    Value {
132        result: Option<String>,
133        event: Option<String>,
134    },
135    /// Not remembered (null/empty cursor) — escalate to the foreground Intent.
136    RequiresApproval,
137    /// Remembered reject (a `rejected` column) — surface NeedsAuth, do NOT
138    /// relaunch the signer.
139    Rejected,
140    /// IPC / parse failure. Transient — does not flip observable state.
141    Error(String),
142}
143
144static NIP55_BACKEND: OnceLock<Box<dyn Nip55Backend>> = OnceLock::new();
145
146/// Register the platform NIP-55 backend. Call once during app startup on
147/// Android. No-op on desktop/CLI (nothing registers), so every op below returns
148/// [`Nip55Error::Missing`] there — the runtime stub.
149pub fn set_nip55_backend(backend: Box<dyn Nip55Backend>) {
150    let _ = NIP55_BACKEND.set(backend);
151}
152
153/// The registered backend, if any. `None` on platforms without an external
154/// signer.
155#[inline]
156pub fn nip55_backend() -> Option<&'static dyn Nip55Backend> {
157    NIP55_BACKEND.get().map(|b| b.as_ref())
158}
159
160// ----------------------------------------------------------------------------
161// Concurrency bound (review W4) — scoped to the fast path only (review W1)
162// ----------------------------------------------------------------------------
163//
164// A deep-sync backfill decrypts one gift wrap per inbound message — thousands
165// of ops. An unbounded `spawn_blocking` per op would park a large slice of
166// tokio's blocking pool, so the fast ContentResolver query is gated behind this
167// semaphore. The permit is released BEFORE the (rare, up-to-2-minute) foreground
168// Intent fallback runs, so a parked signer prompt can't head-of-line-block an
169// interactive send waiting for a permit. See `Nip55Signer::run`.
170
171const NIP55_MAX_CONCURRENT_OPS: usize = 4;
172
173static NIP55_SEMAPHORE: LazyLock<tokio::sync::Semaphore> =
174    LazyLock::new(|| tokio::sync::Semaphore::new(NIP55_MAX_CONCURRENT_OPS));
175
176/// Serializes the foreground-Intent fallback to ONE at a time. Amber shows a
177/// single approval dialog anyway, so without this a burst of un-remembered ops
178/// (e.g. a foreground backfill whose kinds weren't pre-granted) would each park
179/// up to the sign timeout AND stack `startActivityForResult` launches — a
180/// thundering herd. Size 1 turns that into an orderly queue.
181static NIP55_INTENT_SEMAPHORE: LazyLock<tokio::sync::Semaphore> =
182    LazyLock::new(|| tokio::sync::Semaphore::new(1));
183
184// ============================================================================
185// Permissions requested at pairing
186// ============================================================================
187
188/// Encryption/decryption permissions Vector requests at pairing. These are
189/// granted blanket (no `kind`) because Amber only drops a `kind`-less
190/// permission when its type is `sign_event`/`nip` — nip04/nip44 with a null
191/// kind are kept.
192pub const VECTOR_NIP55_ENCRYPT_TYPES: &[&str] = &[
193    "nip44_encrypt",
194    "nip44_decrypt",
195    "nip04_encrypt",
196    "nip04_decrypt",
197];
198
199/// Event kinds Vector signs, requested per-kind at pairing.
200///
201/// CRITICAL: Amber drops a bare `{"type":"sign_event"}` (kind-less) permission
202/// during pairing, so a blanket sign grant silently pre-authorizes NOTHING and
203/// every outbound op would need a foreground tap. We must enumerate every kind
204/// we sign. A kind missed here isn't a hard failure — the first sign of it
205/// prompts once and the user can "remember" — but a background sign of an
206/// un-remembered kind fails soft (no Activity to prompt). Keep this in sync
207/// with the kinds Vector actually signs.
208pub const VECTOR_NIP55_SIGN_KINDS: &[u16] = &[
209    0,     // profile metadata
210    3,     // contacts
211    5,     // deletion
212    7,     // reaction
213    13,    // NIP-17 seal (DM hot path)
214    14,    // NIP-17 chat rumor
215    1059,  // NIP-17 gift wrap
216    8,     // NIP-58 badge award
217    30008, // NIP-58 profile badges
218    30009, // NIP-58 badge definition
219    10030, // emoji pack list
220    10050, // NIP-17 DM relay list
221    10063, // blossom server list
222    22242, // NIP-42 relay auth
223    24242, // blossom auth
224    30078, // app-specific data (invite acceptance)
225    // Concord / Communities (v1 3300-3311, v2 added 3312/3313).
226    3300, 3301, 3302, 3303, 3304, 3305, 3306,
227    3307, 3308, 3309, 3310, 3311, 3312, 3313,
228];
229
230/// Render the pairing `permissions` JSON: blanket encrypt/decrypt plus one
231/// `{"type":"sign_event","kind":K}` per signed kind. Never includes
232/// `get_private_key` — same policy as [`crate::signer::VECTOR_NIP46_PERMS`];
233/// the whole point of an external signer is that the identity nsec never leaves
234/// it.
235pub fn nip55_perms_json() -> String {
236    let mut arr: Vec<serde_json::Value> = VECTOR_NIP55_ENCRYPT_TYPES
237        .iter()
238        .map(|t| serde_json::json!({ "type": t }))
239        .collect();
240    for &kind in VECTOR_NIP55_SIGN_KINDS {
241        arr.push(serde_json::json!({ "type": "sign_event", "kind": kind }));
242    }
243    serde_json::Value::Array(arr).to_string()
244}
245
246// ============================================================================
247// Nip55State — observable pairing lifecycle (mirror BunkerConnectionState)
248// ============================================================================
249
250/// Observable state of the NIP-55 pairing. Backed by an atomic for hot-path
251/// reads; transitions fan out to the frontend as `nip55_state` events.
252#[derive(Copy, Clone, Debug, Eq, PartialEq)]
253#[repr(u8)]
254pub enum Nip55State {
255    /// No active NIP-55 session (local/bunker account, or between login and
256    /// first op).
257    Idle = 0,
258    /// Signer reachable and pre-authorized; ops succeed silently.
259    Ready = 1,
260    /// A background op came back `rejected` — the user must reopen the signer
261    /// and re-grant. Inbound decryption defers to a foreground prompt.
262    NeedsAuth = 2,
263    /// The signer app is gone (uninstalled). The account can't sign until it's
264    /// reinstalled and re-paired.
265    Missing = 3,
266}
267
268impl Nip55State {
269    /// User-facing label, mirrored to the frontend in `nip55_state` events.
270    pub fn as_label(self) -> &'static str {
271        match self {
272            Nip55State::Idle => "idle",
273            Nip55State::Ready => "ready",
274            Nip55State::NeedsAuth => "needs_auth",
275            Nip55State::Missing => "missing",
276        }
277    }
278}
279
280static NIP55_STATE: AtomicU8 = AtomicU8::new(Nip55State::Idle as u8);
281
282/// Read the live NIP-55 pairing state. Backed by an atomic; cheap to call.
283#[inline]
284pub fn nip55_state() -> Nip55State {
285    match NIP55_STATE.load(Ordering::Acquire) {
286        1 => Nip55State::Ready,
287        2 => Nip55State::NeedsAuth,
288        3 => Nip55State::Missing,
289        _ => Nip55State::Idle,
290    }
291}
292
293/// Install a new state and fan out a `nip55_state` event. No-op if unchanged,
294/// so per-op confirmation of an already-known state doesn't spam the UI.
295pub fn set_nip55_state(new_state: Nip55State) {
296    let prev = NIP55_STATE.swap(new_state as u8, Ordering::AcqRel);
297    if prev == new_state as u8 {
298        return;
299    }
300    crate::traits::emit_event_json(
301        "nip55_state",
302        serde_json::json!({ "state": new_state.as_label() }),
303    );
304}
305
306/// Reset NIP-55 observable state to Idle. Called by `reset_session()` on swap
307/// so a stale state from the previous account doesn't leak onto the new one.
308/// (The Android shell separately cancels any stranded Intent waiters.)
309pub fn drain_nip55_state() {
310    set_nip55_state(Nip55State::Idle);
311}
312
313// ============================================================================
314// Nip55Signer — VectorSigner over the platform hook
315// ============================================================================
316
317/// A `VectorSigner` that routes every identity op to an external NIP-55 signer
318/// over the platform hook. Cheap to clone (just a pubkey + a session
319/// generation snapshot).
320///
321/// Captures a [`SessionGuard`](crate::state::SessionGuard) at construction:
322/// state flips after an account swap are suppressed so an in-flight op
323/// resolving against the previous account can't leak `nip55_state` onto the new
324/// one. Wrong-key is impossible regardless — every op is bound to
325/// `user_pubkey` and passes it as `current_user`, so a stale op still signs as
326/// the correct (old) identity; it just must not narrate onto the new session.
327#[derive(Debug, Clone)]
328pub struct Nip55Signer {
329    user_pubkey: PublicKey,
330    session: crate::state::SessionGuard,
331}
332
333impl Nip55Signer {
334    /// Build a signer for the paired identity. Capture happens now so the guard
335    /// is bound to the session that installed this signer.
336    pub fn new(user_pubkey: PublicKey) -> Self {
337        Self {
338            user_pubkey,
339            session: crate::state::SessionGuard::capture(),
340        }
341    }
342
343    /// The paired identity pubkey.
344    #[inline]
345    pub fn user_pubkey(&self) -> PublicKey {
346        self.user_pubkey
347    }
348
349    /// Flip observable state only while the captured session is still active.
350    #[inline]
351    fn flip(&self, state: Nip55State) {
352        if self.session.is_valid() {
353            set_nip55_state(state);
354        }
355    }
356
357    /// Resolver-first op with a foreground-Intent fallback. The semaphore bounds
358    /// ONLY the fast ContentResolver query; the permit is released before the
359    /// (rare, up-to-2-minute) Intent runs so a parked prompt can't head-of-line-block
360    /// interactive signing (review W1). Returns `(result, event)`.
361    async fn run(
362        &self,
363        method: &'static str,
364        intent_type: &'static str,
365        data: String,
366        counterparty: String,
367        current_user: String,
368    ) -> Result<(Option<String>, Option<String>), SignerError> {
369        let backend = match nip55_backend() {
370            Some(b) => b,
371            None => {
372                self.flip(Nip55State::Missing);
373                return Err(SignerError::backend(Nip55Error::Missing));
374            }
375        };
376
377        // Fast ContentResolver path, permit-bounded. Permit drops with this block.
378        let outcome = {
379            let _permit = NIP55_SEMAPHORE.acquire().await.map_err(|_| {
380                SignerError::backend(Nip55Error::Ipc("nip55 semaphore closed".to_string()))
381            })?;
382            let (d, cp, cu) = (data.clone(), counterparty.clone(), current_user.clone());
383            match tokio::task::spawn_blocking(move || backend.resolver_op(method, &d, &cp, &cu)).await {
384                Ok(o) => o,
385                Err(e) => {
386                    return Err(SignerError::backend(Nip55Error::Ipc(format!(
387                        "nip55 worker join error: {e}"
388                    ))))
389                }
390            }
391        };
392
393        match outcome {
394            Nip55ResolverOutcome::Value { result, event } => {
395                self.flip(Nip55State::Ready);
396                Ok((result, event))
397            }
398            Nip55ResolverOutcome::Rejected => {
399                self.flip(Nip55State::NeedsAuth);
400                Err(SignerError::backend(Nip55Error::NotAuthorized))
401            }
402            Nip55ResolverOutcome::Error(e) => Err(SignerError::backend(Nip55Error::Ipc(e))),
403            Nip55ResolverOutcome::RequiresApproval => {
404                // Not remembered — prompt only if the user is actually in the app.
405                if !backend.is_foreground() {
406                    self.flip(Nip55State::NeedsAuth);
407                    return Err(SignerError::backend(Nip55Error::NotAuthorized));
408                }
409                // Serialize to one live prompt (Amber shows one dialog anyway).
410                // Held across the intent, but NOT the fast-path permit, so it
411                // can't head-of-line-block silent signing.
412                let _intent_permit = NIP55_INTENT_SEMAPHORE.acquire().await.map_err(|_| {
413                    SignerError::backend(Nip55Error::Ipc("nip55 intent semaphore closed".to_string()))
414                })?;
415                let res = match tokio::task::spawn_blocking(move || {
416                    backend.intent_op(intent_type, &data, &counterparty, &current_user)
417                })
418                .await
419                {
420                    Ok(r) => r,
421                    Err(e) => {
422                        return Err(SignerError::backend(Nip55Error::Ipc(format!(
423                            "nip55 worker join error: {e}"
424                        ))))
425                    }
426                };
427                match res {
428                    Ok((result, event)) => {
429                        self.flip(Nip55State::Ready);
430                        Ok((result, event))
431                    }
432                    Err(e @ Nip55Error::NotAuthorized) => {
433                        self.flip(Nip55State::NeedsAuth);
434                        Err(SignerError::backend(e))
435                    }
436                    Err(e @ Nip55Error::Missing) => {
437                        self.flip(Nip55State::Missing);
438                        Err(SignerError::backend(e))
439                    }
440                    Err(e) => Err(SignerError::backend(e)),
441                }
442            }
443        }
444    }
445
446    /// Test-only view onto the captured guard so a test can assert the wrapper
447    /// is bound to the session generation at construction.
448    #[cfg(test)]
449    pub(crate) fn session_generation_for_test(&self) -> u64 {
450        self.session.generation()
451    }
452}
453
454impl AsyncGetPublicKey for Nip55Signer {
455    type Error = SignerError;
456
457    fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, Self::Error>> {
458        // Cached (review W6): NIP-55 `get_public_key` is a pairing-time Intent,
459        // never a per-call hop. `my_pk` is resolved constantly on the hot path;
460        // an IPC round-trip here would be catastrophic.
461        Box::pin(async move { Ok(self.user_pubkey) })
462    }
463
464}
465
466impl AsyncSignEvent for Nip55Signer {
467    type Error = SignerError;
468
469    fn sign_event_async(&self, unsigned: UnsignedEvent) -> BoxedFuture<'_, Result<Event, Self::Error>> {
470        Box::pin(async move {
471            let event_json = unsigned.as_json();
472            let user_hex = self.user_pubkey.to_hex();
473            let (_result, signed) = self
474                .run("SIGN_EVENT", "sign_event", event_json, String::new(), user_hex)
475                .await?;
476            let signed_json = signed.ok_or_else(|| {
477                SignerError::backend(Nip55Error::Ipc("signer returned no signed event".to_string()))
478            })?;
479            let event = Event::from_json(&signed_json).map_err(SignerError::backend)?;
480            // Fail closed: the signer must return an event authored by OUR
481            // identity. A mismatch (wrong Amber account, corrupted IPC) would
482            // otherwise fork the wire under a foreign key.
483            if event.pubkey != self.user_pubkey {
484                return Err(SignerError::backend(Nip55Error::Ipc(format!(
485                    "signer returned event authored by {} (expected {})",
486                    event.pubkey.to_hex(),
487                    self.user_pubkey.to_hex()
488                ))));
489            }
490            event.verify().map_err(SignerError::backend)?;
491            Ok(event)
492        })
493    }
494
495}
496
497impl AsyncNip04 for Nip55Signer {
498    type Error = SignerError;
499
500    fn nip04_encrypt_async<'a>(
501        &'a self,
502        public_key: &'a PublicKey,
503        content: &'a str,
504    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
505        Box::pin(async move {
506            let (result, _event) = self
507                .run("NIP04_ENCRYPT", "nip04_encrypt", content.to_string(), public_key.to_hex(), self.user_pubkey.to_hex())
508                .await?;
509            result.ok_or_else(|| SignerError::backend(Nip55Error::Ipc("signer returned no result".to_string())))
510        })
511    }
512
513    fn nip04_decrypt_async<'a>(
514        &'a self,
515        public_key: &'a PublicKey,
516        content: &'a str,
517    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
518        Box::pin(async move {
519            let (result, _event) = self
520                .run("NIP04_DECRYPT", "nip04_decrypt", content.to_string(), public_key.to_hex(), self.user_pubkey.to_hex())
521                .await?;
522            result.ok_or_else(|| SignerError::backend(Nip55Error::Ipc("signer returned no result".to_string())))
523        })
524    }
525
526}
527
528impl AsyncNip44 for Nip55Signer {
529    type Error = SignerError;
530
531    fn nip44_encrypt_async<'a>(
532        &'a self,
533        public_key: &'a PublicKey,
534        content: &'a str,
535    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
536        Box::pin(async move {
537            let (result, _event) = self
538                .run("NIP44_ENCRYPT", "nip44_encrypt", content.to_string(), public_key.to_hex(), self.user_pubkey.to_hex())
539                .await?;
540            result.ok_or_else(|| SignerError::backend(Nip55Error::Ipc("signer returned no result".to_string())))
541        })
542    }
543
544    fn nip44_decrypt_async<'a>(
545        &'a self,
546        public_key: &'a PublicKey,
547        content: &'a str,
548    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
549        Box::pin(async move {
550            let (result, _event) = self
551                .run("NIP44_DECRYPT", "nip44_decrypt", content.to_string(), public_key.to_hex(), self.user_pubkey.to_hex())
552                .await?;
553            result.ok_or_else(|| SignerError::backend(Nip55Error::Ipc("signer returned no result".to_string())))
554        })
555    }
556}
557
558// ============================================================================
559// Pairing + availability helpers (login-flow entry points)
560// ============================================================================
561
562/// Whether an external signer is installed. Returns `Ok(false)` on platforms
563/// without a registered backend (desktop) rather than erroring — the login
564/// screen treats "not installed" and "not supported" the same (hide the
565/// button).
566pub fn nip55_is_installed() -> Result<bool, String> {
567    match nip55_backend() {
568        Some(b) => b.is_installed().map_err(|e| e.to_string()),
569        None => Ok(false),
570    }
571}
572
573/// Run the pairing handshake: fire the `get_public_key` Intent with Vector's
574/// remembered permission set and return the discovered identity pubkey plus the
575/// signer's package name. Blocking (Activity round-trip) — wrapped in
576/// `spawn_blocking`.
577pub async fn nip55_pair() -> Result<(PublicKey, String), String> {
578    let perms = nip55_perms_json();
579    let backend = nip55_backend().ok_or("no external signer available on this platform")?;
580    let (pk_str, package) =
581        tokio::task::spawn_blocking(move || backend.get_public_key_pairing(&perms))
582            .await
583            .map_err(|e| format!("pairing worker join error: {e}"))?
584            .map_err(|e| e.to_string())?;
585    // Amber returns the identity as npub (bech32); `parse` also accepts hex.
586    let pk = PublicKey::parse(&pk_str)
587        .map_err(|e| format!("external signer returned an invalid pubkey: {e}"))?;
588    Ok((pk, package))
589}
590
591#[cfg(test)]
592mod tests {
593    use crate::event_ext::FinalizeUnsignedWithId;
594    use super::*;
595
596    #[test]
597    fn perms_exclude_private_key_and_enumerate_sign_kinds() {
598        let json = nip55_perms_json();
599        // The identity nsec must never leave the signer.
600        assert!(!json.contains("private_key"), "perms leaked private-key access: {json}");
601        // Blanket encrypt/decrypt (kind-less, which Amber keeps).
602        for t in VECTOR_NIP55_ENCRYPT_TYPES {
603            assert!(json.contains(t), "perms JSON missing '{t}': {json}");
604        }
605        // The DM-seal hot-path kind must be pre-granted.
606        assert!(json.contains("\"kind\":13"), "seal kind 13 must be pre-granted: {json}");
607
608        // EVERY sign_event permission must carry an explicit kind — Amber drops
609        // a bare (kind-less) sign_event at pairing, so a blanket grant would
610        // silently pre-authorize nothing.
611        let v: serde_json::Value = serde_json::from_str(&json).expect("perms json parses");
612        let mut saw_sign = false;
613        for entry in v.as_array().expect("perms is an array") {
614            if entry.get("type").and_then(|t| t.as_str()) == Some("sign_event") {
615                saw_sign = true;
616                assert!(
617                    entry.get("kind").and_then(|k| k.as_u64()).is_some(),
618                    "bare sign_event perm would be dropped by Amber: {entry}"
619                );
620            }
621        }
622        assert!(saw_sign, "perms must request sign_event for at least one kind: {json}");
623    }
624
625    #[test]
626    fn state_label_covers_all_variants() {
627        // A new Nip55State without a label would ship an unlabelled event to
628        // the frontend listener. Force the match here.
629        assert_eq!(Nip55State::Idle.as_label(), "idle");
630        assert_eq!(Nip55State::Ready.as_label(), "ready");
631        assert_eq!(Nip55State::NeedsAuth.as_label(), "needs_auth");
632        assert_eq!(Nip55State::Missing.as_label(), "missing");
633    }
634
635    #[tokio::test]
636    async fn get_public_key_is_cached_and_needs_no_backend() {
637        // No backend is registered in pure vector-core tests, yet get_public_key
638        // must still resolve — it returns the cached identity, never an IPC hop.
639        let keys = Keys::generate();
640        let signer = Nip55Signer::new(keys.public_key());
641        let pk = signer.get_public_key_async().await.expect("cached pubkey resolves");
642        assert_eq!(pk, keys.public_key());
643    }
644
645    // NIP55_STATE and SESSION_GENERATION are process-wide. Cargo runs #[test]
646    // functions in parallel, so every mutation of NIP55_STATE is bundled into
647    // THIS single test to keep the sequence deterministic — mirrors
648    // `atomic_state_round_trips_and_drains` / `watched_signer_session_gate...`
649    // in signer.rs.
650    #[tokio::test]
651    async fn global_state_session_gate_and_missing_backend() {
652        use crate::state::{bump_session_generation, current_session_generation};
653
654        // Defensive reset (a prior panic could have left state dirty).
655        set_nip55_state(Nip55State::Idle);
656
657        // State roundtrip.
658        set_nip55_state(Nip55State::Ready);
659        assert_eq!(nip55_state(), Nip55State::Ready);
660        set_nip55_state(Nip55State::NeedsAuth);
661        assert_eq!(nip55_state(), Nip55State::NeedsAuth);
662        set_nip55_state(Nip55State::Missing);
663        assert_eq!(nip55_state(), Nip55State::Missing);
664        drain_nip55_state();
665        assert_eq!(nip55_state(), Nip55State::Idle);
666
667        // Session gate: a signer flips state only while its captured generation
668        // is live.
669        let keys = Keys::generate();
670        let gen_before = current_session_generation();
671        let signer = Nip55Signer::new(keys.public_key());
672        assert_eq!(
673            signer.session_generation_for_test(),
674            gen_before,
675            "signer must capture the live session generation at construction"
676        );
677        // Valid session flips (tolerate a concurrent bump from a sibling test).
678        if signer.session_generation_for_test() == current_session_generation() {
679            signer.flip(Nip55State::Ready);
680            assert_eq!(nip55_state(), Nip55State::Ready, "valid-session flip must apply");
681        }
682        // After a swap the guard is stale; flips are no-ops so a leftover op
683        // can't leak state onto the new account.
684        set_nip55_state(Nip55State::Ready);
685        bump_session_generation();
686        signer.flip(Nip55State::Missing);
687        assert_eq!(nip55_state(), Nip55State::Ready, "stale-session flip must be a no-op");
688
689        // Missing backend: a FRESH signer (guard valid post-bump) signing with
690        // no registered backend fails `Missing` and flips the state.
691        set_nip55_state(Nip55State::Idle);
692        let fresh = Nip55Signer::new(keys.public_key());
693        let unsigned = EventBuilder::new(Kind::TextNote, "hi")
694            .finalize_unsigned_with_id(keys.public_key());
695        let err = fresh.sign_event_async(unsigned).await;
696        assert!(err.is_err(), "no backend registered => sign must fail");
697        assert_eq!(
698            nip55_state(),
699            Nip55State::Missing,
700            "missing-backend op must surface Missing state"
701        );
702
703        // nip55_is_installed with no backend registered is a clean false.
704        assert_eq!(nip55_is_installed().unwrap(), false);
705
706        // Cleanup for sibling tests / reruns.
707        drain_nip55_state();
708    }
709}