//! The web vault's `VaultCore`: the same operations as kv-ffi's, over [`WebStore`] and an injected //! [`kv_sync::DeviceSigner`]. Portable Rust (no JS types), so it is also tested natively, including //! against kv-sync's test server and against kv-ffi. //! //! It deliberately mirrors `kv-ffi/src/lib.rs` (and its `passkeys`/`import` modules); crypto, the //! item model, the store contract and the sync engine are the shared crates, so the bytes on the //! wire and in storage are identical. Differences from kv-ffi: //! * no Local Unlock Key (biometric unlock): the browser has no keystore that can gate a key behind //! user presence, so every unlock needs the master password and Secret Key; //! * no passkey authenticator calls (`create_passkey`/`get_assertion`): the web vault only lists and //! deletes passkeys; //! * no SPKI pins: the browser does TLS for the page's own origin; //! * `sync_now` fails with `Busy` instead of queueing when a sync is already running; //! * the Secret Key (for setup payloads, docs/10) is held only in memory while unlocked, never //! persisted: every unlock on the web takes it anyway, so kv-ffi's sealed copy is not needed. use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use kv_crypto::{ aad::{Aad, ObjectType}, aead::SymKey, kdf, keys::{open_item, seal_item, AccountKeyset, SealedItem}, pairing::{self, PairingKeypair}, random_bytes, secret_key::SecretKey, }; use kv_model::{generator, matching, totp::Totp, Item, ItemKind, Passkey}; use kv_proto::{b64, DeviceEnroll, Envelope, ItemRecord, JoinState as WireJoinState, KdfParams, Platform}; use kv_store::{HistoryEntry, LocalItem, Store, VaultRow}; use kv_sync::{ onboarding::{JoinQr, SetupPayload}, ApiClient, DeviceSigner, LoginOutcome, ServerConfig, SyncContext, SyncError, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; use zeroize::Zeroizing; use crate::{ dto::*, error::{internal, CoreError, Result}, store::WebStore, }; pub const META_ACCOUNT: &str = "account"; pub const META_SERVER: &str = "server"; pub const META_SESSION: &str = "session"; /// Everything needed to unlock: all public or wrapped. Same JSON as kv-ffi's `AccountRecord` /// (minus the native-only `local_unlock` and the sealed `secret_key`), so a record moves between the /// two unchanged. #[derive(Clone, Serialize, Deserialize)] pub struct AccountRecord { pub account_id: Uuid, pub kdf: KdfParams, pub wrapped_keyset: Envelope, #[serde(with = "b64")] pub enc_pk: Vec, #[serde(with = "b64")] pub sig_pk: Vec, pub primary_vault: Uuid, } #[derive(Clone, Serialize, Deserialize)] struct ServerRecord { url: String, pins: Vec, } /// Decrypted key material. Lives only in wasm memory; zeroized on drop (`lock`). #[derive(Clone)] struct Unlocked { keyset: Arc, vault_key: Arc, /// OPAQUE password, kept while unlocked so an expired session can be renewed without prompting. auth_input: Option>, /// 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>, } struct Device { signer: Arc, name: String, platform: Platform, } /// Local state of an in-flight scan-to-join (same as kv-ffi's). enum JoinState { /// This browser is joining (it showed the QR). Joining { rendezvous_id: String, read_token: String, keypair: PairingKeypair, qr_secret: Zeroizing<[u8; 32]>, sender_pub: Option>, }, /// This browser scanned a QR and said hello; waiting for the user to confirm the code. Approving { qr: JoinQr, keypair: PairingKeypair, device: DeviceEnroll }, } #[derive(Default)] struct Inner { account: Option, unlocked: Option, server: Option, device: Option, api: Option>, } pub struct WebCore { store: Arc, inner: Mutex, syncing: AtomicBool, /// In-flight scan-to-join, on either side (docs/10 §4). join: Mutex>, } struct SyncGuard<'a>(&'a AtomicBool); impl Drop for SyncGuard<'_> { fn drop(&mut self) { self.0.store(false, Ordering::Release); } } pub fn now() -> i64 { kv_sync::unix_now() as i64 } impl WebCore { pub fn new(store: Arc) -> Result { let account = store .get_meta(META_ACCOUNT)? .map(|b| serde_json::from_slice::(&b)) .transpose() .map_err(internal)?; let server = store.get_meta(META_SERVER)?.and_then(|b| serde_json::from_slice::(&b).ok()); Ok(Self { store, inner: Mutex::new(Inner { account, server, ..Default::default() }), syncing: AtomicBool::new(false), join: Mutex::new(None), }) } pub fn store(&self) -> &Arc { &self.store } fn lock_inner(&self) -> std::sync::MutexGuard<'_, Inner> { self.inner.lock().unwrap_or_else(|p| p.into_inner()) } // ------------------------------------------------------------ account and session /// Creates the account and its first vault, leaving it unlocked. Returns the Secret Key display /// string for the Emergency Kit. pub fn create_account(&self, master_password: &str) -> Result { if master_password.chars().count() < 10 { return Err(CoreError::Invalid { msg: "master password must be at least 10 characters".into() }); } let mut inner = self.lock_inner(); if inner.account.is_some() { return Err(CoreError::Invalid { msg: "an account already exists in this browser".into() }); } let account_id = Uuid::new_v4(); let secret_key = SecretKey::generate(); let params = kdf::default_params(); let derived = kdf::derive_keys(master_password, &secret_key, account_id, ¶ms)?; let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk); let keyset = AccountKeyset::generate(account_id); let wrapped_keyset = keyset.wrap(&auk)?; let vault_id = Uuid::new_v4(); let vault_key = self.create_vault(&keyset, vault_id)?; let account = AccountRecord { account_id, kdf: params, wrapped_keyset, enc_pk: keyset.enc.public_bytes(), sig_pk: keyset.sig_public().to_vec(), primary_vault: vault_id, }; self.save_account(&account)?; inner.account = Some(account); inner.unlocked = Some(Unlocked { keyset: Arc::new(keyset), vault_key: Arc::new(vault_key), auth_input: Some(derived.auth_input), 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)?; let derived = kdf::derive_keys(master_password, &sk, acct.account_id, &acct.kdf)?; let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk); let keyset = AccountKeyset::unwrap(acct.account_id, &auk, &acct.wrapped_keyset)?; let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?; inner.unlocked = Some(Unlocked { keyset: Arc::new(keyset), vault_key: Arc::new(vault_key), auth_input: Some(derived.auth_input), secret_key: Some(Zeroizing::new(*sk.expose())), }); Ok(()) } /// Drops all decrypted key material (keysets, vault key and OPAQUE input are zeroized on drop). pub fn lock(&self) { let mut inner = self.lock_inner(); inner.unlocked = None; } pub fn is_locked(&self) -> bool { self.lock_inner().unlocked.is_none() } pub fn has_account(&self) -> bool { self.lock_inner().account.is_some() } pub fn account_id(&self) -> Result { Ok(self.lock_inner().account.as_ref().ok_or(CoreError::NoAccount)?.account_id.to_string()) } /// Ed25519 public key of the account (hex), shown when pairing devices. pub fn account_fingerprint(&self) -> Result { let (_, u) = self.unlocked_snapshot()?; Ok(u.keyset.sig_public().iter().map(|b| format!("{b:02x}")).collect()) } /// Forgets everything stored for this browser (account record, replica, session). The device key /// is removed by the wasm layer. pub fn wipe(&self) -> Result<()> { let mut inner = self.lock_inner(); self.store.wipe()?; *inner = Inner::default(); self.join_cancel(); Ok(()) } // ------------------------------------------------------------ items pub fn list_items(&self) -> Result> { let (acct, u) = self.unlocked_snapshot()?; let mut out: Vec = self.all_items(&acct, &u)?.iter().map(summary).collect(); out.sort_by(|a, b| { b.favorite.cmp(&a.favorite).then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase())) }); Ok(out) } pub fn get_item(&self, id: &str) -> Result { let id = parse_id(id)?; let (acct, u) = self.unlocked_snapshot()?; let li = self.store.get(acct.primary_vault, id)?.ok_or(CoreError::NotFound)?; Ok(to_dto(&decrypt(&acct, &u.vault_key, &li.record)?.ok_or(CoreError::NotFound)?)) } /// Inserts or updates an item. Returns its id. The previous version goes to the item's history. pub fn save_item(&self, item: ItemDto) -> Result { let (acct, u) = self.unlocked_snapshot()?; let vault = acct.primary_vault; let now = now(); let id = if item.id.is_empty() { Uuid::new_v4() } else { parse_id(&item.id)? }; let existing = self.store.get(vault, id)?; let previous = match &existing { Some(li) => decrypt(&acct, &u.vault_key, &li.record)?, None => None, }; let mut model = match &previous { // Keep fields the DTO does not carry (passkeys, android apps, unknown future fields). Some(p) => p.clone(), None => Item::new_login(item.title.clone(), now), }; model.id = id; model.kind = kind_from_dto(item.kind); model.title = item.title; model.username = item.username; model.password = item.password; model.urls = item.urls; model.totp = item.totp; model.notes = item.notes; model.favorite = item.favorite; model.tags = item.tags; model.modified_at = now.max(previous.as_ref().map_or(0, |p| p.modified_at + 1)); self.write_item(&acct, &u, &mut model, existing.filter(|_| previous.is_some()), false)?; Ok(id.to_string()) } pub fn delete_item(&self, id: &str) -> Result<()> { let id = parse_id(id)?; let (acct, u) = self.unlocked_snapshot()?; let vault = acct.primary_vault; let li = self.store.get(vault, id)?.filter(|li| !li.record.deleted).ok_or(CoreError::NotFound)?; if li.base.is_none() { // Never reached the server: just forget it. return Ok(self.store.remove(vault, id)?); } let seq = kv_sync::engine::next_local_version(self.store.as_ref(), acct.account_id, vault, &u.vault_key, id)?; let tomb = kv_sync::engine::seal_tombstone(acct.account_id, vault, &u.vault_key, id, seq, li.record.updated_by)?; Ok(self.store.put_local(vault, &tomb)?) } /// Earlier versions of an item (local edits and merge losers), newest first. At most 10. pub fn item_history(&self, id: &str) -> Result> { let id = parse_id(id)?; let (acct, u) = self.unlocked_snapshot()?; let mut out = Vec::new(); for h in self.store.history(acct.primary_vault, id)? { let rec = ItemRecord { item_id: id, revision: h.revision, deleted: false, wrapped_item_key: Some(h.wrapped_item_key), payload: Some(h.payload), updated_by: Uuid::nil(), updated_at: String::new(), }; if let Some(item) = decrypt(&acct, &u.vault_key, &rec)? { out.push(to_dto(&item)); } } Ok(out) } /// Items whose URLs match a page URL, best match first. pub fn items_for_url(&self, page_url: &str) -> Result> { let (acct, u) = self.unlocked_snapshot()?; let mut scored = Vec::new(); for item in self.all_items(&acct, &u)? { let best = item.urls.iter().map(|url| matching::match_url(url, page_url)).max(); if let Some(level) = best.filter(|l| *l > matching::MatchLevel::None) { scored.push((level, summary(&item))); } } scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.title.cmp(&b.1.title))); Ok(scored.into_iter().map(|(_, s)| s).collect()) } // ------------------------------------------------------------ passkeys (list / delete only) pub fn list_passkeys(&self) -> Result> { let (acct, u) = self.unlocked_snapshot()?; let mut out = Vec::new(); for item in self.all_items(&acct, &u)? { out.extend(item.passkeys.iter().map(|pk| passkey_summary(&item, pk))); } Ok(out) } /// Removes one passkey from its item (the item stays; the old version goes to history). pub fn delete_passkey(&self, credential_id: &[u8]) -> Result<()> { let (acct, u) = self.unlocked_snapshot()?; for li in self.store.list(acct.primary_vault)? { let Some(mut item) = decrypt(&acct, &u.vault_key, &li.record)? else { continue }; if item.passkeys.iter().any(|pk| pk.credential_id == credential_id) { item.passkeys.retain(|pk| pk.credential_id != credential_id); return self.write_item(&acct, &u, &mut item, Some(li), true); } } Err(CoreError::NotFound) } // ------------------------------------------------------------ import / export pub fn import_preview(&self, data: &[u8], format: ImportFormat) -> Result { let (acct, u) = self.unlocked_snapshot()?; let mut parsed = parse_import(data, format)?; let existing = self.all_items(&acct, &u)?; let mut preview = ImportPreview { total: parsed.items.len() as u32, logins: parsed.count(ItemKind::Login), secure_notes: parsed.count(ItemKind::SecureNote), cards: parsed.count(ItemKind::Card), identities: parsed.count(ItemKind::Identity), duplicates: 0, attachments_skipped: parsed.attachments_skipped, warnings: std::mem::take(&mut parsed.warnings), }; let items = std::mem::take(&mut parsed.items); preview.duplicates = kv_import::plan(items, existing.iter().map(kv_import::dedup::key), kv_import::DuplicatePolicy::Skip) .skipped; Ok(preview) } pub fn import_commit(&self, data: &[u8], format: ImportFormat, options: ImportOptions) -> Result { let (acct, u) = self.unlocked_snapshot()?; let mut parsed = parse_import(data, format)?; let existing = self.all_items(&acct, &u)?; let policy = match options.duplicates { DuplicateHandling::Skip => kv_import::DuplicatePolicy::Skip, DuplicateHandling::AddSuffix => kv_import::DuplicatePolicy::AddSuffix, }; let plan = kv_import::plan(std::mem::take(&mut parsed.items), existing.iter().map(kv_import::dedup::key), policy); let mut item_ids = Vec::with_capacity(plan.to_add.len()); for mut model in plan.to_add { // Every imported item is new: fresh id, seq 1, every model field kept. model.id = Uuid::new_v4(); model.modified_at = model.modified_at.max(model.created_at); self.write_item(&acct, &u, &mut model, None, false)?; item_ids.push(model.id.to_string()); } Ok(ImportReport { imported: item_ids.len() as u32, skipped_duplicates: plan.skipped, renamed_duplicates: plan.renamed, attachments_skipped: parsed.attachments_skipped, warnings: std::mem::take(&mut parsed.warnings), item_ids, }) } /// All items as a passphrase-encrypted `.kvex` file (restore with `KestroveEncrypted`). pub fn export_encrypted(&self, passphrase: &str) -> Result> { let passphrase = Zeroizing::new(passphrase.to_string()); let (acct, u) = self.unlocked_snapshot()?; let items = self.all_items(&acct, &u)?; Ok(kv_import::export::encrypt(&items, &passphrase, now())?) } // ------------------------------------------------------------ sync server /// Sets the server URL (https; http only for localhost). Pins are stored for parity with the /// apps but not enforced in the browser. pub fn configure_server(&self, url: &str, spki_pins: Vec) -> Result<()> { kv_sync::transport::normalize_base_url(url)?; for p in &spki_pins { kv_sync::transport::parse_pin(p)?; } let rec = ServerRecord { url: url.to_string(), pins: spki_pins }; self.store.put_meta(META_SERVER, &serde_json::to_vec(&rec).map_err(internal)?)?; let mut inner = self.lock_inner(); inner.server = Some(rec); inner.api = None; Ok(()) } pub fn set_device_signer(&self, signer: Arc, name: &str, platform: Platform) { let mut inner = self.lock_inner(); inner.device = Some(Device { signer, name: name.to_string(), platform }); inner.api = None; } pub fn device_id(&self) -> Option { self.lock_inner().device.as_ref().map(|d| d.signer.device_id()) } /// After `DeviceRevoked` (docs/03 "Removed devices"): forgets the session and the kept OPAQUE input, so that /// the new identity the caller installs next (`set_device_signer`) is enrolled only by an explicit login. /// Local vault data stays. The browser binding calls this and replaces the WebCrypto key; it then retries a /// ticket login once. pub fn forget_revoked_device(&self) -> Result<()> { self.store.delete_meta(META_SESSION)?; let mut inner = self.lock_inner(); inner.api = None; if let Some(u) = inner.unlocked.as_mut() { u.auth_input = None; } Ok(()) } pub fn device_key_fingerprint(&self) -> Result { let inner = self.lock_inner(); let d = inner.device.as_ref().ok_or(CoreError::NotConfigured)?; Ok(device_key_fingerprint(&d.signer.public_key())) } pub async fn server_info(&self) -> Result { let info = self.api()?.server_info().await?; Ok(ServerInfoDto { version: info.version, api: info.api, setup_open: info.setup_open, min_client: info.min_client, registration: info.registration.map(registration_name), }) } pub async fn account_details(&self) -> Result { let info = self.api()?.account().await?; Ok(AccountDetailsDto { email: info.email, is_admin: info.is_admin }) } pub async fn set_account_email(&self, email: &str) -> Result<()> { Ok(self.api()?.set_email(email).await?) } // ------------------------------------------------------------ admin (docs/03 "Accounts") pub async fn admin_users(&self) -> Result> { Ok(self .api()? .admin_users() .await? .into_iter() .map(|u| AdminUserDto { account_id: u.account_id.to_string(), email: u.email, is_admin: u.is_admin, disabled: u.disabled, created_at: u.created_at, last_seen: u.last_seen, devices: u.devices, items: u.items, storage_bytes: u.storage_bytes, }) .collect()) } pub async fn admin_update_user( &self, account_id: &str, disabled: Option, is_admin: Option, ) -> Result<()> { let update = kv_proto::AdminUserUpdate { disabled, is_admin }; Ok(self.api()?.admin_update_user(parse_id(account_id)?, &update).await?) } pub async fn admin_delete_user(&self, account_id: &str) -> Result<()> { Ok(self.api()?.admin_delete_user(parse_id(account_id)?).await?) } pub async fn admin_invites(&self) -> Result> { Ok(self .api()? .admin_invites() .await? .into_iter() .map(|i| InviteDto { invite_id: i.invite_id.to_string(), email: i.email, created_at: i.created_at, expires_at: i.expires_at, used: i.used, revoked: i.revoked, }) .collect()) } pub async fn admin_create_invite(&self, email: Option, days: Option) -> Result { let c = self.api()?.admin_create_invite(&kv_proto::InviteCreate { email, days }).await?; Ok(InviteCreatedDto { invite_id: c.invite_id.to_string(), code: c.code, expires_at: c.expires_at }) } pub async fn admin_revoke_invite(&self, invite_id: &str) -> Result<()> { Ok(self.api()?.admin_revoke_invite(parse_id(invite_id)?).await?) } pub async fn admin_settings(&self) -> Result { let s = self.api()?.admin_settings().await?; Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains }) } pub async fn admin_put_settings( &self, registration: &str, allowed_domains: Vec, ) -> Result { let registration = match registration { "open" => kv_proto::Registration::Open, "invite" => kv_proto::Registration::Invite, other => return Err(CoreError::Invalid { msg: format!("unknown sign-up mode {other}") }), }; let s = self.api()?.admin_put_settings(&kv_proto::AdminSettings { registration, allowed_domains }).await?; Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains }) } /// Registers the account: `setup_token` is the server's setup code (first account), an invite code, or empty /// with open sign-up; `email` is required once the server has an account. pub async fn register_account(&self, setup_token: &str, email: Option) -> Result<()> { let api = self.api()?; let (acct, u) = self.unlocked_snapshot()?; let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?; let device = self.enroll()?; api.register( setup_token, email.as_deref(), acct.account_id, &acct.kdf, auth.as_ref(), acct.enc_pk.clone(), acct.sig_pk.clone(), acct.wrapped_keyset.clone(), device, ) .await?; self.do_login(&api, acct.account_id, auth.as_ref(), None, None).await.map(|_| ()) } /// 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, email: Option, ) -> Result { let sk = SecretKey::parse(secret_key).inspect_err(|_| self.forget_secret_key())?; self.login_inner(master_password, sk, totp, None, None, email).await } /// `login`, optionally presenting an enrollment ticket and checking the server holds `expect_account`. async fn login_inner( &self, master_password: &str, sk: SecretKey, totp: Option, ticket: Option, expect_account: Option, email: Option, ) -> Result { let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account, email).await; r.inspect_err(|_| self.forget_secret_key()) } async fn login_unchecked( &self, master_password: &str, sk: SecretKey, totp: Option, ticket: Option, expect_account: Option, email: Option, ) -> Result { let api = self.api()?; let local = self.lock_inner().account.clone(); let (account_id, kdf_params) = match &local { Some(a) => (a.account_id, a.kdf.clone()), None => { let by = match (expect_account, email.as_deref().map(str::trim).filter(|e| !e.is_empty())) { (Some(id), _) => kv_sync::PreloginBy::Account(id), (None, Some(e)) => kv_sync::PreloginBy::Email(e), (None, None) => kv_sync::PreloginBy::OnlyAccount, }; let pre = api.prelogin(by).await?; (pre.account_id, pre.kdf) } }; if expect_account.is_some_and(|want| want != account_id) { return Err(CoreError::Invalid { msg: "the server holds a different account than the setup code".into() }); } // A downgraded KDF from the server is refused before any work is done. kdf::validate(&kdf_params)?; let derived = kdf::derive_keys(master_password, &sk, account_id, &kdf_params)?; if self.do_login(&api, account_id, derived.auth_input.as_ref(), totp, ticket).await? == LoginResult::PendingApproval { return Ok(LoginResult::PendingApproval); } let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk); let acct = match local { Some(a) => a, None => { let info = api.account().await?; if info.account_id != account_id { return Err(internal("server returned a different account")); } kdf::validate(&info.kdf)?; let keyset = AccountKeyset::unwrap(account_id, &auk, &info.wrapped_keyset)?; if keyset.sig_public().as_slice() != info.sig_pk || keyset.enc.public_bytes() != info.enc_pk { return Err(internal("server account keys do not match the keyset")); } AccountRecord { account_id, kdf: info.kdf, wrapped_keyset: info.wrapped_keyset, enc_pk: info.enc_pk, sig_pk: info.sig_pk, primary_vault: Uuid::nil(), // chosen after the first sync } } }; let keyset = AccountKeyset::unwrap(account_id, &auk, &acct.wrapped_keyset)?; let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?; self.save_account(&acct)?; let mut inner = self.lock_inner(); inner.account = Some(acct); inner.unlocked = Some(Unlocked { keyset: Arc::new(keyset), vault_key: Arc::new(vault_key), auth_input: Some(derived.auth_input), secret_key: Some(Zeroizing::new(*sk.expose())), }); Ok(LoginResult::Ok) } /// Pull, merge, push and verify every vault, renewing an expired session if possible. pub async fn sync_now(&self) -> Result { if self.syncing.swap(true, Ordering::AcqRel) { return Err(CoreError::Busy); } let _guard = SyncGuard(&self.syncing); let api = self.api()?; let (_, u) = self.unlocked_snapshot()?; let device_id = api.signer().device_id(); let store: &dyn Store = self.store.as_ref(); let run = || async { SyncContext { api: &api, store, keyset: &u.keyset, device_id }.sync_all().await }; let report = match run().await { Err(SyncError::NotAuthenticated) => { let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?; if self.do_login(&api, u.keyset.account_id, auth.as_ref(), None, None).await? == LoginResult::PendingApproval { return Err(CoreError::PendingApproval); } run().await? } other => other?, }; self.after_sync()?; Ok(SyncReport { pulled: report.pulled, pushed: report.pushed, merged: report.merged, restored_item_ids: report.restored.iter().map(Uuid::to_string).collect(), vaults_synced: report.vaults_synced, vaults_skipped: report.vaults_skipped.len() as u32, }) } pub async fn list_devices(&self) -> Result> { let devices = self.api()?.devices().await?; Ok(devices .into_iter() .map(|d| DeviceDto { device_id: d.device_id.to_string(), name: d.name, platform: platform_to_dto(d.platform), approved: d.approved, revoked: d.revoked_at.is_some(), current: d.current, created_at: d.created_at, key_fingerprint: device_key_fingerprint(&d.p256_pk), }) .collect()) } pub async fn approve_device(&self, device_id: &str) -> Result<()> { Ok(self.api()?.approve_device(parse_id(device_id)?).await?) } pub async fn revoke_device(&self, device_id: &str) -> Result<()> { Ok(self.api()?.revoke_device(parse_id(device_id)?).await?) } /// 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)?; match r { Ok(()) | Err(SyncError::NotAuthenticated) => Ok(()), Err(e) => Err(e.into()), } } pub fn is_logged_in(&self) -> bool { self.store.get_meta(META_SESSION).ok().flatten().is_some() } // ------------------------------------------------------------ onboarding (docs/10) /// The Setup Code for the Emergency Kit: server, pins, account id and Secret Key, no ticket. /// Needs an unlocked vault and a configured server. pub fn setup_payload_for_kit(&self) -> Result { Ok(self.setup_payload(None)?.encode()?.to_string()) } /// The "Add a device" QR: issues a 10-minute, single-use, unbound enrollment ticket and returns /// the payload. Show it as a secret view; on Cancel call `revoke_enrollment_ticket`. pub async fn add_device_payload(&self) -> Result { self.unlocked_snapshot()?; let created = self.api()?.create_ticket(None).await?; let payload = self.setup_payload(Some(created.ticket))?.encode()?.to_string(); Ok(AddDevicePayload { payload, ticket_id: created.ticket_id.to_string(), expires_at: created.expires_at }) } pub async fn revoke_enrollment_ticket(&self, ticket_id: &str) -> Result<()> { Ok(self.api()?.revoke_ticket(parse_id(ticket_id)?).await?) } /// New browser: logs in with the master password and the Secret Key from `setup`, presenting its /// ticket (if any) so no manual approval is needed. Then call `sync_now`. /// /// 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 { 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 { let p = from_info(setup)?; let (account, server) = { let inner = self.lock_inner(); (inner.account.as_ref().map(|a| a.account_id), inner.server.clone()) }; if account.is_some_and(|a| a != p.account_id) { return Err(invalid("this browser holds a different account")); } if let Some(server) = server { if kv_sync::transport::normalize_base_url(&server.url)? != p.server_url { return Err(invalid(&format!("this setup code is for {}; open the web vault there", p.server_url))); } } self.configure_server(&p.server_url, p.spki_pins.clone())?; let sk = SecretKey::parse(&p.secret_key)?; self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id), None).await } /// Joining browser: opens a rendezvous on `server_url` and returns the QR to show. Then call /// `join_poll` every 2 seconds. pub async fn join_start(&self, server_url: &str) -> Result { if self.has_account() { return Err(invalid("this browser already has an account")); } // Only ciphertext and public data cross this connection; the setup arrives sealed. self.configure_server(server_url, vec![])?; let api = self.api()?; let created = api.join_create(self.enroll()?).await?; let keypair = PairingKeypair::generate(); let qr_secret = Zeroizing::new(random_bytes::<32>()); let qr = JoinQr { server_url: api.base_url().to_string(), rendezvous_id: created.rendezvous_id.clone(), public_key: keypair.public_bytes(), qr_secret: qr_secret.clone(), } .encode()?; *self.lock_join() = Some(JoinState::Joining { rendezvous_id: created.rendezvous_id, read_token: created.read_token, keypair, qr_secret, sender_pub: None, }); Ok(JoinSession { qr, expires_at: created.expires_at }) } /// Joining browser: polls the rendezvous. `NotFound` means it expired (start again). pub async fn join_poll(&self) -> Result { let (rid, token) = match &*self.lock_join() { Some(JoinState::Joining { rendezvous_id, read_token, .. }) => (rendezvous_id.clone(), read_token.clone()), _ => return Err(invalid("no join in progress")), }; let poll = match self.api()?.join_poll(&rid, &token).await { Err(SyncError::NotFound) => { self.join_cancel(); return Err(CoreError::NotFound); } other => other?, }; let mut guard = self.lock_join(); let Some(JoinState::Joining { rendezvous_id, keypair, qr_secret, sender_pub, .. }) = guard.as_mut() else { return Err(invalid("join was cancelled")); }; if *rendezvous_id != rid { // Cancelled and restarted while this poll was in flight. return Err(invalid("join was restarted")); } let rid_bytes = URL_SAFE_NO_PAD.decode(&rid).map_err(internal)?; if let Some(p) = &poll.sender_pub { // The code was shown for this key; a different one now means someone swapped it. if sender_pub.as_ref().is_some_and(|known| known != p) { *guard = None; return Err(invalid("the approving device changed; start again")); } *sender_pub = Some(p.clone()); } let code = sender_pub.as_ref().map(|p| pairing::sas(&rid_bytes, &keypair.public_bytes(), p)); if poll.state != WireJoinState::Sealed { return Ok(JoinProgress { code, setup: None }); } let (Some(bundle), Some(p)) = (&poll.bundle, sender_pub.as_ref()) else { *guard = None; return Err(internal("sealed rendezvous without bundle or sender key")); }; let opened = pairing::open(keypair, p, qr_secret.as_ref(), &rid_bytes, bundle); *guard = None; let plaintext = Zeroizing::new(opened.map_err(|_| invalid("the setup bundle could not be opened; start again"))?); let text = std::str::from_utf8(&plaintext).map_err(|_| invalid("malformed setup bundle"))?; Ok(JoinProgress { code, setup: Some(to_info(SetupPayload::parse(text)?)) }) } /// Forgets the local join state on either side. pub fn join_cancel(&self) { *self.lock_join() = None; } /// Approving browser (signed in, unlocked): answers a scanned or pasted join QR and returns the /// code to compare. Refuses a QR for a different server. Nothing secret is sent until `join_send`. pub async fn join_approve(&self, qr: &str) -> Result { let qr = JoinQr::parse(qr)?; let api = self.api()?; if kv_sync::transport::normalize_base_url(&qr.server_url)? != api.base_url() { return Err(invalid("this computer is connecting to a different server")); } self.unlocked_snapshot()?; let keypair = PairingKeypair::generate(); let hello = api.join_hello(&qr.rendezvous_id, keypair.public_bytes().to_vec()).await?; let rid_bytes = URL_SAFE_NO_PAD.decode(&qr.rendezvous_id).map_err(internal)?; let confirm = JoinConfirm { code: pairing::sas(&rid_bytes, &qr.public_key, &keypair.public_bytes()), device_name: hello.device.name.clone(), platform: platform_to_dto(hello.device.platform), key_fingerprint: device_key_fingerprint(&hello.device.p256_pk), }; *self.lock_join() = Some(JoinState::Approving { qr, keypair, device: hello.device }); Ok(confirm) } /// Approving browser, after the user confirmed the code: issues a ticket bound to the joining /// device's key and sends the sealed setup bundle. pub async fn join_send(&self) -> Result<()> { let Some(JoinState::Approving { qr, keypair, device }) = self.lock_join().take() else { return Err(invalid("no join to approve")); }; let api = self.api()?; // Fail before issuing a ticket if the vault was locked meanwhile. self.setup_payload(None)?; let ticket = api.create_ticket(Some(Sha256::digest(&device.p256_pk).to_vec())).await?; let payload = self.setup_payload(Some(ticket.ticket))?.encode()?; let rid_bytes = URL_SAFE_NO_PAD.decode(&qr.rendezvous_id).map_err(internal)?; let bundle = pairing::seal(&keypair, &qr.public_key, qr.qr_secret.as_ref(), &rid_bytes, payload.as_bytes())?; Ok(api.join_send(&qr.rendezvous_id, bundle).await?) } // ------------------------------------------------------------ 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> { self.join.lock().unwrap_or_else(|p| p.into_inner()) } /// This browser's account as a setup payload (unlocked, server configured). fn setup_payload(&self, ticket: Option) -> Result { let (acct, u) = self.unlocked_snapshot()?; let server = self.lock_inner().server.clone().ok_or(CoreError::NotConfigured)?; Ok(SetupPayload { 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.as_ref().ok_or(CoreError::Locked)?).to_display(), ticket, }) } fn unlocked_snapshot(&self) -> Result<(AccountRecord, Unlocked)> { let inner = self.lock_inner(); let acct = inner.account.clone().ok_or(CoreError::NoAccount)?; let u = inner.unlocked.clone().ok_or(CoreError::Locked)?; Ok((acct, u)) } fn save_account(&self, acct: &AccountRecord) -> Result<()> { Ok(self.store.put_meta(META_ACCOUNT, &serde_json::to_vec(acct).map_err(internal)?)?) } fn create_vault(&self, keyset: &AccountKeyset, vault_id: Uuid) -> Result { let (vault_key, sealed) = keyset.new_vault_key(vault_id)?; let meta = vault_key.seal( br#"{"name":"Personal"}"#, &Aad::build(ObjectType::VaultMeta, keyset.account_id, Some(vault_id), None), )?; self.store.put_vault(&VaultRow::new(vault_id, sealed, Some(meta)))?; Ok(vault_key) } fn open_vault_key(&self, keyset: &AccountKeyset, vault_id: Uuid) -> Result { if vault_id.is_nil() { // Enrolled but the first sync has not brought the vault yet. return Ok(SymKey::from_bytes(Uuid::nil(), [0; 32])); } let row = self.store.vault(vault_id)?.ok_or_else(|| internal("primary vault missing"))?; Ok(keyset.open_vault_key(vault_id, &row.sealed_key)?) } fn all_items(&self, acct: &AccountRecord, u: &Unlocked) -> Result> { let mut out = Vec::new(); for li in self.store.list(acct.primary_vault)? { if let Some(item) = decrypt(acct, &u.vault_key, &li.record)? { out.push(item); } } Ok(out) } /// Seals and stores an item like kv-ffi does: next `seq`, previous version to history, queued /// for push. `bump` advances `modified_at` for edits that did not set it themselves. fn write_item( &self, acct: &AccountRecord, u: &Unlocked, model: &mut Item, existing: Option, bump: bool, ) -> Result<()> { let vault = acct.primary_vault; let id = model.id; let now = now(); if bump && existing.is_some() { model.modified_at = now.max(model.modified_at + 1); } model.seq = kv_sync::engine::next_local_version(self.store.as_ref(), acct.account_id, vault, &u.vault_key, id)?; let json = Zeroizing::new(serde_json::to_vec(model).map_err(internal)?); let sealed = seal_item(acct.account_id, vault, id, &u.vault_key, &json)?; if let Some(li) = existing.filter(|li| !li.record.deleted) { if let (Some(k), Some(p)) = (li.record.wrapped_item_key, li.record.payload) { self.store.push_history( vault, id, &HistoryEntry { revision: li.record.revision, wrapped_item_key: k, payload: p, saved_at: now }, )?; } } let device = self.device_id().unwrap_or(Uuid::nil()); let record = ItemRecord { item_id: id, revision: 0, deleted: false, wrapped_item_key: Some(sealed.wrapped_item_key), payload: Some(sealed.payload), updated_by: device, updated_at: String::new(), }; Ok(self.store.put_local(vault, &record)?) } fn enroll(&self) -> Result { let inner = self.lock_inner(); let d = inner.device.as_ref().ok_or(CoreError::NotConfigured)?; Ok(DeviceEnroll { device_id: d.signer.device_id(), name: d.name.chars().take(64).collect(), platform: d.platform, p256_pk: d.signer.public_key(), }) } fn api(&self) -> Result> { let mut inner = self.lock_inner(); if let Some(api) = &inner.api { return Ok(api.clone()); } let server = inner.server.clone().ok_or(CoreError::NotConfigured)?; let signer = inner.device.as_ref().ok_or(CoreError::NotConfigured)?.signer.clone(); let api = Arc::new(ApiClient::new(&ServerConfig::new(server.url, server.pins), signer)?); if let Some(s) = self.store.get_meta(META_SESSION)? { api.set_session(Some(String::from_utf8_lossy(&s).into_owned())); } inner.api = Some(api.clone()); Ok(api) } async fn do_login( &self, api: &ApiClient, account_id: Uuid, auth_input: &[u8], totp: Option, ticket: Option, ) -> Result { let device = self.enroll()?; match api.login_with_ticket(account_id, auth_input, device, totp, ticket).await? { LoginOutcome::Ok { session } => { self.store.put_meta(META_SESSION, session.as_bytes())?; Ok(LoginResult::Ok) } LoginOutcome::PendingApproval => Ok(LoginResult::PendingApproval), } } /// Persists a renewed session and picks the primary vault on a newly enrolled browser. fn after_sync(&self) -> Result<()> { let api_session = self.lock_inner().api.as_ref().and_then(|a| a.session()); if let Some(s) = api_session { self.store.put_meta(META_SESSION, s.as_bytes())?; } let mut inner = self.lock_inner(); let Some(mut acct) = inner.account.clone() else { return Ok(()) }; if !acct.primary_vault.is_nil() { return Ok(()); } let keyset = inner.unlocked.as_ref().ok_or(CoreError::Locked)?.keyset.clone(); let vault_id = match self.store.vaults()?.first() { Some(v) => v.vault_id, None => { let id = Uuid::new_v4(); self.create_vault(&keyset, id)?; id } }; acct.primary_vault = vault_id; let vault_key = self.open_vault_key(&keyset, vault_id)?; self.save_account(&acct)?; inner.account = Some(acct); if let Some(u) = inner.unlocked.as_mut() { u.vault_key = Arc::new(vault_key); } Ok(()) } } // ---------------------------------------------------------------- free functions /// Validates a scanned or pasted Setup Code (`kestrove://setup/v1?…`, docs/10 §2). pub fn parse_setup_payload(payload: &str) -> Result { Ok(to_info(SetupPayload::parse(payload)?)) } fn to_info(p: SetupPayload) -> SetupInfo { SetupInfo { server_url: p.server_url, spki_pins: p.spki_pins, account_id: p.account_id.to_string(), secret_key: p.secret_key.to_string(), ticket: p.ticket, } } fn from_info(i: &SetupInfo) -> Result { Ok(SetupPayload { server_url: kv_sync::transport::normalize_base_url(&i.server_url)?, spki_pins: i.spki_pins.clone(), account_id: parse_id(&i.account_id)?, secret_key: Zeroizing::new(i.secret_key.clone()), ticket: i.ticket.clone(), }) } fn invalid(msg: &str) -> CoreError { CoreError::Invalid { msg: msg.into() } } pub fn generate_password(length: u32, symbols: bool) -> String { generator::generate_password(&generator::PasswordOptions { length: length as usize, symbols, ..Default::default() }) } pub fn totp_code(secret_or_uri: &str, unix_time: u64) -> Result { let t = Totp::parse(secret_or_uri).map_err(|e| CoreError::Invalid { msg: e.to_string() })?; Ok(TotpCode { code: t.code_at(unix_time), seconds_remaining: t.remaining(unix_time), period: t.period }) } pub fn match_url(item_url: &str, page_url: &str) -> MatchLevelDto { match matching::match_url(item_url, page_url) { matching::MatchLevel::None => MatchLevelDto::None, matching::MatchLevel::Domain => MatchLevelDto::Domain, matching::MatchLevel::Host => MatchLevelDto::Host, matching::MatchLevel::Exact => MatchLevelDto::Exact, } } /// `None` for tombstones. pub fn decrypt(acct: &AccountRecord, vault_key: &SymKey, record: &ItemRecord) -> Result> { let (Some(k), Some(p)) = (&record.wrapped_item_key, &record.payload) else { return Ok(None) }; if record.deleted { return Ok(None); } let sealed = SealedItem { wrapped_item_key: k.clone(), payload: p.clone() }; let pt = open_item(acct.account_id, acct.primary_vault, record.item_id, vault_key, &sealed)?; serde_json::from_slice(&pt).map(Some).map_err(internal) } fn parse_import(data: &[u8], format: ImportFormat) -> Result { let format = match format { ImportFormat::OnePux => kv_import::Format::OnePux, ImportFormat::Csv => kv_import::Format::Csv, // `Format` wipes the passphrase on drop. ImportFormat::KestroveEncrypted { passphrase } => kv_import::Format::Encrypted { passphrase }, }; Ok(kv_import::parse(data, &format, now())?) } fn summary(i: &Item) -> ItemSummary { ItemSummary { id: i.id.to_string(), kind: kind_to_dto(i.kind), title: i.title.clone(), username: i.username.clone(), urls: i.urls.clone(), favorite: i.favorite, has_totp: i.totp.is_some(), tags: i.tags.clone(), modified_at: i.modified_at, } } fn passkey_summary(item: &Item, pk: &Passkey) -> PasskeySummary { PasskeySummary { item_id: item.id.to_string(), credential_id: pk.credential_id.clone(), rp_id: pk.rp_id.clone(), user_handle: pk.user_handle.clone(), user_name: pk.user_name.clone(), created_at: pk.created_at, } } pub fn to_dto(i: &Item) -> ItemDto { ItemDto { id: i.id.to_string(), kind: kind_to_dto(i.kind), title: i.title.clone(), username: i.username.clone(), password: i.password.clone(), urls: i.urls.clone(), totp: i.totp.clone(), notes: i.notes.clone(), favorite: i.favorite, tags: i.tags.clone(), modified_at: i.modified_at, } } fn kind_to_dto(k: ItemKind) -> ItemKindDto { match k { ItemKind::Login => ItemKindDto::Login, ItemKind::SecureNote => ItemKindDto::SecureNote, ItemKind::Card => ItemKindDto::Card, ItemKind::Identity => ItemKindDto::Identity, } } fn kind_from_dto(k: ItemKindDto) -> ItemKind { match k { ItemKindDto::Login => ItemKind::Login, ItemKindDto::SecureNote => ItemKind::SecureNote, ItemKindDto::Card => ItemKind::Card, ItemKindDto::Identity => ItemKind::Identity, } } fn platform_to_dto(p: Platform) -> PlatformDto { match p { Platform::Ios => PlatformDto::Ios, Platform::Macos => PlatformDto::Macos, Platform::Android => PlatformDto::Android, Platform::Windows => PlatformDto::Windows, Platform::Linux => PlatformDto::Linux, } } /// Hex of the first 16 bytes of SHA-256(P-256 public key), as kv-ffi shows it. pub fn device_key_fingerprint(p256_pk: &[u8]) -> String { Sha256::digest(p256_pk)[..16].iter().map(|b| format!("{b:02x}")).collect() } fn parse_id(s: &str) -> Result { Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "id must be a UUID".into() }) } fn registration_name(r: kv_proto::Registration) -> String { match r { kv_proto::Registration::Invite => "invite".into(), kv_proto::Registration::Open => "open".into(), } }