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
+4 -2
View File
@@ -113,8 +113,10 @@ two replicas overwriting each other.
the platform defaults to the OS from the user agent because the server has no `web` platform yet. the platform defaults to the OS from the user agent because the server has no `web` platform yet.
4. **Every unlock needs the master password and Secret Key.** There is no Local Unlock Key on the 4. **Every unlock needs the master password and Secret Key.** There is no Local Unlock Key on the
web: nothing secret is ever written to IndexedDB, so a reload, `lock()` or closing the tab web: nothing secret is ever written to IndexedDB, so a reload, `lock()` or closing the tab
leaves the vault locked. (The Secret Key that setup codes carry is kept in wasm memory while leaves the vault locked. The Secret Key that setup codes carry is kept in wasm memory only, set
unlocked and dropped on `lock()`, unlike kv-ffi, which keeps a sealed copy on disk.) Lock on idle and on `visibilitychange`/`pagehide` as the UI sees fit; after an unlock/login proved it and dropped on `lock()`, `logout()` and any failed unlock or
login; no getter exposes it (unlike kv-ffi, which keeps a sealed copy on disk). See
kestrove-spec docs/10 §5 "Web vault". Lock on idle and on `visibilitychange`/`pagehide` as the UI sees fit;
`close()` drops the worker and every key in it. `close()` drops the worker and every key in it.
5. **Secrets in JS.** Master password, Secret Key and decrypted `ItemDto`s are JS strings the 5. **Secrets in JS.** Master password, Secret Key and decrypted `ItemDto`s are JS strings the
core cannot wipe. Do not keep them in long-lived state, logs or URLs. The core zeroizes its own core cannot wipe. Do not keep them in long-lived state, logs or URLs. The core zeroizes its own
+38 -8
View File
@@ -79,8 +79,10 @@ struct Unlocked {
vault_key: Arc<SymKey>, vault_key: Arc<SymKey>,
/// OPAQUE password, kept while unlocked so an expired session can be renewed without prompting. /// OPAQUE password, kept while unlocked so an expired session can be renewed without prompting.
auth_input: Option<Zeroizing<[u8; 32]>>, auth_input: Option<Zeroizing<[u8; 32]>>,
/// The Secret Key, for setup payloads (Emergency Kit, "Add a device", scan-to-join). /// The Secret Key, for setup payloads (Emergency Kit, "Add a device", scan-to-join). Set only
secret_key: Zeroizing<[u8; 16]>, /// 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 { struct Device {
@@ -191,12 +193,16 @@ impl WebCore {
keyset: Arc::new(keyset), keyset: Arc::new(keyset),
vault_key: Arc::new(vault_key), vault_key: Arc::new(vault_key),
auth_input: Some(derived.auth_input), 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()) Ok(secret_key.to_display().to_string())
} }
pub fn unlock(&self, master_password: &str, secret_key: &str) -> Result<()> { 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 mut inner = self.lock_inner();
let acct = inner.account.clone().ok_or(CoreError::NoAccount)?; let acct = inner.account.clone().ok_or(CoreError::NoAccount)?;
let sk = SecretKey::parse(secret_key)?; let sk = SecretKey::parse(secret_key)?;
@@ -208,7 +214,7 @@ impl WebCore {
keyset: Arc::new(keyset), keyset: Arc::new(keyset),
vault_key: Arc::new(vault_key), vault_key: Arc::new(vault_key),
auth_input: Some(derived.auth_input), auth_input: Some(derived.auth_input),
secret_key: Zeroizing::new(*sk.expose()), secret_key: Some(Zeroizing::new(*sk.expose())),
}); });
Ok(()) 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 /// 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. /// 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> { 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 self.login_inner(master_password, sk, totp, None, None).await
} }
@@ -508,6 +514,18 @@ impl WebCore {
totp: Option<String>, totp: Option<String>,
ticket: Option<String>, ticket: Option<String>,
expect_account: Option<Uuid>, 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> { ) -> Result<LoginResult> {
let api = self.api()?; let api = self.api()?;
let local = self.lock_inner().account.clone(); let local = self.lock_inner().account.clone();
@@ -561,7 +579,7 @@ impl WebCore {
keyset: Arc::new(keyset), keyset: Arc::new(keyset),
vault_key: Arc::new(vault_key), vault_key: Arc::new(vault_key),
auth_input: Some(derived.auth_input), auth_input: Some(derived.auth_input),
secret_key: Zeroizing::new(*sk.expose()), secret_key: Some(Zeroizing::new(*sk.expose())),
}); });
Ok(LoginResult::Ok) Ok(LoginResult::Ok)
} }
@@ -625,8 +643,9 @@ impl WebCore {
Ok(self.api()?.revoke_device(parse_id(device_id)?).await?) 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<()> { pub async fn logout(&self) -> Result<()> {
self.forget_secret_key();
let api = self.api()?; let api = self.api()?;
let r = api.logout().await; let r = api.logout().await;
self.store.delete_meta(META_SESSION)?; 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 /// 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. /// 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> { 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 p = from_info(setup)?;
let (account, server) = { let (account, server) = {
let inner = self.lock_inner(); let inner = self.lock_inner();
@@ -805,6 +829,12 @@ impl WebCore {
// ------------------------------------------------------------ internals // ------------------------------------------------------------ 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>> { fn lock_join(&self) -> std::sync::MutexGuard<'_, Option<JoinState>> {
self.join.lock().unwrap_or_else(|p| p.into_inner()) 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)?, server_url: kv_sync::transport::normalize_base_url(&server.url)?,
spki_pins: server.pins, spki_pins: server.pins,
account_id: acct.account_id, 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, ticket,
}) })
} }
+5
View File
@@ -239,6 +239,11 @@ async fn setup_codes_in_the_browser_keep_the_secret_key_in_memory_only() {
let text = String::from_utf8_lossy(&value); let text = String::from_utf8_lossy(&value);
assert!(!text.contains(&secret_key) && !text.contains(&compact[2..]), "{key} holds the Secret Key"); assert!(!text.contains(&secret_key) && !text.contains(&compact[2..]), "{key} holds the Secret Key");
} }
// A failed unlock forgets it; lock drops it with the keys.
assert_eq!(err_code(core.unlock("wrong password!".into(), secret_key.clone())).await, "BadCredentials");
assert_eq!(err_code(core.setup_payload_for_kit()).await, "Locked");
ok(core.unlock(PW.into(), secret_key.clone())).await;
assert_eq!(ok(core.setup_payload_for_kit()).await.as_string().as_deref(), Some(kit.as_str()));
ok(core.lock()).await; ok(core.lock()).await;
assert_eq!(err_code(core.setup_payload_for_kit()).await, "Locked"); assert_eq!(err_code(core.setup_payload_for_kit()).await, "Locked");
db.close(); db.close();
+11
View File
@@ -247,6 +247,13 @@ async fn web_vault_enrolls_by_ticket_both_ways() {
reloaded.unlock(PW, &sk).unwrap(); reloaded.unlock(PW, &sk).unwrap();
assert_eq!(reloaded.setup_payload_for_kit().unwrap(), kit); assert_eq!(reloaded.setup_payload_for_kit().unwrap(), kit);
web.unlock(PW, &sk).unwrap(); web.unlock(PW, &sk).unwrap();
// A failed unlock (or login) forgets it until the next one that succeeds.
assert!(matches!(web.unlock("wrong password!!", &sk), Err(CoreError::BadCredentials)));
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
web.unlock(PW, &sk).unwrap();
assert!(web.login("wrong password!!", &sk, None).await.is_err());
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
web.unlock(PW, &sk).unwrap();
// Single use: another browser with the same QR waits for approval. // Single use: another browser with the same QR waits for approval.
let (_, third) = new_web(&server); let (_, third) = new_web(&server);
@@ -273,6 +280,10 @@ async fn web_vault_enrolls_by_ticket_both_ways() {
let (_, late) = new_web(&server); let (_, late) = new_web(&server);
let setup = kv_wasm::core::parse_setup_payload(&add.payload).unwrap(); let setup = kv_wasm::core::parse_setup_payload(&add.payload).unwrap();
assert_eq!(late.login_with_setup(&setup, PW).await.unwrap(), LoginResult::PendingApproval); assert_eq!(late.login_with_setup(&setup, PW).await.unwrap(), LoginResult::PendingApproval);
// Logging out forgets the Secret Key too.
web.logout().await.unwrap();
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
} }
#[tokio::test] #[tokio::test]