//! 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. use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, }; use kv_crypto::{ aad::{Aad, ObjectType}, aead::SymKey, kdf, keys::{open_item, seal_item, AccountKeyset, SealedItem}, secret_key::SecretKey, }; use kv_model::{generator, matching, totp::Totp, Item, ItemKind, Passkey}; use kv_proto::{b64, DeviceEnroll, Envelope, ItemRecord, KdfParams, Platform}; use kv_store::{HistoryEntry, LocalItem, Store, VaultRow}; use kv_sync::{ApiClient, DeviceSigner, LoginOutcome, ServerConfig, SyncContext, SyncError}; use serde::{Deserialize, Serialize}; 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`), 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>, } struct Device { signer: Arc, name: String, platform: Platform, } #[derive(Default)] struct Inner { account: Option, unlocked: Option, server: Option, device: Option, api: Option>, } pub struct WebCore { store: Arc, inner: Mutex, syncing: AtomicBool, } 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), }) } 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), }); Ok(secret_key.to_display().to_string()) } pub fn unlock(&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), }); 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(); 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()) } 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, }) } /// First device only: registers the account with a fresh server using its one-time setup token. pub async fn register_account(&self, setup_token: &str) -> 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, 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).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) -> Result { let api = self.api()?; let sk = SecretKey::parse(secret_key)?; let local = self.lock_inner().account.clone(); let (account_id, kdf_params) = match &local { Some(a) => (a.account_id, a.kdf.clone()), None => { let pre = api.prelogin().await?; (pre.account_id, pre.kdf) } }; // 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).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), }); 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).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). pub async fn logout(&self) -> Result<()> { 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() } // ------------------------------------------------------------ internals 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, ) -> Result { let device = self.enroll()?; match api.login(account_id, auth_input, device, totp).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 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(), } } 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 { use sha2::{Digest, Sha256}; 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() }) }