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
|
// 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.
|
// both see the same vault (including its unlock key) without racing writes.
|
||||||
let app = Arc::new(Mutex::new(App::load()?));
|
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 stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout()));
|
||||||
|
|
||||||
let stdin = tokio::io::stdin();
|
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 {
|
if let Some(npub) = &app.vault.active_profile {
|
||||||
signer.set_active_profile(Some(npub.clone())).await;
|
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()))
|
Ok(json!(app.state_view()))
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,11 @@ struct Nip46Inner {
|
||||||
/// (Amber's `bunker://` flow mints one per app) and must never be used
|
/// (Amber's `bunker://` flow mints one per app) and must never be used
|
||||||
/// as an identity. `None` until the handshake resolves it.
|
/// as an identity. `None` until the handshake resolves it.
|
||||||
identity: Option<PublicKey>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -167,6 +172,7 @@ impl Nip46ClientSigner {
|
||||||
active_npub: None,
|
active_npub: None,
|
||||||
pairing: None,
|
pairing: None,
|
||||||
identity: None,
|
identity: None,
|
||||||
|
expected_identity: None,
|
||||||
})),
|
})),
|
||||||
app,
|
app,
|
||||||
}
|
}
|
||||||
|
|
@ -379,6 +385,16 @@ impl Nip46ClientSigner {
|
||||||
// The reconnect carries no secret — drop any stale stored one.
|
// The reconnect carries no secret — drop any stale stored one.
|
||||||
crate::vault::delete_connection_secret(&mut app.vault, &vault_ref);
|
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.vault.nip46_connections.push(connection.clone());
|
||||||
app.save_vault()?;
|
app.save_vault()?;
|
||||||
}
|
}
|
||||||
|
|
@ -398,6 +414,7 @@ impl Nip46ClientSigner {
|
||||||
// Identity is unknown until the handshake's `get_public_key`
|
// Identity is unknown until the handshake's `get_public_key`
|
||||||
// resolves it; nothing may sign before then.
|
// resolves it; nothing may sign before then.
|
||||||
inner.identity = None;
|
inner.identity = None;
|
||||||
|
inner.expected_identity = None;
|
||||||
inner.active_npub = None;
|
inner.active_npub = None;
|
||||||
inner.pending.clear();
|
inner.pending.clear();
|
||||||
}
|
}
|
||||||
|
|
@ -438,6 +455,7 @@ impl Nip46ClientSigner {
|
||||||
stored.revoked_at = crate::vault::unix_timestamp().ok();
|
stored.revoked_at = crate::vault::unix_timestamp().ok();
|
||||||
}
|
}
|
||||||
crate::vault::delete_connection_secret(&mut app.vault, &vault_ref);
|
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();
|
let _ = app.save_vault();
|
||||||
}
|
}
|
||||||
inner.phase = Nip46Phase::Stopped;
|
inner.phase = Nip46Phase::Stopped;
|
||||||
|
|
@ -460,6 +478,169 @@ impl Nip46ClientSigner {
|
||||||
self.disconnect().await
|
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
|
/// Begin a client-initiated pairing (NIP-46 §Direct connection initiated
|
||||||
/// by the client): we mint an ephemeral key + secret, publish a
|
/// by the client): we mint an ephemeral key + secret, publish a
|
||||||
/// `nostrconnect://` token (returned in `status().pairing_uri` for the
|
/// `nostrconnect://` token (returned in `status().pairing_uri` for the
|
||||||
|
|
@ -874,6 +1055,17 @@ impl Nip46ClientSigner {
|
||||||
&secret,
|
&secret,
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.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.vault.nip46_connections.push(connection.clone());
|
||||||
app.save_vault().map_err(|e| e.to_string())?;
|
app.save_vault().map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
@ -1971,6 +2163,27 @@ impl Nip46ClientSigner {
|
||||||
};
|
};
|
||||||
let identity = PublicKey::from_hex(identity.trim())
|
let identity = PublicKey::from_hex(identity.trim())
|
||||||
.map_err(|e| format!("The signer returned an unreadable public key: {e}"))?;
|
.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
|
let identity_npub = identity
|
||||||
.to_bech32()
|
.to_bech32()
|
||||||
.map_err(|e| format!("Could not encode identity npub: {e}"))?;
|
.map_err(|e| format!("Could not encode identity npub: {e}"))?;
|
||||||
|
|
@ -2004,9 +2217,8 @@ impl Nip46ClientSigner {
|
||||||
)
|
)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten();
|
.flatten();
|
||||||
|
let new_ref = crate::signer::VaultRef::new(Some(identity_npub.clone()), peer.to_hex());
|
||||||
if let Some(s) = &secret {
|
if let Some(s) = &secret {
|
||||||
let new_ref =
|
|
||||||
crate::signer::VaultRef::new(Some(identity_npub.clone()), peer.to_hex());
|
|
||||||
crate::vault::store_connection_secret(
|
crate::vault::store_connection_secret(
|
||||||
&mut app.vault,
|
&mut app.vault,
|
||||||
vault_key.as_ref(),
|
vault_key.as_ref(),
|
||||||
|
|
@ -2016,6 +2228,29 @@ impl Nip46ClientSigner {
|
||||||
.map_err(|e| e.message().to_string())?;
|
.map_err(|e| e.message().to_string())?;
|
||||||
crate::vault::delete_connection_secret(&mut app.vault, &connect_ref);
|
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| {
|
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())
|
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.
|
/// handled; a password-protected vault encrypts them.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub connection_secrets: Vec<ConnectionSecret>,
|
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
|
/// Standing "always allow" grants for apps that use this machine as
|
||||||
/// their NIP-46 signer (bunker mode). Keyed by the *app's* pubkey and
|
/// 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
|
/// the gated method it was allowed to run; a matching request skips the
|
||||||
|
|
@ -161,6 +172,23 @@ pub struct ConnectionSecret {
|
||||||
pub secret: String,
|
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 {
|
impl Vault {
|
||||||
/// A fresh, empty vault.
|
/// A fresh, empty vault.
|
||||||
pub fn empty() -> Self {
|
pub fn empty() -> Self {
|
||||||
|
|
@ -172,6 +200,7 @@ impl Vault {
|
||||||
profiles: Vec::new(),
|
profiles: Vec::new(),
|
||||||
nip46_connections: Vec::new(),
|
nip46_connections: Vec::new(),
|
||||||
connection_secrets: Vec::new(),
|
connection_secrets: Vec::new(),
|
||||||
|
connection_client_keys: Vec::new(),
|
||||||
signer_grants: Vec::new(),
|
signer_grants: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -396,6 +425,7 @@ pub fn parse_vault(content: &str) -> Result<Vault, AppError> {
|
||||||
profiles,
|
profiles,
|
||||||
nip46_connections: Vec::new(),
|
nip46_connections: Vec::new(),
|
||||||
connection_secrets: Vec::new(),
|
connection_secrets: Vec::new(),
|
||||||
|
connection_client_keys: Vec::new(),
|
||||||
signer_grants: 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
|
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
|
/// Persist the vault to the stable application-data location with
|
||||||
/// restrictive permissions.
|
/// restrictive permissions.
|
||||||
pub fn save_vault(vault: &Vault) -> Result<(), AppError> {
|
pub fn save_vault(vault: &Vault) -> Result<(), AppError> {
|
||||||
|
|
@ -1079,4 +1179,90 @@ mod tests {
|
||||||
// Connection remains None - cannot infer ownership
|
// Connection remains None - cannot infer ownership
|
||||||
assert!(vault.nip46_connections[0].profile_npub.is_none());
|
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();
|
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