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.
This commit is contained in:
Avi 2026-09-27 21:01:23 -05:00
commit c89b31aaf1
9 changed files with 482 additions and 23 deletions

View file

@ -1324,3 +1324,248 @@ async fn nip46_session_restore_redials_and_refuses_wrong_identity() {
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();
}