feat(nip46): restore saved signer sessions on startup/unlock — no fresh scan

Amber remembers our client pubkey for the life of a connection, so the
client secret key minted at pairing is now persisted in the vault
(encrypted like connection secrets, keyed by the same VaultRef, re-keyed
to the identity ref when the handshake resolves it). A restart re-dials
the saved session with the exact keypair, re-sends connect, and enforces
expected_identity in adopt_identity: a signer answering as a different
account is refused, never adopted. Restore hooks run at serve() for
unencrypted vaults and after UnlockVault; failures never block the GUI.
Legacy rows without a stored client key are skipped (one fresh scan
makes them restorable).
This commit is contained in:
Avi 2026-09-24 18:07:12 -05:00
commit 0982dad566
4 changed files with 661 additions and 2 deletions

View file

@ -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<serde_json::Val
if let Some(npub) = &app.vault.active_profile {
signer.set_active_profile(Some(npub.clone())).await;
}
// The vault key is now in memory: the stored NIP-46
// client keys decrypt, so a saved signer session can be
// re-dialed without a fresh scan (spec: session restore).
if let Err(e) = signer.reactivate_saved_sessions().await {
eprintln!("[NIP46] session restore after unlock failed: {e}");
}
}
}
Ok(json!(app.state_view()))

View file

@ -140,6 +140,11 @@ struct Nip46Inner {
/// (Amber's `bunker://` flow mints one per app) and must never be used
/// as an identity. `None` until the handshake resolves it.
identity: Option<PublicKey>,
/// 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<PublicKey>,
}
#[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<usize, AppError> {
{
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<RelayUrl> = 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())
}) {

View file

@ -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<ConnectionSecret>,
/// 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<ConnectionClientKey>,
/// 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<Vault, AppError> {
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<Option<Zeroizing<String>>, 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());
}
}

View file

@ -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;
}
}