diff --git a/src/ipc.rs b/src/ipc.rs index b465577..5ed59d5 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -259,6 +259,31 @@ pub async fn serve() -> Result<(), AppError> { // Shared state, so the NIP-46 signer's background task and the request loop // both see the same vault (including its unlock key) without racing writes. let app = Arc::new(Mutex::new(App::load()?)); + + // Restore saved NIP-46 signer sessions (spec: "reuse previously + // established signer sessions whenever possible"). When the vault is not + // password-encrypted the stored client keys resolve right now, so Amber + // never sees a fresh scan for an already-approved connection. An + // encrypted vault restores later, on UnlockVault, once the keys can be + // decrypted — this call simply no-ops until then. Fail-safe: a restore + // error must never prevent the backend from serving the GUI. + { + let restorable = { + let guard = app.lock().await; + matches!(guard.signer_mode, SignerMode::Nip46Client) && guard.vault.crypto.is_none() + }; + if restorable { + // `ensure_nip46_signer` constructs the handle lazily; App::load + // leaves it None until the mode is touched, so go through it + // rather than reading the field. + if let Some(signer) = ensure_nip46_signer(&app).await { + if let Err(e) = signer.reactivate_saved_sessions().await { + eprintln!("[NIP46] session restore at startup failed: {e}"); + } + } + } + } + let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout())); let stdin = tokio::io::stdin(); @@ -829,6 +854,12 @@ async fn run_with_app(app: &mut App, request: Request) -> Result, + /// On a restored (re-dialed) session: the account npub the signer MUST + /// answer as. Enforced inside `adopt_identity` — a session that reveals + /// a different identity is refused, never adopted. `None` on fresh + /// pairings, where the signer's answer defines the identity. + expected_identity: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -167,6 +172,7 @@ impl Nip46ClientSigner { active_npub: None, pairing: None, identity: None, + expected_identity: None, })), app, } @@ -379,6 +385,16 @@ impl Nip46ClientSigner { // The reconnect carries no secret — drop any stale stored one. crate::vault::delete_connection_secret(&mut app.vault, &vault_ref); } + // Persist OUR client secret key under the same ref: the signer + // remembers our client pubkey for the life of the connection, so + // this is what lets the session be re-dialed after a restart + // without a fresh scan. + crate::vault::store_connection_client_key( + &mut app.vault, + vault_key.as_ref(), + &vault_ref, + &hex::encode(keys.secret_key().to_secret_bytes()), + )?; app.vault.nip46_connections.push(connection.clone()); app.save_vault()?; } @@ -398,6 +414,7 @@ impl Nip46ClientSigner { // Identity is unknown until the handshake's `get_public_key` // resolves it; nothing may sign before then. inner.identity = None; + inner.expected_identity = None; inner.active_npub = None; inner.pending.clear(); } @@ -438,6 +455,7 @@ impl Nip46ClientSigner { stored.revoked_at = crate::vault::unix_timestamp().ok(); } crate::vault::delete_connection_secret(&mut app.vault, &vault_ref); + crate::vault::delete_connection_client_key(&mut app.vault, &vault_ref); let _ = app.save_vault(); } inner.phase = Nip46Phase::Stopped; @@ -460,6 +478,169 @@ impl Nip46ClientSigner { self.disconnect().await } + /// Re-dial the saved signer sessions after startup/unlock, no scan. + /// + /// Amber remembers our *client pubkey* as the identity of an approved + /// connection for its whole life, and this app now persists that client + /// secret key (encrypted, per [`crate::vault::Vault::connection_client_keys`]). + /// A saved connection is therefore re-dialable: we reuse the exact client + /// keypair, re-derive the NIP-44 conversation key, send `connect` again, + /// and — critically — require the signer's `get_public_key` answer to + /// equal the stored profile identity (`expected_identity`, enforced in + /// [`Self::adopt_identity`]). A signer that answers as anyone else fails + /// the restore; the session is never adopted, so a re-dial can never + /// silently bind the wrong account (spec: "prevent cross-account signer + /// use"). + /// + /// Connections without a stored client key (pairings created before key + /// persistence existed) are skipped — those need one fresh scan, after + /// which they become restorable too. Only one session is restored per + /// call (the signer holds a single live session): the active profile's + /// connection first, then any other live connection. + /// + /// Returns `Ok(1)` when a re-dial was started, `Ok(0)` when there was + /// nothing restorable or a session is already live. A started re-dial + /// resolves asynchronously through the same handshake as a fresh + /// `connect()`; until it reports Connected the profile stays effectively + /// read-only (every signing path fails closed on `Connecting`). + pub async fn reactivate_saved_sessions(&self) -> Result { + { + let inner = self.inner.lock().await; + if inner.task.is_some() || inner.pairing.is_some() { + return Ok(0); + } + } + + // Pick the connection to restore and everything needed to re-dial it, + // all in one vault snapshot. The vault key is copied out before the + // mutable borrows, mirroring `connect()`. + let picked = { + let app = self.app.lock().await; + let vault_key = app.vault_key().copied(); + let now = crate::vault::unix_timestamp().unwrap_or(0); + + let mut candidates: Vec<&Nip46Connection> = app + .vault + .nip46_connections + .iter() + .filter(|c| { + // Live rows only: revoked, expired, or identity-less + // connections cannot be re-dialed. + c.revoked_at.is_none() + && c.expires_at.map(|t| t > now).unwrap_or(true) + && c.profile_npub.is_some() + }) + .collect(); + // Prefer the active profile's connection, then creation order. + let active = app.vault.active_profile.clone(); + candidates + .sort_by_key(|c| (c.profile_npub.as_deref() != active.as_deref(), c.created_at)); + + let mut picked = None; + for conn in candidates { + let vault_ref = crate::signer::VaultRef::from_connection(conn); + // A restorable session needs the client key we persisted at + // pairing. Legacy rows without one are skipped. + let Ok(Some(client_key_hex)) = crate::vault::resolve_connection_client_key( + &app.vault, + vault_key.as_ref(), + &vault_ref, + ) else { + continue; + }; + // The pairing secret is optional on a re-dial (a bunker-style + // signer accepts a bare `connect`), but resolve it when the + // vault still holds one. + let connect_secret = crate::vault::resolve_connection_secret( + &app.vault, + vault_key.as_ref(), + &vault_ref, + ) + .ok() + .flatten() + .map(|s| s.to_string()); + let expected = match conn + .profile_npub + .as_deref() + .and_then(|npub| PublicKey::from_bech32(npub).ok()) + { + Some(pk) => pk, + None => continue, + }; + picked = Some(( + conn.clone(), + client_key_hex.to_string(), + connect_secret, + expected, + )); + break; + } + picked + }; + + let Some((connection, client_key_hex, connect_secret, expected_identity)) = picked else { + return Ok(0); + }; + + // Rebuild the exact wire identity of the saved session. + let secret_key = SecretKey::from_hex(client_key_hex.trim()) + .map_err(|e| AppError::internal(format!("Stored client key is unreadable: {e}")))?; + let keys = Keys::new(secret_key); + let peer = PublicKey::from_hex(&connection.signer_pubkey) + .map_err(|e| AppError::internal(format!("Stored signer key is unreadable: {e}")))?; + let relays: Vec = connection + .relays + .iter() + .filter_map(|r| RelayUrl::parse(r).ok()) + .collect(); + if relays.is_empty() { + return Err(AppError::config( + "The saved signer connection names no usable relays.", + )); + } + let conversation = ConversationKey::derive(keys.secret_key(), &peer) + .map_err(|e| AppError::internal(format!("Could not derive session key: {e}")))?; + + eprintln!( + "[NIP46] restoring session: peer={} as {} (client pubkey={})", + peer.to_hex(), + expected_identity.to_bech32().unwrap_or_default(), + keys.public_key().to_hex(), + ); + + { + let mut inner = self.inner.lock().await; + if inner.task.is_some() { + return Ok(0); + } + inner.phase = Nip46Phase::Connecting; + inner.connection = Some(connection.clone()); + inner.conversation_key = Some(conversation); + inner.keys = Some(keys.clone()); + // Identity is unknown until the handshake re-proves it; nothing + // may sign before then, and what it proves MUST be this account. + inner.identity = None; + inner.expected_identity = Some(expected_identity); + inner.active_npub = None; + inner.pending.clear(); + } + + let uri = ConnectUri { + peer, + relays, + secret: connect_secret, + permissions: None, + }; + let signer = self.clone(); + let task = tokio::spawn(async move { + if let Err(e) = signer.clone().run_sign_task(uri).await { + signer.fail(e); + } + }); + self.inner.lock().await.task = Some(task); + Ok(1) + } + /// Begin a client-initiated pairing (NIP-46 §Direct connection initiated /// by the client): we mint an ephemeral key + secret, publish a /// `nostrconnect://` token (returned in `status().pairing_uri` for the @@ -874,6 +1055,17 @@ impl Nip46ClientSigner { &secret, ) .map_err(|e| e.to_string())?; + // Persist OUR client secret key alongside the pairing secret: + // Amber remembers this client pubkey as our identity for the + // life of the connection, so re-dialing after a restart must + // reuse it (see Vault::connection_client_keys). + crate::vault::store_connection_client_key( + &mut app.vault, + vault_key.as_ref(), + &vault_ref, + &hex::encode(keys.secret_key().to_secret_bytes()), + ) + .map_err(|e| e.to_string())?; app.vault.nip46_connections.push(connection.clone()); app.save_vault().map_err(|e| e.to_string())?; } @@ -1971,6 +2163,27 @@ impl Nip46ClientSigner { }; let identity = PublicKey::from_hex(identity.trim()) .map_err(|e| format!("The signer returned an unreadable public key: {e}"))?; + // Cross-account guard: on a RESTORED session the account identity was + // already pinned before we dialed. If the signer answers as a + // different key — a different Amber account, a mistyped bunker, a + // relay spoof — refuse the session outright. Fresh pairings have no + // expectation (the signer's answer defines the identity) and are + // unaffected. + let expected = self.inner.lock().await.expected_identity; + if let Some(expected) = expected { + if identity != expected { + eprintln!( + "[NIP46] identity check on restored session: FAIL (signer answered {}, expected {})", + identity.to_hex(), + expected.to_hex() + ); + return Err(format!( + "The restored signer answered as a different account ({}). Refusing to connect it to this profile — reconnect with a fresh scan.", + identity.to_bech32().unwrap_or_else(|_| identity.to_hex()) + )); + } + eprintln!("[NIP46] identity check on restored session: PASS"); + } let identity_npub = identity .to_bech32() .map_err(|e| format!("Could not encode identity npub: {e}"))?; @@ -2004,9 +2217,8 @@ impl Nip46ClientSigner { ) .ok() .flatten(); + let new_ref = crate::signer::VaultRef::new(Some(identity_npub.clone()), peer.to_hex()); if let Some(s) = &secret { - let new_ref = - crate::signer::VaultRef::new(Some(identity_npub.clone()), peer.to_hex()); crate::vault::store_connection_secret( &mut app.vault, vault_key.as_ref(), @@ -2016,6 +2228,29 @@ impl Nip46ClientSigner { .map_err(|e| e.message().to_string())?; crate::vault::delete_connection_secret(&mut app.vault, &connect_ref); } + // Re-key OUR client secret key the same way, so the + // identity-keyed VaultRef can resolve it for reactivation. + // MUST run even when there is no pairing secret (a bare + // bunker:// connect carries none) — otherwise the key strands + // under the pre-identity ref and the session is never + // restorable. + if let Some(ck) = crate::vault::resolve_connection_client_key( + &app.vault, + vault_key.as_ref(), + &connect_ref, + ) + .ok() + .flatten() + { + crate::vault::store_connection_client_key( + &mut app.vault, + vault_key.as_ref(), + &new_ref, + &ck, + ) + .map_err(|e| e.message().to_string())?; + crate::vault::delete_connection_client_key(&mut app.vault, &connect_ref); + } if let Some(conn) = app.vault.nip46_connections.iter_mut().find(|c| { c.signer_pubkey == peer.to_hex() && c.profile_npub != Some(identity_npub.clone()) }) { diff --git a/src/vault.rs b/src/vault.rs index d1ddb7c..0e4d907 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -120,6 +120,17 @@ pub struct Vault { /// handled; a password-protected vault encrypts them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub connection_secrets: Vec, + /// Our NIP-46 client secret keys, one per connection. + /// + /// The client keypair minted at pairing is not just a handshake nonce: + /// the signer (Amber) remembers it as our identity for the whole + /// connection, so re-dialing after an app restart MUST reuse the exact + /// same key or the signer answers a stranger and the session cannot be + /// reactivated without a fresh scan. Stored encrypted under the vault + /// key — exactly like [`Vault::connection_secrets`] — keyed by the same + /// [`crate::signer::VaultRef`], never inline on `Nip46Connection`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub connection_client_keys: Vec, /// Standing "always allow" grants for apps that use this machine as /// their NIP-46 signer (bunker mode). Keyed by the *app's* pubkey and /// the gated method it was allowed to run; a matching request skips the @@ -161,6 +172,23 @@ pub struct ConnectionSecret { pub secret: String, } +/// Our NIP-46 client secret key for one connection, keyed by its +/// [`crate::signer::VaultRef`] — the same keying as [`ConnectionSecret`]. +/// +/// The stored value is the 64-char hex secret key: plaintext when the vault +/// has no password, a base64 AES-256-GCM blob under the vault key when it +/// does. It is a credential: the signer recognizes our client pubkey for the +/// life of the connection, so this key is what makes reactivation-after- +/// restart possible without a fresh scan, and it must never be serialized +/// anywhere the UI or logs can see it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionClientKey { + /// The opaque reference (profile npub + remote signer pubkey). + pub ref_: crate::signer::VaultRef, + /// Our client secret key (hex) — plaintext or encrypted, per the vault. + pub secret_hex: String, +} + impl Vault { /// A fresh, empty vault. pub fn empty() -> Self { @@ -172,6 +200,7 @@ impl Vault { profiles: Vec::new(), nip46_connections: Vec::new(), connection_secrets: Vec::new(), + connection_client_keys: Vec::new(), signer_grants: Vec::new(), } } @@ -396,6 +425,7 @@ pub fn parse_vault(content: &str) -> Result { profiles, nip46_connections: Vec::new(), connection_secrets: Vec::new(), + connection_client_keys: Vec::new(), signer_grants: Vec::new(), }); } @@ -506,6 +536,76 @@ pub fn delete_connection_secret(vault: &mut Vault, ref_: &crate::signer::VaultRe vault.connection_secrets.len() != before } +/// Store (or replace) our NIP-46 client secret key for a connection. +/// +/// Encryption behavior mirrors [`store_connection_secret`]: encrypted under +/// the vault key when the vault is password-protected (fail-closed on a +/// locked vault), plaintext otherwise. Replacing in place keeps one key per +/// connection so reconnects never strand a stale secret. +pub fn store_connection_client_key( + vault: &mut Vault, + key: Option<&crate::crypto::VaultKey>, + ref_: &crate::signer::VaultRef, + secret_hex: &str, +) -> Result<(), AppError> { + let stored = match &vault.crypto { + Some(_) => { + let key = key.ok_or_else(AppError::vault_locked)?; + crate::crypto::encrypt_secret(key, secret_hex)? + } + None => secret_hex.to_string(), + }; + if let Some(entry) = vault + .connection_client_keys + .iter_mut() + .find(|c| c.ref_ == *ref_) + { + entry.secret_hex = stored; + } else { + vault.connection_client_keys.push(ConnectionClientKey { + ref_: ref_.clone(), + secret_hex: stored, + }); + } + Ok(()) +} + +/// Resolve (decrypt) the stored NIP-46 client secret key for a reference. +/// +/// Same contract as [`resolve_connection_secret`]: `Ok(None)` when nothing is +/// stored, fail-closed `Err(vault_locked)` when encrypted-but-locked, and a +/// [`Zeroizing`] plaintext on success. +pub fn resolve_connection_client_key( + vault: &Vault, + key: Option<&crate::crypto::VaultKey>, + ref_: &crate::signer::VaultRef, +) -> Result>, AppError> { + let entry = vault + .connection_client_keys + .iter() + .find(|c| c.ref_ == *ref_); + let Some(entry) = entry else { + return Ok(None); + }; + match &vault.crypto { + Some(_) => { + let key = key.ok_or_else(AppError::vault_locked)?; + let plain = crate::crypto::decrypt_secret(key, &entry.secret_hex)?; + Ok(Some(plain)) + } + None => Ok(Some(Zeroizing::new(entry.secret_hex.clone()))), + } +} + +/// Remove a stored NIP-46 client secret key (e.g. on disconnect/revoke). +/// +/// Returns `true` when an entry was removed. +pub fn delete_connection_client_key(vault: &mut Vault, ref_: &crate::signer::VaultRef) -> bool { + let before = vault.connection_client_keys.len(); + vault.connection_client_keys.retain(|c| c.ref_ != *ref_); + vault.connection_client_keys.len() != before +} + /// Persist the vault to the stable application-data location with /// restrictive permissions. pub fn save_vault(vault: &Vault) -> Result<(), AppError> { @@ -1079,4 +1179,90 @@ mod tests { // Connection remains None - cannot infer ownership assert!(vault.nip46_connections[0].profile_npub.is_none()); } + + #[test] + fn connection_client_key_plaintext_roundtrip() { + let mut vault = Vault::empty(); + let ref_ = crate::signer::VaultRef::new(Some("npub1bob".to_string()), "aabb".to_string()); + + assert!(resolve_connection_client_key(&vault, None, &ref_) + .unwrap() + .is_none()); + + store_connection_client_key(&mut vault, None, &ref_, "00ff").unwrap(); + assert_eq!( + resolve_connection_client_key(&vault, None, &ref_) + .unwrap() + .unwrap() + .as_str(), + "00ff" + ); + + // Storing again replaces (one entry per ref). + store_connection_client_key(&mut vault, None, &ref_, "11ee").unwrap(); + assert_eq!(vault.connection_client_keys.len(), 1); + assert_eq!( + resolve_connection_client_key(&vault, None, &ref_) + .unwrap() + .unwrap() + .as_str(), + "11ee" + ); + + assert!(delete_connection_client_key(&mut vault, &ref_)); + assert!(!delete_connection_client_key(&mut vault, &ref_)); + assert!(resolve_connection_client_key(&vault, None, &ref_) + .unwrap() + .is_none()); + } + + #[test] + fn connection_client_key_encrypted_when_vault_locked() { + use crate::crypto; + + let mut vault = Vault::empty(); + let salt = crypto::generate_salt().unwrap(); + let key = crypto::derive_key("pw", &salt, 1024, 1, 1).unwrap(); + vault.crypto = Some(VaultCrypto { + kdf: crate::vault::KdfParams { + algorithm: "argon2id".to_string(), + m_cost: 1024, + t_cost: 1, + p_cost: 1, + salt: B64.encode(salt), + }, + verifier: crypto::make_verifier(&key).unwrap(), + }); + let ref_ = crate::signer::VaultRef::new(Some("npub1bob".to_string()), "aabb".to_string()); + + store_connection_client_key(&mut vault, Some(&key), &ref_, "deadbeef").unwrap(); + // The on-disk form must not carry the plaintext key. + let serialized = serde_json::to_string(&vault).unwrap(); + assert!(!serialized.contains("deadbeef")); + + // Locked vault: fail closed. + let err = resolve_connection_client_key(&vault, None, &ref_).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::VaultLocked); + + // Unlocked: exact roundtrip. + assert_eq!( + resolve_connection_client_key(&vault, Some(&key), &ref_) + .unwrap() + .unwrap() + .as_str(), + "deadbeef" + ); + } + + #[test] + fn connection_client_keys_absent_in_legacy_vault() { + // A vault JSON without the new field must still parse (serde default). + let json = r#"{ + "version": 2, + "profiles": [], + "nip46_connections": [] + }"#; + let vault: Vault = serde_json::from_str(json).unwrap(); + assert!(vault.connection_client_keys.is_empty()); + } } diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs index 68e6a9a..5e20139 100644 --- a/tests/nip46_e2e.rs +++ b/tests/nip46_e2e.rs @@ -1062,3 +1062,210 @@ async fn run_qr_pairing(connect_shape: &'static str) { 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().unwrap(); + 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.) + 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; + } +}