Keynctr/tests/nip46_e2e.rs
Avi c89b31aaf1 feat(nip46): pair a second signer account — park the live session, switch re-dials it
One live NIP-46 session, many saved ones (Option A):
- start_pairing/connect while a session is live PARKS it instead of
  refusing: row, pairing secret, and persisted client key stay intact,
  so the parked account is restorable with no fresh scan.
- SelectProfile follows the switch: target has a restorable connection ->
  park current + re-dial target's row (expected_identity guard applies);
  target is local-key or unpaired -> live session untouched.
- New nip46_cancel_pairing IPC: aborts ONLY an in-flight pairing and
  re-dials the parked session, so cancel-after-park is transparent.
  The QR cancel paths (Add-profile modal, Signer Mode screen) use it —
  plain disconnect would revoke the parked connection.
- e2e: two fake Ambers on one relay; A pairs, B's pairing parks A
  (revoked_at none, client key resolvable), switch back re-dials A and
  signs; no-op switch; local profile leaves session alone; B restorable.
2026-09-27 21:01:23 -05:00

1571 lines
60 KiB
Rust

//! End-to-end NIP-46 client tests: the real `Nip46ClientSigner` runs against
//! a local relay and fake signers — a bunker:// Amber (per-connection comms
//! key, delayed human-approval ack) and a QR scanner that consumes a
//! client-minted `nostrconnect://` pairing token with secret verification.
//! Both reveal a real identity only via `get_public_key`.
//!
//! Exercises in one process: relay I/O, NIP-44 encryption, both handshake
//! directions, the deferred-identity flow, `sign_event` with full
//! verification, and vault persistence of the remote profile.
//! No network, no phone.
use std::collections::HashMap;
use std::net::TcpListener as StdTcpListener;
use std::time::Duration;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use keynectr::app::App;
use keynectr::signer::nip46_client::Nip46ClientSigner;
use keynectr::signer::Signer as SignerTrait;
use keynectr::vault::Vault;
use nostr::nips::nip19::ToBech32;
use nostr::nips::nip44::v2;
use nostr::nips::nip44::v2::ConversationKey;
use nostr_sdk::prelude::*;
use serde_json::{json, Value};
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::Message;
/// Vault setup touches the process-global `XDG_DATA_HOME`; serialize it so
/// the two e2e tests in this binary cannot read each other's vault.
static VAULT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
// ---------------------------------------------------------------------------
// Minimal in-process nostr relay
// ---------------------------------------------------------------------------
struct Session {
tx: mpsc::UnboundedSender<String>,
subs: HashMap<String, Vec<Value>>,
}
struct RelayState {
sessions: Vec<Session>,
events: Vec<Event>,
}
/// Match a stored/incoming event against a REQ filter (subset of the nostr
/// relay spec sufficient for this test: kinds + authors).
fn matches(filter: &Value, ev: &Event) -> bool {
if let Some(kinds) = filter.get("kinds").and_then(|k| k.as_array()) {
if !kinds
.iter()
.any(|k| k.as_u64() == Some(u64::from(u16::from(ev.kind))))
{
return false;
}
}
if let Some(authors) = filter.get("authors").and_then(|a| a.as_array()) {
if !authors.is_empty()
&& !authors
.iter()
.any(|a| a.as_str() == Some(ev.pubkey.to_hex().as_str()))
{
return false;
}
}
true
}
async fn start_relay() -> String {
let std_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind relay");
std_listener.set_nonblocking(true).expect("nonblocking");
let listener = tokio::net::TcpListener::from_std(std_listener).expect("tokio listener");
let port = listener.local_addr().unwrap().port();
let url = format!("ws://127.0.0.1:{port}");
let state: std::sync::Arc<Mutex<RelayState>> = std::sync::Arc::new(Mutex::new(RelayState {
sessions: Vec::new(),
events: Vec::new(),
}));
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
continue;
};
let Ok(socket) = tokio_tungstenite::accept_async(stream).await else {
continue;
};
let state = state.clone();
tokio::spawn(async move {
let (mut write, mut read) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let session_idx = {
let mut s = state.lock().await;
s.sessions.push(Session {
tx,
subs: HashMap::new(),
});
s.sessions.len() - 1
};
// Writer half.
let writer = tokio::spawn(async move {
while let Some(line) = rx.recv().await {
if write.send(Message::Text(line.into())).await.is_err() {
break;
}
}
});
while let Some(Ok(msg)) = read.next().await {
let Message::Text(text) = msg else { continue };
let Ok(arr) = serde_json::from_str::<Vec<Value>>(&text) else {
continue;
};
match arr.first().and_then(|v| v.as_str()).unwrap_or("") {
"REQ" => {
let Some(sub_id) = arr.get(1).and_then(|v| v.as_str()) else {
continue;
};
let filters: Vec<Value> = arr[2..].to_vec();
let mut s = state.lock().await;
if let Some(sess) = s.sessions.get_mut(session_idx) {
sess.subs.insert(sub_id.to_string(), filters.clone());
}
// Replay matching stored events so late joiners
// never miss messages they raced past.
for ev in &s.events {
for f in &filters {
if matches(f, ev) {
let out = json!([
"EVENT",
sub_id,
serde_json::from_str::<Value>(&ev.as_json())
.unwrap_or_default()
])
.to_string();
if let Some(sess) = s.sessions.get(session_idx) {
eprintln!(
"[relay] replay {} to new sub {}",
&ev.id.to_hex()[..16],
sub_id
);
let _ = sess.tx.send(out);
}
break;
}
}
}
let eose = json!(["EOSE", sub_id]).to_string();
if let Some(sess) = s.sessions.get(session_idx) {
let _ = sess.tx.send(eose);
}
}
"CLOSE" => {
if let Some(sub_id) = arr.get(1).and_then(|v| v.as_str()) {
let mut s = state.lock().await;
if let Some(sess) = s.sessions.get_mut(session_idx) {
sess.subs.remove(sub_id);
}
}
}
"EVENT" => {
let Some(ev) = arr.get(1).and_then(|v| v.as_object()).and_then(|o| {
Event::from_json(serde_json::to_string(o).ok()?.as_bytes()).ok()
}) else {
continue;
};
let mut s = state.lock().await;
s.events.push(ev.clone());
// OK notice: nostr-sdk's send_event waits for the
// relay to accept the event before resolving.
let ok = json!(["OK", ev.id.to_hex(), true, ""]).to_string();
if let Some(sess) = s.sessions.get(session_idx) {
let _ = sess.tx.send(ok);
}
let ev_json =
serde_json::from_str::<Value>(&ev.as_json()).unwrap_or_default();
// Broadcast to every OTHER session with a
// matching subscription (relay spec: no echo to
// origin).
for (idx, sess) in s.sessions.iter().enumerate() {
if idx == session_idx {
continue;
}
for (sub_id, filters) in &sess.subs {
if filters.iter().any(|f| matches(f, &ev)) {
let out = json!(["EVENT", sub_id, ev_json]).to_string();
let _ = sess.tx.send(out.clone());
break;
}
}
}
}
_ => {}
}
}
writer.abort();
let mut s = state.lock().await;
if let Some(sess) = s.sessions.get_mut(session_idx) {
// Leave a dead session slot; harmless for a test relay.
sess.subs.clear();
}
});
}
});
url
}
// ---------------------------------------------------------------------------
// Fake Amber: signer role with a per-connection comms key + real identity
// ---------------------------------------------------------------------------
fn nip44_enc(conversation: &ConversationKey, plaintext: &str) -> String {
let mut nonce = [0u8; 32];
getrandom::getrandom(&mut nonce).unwrap();
let bytes = v2::encrypt_to_bytes_with_nonce(conversation, plaintext.as_bytes(), nonce).unwrap();
B64.encode(bytes)
}
fn nip44_dec(conversation: &ConversationKey, content: &str) -> Option<String> {
let bytes = B64.decode(content).ok()?;
let plain = v2::decrypt_to_bytes(conversation, &bytes).ok()?;
String::from_utf8(plain).ok()
}
/// Connect to the relay as Amber: subscribe to kind 24133, answer the
/// bunker:// handshake (simulated human approval delay), reveal the real
/// identity key, and sign events with it.
async fn run_fake_amber(relay_url: String, comms: Keys, identity: Keys, approval_delay: Duration) {
let (mut ws, _) = tokio_tungstenite::connect_async(&relay_url)
.await
.expect("amber connect");
ws.send(Message::Text(
json!(["REQ", "amber", {"kinds": [24133]}])
.to_string()
.into(),
))
.await
.unwrap();
let comms_pub = comms.public_key();
while let Some(Ok(msg)) = ws.next().await {
let Message::Text(text) = msg else { continue };
let Ok(arr) = serde_json::from_str::<Vec<Value>>(&text) else {
continue;
};
if arr.first().and_then(|v| v.as_str()) != Some("EVENT") {
continue;
}
let Some(ev) = arr
.get(2)
.and_then(|v| v.as_object())
.and_then(|o| Event::from_json(serde_json::to_string(o).ok()?.as_bytes()).ok())
else {
continue;
};
// Never answer our own messages.
if ev.pubkey == comms_pub {
continue;
}
// Try to decrypt with a conversation keyed to this sender. A failure
// means the message was not addressed to us.
let Ok(conversation) = ConversationKey::derive(comms.secret_key(), &ev.pubkey) else {
continue;
};
let Some(plain) = nip44_dec(&conversation, &ev.content) else {
continue;
};
let Ok(req) = serde_json::from_str::<Value>(&plain) else {
continue;
};
let Some(method) = req.get("method").and_then(|m| m.as_str()) else {
continue;
};
let id = req
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let response: Value = match method {
"connect" => {
// Simulate a human tapping "approve" in Amber. Amber's
// ack SHAPE depends on the connection state: a first-time
// pairing (no secret in params) gets a plain ack; an
// ALREADY-APPROVED connection re-dialing with the stored
// secret gets `true` WITHOUT a secret echo (live Amber,
// Sep 25 — the client must not demand an echo there).
tokio::time::sleep(approval_delay).await;
if req["params"].as_array().is_some_and(|p| p.len() > 1) {
json!({"id": id, "result": true})
} else {
json!({"id": id, "result": "ack"})
}
}
"get_public_key" => json!({"id": id, "result": identity.public_key().to_hex()}),
"sign_event" => {
let unsigned_json = req["params"].get(0).and_then(|v| v.as_str());
match unsigned_json.and_then(|s| serde_json::from_str::<Value>(s).ok()) {
Some(mut v) => {
if v.get("pubkey").is_none() {
v["pubkey"] = json!(identity.public_key().to_hex());
}
match serde_json::from_value::<UnsignedEvent>(v)
.ok()
.and_then(|u| identity.sign_event(u).ok())
{
Some(signed) => {
json!({"id": id, "result": signed.as_json()})
}
None => json!({"id": id, "error": "sign failed"}),
}
}
None => json!({"id": id, "error": "bad params"}),
}
}
other => json!({"id": id, "error": format!("unsupported: {other}")}),
};
let content = nip44_enc(&conversation, &response.to_string());
let out = EventBuilder::new(Kind::NostrConnect, content)
.tags([Tag::parse(["p", ev.pubkey.to_hex().as_str()]).unwrap()])
.finalize(&comms)
.unwrap();
ws.send(Message::Text(
json!([
"EVENT",
serde_json::from_str::<Value>(&out.as_json()).unwrap()
])
.to_string()
.into(),
))
.await
.unwrap();
}
}
// ---------------------------------------------------------------------------
// The test
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
/// Serializes the e2e tests against each other (see the whole-body `_vault_guard`
/// below). `await_holding_lock` is allowed here deliberately: the guard is a
/// test-only serialization lock, never nested, never shared with production
/// code — holding it across awaits is the entire point.
#[allow(clippy::await_holding_lock)]
async fn nip46_client_handshake_and_sign_against_fake_amber() {
// Isolated vault so the test never touches the real user vault.
// XDG_DATA_HOME is process-global and every test in this binary sets it,
// so the lock is held for the WHOLE test: data_dir() re-reads the env on
// every save, and a setup-only guard lets parallel tests cross-write.
let _vault_guard = VAULT_ENV_LOCK
.lock()
// Poison-tolerant on purpose: this lock only serializes the
// process-global XDG_DATA_HOME. A sibling test that panics while
// holding it must not cascade into PoisonError failures of every
// later test — one real failure should report as ONE failure.
.unwrap_or_else(|e| e.into_inner());
let app = {
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-{}", std::process::id()));
// data_dir() is $XDG_DATA_HOME/keynectr — the isolation vault must
// live there. Writing it one level too shallow left the app finding
// NO vault at the real path, which silently migrated the legacy
// repo vault (with the user's real keys!) into the test instead.
let app_dir = tmp.join("keynectr");
std::fs::create_dir_all(&app_dir).unwrap();
std::fs::write(
app_dir.join("profiles_vault.json"),
serde_json::to_string(&Vault::empty()).unwrap(),
)
.unwrap();
std::env::set_var("XDG_DATA_HOME", &tmp);
let app = std::sync::Arc::new(Mutex::new(App::load().expect("load app")));
// Guard: if seeding ever misses the real data_dir path again,
// load_vault() silently migrates the legacy repo vault (real user
// keys) into the test. An isolated run must load an EMPTY vault.
assert!(
app.try_lock().unwrap().vault.profiles.is_empty(),
"e2e vault isolation failed: a non-empty vault was loaded — \
the seeded vault is not where data_dir() looks"
);
app
};
// Amber's keys: `comms` is the per-connection key in the bunker:// URI;
// `identity` is the REAL signing identity, never in the URI.
let comms = Keys::generate();
let identity = Keys::generate();
let relay_url = start_relay().await;
tokio::spawn(run_fake_amber(
relay_url.clone(),
comms.clone(),
identity.clone(),
Duration::from_millis(400),
));
let signer = Nip46ClientSigner::new(app.clone());
// Fail closed: signing before connect must error, never fall back.
let unsigned = UnsignedEvent::new(
identity.public_key(),
Timestamp::now(),
Kind::TextNote,
vec![],
"hello via amber".to_string(),
);
assert!(
SignerTrait::sign_event(&signer, unsigned.clone())
.await
.is_err(),
"signing before connect must fail closed"
);
// Amber shows exactly this URI: authority = comms key, no identity.
let uri = format!(
"bunker://{}?relay={}",
comms.public_key().to_hex(),
relay_url
);
let status = signer
.connect(&uri, "fake amber".to_string())
.await
.expect("connect");
// Session starts Connecting, not Connected: identity is not yet proven.
assert!(!status.connected, "must not be connected before handshake");
// Wait for the handshake (approval delay + get_public_key) to complete.
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
loop {
let status = signer.status().await;
if let Some(err) = &status.error {
panic!("signer failed: {err}");
}
if status.connected {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"handshake never completed; last status: {:?}",
signer.status().await
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Identity must be the REAL key, not the URI comms key.
let resolved = SignerTrait::get_public_key(&signer)
.await
.expect("identity resolved");
assert_eq!(
resolved,
identity.public_key(),
"identity must come from get_public_key"
);
assert_ne!(
resolved,
comms.public_key(),
"URI key must never become identity"
);
// Sign a note through the external signer and verify the client checks
// identity, id, and signature on the returned event.
let signed = SignerTrait::sign_event(&signer, unsigned.clone())
.await
.expect("remote sign_event");
assert_eq!(signed.pubkey, identity.public_key());
assert_eq!(signed.content, "hello via amber");
assert_eq!(signed.id, unsigned.compute_id());
assert!(signed.verify_signature());
// Vault persistence: the handshake stored a remote profile under the
// REAL identity, in external-signer mode.
let identity_npub = identity.public_key().to_bech32().unwrap();
let app_guard = app.lock().await;
let profile = app_guard
.vault
.profiles
.iter()
.find(|p| p.public_key == identity_npub)
.expect("remote profile row created");
assert_eq!(
profile.signer_mode,
keynectr::vault::SignerMode::Nip46Client,
"remote profile must be in external-signer mode"
);
assert!(
profile.secret_key.trim().is_empty(),
"no secret material for remote profiles"
);
drop(app_guard);
// Clean teardown so a failed run cannot leave a stuck task.
signer.disconnect().await.ok();
let _ = PublicKey::from_hex; // keep import used across cfg variations
}
// ---------------------------------------------------------------------------
// Strict Amber for the bunker:// (paste-URI) flow: validates the `connect`
// request against NIP-46 instead of acking anything. params[0] MUST be the
// remote signer's pubkey (the URI authority) — the client's own pubkey there
// is a spec violation that real signers answer with silence, stalling the
// handshake with zero feedback. This test FAILS on the old param order and
// passes on the fixed one.
// ---------------------------------------------------------------------------
/// Run Amber in strict mode: enforce the NIP-46 `connect` shape, then behave
/// like the lenient fake (delayed approval ack, real identity, remote sign).
async fn run_strict_amber(
relay_url: String,
comms: Keys,
identity: Keys,
approval_delay: Duration,
) {
let (mut ws, _) = tokio_tungstenite::connect_async(&relay_url)
.await
.expect("strict amber connect");
ws.send(Message::Text(
json!(["REQ", "strict-amber", {"kinds": [24133]}])
.to_string()
.into(),
))
.await
.unwrap();
let comms_pub = comms.public_key();
let comms_hex = comms_pub.to_hex();
while let Some(Ok(msg)) = ws.next().await {
let Message::Text(text) = msg else { continue };
let Ok(arr) = serde_json::from_str::<Vec<Value>>(&text) else {
continue;
};
if arr.first().and_then(|v| v.as_str()) != Some("EVENT") {
continue;
}
let Some(ev) = arr
.get(2)
.and_then(|v| v.as_object())
.and_then(|o| Event::from_json(serde_json::to_string(o).ok()?.as_bytes()).ok())
else {
continue;
};
if ev.pubkey == comms_pub {
continue;
}
let Ok(conversation) = ConversationKey::derive(comms.secret_key(), &ev.pubkey) else {
continue;
};
let Some(plain) = nip44_dec(&conversation, &ev.content) else {
continue;
};
let Ok(req) = serde_json::from_str::<Value>(&plain) else {
continue;
};
let Some(method) = req.get("method").and_then(|m| m.as_str()) else {
continue;
};
let id = req
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let response: Value = match method {
"connect" => {
let first_param = req
.get("params")
.and_then(|p| p.get(0))
.and_then(|v| v.as_str())
.unwrap_or("");
if first_param != comms_hex {
json!({"id": id, "error": "connect params must start with the remote signer pubkey"})
} else {
tokio::time::sleep(approval_delay).await;
json!({"id": id, "result": "ack"})
}
}
"get_public_key" => json!({"id": id, "result": identity.public_key().to_hex()}),
"sign_event" => {
let unsigned_json = req["params"].get(0).and_then(|v| v.as_str());
match unsigned_json.and_then(|s| serde_json::from_str::<Value>(s).ok()) {
Some(mut v) => {
if v.get("pubkey").is_none() {
v["pubkey"] = json!(identity.public_key().to_hex());
}
match serde_json::from_value::<UnsignedEvent>(v)
.ok()
.and_then(|u| identity.sign_event(u).ok())
{
Some(signed) => {
json!({"id": id, "result": signed.as_json()})
}
None => json!({"id": id, "error": "sign failed"}),
}
}
None => json!({"id": id, "error": "bad params"}),
}
}
other => json!({"id": id, "error": format!("unsupported: {other}")}),
};
let content = nip44_enc(&conversation, &response.to_string());
let out = EventBuilder::new(Kind::NostrConnect, content)
.tags([Tag::parse(["p", ev.pubkey.to_hex().as_str()]).unwrap()])
.finalize(&comms)
.unwrap();
ws.send(Message::Text(
json!([
"EVENT",
serde_json::from_str::<Value>(&out.as_json()).unwrap()
])
.to_string()
.into(),
))
.await
.unwrap();
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
/// Serializes the e2e tests against each other (see the whole-body `_vault_guard`
/// below). `await_holding_lock` is allowed here deliberately: the guard is a
/// test-only serialization lock, never nested, never shared with production
/// code — holding it across awaits is the entire point.
#[allow(clippy::await_holding_lock)]
async fn nip46_bunker_connect_params_match_spec_against_strict_amber() {
// Whole-body env lock: data_dir() re-reads XDG_DATA_HOME on every save.
let _vault_guard = VAULT_ENV_LOCK
.lock()
// Poison-tolerant on purpose: this lock only serializes the
// process-global XDG_DATA_HOME. A sibling test that panics while
// holding it must not cascade into PoisonError failures of every
// later test — one real failure should report as ONE failure.
.unwrap_or_else(|e| e.into_inner());
let app = {
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-strict-{}", std::process::id()));
let app_dir = tmp.join("keynectr");
std::fs::create_dir_all(&app_dir).unwrap();
std::fs::write(
app_dir.join("profiles_vault.json"),
serde_json::to_string(&Vault::empty()).unwrap(),
)
.unwrap();
std::env::set_var("XDG_DATA_HOME", &tmp);
let app = std::sync::Arc::new(Mutex::new(App::load().expect("load app")));
assert!(
app.try_lock().unwrap().vault.profiles.is_empty(),
"e2e vault isolation failed: a non-empty vault was loaded"
);
app
};
// `comms` is the per-connection key in the bunker:// URI; `identity` is
// the REAL signing identity, never in the URI.
let comms = Keys::generate();
let identity = Keys::generate();
let relay_url = start_relay().await;
tokio::spawn(run_strict_amber(
relay_url.clone(),
comms.clone(),
identity.clone(),
Duration::from_millis(200),
));
let signer = Nip46ClientSigner::new(app.clone());
// Register the handle on the App exactly like production's
// `ensure_nip46_signer` does: `App::signing_for` (used below for the
// kind-0 publish) resolves the live session through this handle.
// `Nip46ClientSigner::clone` shares the session state.
app.lock().await.nip46_signer = Some(std::sync::Arc::new(signer.clone()));
// No secret in the URI: the strict signer must still ack a well-formed
// connect whose params[0] is its own pubkey.
let uri = format!(
"bunker://{}?relay={}",
comms.public_key().to_hex(),
relay_url
);
let status = signer
.connect(&uri, "strict amber".to_string())
.await
.expect("connect");
assert!(!status.connected, "must not be connected before handshake");
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
loop {
let status = signer.status().await;
if let Some(err) = &status.error {
panic!("strict handshake failed: {err}");
}
if status.connected {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"strict handshake never completed; last status: {:?}",
signer.status().await
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Identity must be the REAL key from get_public_key — the actual user
// pubkey the account manager stores — never the URI comms key and never
// the client's own ephemeral key.
let resolved = SignerTrait::get_public_key(&signer)
.await
.expect("identity resolved");
assert_eq!(resolved, identity.public_key());
assert_ne!(resolved, comms.public_key());
let identity_npub = identity.public_key().to_bech32().unwrap();
let app_guard = app.lock().await;
assert!(
app_guard
.vault
.profiles
.iter()
.any(|p| p.public_key == identity_npub && p.secret_key.trim().is_empty()),
"remote profile row for the real identity must be stored with no secret"
);
assert_eq!(
app_guard.vault.active_profile.as_deref(),
Some(identity_npub.as_str()),
"the connected account must become the active profile"
);
drop(app_guard);
// Kind-0 through the remote signer: the vault label becomes a signed
// network-visible profile (what other clients display as the name).
// Exercises the real GUI "Publish name" path — Signing::External with
// identity validation — against the strict signer.
// Point settings at the in-process relay FIRST: the default relay set is
// the real internet (damus + nostr.band, the latter a known-hanger), so
// leaving it made this assertion a network lottery — it failed whenever
// damus dawdled past the 6s send timeout. The QR test already does this;
// the strict test shipped without it.
{
let mut a = app.lock().await;
a.settings.relays = vec![keynectr::settings::RelayConfig::new(relay_url.clone())];
a.save_settings().expect("save settings");
}
let (settings, signing) = {
let guard = app.lock().await;
(
guard.settings.clone(),
guard
.signing_for(&identity_npub)
.await
.expect("signing source for the paired profile"),
)
};
let report = keynectr::profiles::publish_metadata_signed(
&settings,
"Strict Amber",
None,
None,
&signing,
)
.await
.expect("remote kind-0 publish");
assert!(
!report.succeeded.is_empty(),
"at least one relay must accept the signed kind-0"
);
signer.disconnect().await.ok();
}
// ---------------------------------------------------------------------------
// QR pairing (client-initiated nostrconnect://): the fake signer plays the
// scanner role — it reads the pairing token the GUI would render, sends the
// `connect` request with the echoed secret, verifies the client's secret
// answer, then reveals its identity and signs like Amber.
// ---------------------------------------------------------------------------
async fn run_fake_scanner(
relay_url: String,
client_pk: PublicKey,
expected_secret: String,
identity: Keys,
connect_shape: &'static str,
) {
let (mut ws, _) = tokio_tungstenite::connect_async(&relay_url)
.await
.expect("scanner connect");
ws.send(Message::Text(
json!(["REQ", "scanner", {"kinds": [24133]}])
.to_string()
.into(),
))
.await
.unwrap();
let conversation = ConversationKey::derive(identity.secret_key(), &client_pk).unwrap();
// The scanned URI tells us who to contact and what secret to echo.
// Two shapes:
// - "request": {"id","method":"connect","params":[secret]} — what the
// e2e originally simulated; the client answers with the secret.
// - "response": {"id","result":secret} — what NIP-46 actually specifies
// for nostrconnect:// ("the _remote-signer_ … sends `connect`
// *response* event"), and what Amber sends. No answer expected.
let connect_msg = match connect_shape {
"response" => json!({ "id": "pair-1", "result": expected_secret.clone() }),
_ => json!({
"id": "pair-1",
"method": "connect",
"params": [expected_secret.clone()],
}),
};
let content = nip44_enc(&conversation, &connect_msg.to_string());
let out = EventBuilder::new(Kind::NostrConnect, content)
.tags([Tag::parse(["p", client_pk.to_hex().as_str()]).unwrap()])
.finalize(&identity)
.unwrap();
ws.send(Message::Text(
json!([
"EVENT",
serde_json::from_str::<Value>(&out.as_json()).unwrap()
])
.to_string()
.into(),
))
.await
.unwrap();
while let Some(Ok(msg)) = ws.next().await {
let Message::Text(text) = msg else { continue };
let Ok(arr) = serde_json::from_str::<Vec<Value>>(&text) else {
continue;
};
if arr.first().and_then(|v| v.as_str()) != Some("EVENT") {
continue;
}
let Some(ev) = arr
.get(2)
.and_then(|v| v.as_object())
.and_then(|o| Event::from_json(serde_json::to_string(o).ok()?.as_bytes()).ok())
else {
continue;
};
if ev.pubkey == identity.public_key() {
continue;
}
let Some(plain) = nip44_dec(&conversation, &ev.content) else {
eprintln!(
"[scanner] event from {} not decryptable",
&ev.pubkey.to_hex()[..16]
);
continue;
};
eprintln!("[scanner] decrypted: {}", &plain[..plain.len().min(120)]);
let Ok(req) = serde_json::from_str::<Value>(&plain) else {
continue;
};
// The client's answer to our connect must carry the secret back —
// this is the anti-spoofing check the scanner performs in Amber.
if req.get("id").and_then(|v| v.as_str()) == Some("pair-1")
&& req.get("result").and_then(|v| v.as_str()) == Some(expected_secret.as_str())
{
// Secret echoed correctly — the check an actual scanner performs
// before approving. Nothing further to do with the ack itself.
continue;
}
let Some(method) = req.get("method").and_then(|m| m.as_str()) else {
continue;
};
let id = req
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let response: Value = match method {
"get_public_key" => json!({"id": id, "result": identity.public_key().to_hex()}),
"sign_event" => {
let unsigned_json = req["params"].get(0).and_then(|v| v.as_str());
match unsigned_json.and_then(|s| serde_json::from_str::<Value>(s).ok()) {
Some(mut v) => {
if v.get("pubkey").is_none() {
v["pubkey"] = json!(identity.public_key().to_hex());
}
match serde_json::from_value::<UnsignedEvent>(v)
.ok()
.and_then(|u| identity.sign_event(u).ok())
{
Some(signed) => json!({"id": id, "result": signed.as_json()}),
None => json!({"id": id, "error": "sign failed"}),
}
}
None => json!({"id": id, "error": "bad params"}),
}
}
other => json!({"id": id, "error": format!("unsupported: {other}")}),
};
let content = nip44_enc(&conversation, &response.to_string());
let out = EventBuilder::new(Kind::NostrConnect, content)
.tags([Tag::parse(["p", client_pk.to_hex().as_str()]).unwrap()])
.finalize(&identity)
.unwrap();
ws.send(Message::Text(
json!([
"EVENT",
serde_json::from_str::<Value>(&out.as_json()).unwrap()
])
.to_string()
.into(),
))
.await
.unwrap();
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
/// Serializes the e2e tests against each other (see the whole-body `_vault_guard`
/// below). `await_holding_lock` is allowed here deliberately: the guard is a
/// test-only serialization lock, never nested, never shared with production
/// code — holding it across awaits is the entire point.
#[allow(clippy::await_holding_lock)]
async fn nip46_qr_pairing_handshake_and_sign() {
run_qr_pairing("request").await;
}
/// The shape NIP-46 actually specifies for a `nostrconnect://` scan — and
/// what Amber sends — is a connect *response* (`{"id","result":"<secret>"}`),
/// not a `connect` request. Pairing must complete on that shape too.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
/// Serializes the e2e tests against each other (see the whole-body `_vault_guard`
/// below). `await_holding_lock` is allowed here deliberately: the guard is a
/// test-only serialization lock, never nested, never shared with production
/// code — holding it across awaits is the entire point.
#[allow(clippy::await_holding_lock)]
async fn nip46_qr_pairing_connect_response_shape() {
run_qr_pairing("response").await;
}
/// Whole-body env lock like the test fns above (test-only, never nested).
#[allow(clippy::await_holding_lock)]
async fn run_qr_pairing(connect_shape: &'static str) {
let relay_url = start_relay().await;
// Isolated vault + pairing relay; the env lock is held for the whole
// helper body (see above) so parallel tests cannot cross-write vaults.
let _vault_guard = VAULT_ENV_LOCK
.lock()
// Poison-tolerant on purpose: this lock only serializes the
// process-global XDG_DATA_HOME. A sibling test that panics while
// holding it must not cascade into PoisonError failures of every
// later test — one real failure should report as ONE failure.
.unwrap_or_else(|e| e.into_inner());
let app = {
let tmp = std::env::temp_dir().join(format!(
"keynectr-e2e-pair-{}-{}",
std::process::id(),
connect_shape
));
// Must be $XDG_DATA_HOME/keynectr/profiles_vault.json (see the
// sibling test above): the old shallow path let every e2e run
// migrate the real legacy repo vault into the test process.
let app_dir = tmp.join("keynectr");
std::fs::create_dir_all(&app_dir).unwrap();
std::fs::write(
app_dir.join("profiles_vault.json"),
serde_json::to_string(&Vault::empty()).unwrap(),
)
.unwrap();
std::env::set_var("XDG_DATA_HOME", &tmp);
let app = std::sync::Arc::new(Mutex::new(App::load().expect("load app")));
// Same empty-vault guard as the sibling test above.
assert!(
app.try_lock().unwrap().vault.profiles.is_empty(),
"e2e vault isolation failed: a non-empty vault was loaded — \
the seeded vault is not where data_dir() looks"
);
app
};
{
let mut a = app.lock().await;
a.settings.relays = vec![keynectr::settings::RelayConfig::new(relay_url.clone())];
a.save_settings().expect("save settings");
}
let identity = Keys::generate();
let signer = Nip46ClientSigner::new(app.clone());
// Start pairing: we get back the token the GUI renders as a QR.
let status = signer
.start_pairing("qr pairing test".to_string())
.await
.expect("start pairing");
assert!(!status.connected, "not connected until someone scans");
let pairing_uri = status.pairing_uri.clone().expect("pairing URI present");
assert!(
pairing_uri.starts_with("nostrconnect://"),
"pairing token must be a nostrconnect:// URI"
);
// The token must be a well-formed client-initiated URI: ephemeral
// authority key, our relay, and the anti-spoofing secret.
let parsed = nostr::nips::nip46::NostrConnectUri::parse(&pairing_uri)
.expect("pairing URI parses with the same parser real signers use");
let nostr::nips::nip46::NostrConnectUri::Client {
public_key: client_pk,
secret,
relays,
..
} = parsed
else {
panic!("pairing URI must be the client variant");
};
// The e2e relay set is loopback-only, and loopback sets skip the curated
// pairing-relay widening, so the token carries exactly our relay.
assert_eq!(relays.len(), 1);
assert!(!secret.is_empty());
// The scanner (Amber role) consumes the token.
tokio::spawn(run_fake_scanner(
relay_url.clone(),
client_pk,
secret.clone(),
identity.clone(),
connect_shape,
));
// Wait for scan -> secret echo -> identity adoption.
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
loop {
let status = signer.status().await;
if let Some(err) = &status.error {
panic!("pairing failed: {err}");
}
if status.connected {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"pairing never completed; last status: {:?}",
signer.status().await
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
// The QR token is single-use: consumed, so it no longer appears.
assert!(
signer.status().await.pairing_uri.is_none(),
"pairing URI must be dropped once scanned"
);
// Identity came from get_public_key, not from the URI authority key.
let resolved = SignerTrait::get_public_key(&signer)
.await
.expect("identity resolved");
assert_eq!(resolved, identity.public_key());
assert_ne!(
resolved, client_pk,
"ephemeral pairing key must never become identity"
);
// Sign through the paired signer.
let unsigned = UnsignedEvent::new(
identity.public_key(),
Timestamp::now(),
Kind::TextNote,
vec![],
"paired via QR".to_string(),
);
let signed = SignerTrait::sign_event(&signer, unsigned.clone())
.await
.expect("remote sign_event after pairing");
assert_eq!(signed.pubkey, identity.public_key());
assert_eq!(signed.content, "paired via QR");
assert!(signed.verify_signature());
// Vault persistence: remote profile under the real identity, no secrets.
let identity_npub = identity.public_key().to_bech32().unwrap();
let app_guard = app.lock().await;
let profile = app_guard
.vault
.profiles
.iter()
.find(|p| p.public_key == identity_npub)
.expect("remote profile row created by pairing");
assert_eq!(
profile.signer_mode,
keynectr::vault::SignerMode::Nip46Client
);
assert!(
profile.secret_key.trim().is_empty(),
"no secret material for remote profiles"
);
drop(app_guard);
signer.disconnect().await.ok();
}
// ---------------------------------------------------------------------------
// Session restore (re-dial without a scan): Amber remembers our CLIENT
// pubkey for the life of a connection, so a restart must reuse the exact
// keypair persisted at pairing, re-send `connect`, and refuse the session
// if the signer answers as a different account.
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[allow(clippy::await_holding_lock)]
async fn nip46_session_restore_redials_and_refuses_wrong_identity() {
let _vault_guard = VAULT_ENV_LOCK
.lock()
// Poison-tolerant on purpose: this lock only serializes the
// process-global XDG_DATA_HOME. A sibling test that panics while
// holding it must not cascade into PoisonError failures of every
// later test — one real failure should report as ONE failure.
.unwrap_or_else(|e| e.into_inner());
let app = {
let tmp = std::env::temp_dir().join(format!(
"keynectr-e2e-restore-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let app_dir = tmp.join("keynectr");
std::fs::create_dir_all(&app_dir).unwrap();
std::fs::write(
app_dir.join("profiles_vault.json"),
serde_json::to_string(&Vault::empty()).unwrap(),
)
.unwrap();
std::env::set_var("XDG_DATA_HOME", &tmp);
let app = std::sync::Arc::new(Mutex::new(App::load().expect("load app")));
assert!(
app.try_lock().unwrap().vault.profiles.is_empty(),
"e2e vault isolation failed for restore test"
);
app
};
let comms = Keys::generate();
let identity = Keys::generate();
let relay_url = start_relay().await;
tokio::spawn(run_fake_amber(
relay_url.clone(),
comms.clone(),
identity.clone(),
Duration::from_millis(50),
));
// --- 1. Fresh pairing: the flow that must later NOT need repeating.
let signer = Nip46ClientSigner::new(app.clone());
let uri = format!(
"bunker://{}?relay={}",
comms.public_key().to_hex(),
relay_url
);
signer
.connect(&uri, "fake amber".to_string())
.await
.expect("fresh connect");
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
loop {
let st = signer.status().await;
if let Some(err) = &st.error {
panic!("fresh pairing failed: {err}");
}
if st.connected {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"fresh pairing never connected: {:?}",
signer.status().await
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Pairing must have persisted OUR client secret key, re-keyed under the
// identity-keyed VaultRef (that is what a re-dial resolves).
let identity_npub = identity.public_key().to_bech32().unwrap();
let ref_id =
keynectr::signer::VaultRef::new(Some(identity_npub.clone()), comms.public_key().to_hex());
let client_key_hex = {
let g = app.lock().await;
let ck = keynectr::vault::resolve_connection_client_key(&g.vault, None, &ref_id)
.expect("resolve client key")
.expect("client secret key must be persisted at pairing");
ck.to_string()
};
// --- 2. Simulated app restart: a NEW signer instance over the same
// vault must re-dial the saved session with no scan and no bunker URI.
// (The old instance's listener task is left running on purpose — the
// live process exiting is modeled by the new instance, not by
// `disconnect()`, which revokes and wipes the stored key.)
//
// Seed a pairing secret at the identity ref first: a QR pairing stores
// one, and the restore re-sends it — real Amber then answers `true`
// WITHOUT echoing (already-approved connection), which the fake models.
// Without the restore-mode skip this handshake fails "did not echo the
// connection secret" (the exact live Sep 25 failure).
{
let mut g = app.lock().await;
keynectr::vault::store_connection_secret(&mut g.vault, None, &ref_id, "restore-secret-123")
.unwrap();
g.save_vault().unwrap();
}
let signer2 = Nip46ClientSigner::new(app.clone());
let restored = signer2
.reactivate_saved_sessions()
.await
.expect("restore call");
assert_eq!(restored, 1, "one saved session must be restorable");
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
loop {
let st = signer2.status().await;
if let Some(err) = &st.error {
panic!("restored session failed: {err}");
}
if st.connected {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"restored session never connected: {:?}",
signer2.status().await
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
let resolved = SignerTrait::get_public_key(&signer2)
.await
.expect("identity on restored session");
assert_eq!(
resolved,
identity.public_key(),
"restored session must bind the ORIGINAL identity"
);
// The restored session is fully usable: remote sign_event verifies.
let unsigned = UnsignedEvent::new(
identity.public_key(),
Timestamp::now(),
Kind::TextNote,
vec![],
"signed after restart".to_string(),
);
let signed = SignerTrait::sign_event(&signer2, unsigned.clone())
.await
.expect("sign through restored session");
assert_eq!(signed.pubkey, identity.public_key());
assert_eq!(signed.content, "signed after restart");
assert_eq!(signed.id, unsigned.compute_id());
assert!(signed.verify_signature());
// --- 3. Legacy skip: a connection with no stored client key (paired
// before key persistence existed) is NOT re-dialed — one fresh scan is
// required for those.
{
let mut g = app.lock().await;
keynectr::vault::delete_connection_client_key(&mut g.vault, &ref_id);
g.save_vault().unwrap();
}
let signer3 = Nip46ClientSigner::new(app.clone());
assert_eq!(
signer3
.reactivate_saved_sessions()
.await
.expect("legacy restore call"),
0,
"keyless legacy connection must be skipped"
);
// --- 4. Cross-account guard: put the client key under a DIFFERENT
// profile's ref (as if profile B reused this Amber connection) and move
// the connection row to that profile. The re-dial dials fine, but the
// fake Amber still answers as the ORIGINAL identity — the identity
// check must refuse the session outright, never adopt it.
let impostor = Keys::generate();
let impostor_npub = impostor.public_key().to_bech32().unwrap();
{
let mut g = app.lock().await;
let ref_b = keynectr::signer::VaultRef::new(
Some(impostor_npub.clone()),
comms.public_key().to_hex(),
);
keynectr::vault::store_connection_client_key(&mut g.vault, None, &ref_b, &client_key_hex)
.unwrap();
g.vault.nip46_connections[0].profile_npub = Some(impostor_npub.clone());
g.save_vault().unwrap();
}
let signer4 = Nip46ClientSigner::new(app.clone());
assert_eq!(
signer4
.reactivate_saved_sessions()
.await
.expect("cross-account restore call"),
1,
"the re-dial itself must start; refusal happens in the handshake"
);
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
loop {
let st = signer4.status().await;
if let Some(err) = &st.error {
assert!(
err.contains("different account"),
"cross-account restore failed for the wrong reason: {err}"
);
break;
}
assert!(
!st.connected,
"a restored session answering as the wrong account must NEVER connect"
);
assert!(
tokio::time::Instant::now() < deadline,
"cross-account restore never failed: {:?}",
signer4.status().await
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
// ---------------------------------------------------------------------------
// Option A: many saved sessions, one live. Pairing/connecting a second
// signer account PARKS the live session (restorable, never revoked), and
// switching a profile back to a parked account re-dials it with no scan.
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[allow(clippy::await_holding_lock)]
async fn nip46_second_account_parks_first_and_switch_restores_it() {
let _vault_guard = VAULT_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let app = {
let tmp = std::env::temp_dir().join(format!(
"keynectr-e2e-switch-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let app_dir = tmp.join("keynectr");
std::fs::create_dir_all(&app_dir).unwrap();
std::fs::write(
app_dir.join("profiles_vault.json"),
serde_json::to_string(&Vault::empty()).unwrap(),
)
.unwrap();
std::env::set_var("XDG_DATA_HOME", &tmp);
let app = std::sync::Arc::new(Mutex::new(App::load().expect("load app")));
assert!(
app.try_lock().unwrap().vault.profiles.is_empty(),
"e2e vault isolation failed for switch test"
);
app
};
let relay_url = start_relay().await;
let comms_a = Keys::generate();
let identity_a = Keys::generate();
let comms_b = Keys::generate();
let identity_b = Keys::generate();
// Two fake Ambers on one relay: each only answers traffic it can
// NIP-44-decrypt with its own comms key, so they never cross-talk.
tokio::spawn(run_fake_amber(
relay_url.clone(),
comms_a.clone(),
identity_a.clone(),
Duration::from_millis(30),
));
tokio::spawn(run_fake_amber(
relay_url.clone(),
comms_b.clone(),
identity_b.clone(),
Duration::from_millis(30),
));
let signer = Nip46ClientSigner::new(app.clone());
let wait_connected = |signer: Nip46ClientSigner| async move {
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
loop {
let st = signer.status().await;
if let Some(err) = &st.error {
panic!("session failed: {err}");
}
if st.connected {
return;
}
assert!(
tokio::time::Instant::now() < deadline,
"session never connected: {:?}",
signer.status().await
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
};
// --- 1. Account A pairs (the flow the GUI runs).
signer
.connect(
&format!(
"bunker://{}?relay={}",
comms_a.public_key().to_hex(),
relay_url
),
"amber A".to_string(),
)
.await
.expect("connect account A");
wait_connected(signer.clone()).await;
let npub_a = SignerTrait::get_public_key(&signer)
.await
.expect("identity A")
.to_bech32()
.unwrap();
// --- 2. Account B pairs while A is live. The IPC dispatcher parks the
// live session first (the exact sequence the dispatcher now runs).
assert!(signer.has_live_session().await);
signer.park_live_session().await;
assert!(
!signer.has_live_session().await,
"park must clear the live slot"
);
signer
.connect(
&format!(
"bunker://{}?relay={}",
comms_b.public_key().to_hex(),
relay_url
),
"amber B".to_string(),
)
.await
.expect("connect account B while A is parked");
wait_connected(signer.clone()).await;
let npub_b = SignerTrait::get_public_key(&signer)
.await
.expect("identity B")
.to_bech32()
.unwrap();
assert_ne!(npub_a, npub_b, "two accounts, two identities");
// B is fully usable: sign through it.
let unsigned = UnsignedEvent::new(
identity_b.public_key(),
Timestamp::now(),
Kind::TextNote,
vec![],
"signed by B".to_string(),
);
let signed = SignerTrait::sign_event(&signer, unsigned.clone())
.await
.expect("sign through B");
assert!(signed.verify_signature());
// Parking must NOT have revoked A: its row stays live with its client
// key resolvable — that is what makes it restorable.
let ref_a =
keynectr::signer::VaultRef::new(Some(npub_a.clone()), comms_a.public_key().to_hex());
{
let g = app.lock().await;
let row = g
.vault
.nip46_connections
.iter()
.find(|c| c.profile_npub.as_deref() == Some(npub_a.as_str()))
.expect("A's connection row survives B's pairing");
assert!(
row.revoked_at.is_none(),
"parked session must not be revoked"
);
assert!(
keynectr::vault::resolve_connection_client_key(&g.vault, None, &ref_a)
.expect("resolve")
.is_some(),
"parked session must keep its client key"
);
}
// --- 3. Switch back to A: park B, re-dial A — no fresh pairing.
let dialed = signer
.switch_to_profile(&npub_a)
.await
.expect("switch to A");
assert!(dialed, "A has a restorable session, switch must re-dial it");
wait_connected(signer.clone()).await;
let back = SignerTrait::get_public_key(&signer)
.await
.expect("identity after switch");
assert_eq!(
back.to_bech32().unwrap(),
npub_a,
"switched session must answer as A"
);
let unsigned_a = UnsignedEvent::new(
identity_a.public_key(),
Timestamp::now(),
Kind::TextNote,
vec![],
"signed by A again".to_string(),
);
let signed_a = SignerTrait::sign_event(&signer, unsigned_a.clone())
.await
.expect("sign through A after switch");
assert!(signed_a.verify_signature());
assert_eq!(signed_a.content, "signed by A again");
// Switching to A again is a no-op (already serving A): no second dial.
assert!(
!signer
.switch_to_profile(&npub_a)
.await
.expect("noop switch"),
"switch to the identity already live must not re-dial"
);
// --- 4. A profile with NO signer connection must leave B... (here A)
// alone: local-key profiles route signing through their own source.
let local = Keys::generate();
let npub_local = local.public_key().to_bech32().unwrap();
{
let mut g = app.lock().await;
g.vault.profiles.push(keynectr::vault::StoredProfile {
label: "local".to_string(),
public_key: npub_local.clone(),
secret_key: local
.secret_key()
.to_secret_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect(),
created_at: 0,
picture: None,
nip05: None,
signer_mode: keynectr::vault::SignerMode::Embedded,
});
g.save_vault().unwrap();
}
assert!(
!signer
.switch_to_profile(&npub_local)
.await
.expect("switch to local"),
"local-key profile must not touch the live signer session"
);
assert!(
signer.status().await.connected,
"live A session survives a switch to a local profile"
);
let still_a = SignerTrait::get_public_key(&signer).await.expect("still A");
assert_eq!(still_a.to_bech32().unwrap(), npub_a);
// And switching back to B works too — B was parked, never revoked.
let dialed_b = signer
.switch_to_profile(&npub_b)
.await
.expect("switch back to B");
assert!(dialed_b, "B was parked, must be restorable");
wait_connected(signer.clone()).await;
let b_again = SignerTrait::get_public_key(&signer).await.expect("B again");
assert_eq!(b_again.to_bech32().unwrap(), npub_b);
signer.disconnect().await.ok();
}