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

@ -121,6 +121,7 @@ export const api = {
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }),
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
nip46CancelPairing: () => call<Nip46SignerStatus>('nip46_cancel_pairing'),
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
nip46Approve: (id: string, approved: boolean, always = false) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved, always }),

View file

@ -15,7 +15,8 @@ interface CreateProfileModalProps {
type Phase = 'choice' | 'pairing' | 'paired' | 'local' | 'creating' | 'success';
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp();
const { state, createProfile, nip46PairStart, nip46Status, nip46CancelPairing, refresh } =
useApp();
const [label, setLabel] = useState('');
const [phase, setPhase] = useState<Phase>('choice');
const [error, setError] = useState<string | null>(null);
@ -149,13 +150,16 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
// Leaving the QR view mid-pairing aborts the in-flight pairing; nothing
// was persisted yet, so teardown is safe at any point (same as Signer
// Mode's "Cancel pairing").
// Mode's "Cancel pairing"). Cancel (not disconnect): when pairing a
// second signer account parked the first one, cancelling must restore
// the parked session rather than revoke anything.
const cancelPairing = async () => {
setLivePairingUri(null);
setPairingQr(null);
setPhase('choice');
try {
await nip46Disconnect();
await nip46CancelPairing();
void refresh();
} catch {
// Best-effort abort; a dead pairing attempt expires on its own.
}

View file

@ -17,6 +17,7 @@ export function SignerModeScreen() {
nip46Connect,
nip46PairStart,
nip46Disconnect,
nip46CancelPairing,
nip46Approve,
embeddedSignerApprove,
refresh,
@ -192,17 +193,19 @@ export function SignerModeScreen() {
};
}, [pairingUri]);
// Abort an in-flight pairing (e.g. expired QR) — same teardown as a
// disconnect; nothing was persisted yet so it is safe at any point.
// Abort an in-flight pairing (e.g. expired QR): cancel the pairing
// attempt only. Never disconnect here — if pairing a second signer
// account parked the first one, a cancel must bring the parked session
// back instead of revoking it.
const handlePairCancel = useCallback(async () => {
setPairError(null);
try {
const status = await nip46Disconnect();
const status = await nip46CancelPairing();
setNip46StatusState(status);
} catch (err) {
setPairError(err instanceof Error ? err.message : String(err));
}
}, [nip46Disconnect]);
}, [nip46CancelPairing]);
const handleNip46Disconnect = useCallback(async () => {
setError(null);

View file

@ -82,6 +82,7 @@ interface AppContextValue {
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
nip46PairStart: (label: string) => Promise<Nip46SignerStatus>;
nip46Disconnect: () => Promise<Nip46SignerStatus>;
nip46CancelPairing: () => Promise<Nip46SignerStatus>;
nip46Status: () => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise<Nip46SignerStatus>;
// Legacy NIP-46 bunker (deprecated)
@ -289,6 +290,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
[],
);
const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
const nip46CancelPairing = useCallback(() => api.nip46CancelPairing(), []);
const nip46Status = useCallback(() => api.nip46Status(), []);
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
const nip46Approve = useCallback(
@ -385,6 +387,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
nip46Connect,
nip46PairStart,
nip46Disconnect,
nip46CancelPairing,
nip46Status,
nip46Approve,
signerConnect,
@ -445,6 +448,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
nip46Connect,
nip46PairStart,
nip46Disconnect,
nip46CancelPairing,
nip46Status,
nip46Approve,
signerConnect,

View file

@ -137,7 +137,10 @@ describe('CreateProfileModal', () => {
await screen.findByText(/Waiting for the signer to scan/i);
await user.click(screen.getByRole('button', { name: 'Cancel pairing' }));
expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(true);
// Cancel must be the NON-revoking cancel (parked sessions survive it),
// never the disconnect that revokes the stored connection.
expect(backend.requests.some((r) => r.method === 'nip46_cancel_pairing')).toBe(true);
expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(false);
expect(
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
).toBeInTheDocument();

View file

@ -226,6 +226,14 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
backend.setNip46(next);
return next;
}
case 'nip46_cancel_pairing': {
// Mirrors the real backend: aborts ONLY the pairing attempt and
// re-dials the parked session — a cancel must not clear a
// connected session, only the pairing URI.
const next = { ...backend.nip46, pairing_uri: undefined };
backend.setNip46(next);
return next;
}
case 'nip46_approve':
return backend.nip46;

View file

@ -173,6 +173,12 @@ pub enum Request {
},
/// Disconnect from the NIP-46 signer.
Nip46Disconnect,
/// Cancel an in-flight pairing (the GUI left the QR view): abort the
/// pairing attempt WITHOUT touching any parked/saved session, then try
/// to re-dial the active profile's saved session so parking for a
/// cancelled pairing is fully transparent. (Plain Nip46Disconnect would
/// revoke the currently stored connection — wrong for a cancel.)
Nip46CancelPairing,
/// Get NIP-46 connection status.
Nip46Status,
/// Approve/reject a pending NIP-46 request. `always = true` additionally
@ -492,6 +498,13 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
"NIP-46 signer not initialized. Set signer mode to nip46 first.",
));
};
// Option A (one live session, many saved): connecting a signer
// while another session is live PARKS the current one — its
// vault row, secret, and client key stay restorable, so the
// parked account can be switched back to later with no scan.
if signer.has_live_session().await {
signer.park_live_session().await;
}
let status = signer.connect(&uri, label).await?;
Ok(json!(status))
}
@ -504,6 +517,12 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
"NIP-46 signer not initialized. Set signer mode to nip46 first.",
));
};
// Pairing a second signer account (e.g. another Amber account)
// parks the live session instead of refusing it (Option A):
// the parked account stays restorable for a later switch.
if signer.has_live_session().await {
signer.park_live_session().await;
}
let status = signer.start_pairing(label).await?;
Ok(json!(status))
}
@ -515,6 +534,14 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
let status = signer.status().await;
Ok(json!(status))
}
Request::Nip46CancelPairing => {
let Some(signer) = ensure_nip46_signer(app).await else {
return Err(AppError::config("NIP-46 signer not initialized"));
};
signer.cancel_pairing().await?;
let status = signer.status().await;
Ok(json!(status))
}
Request::Nip46Status => {
let Some(signer) = ensure_nip46_signer(app).await else {
return Ok(json!({ "connected": false, "error": "Not initialized" }));
@ -654,6 +681,41 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
Ok(json!(items))
}
// Handled here (not in run_with_app) so the App guard can be
// dropped before the signer session switch: `switch_to_profile`
// re-locks the App to read the vault, exactly like connect().
Request::SelectProfile { npub } => {
{
let mut guard = app.lock().await;
profiles::set_active(&mut guard.vault, &npub)?;
if guard.signer_mode == SignerMode::Embedded {
if let Some(signer) = &guard.embedded_signer {
signer.set_active_profile(Some(npub.clone())).await;
}
} else if guard.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = &guard.nip46_signer {
signer.set_active_profile(Some(npub.clone())).await;
}
}
guard.save_vault()?;
}
// Option A: follow the switch with the signer session. If the
// target profile has a restorable NIP-46 connection, the live
// session (if any, serving a different account) is parked and
// this profile's session is re-dialed — no fresh scan. If the
// target has no signer connection (local-key profile), the live
// session is left alone.
if app.lock().await.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = ensure_nip46_signer(app).await {
if let Err(e) = signer.switch_to_profile(&npub).await {
eprintln!("[NIP46] profile switch session change failed: {e}");
}
}
}
let guard = app.lock().await;
Ok(json!(guard.state_view()))
}
// Vault state requests (require lock)
other => {
let mut guard = app.lock().await;
@ -711,21 +773,8 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Ok(json!({ "profile": summary, "state": app.state_view() }))
}
Request::SelectProfile { npub } => {
profiles::set_active(&mut app.vault, &npub)?;
if app.signer_mode == SignerMode::Embedded {
if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(Some(npub)).await;
}
} else if app.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = &app.nip46_signer {
signer.set_active_profile(Some(npub)).await;
}
}
app.save_vault()?;
Ok(json!(app.state_view()))
}
// NOTE: SelectProfile is handled in `run` (above), not here — it
// must drop the App guard before switching the signer session.
Request::PublishProfileMetadata { npub } => {
// Route through the profile's Signing source, exactly like
// PublishNote: an embedded profile signs locally, a paired

View file

@ -488,6 +488,148 @@ impl Nip46ClientSigner {
self.disconnect().await
}
/// Park the live session: stop the wire task and clear in-memory state,
/// but keep the vault row, pairing secret, and persisted client key
/// INTACT, so the session can be re-dialed later with no fresh scan.
///
/// Unlike [`Self::disconnect`] this does NOT revoke: switching signer
/// accounts (pair a second Amber account, or switch back to a previously
/// paired one) must leave the parked account restorable. The vault row
/// is what `reactivate_saved_sessions` dials from, so a parked session
/// is exactly a saved session.
pub async fn park_live_session(&self) {
let mut inner = self.inner.lock().await;
if let Some(task) = inner.task.take() {
task.abort();
}
if let Some(pairing) = inner.pairing.take() {
pairing.task.abort();
}
if let Some(client) = inner.client.take() {
let _ = client.disconnect().await;
}
inner.phase = Nip46Phase::Stopped;
inner.connection = None;
inner.conversation_key = None;
inner.keys = None;
inner.identity = None;
inner.expected_identity = None;
inner.active_npub = None;
inner.pending.clear();
// Wake any callers awaiting a remote response; their waiters turn
// into `NotConnected` rather than hanging until the request timeout.
for (_, waiter) in inner.remote_pending.drain() {
let _ = waiter.sender.send(Err(
"Switched signer accounts before the signer responded.".to_string(),
));
}
}
/// Whether a session or pairing is currently live.
pub async fn has_live_session(&self) -> bool {
let inner = self.inner.lock().await;
inner.task.is_some() || inner.pairing.is_some()
}
/// Cancel an in-flight pairing attempt: abort ONLY the pairing task and
/// its session state, then try to re-dial the active profile's saved
/// session. Used by the GUI when the user leaves the QR view after
/// pairing-for-a-second-account parked the first one: the cancel must
/// not revoke anything, and the parked session should come back so the
/// park is invisible.
pub async fn cancel_pairing(&self) -> Result<(), AppError> {
{
let mut inner = self.inner.lock().await;
if let Some(pairing) = inner.pairing.take() {
pairing.task.abort();
}
// A pairing that was cancelled before anyone scanned never
// reached the vault; the only live slot it held is now free.
// If a *connected* session exists (task, not pairing), leave it
// alone — cancelling pairing must not kill a working session.
if inner.task.is_some() {
return Ok(());
}
inner.phase = Nip46Phase::Stopped;
inner.connection = None;
inner.conversation_key = None;
inner.keys = None;
inner.identity = None;
inner.expected_identity = None;
inner.active_npub = None;
inner.pending.clear();
}
// The park-then-pair flow (Option A) may have left the previous
// account parked: re-dial it (reactivate prefers the active
// profile's row) so the cancelled pairing changes nothing.
self.reactivate_saved_sessions().await?;
Ok(())
}
/// Follow a profile switch with the signer session (Option A: one live
/// session, many saved ones).
///
/// - The live session already serves `npub`: keep it, do nothing.
/// - `npub` has no live saved NIP-46 connection (local-key profile, or
/// never paired): leave the live session alone — signing for `npub`
/// routes through its own source and killing a working Amber session
/// to look at a local profile would be a regression.
/// - `npub` has a restorable saved connection: park the live session
/// (restorable, not revoked) and re-dial `npub`'s row.
///
/// Returns `true` when a re-dial was started. The re-dial resolves
/// asynchronously through the same handshake as restore, including the
/// `expected_identity` cross-account guard.
pub async fn switch_to_profile(&self, npub: &str) -> Result<bool, AppError> {
// Already serving the target identity? Nothing to do.
{
let inner = self.inner.lock().await;
if let Some(identity) = &inner.identity {
if identity.to_bech32().ok().as_deref() == Some(npub) {
return Ok(false);
}
}
}
// Does the target profile have a live, restorable saved connection?
let restorable = {
let app = self.app.lock().await;
let vault_key = app.vault_key().copied();
let now = crate::vault::unix_timestamp().unwrap_or(0);
app.vault.nip46_connections.iter().any(|c| {
c.profile_npub.as_deref() == Some(npub)
&& c.revoked_at.is_none()
&& c.expires_at.map(|t| t > now).unwrap_or(true)
&& crate::vault::resolve_connection_client_key(
&app.vault,
vault_key.as_ref(),
&crate::signer::VaultRef::from_connection(c),
)
.ok()
.flatten()
.is_some()
})
};
if !restorable {
return Ok(false);
}
// Make sure the vault agrees on the target before re-dialing (the
// IPC path already did this; idempotent here, and it makes the
// reactivate preference order correct regardless of caller).
{
let mut app = self.app.lock().await;
if app.vault.active_profile.as_deref() != Some(npub) {
crate::profiles::set_active(&mut app.vault, npub)?;
app.save_vault()?;
}
}
self.park_live_session().await;
let dialed = self.reactivate_saved_sessions().await?;
Ok(dialed > 0)
}
/// Re-dial the saved signer sessions after startup/unlock, no scan.
///
/// Amber remembers our *client pubkey* as the identity of an approved

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();
}