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, ¤t_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}