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