Files
kestrove-core/crates/kv-wasm/src/core.rs
T
samiandClaude Opus 5.5 b5ced32be3 Add kv-wasm: the core compiled to WebAssembly for the web vault
New crate crates/kv-wasm runs the same crypto, item model and sync engine in the browser:
- WebStore: in-memory replica of kv-store's Store, each changed entry written to IndexedDB
  (ciphertext only) before the JS call resolves.
- Device key: non-extractable WebCrypto ECDSA P-256 key in IndexedDB (raw r||s signatures).
- WebCore mirrors kv-ffi's VaultCore; wasm-bindgen surface in camelCase with TypeScript types,
  plus a Web Worker wrapper (connectVault) so Argon2id never blocks the page.
- Tests: Chrome (IndexedDB, WebCrypto, RFC 9421 verify), shared envelope fixtures both ways
  with kv-ffi, native sync with kv-ffi via the test server, Node smoke of the built package.

Small changes to shared crates so they build for wasm32:
- kv-store: SqliteStore moved to a native-only module.
- kv-sync: rustls/pinning/tokio native only (fetch on wasm); DeviceSigner::sign_async (default
  wraps sign) used by ApiClient; unix_now() instead of SystemTime/now_utc.
- kv-model: Public Suffix List behind a default `psl` feature (kv-ffi keeps it; the web build
  drops ~0.9 MB of code and matches same-host only).
- wasm-release profile (opt-level z, Argon2 at 3) and a wasm CI job.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 23:58:07 +09:00

866 lines
33 KiB
Rust

//! 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<u8>,
#[serde(with = "b64")]
pub sig_pk: Vec<u8>,
pub primary_vault: Uuid,
}
#[derive(Clone, Serialize, Deserialize)]
struct ServerRecord {
url: String,
pins: Vec<String>,
}
/// Decrypted key material. Lives only in wasm memory; zeroized on drop (`lock`).
#[derive(Clone)]
struct Unlocked {
keyset: Arc<AccountKeyset>,
vault_key: Arc<SymKey>,
/// OPAQUE password, kept while unlocked so an expired session can be renewed without prompting.
auth_input: Option<Zeroizing<[u8; 32]>>,
}
struct Device {
signer: Arc<dyn DeviceSigner>,
name: String,
platform: Platform,
}
#[derive(Default)]
struct Inner {
account: Option<AccountRecord>,
unlocked: Option<Unlocked>,
server: Option<ServerRecord>,
device: Option<Device>,
api: Option<Arc<ApiClient>>,
}
pub struct WebCore {
store: Arc<WebStore>,
inner: Mutex<Inner>,
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<WebStore>) -> Result<Self> {
let account = store
.get_meta(META_ACCOUNT)?
.map(|b| serde_json::from_slice::<AccountRecord>(&b))
.transpose()
.map_err(internal)?;
let server = store.get_meta(META_SERVER)?.and_then(|b| serde_json::from_slice::<ServerRecord>(&b).ok());
Ok(Self {
store,
inner: Mutex::new(Inner { account, server, ..Default::default() }),
syncing: AtomicBool::new(false),
})
}
pub fn store(&self) -> &Arc<WebStore> {
&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<String> {
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, &params)?;
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<String> {
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<String> {
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<Vec<ItemSummary>> {
let (acct, u) = self.unlocked_snapshot()?;
let mut out: Vec<ItemSummary> = 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<ItemDto> {
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<String> {
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<Vec<ItemDto>> {
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<Vec<ItemSummary>> {
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<Vec<PasskeySummary>> {
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<ImportPreview> {
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<ImportReport> {
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<Vec<u8>> {
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<String>) -> 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<dyn DeviceSigner>, 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<Uuid> {
self.lock_inner().device.as_ref().map(|d| d.signer.device_id())
}
pub fn device_key_fingerprint(&self) -> Result<String> {
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<ServerInfoDto> {
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<String>) -> Result<LoginResult> {
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<SyncReport> {
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<Vec<DeviceDto>> {
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<SymKey> {
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<SymKey> {
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<Vec<Item>> {
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<LocalItem>,
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<DeviceEnroll> {
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<Arc<ApiClient>> {
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<String>,
) -> Result<LoginResult> {
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<TotpCode> {
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<Option<Item>> {
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<kv_import::Parsed> {
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> {
Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "id must be a UUID".into() })
}