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

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