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