kv-wasm: forget the Secret Key on logout and on any failed unlock or login
ci / test (push) Canceled after 0s
ci / wasm (push) Canceled after 0s
ci / deny (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 07:00:50 +09:00
co-authored by Claude Opus 5.5
parent 2e2c8cd26f
commit 1e073e1f09
4 changed files with 58 additions and 10 deletions
+38 -8
View File
@@ -79,8 +79,10 @@ struct Unlocked {
vault_key: Arc<SymKey>,
/// OPAQUE password, kept while unlocked so an expired session can be renewed without prompting.
auth_input: Option<Zeroizing<[u8; 32]>>,
/// The Secret Key, for setup payloads (Emergency Kit, "Add a device", scan-to-join).
secret_key: Zeroizing<[u8; 16]>,
/// The Secret Key, for setup payloads (Emergency Kit, "Add a device", scan-to-join). Set only
/// once an unlock or login proved it (the keyset unwrapped); dropped on `lock`, `logout` and any
/// failed unlock or login. Never persisted in any form, and no getter exposes it.
secret_key: Option<Zeroizing<[u8; 16]>>,
}
struct Device {
@@ -191,12 +193,16 @@ impl WebCore {
keyset: Arc::new(keyset),
vault_key: Arc::new(vault_key),
auth_input: Some(derived.auth_input),
secret_key: Zeroizing::new(*secret_key.expose()),
secret_key: Some(Zeroizing::new(*secret_key.expose())),
});
Ok(secret_key.to_display().to_string())
}
pub fn unlock(&self, master_password: &str, secret_key: &str) -> Result<()> {
self.unlock_inner(master_password, secret_key).inspect_err(|_| self.forget_secret_key())
}
fn unlock_inner(&self, master_password: &str, secret_key: &str) -> Result<()> {
let mut inner = self.lock_inner();
let acct = inner.account.clone().ok_or(CoreError::NoAccount)?;
let sk = SecretKey::parse(secret_key)?;
@@ -208,7 +214,7 @@ impl WebCore {
keyset: Arc::new(keyset),
vault_key: Arc::new(vault_key),
auth_input: Some(derived.auth_input),
secret_key: Zeroizing::new(*sk.expose()),
secret_key: Some(Zeroizing::new(*sk.expose())),
});
Ok(())
}
@@ -496,7 +502,7 @@ impl WebCore {
/// Logs in with the master password and Secret Key. A browser with no account fetches it from the
/// server; call `sync_now` afterwards to download the vault.
pub async fn login(&self, master_password: &str, secret_key: &str, totp: Option<String>) -> Result<LoginResult> {
let sk = SecretKey::parse(secret_key)?;
let sk = SecretKey::parse(secret_key).inspect_err(|_| self.forget_secret_key())?;
self.login_inner(master_password, sk, totp, None, None).await
}
@@ -508,6 +514,18 @@ impl WebCore {
totp: Option<String>,
ticket: Option<String>,
expect_account: Option<Uuid>,
) -> Result<LoginResult> {
let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account).await;
r.inspect_err(|_| self.forget_secret_key())
}
async fn login_unchecked(
&self,
master_password: &str,
sk: SecretKey,
totp: Option<String>,
ticket: Option<String>,
expect_account: Option<Uuid>,
) -> Result<LoginResult> {
let api = self.api()?;
let local = self.lock_inner().account.clone();
@@ -561,7 +579,7 @@ impl WebCore {
keyset: Arc::new(keyset),
vault_key: Arc::new(vault_key),
auth_input: Some(derived.auth_input),
secret_key: Zeroizing::new(*sk.expose()),
secret_key: Some(Zeroizing::new(*sk.expose())),
});
Ok(LoginResult::Ok)
}
@@ -625,8 +643,9 @@ impl WebCore {
Ok(self.api()?.revoke_device(parse_id(device_id)?).await?)
}
/// Ends the server session (local data stays).
/// Ends the server session (local data stays) and forgets the Secret Key.
pub async fn logout(&self) -> Result<()> {
self.forget_secret_key();
let api = self.api()?;
let r = api.logout().await;
self.store.delete_meta(META_SESSION)?;
@@ -667,6 +686,11 @@ impl WebCore {
/// Web only: the browser talks to its own origin (`connect-src 'self'`), so a setup code for
/// another server than the configured one is refused instead of switching servers.
pub async fn login_with_setup(&self, setup: &SetupInfo, master_password: &str) -> Result<LoginResult> {
let r = self.login_with_setup_inner(setup, master_password).await;
r.inspect_err(|_| self.forget_secret_key())
}
async fn login_with_setup_inner(&self, setup: &SetupInfo, master_password: &str) -> Result<LoginResult> {
let p = from_info(setup)?;
let (account, server) = {
let inner = self.lock_inner();
@@ -805,6 +829,12 @@ impl WebCore {
// ------------------------------------------------------------ internals
fn forget_secret_key(&self) {
if let Some(u) = self.lock_inner().unlocked.as_mut() {
u.secret_key = None;
}
}
fn lock_join(&self) -> std::sync::MutexGuard<'_, Option<JoinState>> {
self.join.lock().unwrap_or_else(|p| p.into_inner())
}
@@ -817,7 +847,7 @@ impl WebCore {
server_url: kv_sync::transport::normalize_base_url(&server.url)?,
spki_pins: server.pins,
account_id: acct.account_id,
secret_key: SecretKey::from_bytes(*u.secret_key).to_display(),
secret_key: SecretKey::from_bytes(**u.secret_key.as_ref().ok_or(CoreError::Locked)?).to_display(),
ticket,
})
}