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:
parent
73bf17c3c8
commit
0982dad566
4 changed files with 661 additions and 2 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue