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:
parent
1b4655c07b
commit
c89b31aaf1
9 changed files with 482 additions and 23 deletions
|
|
@ -121,6 +121,7 @@ export const api = {
|
||||||
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
|
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
|
||||||
nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }),
|
nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }),
|
||||||
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
|
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
|
||||||
|
nip46CancelPairing: () => call<Nip46SignerStatus>('nip46_cancel_pairing'),
|
||||||
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
|
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
|
||||||
nip46Approve: (id: string, approved: boolean, always = false) =>
|
nip46Approve: (id: string, approved: boolean, always = false) =>
|
||||||
call<Nip46SignerStatus>('nip46_approve', { id, approved, always }),
|
call<Nip46SignerStatus>('nip46_approve', { id, approved, always }),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ interface CreateProfileModalProps {
|
||||||
type Phase = 'choice' | 'pairing' | 'paired' | 'local' | 'creating' | 'success';
|
type Phase = 'choice' | 'pairing' | 'paired' | 'local' | 'creating' | 'success';
|
||||||
|
|
||||||
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
|
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 [label, setLabel] = useState('');
|
||||||
const [phase, setPhase] = useState<Phase>('choice');
|
const [phase, setPhase] = useState<Phase>('choice');
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// 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
|
// 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 () => {
|
const cancelPairing = async () => {
|
||||||
setLivePairingUri(null);
|
setLivePairingUri(null);
|
||||||
setPairingQr(null);
|
setPairingQr(null);
|
||||||
setPhase('choice');
|
setPhase('choice');
|
||||||
try {
|
try {
|
||||||
await nip46Disconnect();
|
await nip46CancelPairing();
|
||||||
|
void refresh();
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort abort; a dead pairing attempt expires on its own.
|
// Best-effort abort; a dead pairing attempt expires on its own.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export function SignerModeScreen() {
|
||||||
nip46Connect,
|
nip46Connect,
|
||||||
nip46PairStart,
|
nip46PairStart,
|
||||||
nip46Disconnect,
|
nip46Disconnect,
|
||||||
|
nip46CancelPairing,
|
||||||
nip46Approve,
|
nip46Approve,
|
||||||
embeddedSignerApprove,
|
embeddedSignerApprove,
|
||||||
refresh,
|
refresh,
|
||||||
|
|
@ -192,17 +193,19 @@ export function SignerModeScreen() {
|
||||||
};
|
};
|
||||||
}, [pairingUri]);
|
}, [pairingUri]);
|
||||||
|
|
||||||
// Abort an in-flight pairing (e.g. expired QR) — same teardown as a
|
// Abort an in-flight pairing (e.g. expired QR): cancel the pairing
|
||||||
// disconnect; nothing was persisted yet so it is safe at any point.
|
// 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 () => {
|
const handlePairCancel = useCallback(async () => {
|
||||||
setPairError(null);
|
setPairError(null);
|
||||||
try {
|
try {
|
||||||
const status = await nip46Disconnect();
|
const status = await nip46CancelPairing();
|
||||||
setNip46StatusState(status);
|
setNip46StatusState(status);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setPairError(err instanceof Error ? err.message : String(err));
|
setPairError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
}, [nip46Disconnect]);
|
}, [nip46CancelPairing]);
|
||||||
|
|
||||||
const handleNip46Disconnect = useCallback(async () => {
|
const handleNip46Disconnect = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ interface AppContextValue {
|
||||||
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
|
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
|
||||||
nip46PairStart: (label: string) => Promise<Nip46SignerStatus>;
|
nip46PairStart: (label: string) => Promise<Nip46SignerStatus>;
|
||||||
nip46Disconnect: () => Promise<Nip46SignerStatus>;
|
nip46Disconnect: () => Promise<Nip46SignerStatus>;
|
||||||
|
nip46CancelPairing: () => Promise<Nip46SignerStatus>;
|
||||||
nip46Status: () => Promise<Nip46SignerStatus>;
|
nip46Status: () => Promise<Nip46SignerStatus>;
|
||||||
nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise<Nip46SignerStatus>;
|
nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise<Nip46SignerStatus>;
|
||||||
// Legacy NIP-46 bunker (deprecated)
|
// Legacy NIP-46 bunker (deprecated)
|
||||||
|
|
@ -289,6 +290,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
|
const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
|
||||||
|
const nip46CancelPairing = useCallback(() => api.nip46CancelPairing(), []);
|
||||||
const nip46Status = useCallback(() => api.nip46Status(), []);
|
const nip46Status = useCallback(() => api.nip46Status(), []);
|
||||||
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
|
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
|
||||||
const nip46Approve = useCallback(
|
const nip46Approve = useCallback(
|
||||||
|
|
@ -385,6 +387,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
nip46Connect,
|
nip46Connect,
|
||||||
nip46PairStart,
|
nip46PairStart,
|
||||||
nip46Disconnect,
|
nip46Disconnect,
|
||||||
|
nip46CancelPairing,
|
||||||
nip46Status,
|
nip46Status,
|
||||||
nip46Approve,
|
nip46Approve,
|
||||||
signerConnect,
|
signerConnect,
|
||||||
|
|
@ -445,6 +448,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
nip46Connect,
|
nip46Connect,
|
||||||
nip46PairStart,
|
nip46PairStart,
|
||||||
nip46Disconnect,
|
nip46Disconnect,
|
||||||
|
nip46CancelPairing,
|
||||||
nip46Status,
|
nip46Status,
|
||||||
nip46Approve,
|
nip46Approve,
|
||||||
signerConnect,
|
signerConnect,
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,10 @@ describe('CreateProfileModal', () => {
|
||||||
await screen.findByText(/Waiting for the signer to scan/i);
|
await screen.findByText(/Waiting for the signer to scan/i);
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Cancel pairing' }));
|
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(
|
expect(
|
||||||
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
|
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,14 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
backend.setNip46(next);
|
backend.setNip46(next);
|
||||||
return 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':
|
case 'nip46_approve':
|
||||||
return backend.nip46;
|
return backend.nip46;
|
||||||
|
|
||||||
|
|
|
||||||
79
src/ipc.rs
79
src/ipc.rs
|
|
@ -173,6 +173,12 @@ pub enum Request {
|
||||||
},
|
},
|
||||||
/// Disconnect from the NIP-46 signer.
|
/// Disconnect from the NIP-46 signer.
|
||||||
Nip46Disconnect,
|
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.
|
/// Get NIP-46 connection status.
|
||||||
Nip46Status,
|
Nip46Status,
|
||||||
/// Approve/reject a pending NIP-46 request. `always = true` additionally
|
/// 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.",
|
"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?;
|
let status = signer.connect(&uri, label).await?;
|
||||||
Ok(json!(status))
|
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.",
|
"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?;
|
let status = signer.start_pairing(label).await?;
|
||||||
Ok(json!(status))
|
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;
|
let status = signer.status().await;
|
||||||
Ok(json!(status))
|
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 => {
|
Request::Nip46Status => {
|
||||||
let Some(signer) = ensure_nip46_signer(app).await else {
|
let Some(signer) = ensure_nip46_signer(app).await else {
|
||||||
return Ok(json!({ "connected": false, "error": "Not initialized" }));
|
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))
|
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)
|
// Vault state requests (require lock)
|
||||||
other => {
|
other => {
|
||||||
let mut guard = app.lock().await;
|
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() }))
|
Ok(json!({ "profile": summary, "state": app.state_view() }))
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::SelectProfile { npub } => {
|
// NOTE: SelectProfile is handled in `run` (above), not here — it
|
||||||
profiles::set_active(&mut app.vault, &npub)?;
|
// must drop the App guard before switching the signer session.
|
||||||
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()))
|
|
||||||
}
|
|
||||||
|
|
||||||
Request::PublishProfileMetadata { npub } => {
|
Request::PublishProfileMetadata { npub } => {
|
||||||
// Route through the profile's Signing source, exactly like
|
// Route through the profile's Signing source, exactly like
|
||||||
// PublishNote: an embedded profile signs locally, a paired
|
// PublishNote: an embedded profile signs locally, a paired
|
||||||
|
|
|
||||||
|
|
@ -488,6 +488,148 @@ impl Nip46ClientSigner {
|
||||||
self.disconnect().await
|
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.
|
/// Re-dial the saved signer sessions after startup/unlock, no scan.
|
||||||
///
|
///
|
||||||
/// Amber remembers our *client pubkey* as the identity of an approved
|
/// Amber remembers our *client pubkey* as the identity of an approved
|
||||||
|
|
|
||||||
|
|
@ -1324,3 +1324,248 @@ async fn nip46_session_restore_redials_and_refuses_wrong_identity() {
|
||||||
tokio::time::sleep(Duration::from_millis(100)).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();
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue