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
31
src/ipc.rs
31
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<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()))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}) {
|
||||
|
|
|
|||
186
src/vault.rs
186
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<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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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