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]>
446 lines
16 KiB
Rust
446 lines
16 KiB
Rust
//! The web vault's [`Store`]: an in-memory replica that records which keys changed, so the wasm
|
|
//! layer can write just those entries to IndexedDB after each call (and read them all back on
|
|
//! start-up). [`Store`] is synchronous and IndexedDB is not, hence the split.
|
|
//!
|
|
//! Like the SQLite store it holds only ciphertext envelopes, ids and sync bookkeeping. Entries are
|
|
//! JSON bytes keyed by:
|
|
//!
|
|
//! | key | value |
|
|
//! |-----------------------------|---------------------------------------------|
|
|
//! | `meta/<key>` | raw meta bytes (account record, server, session) |
|
|
//! | `vault/<vault_id>` | [`VaultEntry`] |
|
|
//! | `item/<vault_id>/<item_id>` | [`ItemEntry`] (working copy + base) |
|
|
//! | `history/<vault_id>/<item_id>` | newest-first list of [`HistoryEntryJson`] |
|
|
|
|
use std::{
|
|
collections::{BTreeMap, BTreeSet, HashMap},
|
|
sync::Mutex,
|
|
};
|
|
|
|
use kv_proto::{Envelope, ItemRecord};
|
|
use kv_store::{HistoryEntry, LocalItem, Result, Store, StoreError, SyncState, VaultRow, HISTORY_LIMIT};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
/// One write for the persistence layer.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Change {
|
|
/// Delete every entry first (sign-out / wipe). Always the first change when present.
|
|
Clear,
|
|
Put(String, Vec<u8>),
|
|
Delete(String),
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct VaultEntry {
|
|
/// Insertion order, so `vaults()` lists in the same order after a reload (like SQLite's rowid).
|
|
order: u64,
|
|
vault_id: Uuid,
|
|
sealed_key: Envelope,
|
|
meta: Option<Envelope>,
|
|
cursor: u64,
|
|
manifest_version: u64,
|
|
manifest_hash: Option<[u8; 32]>,
|
|
integrity_alarm: bool,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct ItemEntry {
|
|
record: ItemRecord,
|
|
pending: bool,
|
|
base: Option<ItemRecord>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct HistoryEntryJson {
|
|
revision: u64,
|
|
wrapped_item_key: Envelope,
|
|
payload: Envelope,
|
|
saved_at: i64,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Mem {
|
|
meta: HashMap<String, Vec<u8>>,
|
|
/// (order, row)
|
|
vaults: Vec<(u64, VaultRow)>,
|
|
items: HashMap<Uuid, BTreeMap<Uuid, LocalItem>>,
|
|
history: HashMap<(Uuid, Uuid), Vec<HistoryEntry>>,
|
|
next_order: u64,
|
|
dirty: BTreeSet<String>,
|
|
cleared: bool,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct WebStore {
|
|
inner: Mutex<Mem>,
|
|
}
|
|
|
|
fn meta_key(k: &str) -> String {
|
|
format!("meta/{k}")
|
|
}
|
|
fn vault_key(v: Uuid) -> String {
|
|
format!("vault/{v}")
|
|
}
|
|
fn item_key(v: Uuid, i: Uuid) -> String {
|
|
format!("item/{v}/{i}")
|
|
}
|
|
fn history_key(v: Uuid, i: Uuid) -> String {
|
|
format!("history/{v}/{i}")
|
|
}
|
|
|
|
fn corrupt(key: &str, e: impl std::fmt::Display) -> StoreError {
|
|
StoreError::Io(format!("corrupt entry {key}: {e}"))
|
|
}
|
|
|
|
fn json<T: Serialize>(v: &T) -> Vec<u8> {
|
|
// Only plain data structures are serialized here; this cannot fail.
|
|
serde_json::to_vec(v).unwrap_or_default()
|
|
}
|
|
|
|
fn two_ids(key: &str, rest: &str) -> Result<(Uuid, Uuid)> {
|
|
let (a, b) = rest.split_once('/').ok_or_else(|| corrupt(key, "key"))?;
|
|
Ok((Uuid::parse_str(a).map_err(|e| corrupt(key, e))?, Uuid::parse_str(b).map_err(|e| corrupt(key, e))?))
|
|
}
|
|
|
|
impl WebStore {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
fn mem(&self) -> std::sync::MutexGuard<'_, Mem> {
|
|
self.inner.lock().unwrap_or_else(|p| p.into_inner())
|
|
}
|
|
|
|
/// Rebuilds the replica from persisted entries (start-up). Nothing is marked dirty.
|
|
pub fn load(entries: impl IntoIterator<Item = (String, Vec<u8>)>) -> Result<Self> {
|
|
let mut m = Mem::default();
|
|
for (key, value) in entries {
|
|
if let Some(k) = key.strip_prefix("meta/") {
|
|
m.meta.insert(k.to_string(), value);
|
|
} else if key.starts_with("vault/") {
|
|
let e: VaultEntry = serde_json::from_slice(&value).map_err(|e| corrupt(&key, e))?;
|
|
m.next_order = m.next_order.max(e.order + 1);
|
|
m.vaults.push((
|
|
e.order,
|
|
VaultRow {
|
|
vault_id: e.vault_id,
|
|
sealed_key: e.sealed_key,
|
|
meta: e.meta,
|
|
cursor: e.cursor,
|
|
manifest_version: e.manifest_version,
|
|
manifest_hash: e.manifest_hash,
|
|
integrity_alarm: e.integrity_alarm,
|
|
},
|
|
));
|
|
} else if let Some(rest) = key.strip_prefix("item/") {
|
|
let (v, i) = two_ids(&key, rest)?;
|
|
let e: ItemEntry = serde_json::from_slice(&value).map_err(|e| corrupt(&key, e))?;
|
|
if e.record.item_id != i {
|
|
return Err(corrupt(&key, "item id"));
|
|
}
|
|
let state = if e.pending { SyncState::Pending } else { SyncState::Clean };
|
|
m.items.entry(v).or_default().insert(i, LocalItem { record: e.record, state, base: e.base });
|
|
} else if let Some(rest) = key.strip_prefix("history/") {
|
|
let ids = two_ids(&key, rest)?;
|
|
let h: Vec<HistoryEntryJson> = serde_json::from_slice(&value).map_err(|e| corrupt(&key, e))?;
|
|
let h = h
|
|
.into_iter()
|
|
.map(|h| HistoryEntry {
|
|
revision: h.revision,
|
|
wrapped_item_key: h.wrapped_item_key,
|
|
payload: h.payload,
|
|
saved_at: h.saved_at,
|
|
})
|
|
.collect();
|
|
m.history.insert(ids, h);
|
|
}
|
|
// Unknown prefixes come from a newer version; ignore them.
|
|
}
|
|
m.vaults.sort_by_key(|(o, _)| *o);
|
|
Ok(Self { inner: Mutex::new(m) })
|
|
}
|
|
|
|
/// Everything written since the last call, ready for IndexedDB.
|
|
pub fn take_changes(&self) -> Vec<Change> {
|
|
let mut m = self.mem();
|
|
let mut out = Vec::new();
|
|
if std::mem::take(&mut m.cleared) {
|
|
out.push(Change::Clear);
|
|
}
|
|
let dirty = std::mem::take(&mut m.dirty);
|
|
for key in dirty {
|
|
let value = Self::encode(&m, &key);
|
|
out.push(match value {
|
|
Some(v) => Change::Put(key, v),
|
|
None => Change::Delete(key),
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// True when [`WebStore::take_changes`] would return something.
|
|
pub fn has_changes(&self) -> bool {
|
|
let m = self.mem();
|
|
m.cleared || !m.dirty.is_empty()
|
|
}
|
|
|
|
/// Every entry, as [`WebStore::load`] takes them (tests, export of the raw replica).
|
|
pub fn entries(&self) -> Vec<(String, Vec<u8>)> {
|
|
let m = self.mem();
|
|
let mut keys: Vec<String> = m.meta.keys().map(|k| meta_key(k)).collect();
|
|
keys.extend(m.vaults.iter().map(|(_, v)| vault_key(v.vault_id)));
|
|
for (v, items) in &m.items {
|
|
keys.extend(items.keys().map(|i| item_key(*v, *i)));
|
|
}
|
|
keys.extend(m.history.keys().map(|(v, i)| history_key(*v, *i)));
|
|
keys.into_iter().filter_map(|k| Self::encode(&m, &k).map(|v| (k, v))).collect()
|
|
}
|
|
|
|
fn encode(m: &Mem, key: &str) -> Option<Vec<u8>> {
|
|
if let Some(k) = key.strip_prefix("meta/") {
|
|
return m.meta.get(k).cloned();
|
|
}
|
|
if let Some(id) = key.strip_prefix("vault/") {
|
|
let id = Uuid::parse_str(id).ok()?;
|
|
let (order, v) = m.vaults.iter().find(|(_, v)| v.vault_id == id)?;
|
|
return Some(json(&VaultEntry {
|
|
order: *order,
|
|
vault_id: v.vault_id,
|
|
sealed_key: v.sealed_key.clone(),
|
|
meta: v.meta.clone(),
|
|
cursor: v.cursor,
|
|
manifest_version: v.manifest_version,
|
|
manifest_hash: v.manifest_hash,
|
|
integrity_alarm: v.integrity_alarm,
|
|
}));
|
|
}
|
|
if let Some(rest) = key.strip_prefix("item/") {
|
|
let (v, i) = two_ids(key, rest).ok()?;
|
|
let li = m.items.get(&v)?.get(&i)?;
|
|
return Some(json(&ItemEntry {
|
|
record: li.record.clone(),
|
|
pending: li.state == SyncState::Pending,
|
|
base: li.base.clone(),
|
|
}));
|
|
}
|
|
if let Some(rest) = key.strip_prefix("history/") {
|
|
let ids = two_ids(key, rest).ok()?;
|
|
let h = m.history.get(&ids)?;
|
|
let h: Vec<HistoryEntryJson> = h
|
|
.iter()
|
|
.map(|h| HistoryEntryJson {
|
|
revision: h.revision,
|
|
wrapped_item_key: h.wrapped_item_key.clone(),
|
|
payload: h.payload.clone(),
|
|
saved_at: h.saved_at,
|
|
})
|
|
.collect();
|
|
return Some(json(&h));
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
impl Store for WebStore {
|
|
fn get_meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
|
|
Ok(self.mem().meta.get(key).cloned())
|
|
}
|
|
|
|
fn put_meta(&self, key: &str, value: &[u8]) -> Result<()> {
|
|
let mut m = self.mem();
|
|
m.meta.insert(key.into(), value.to_vec());
|
|
m.dirty.insert(meta_key(key));
|
|
Ok(())
|
|
}
|
|
|
|
fn delete_meta(&self, key: &str) -> Result<()> {
|
|
let mut m = self.mem();
|
|
m.meta.remove(key);
|
|
m.dirty.insert(meta_key(key));
|
|
Ok(())
|
|
}
|
|
|
|
fn put_vault(&self, vault: &VaultRow) -> Result<()> {
|
|
let mut m = self.mem();
|
|
match m.vaults.iter_mut().find(|(_, v)| v.vault_id == vault.vault_id) {
|
|
Some((_, v)) => *v = vault.clone(),
|
|
None => {
|
|
let order = m.next_order;
|
|
m.next_order += 1;
|
|
m.vaults.push((order, vault.clone()));
|
|
}
|
|
}
|
|
m.dirty.insert(vault_key(vault.vault_id));
|
|
Ok(())
|
|
}
|
|
|
|
fn vault(&self, vault: Uuid) -> Result<Option<VaultRow>> {
|
|
Ok(self.mem().vaults.iter().find(|(_, v)| v.vault_id == vault).map(|(_, v)| v.clone()))
|
|
}
|
|
|
|
fn vaults(&self) -> Result<Vec<VaultRow>> {
|
|
Ok(self.mem().vaults.iter().map(|(_, v)| v.clone()).collect())
|
|
}
|
|
|
|
fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()> {
|
|
let mut m = self.mem();
|
|
m.items.entry(vault).or_default().insert(item.record.item_id, item.clone());
|
|
m.dirty.insert(item_key(vault, item.record.item_id));
|
|
Ok(())
|
|
}
|
|
|
|
fn get(&self, vault: Uuid, item: Uuid) -> Result<Option<LocalItem>> {
|
|
Ok(self.mem().items.get(&vault).and_then(|v| v.get(&item)).cloned())
|
|
}
|
|
|
|
fn list(&self, vault: Uuid) -> Result<Vec<LocalItem>> {
|
|
Ok(self.mem().items.get(&vault).map(|v| v.values().cloned().collect()).unwrap_or_default())
|
|
}
|
|
|
|
fn remove(&self, vault: Uuid, item: Uuid) -> Result<()> {
|
|
let mut m = self.mem();
|
|
if let Some(v) = m.items.get_mut(&vault) {
|
|
v.remove(&item);
|
|
}
|
|
m.dirty.insert(item_key(vault, item));
|
|
Ok(())
|
|
}
|
|
|
|
fn push_history(&self, vault: Uuid, item: Uuid, entry: &HistoryEntry) -> Result<()> {
|
|
let mut m = self.mem();
|
|
let h = m.history.entry((vault, item)).or_default();
|
|
h.insert(0, entry.clone());
|
|
h.truncate(HISTORY_LIMIT);
|
|
m.dirty.insert(history_key(vault, item));
|
|
Ok(())
|
|
}
|
|
|
|
fn history(&self, vault: Uuid, item: Uuid) -> Result<Vec<HistoryEntry>> {
|
|
Ok(self.mem().history.get(&(vault, item)).cloned().unwrap_or_default())
|
|
}
|
|
|
|
fn wipe(&self) -> Result<()> {
|
|
let mut m = self.mem();
|
|
*m = Mem { cleared: true, ..Mem::default() };
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use kv_proto::{Alg, FORMAT_VERSION};
|
|
|
|
use super::*;
|
|
|
|
fn env(tag: u8) -> Envelope {
|
|
Envelope {
|
|
v: FORMAT_VERSION,
|
|
alg: Alg::XChaCha20Poly1305,
|
|
kid: Uuid::nil(),
|
|
n: vec![tag; 24],
|
|
ct: vec![tag; 20],
|
|
enc: vec![],
|
|
}
|
|
}
|
|
|
|
fn record(id: Uuid, rev: u64, tag: u8) -> ItemRecord {
|
|
ItemRecord {
|
|
item_id: id,
|
|
revision: rev,
|
|
deleted: false,
|
|
wrapped_item_key: Some(env(tag)),
|
|
payload: Some(env(tag + 1)),
|
|
updated_by: Uuid::nil(),
|
|
updated_at: "2026-01-01T00:00:00Z".into(),
|
|
}
|
|
}
|
|
|
|
/// Applies changes to a fake IndexedDB object store.
|
|
fn apply(db: &mut BTreeMap<String, Vec<u8>>, changes: Vec<Change>) {
|
|
for c in changes {
|
|
match c {
|
|
Change::Clear => db.clear(),
|
|
Change::Put(k, v) => {
|
|
db.insert(k, v);
|
|
}
|
|
Change::Delete(k) => {
|
|
db.remove(&k);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn changes_round_trip_through_a_reload() {
|
|
let s = WebStore::new();
|
|
let mut db = BTreeMap::new();
|
|
let (v1, v2) = (Uuid::from_u128(9), Uuid::from_u128(1)); // v2 sorts first but was added second
|
|
s.put_vault(&VaultRow::new(v1, env(1), Some(env(2)))).unwrap();
|
|
s.put_vault(&VaultRow::new(v2, env(3), None)).unwrap();
|
|
s.put_meta("account", b"{}").unwrap();
|
|
let id = Uuid::new_v4();
|
|
s.put_remote(v1, &record(id, 4, 10)).unwrap();
|
|
s.put_local(v1, &record(id, 0, 20)).unwrap();
|
|
s.push_history(v1, id, &HistoryEntry { revision: 4, wrapped_item_key: env(10), payload: env(11), saved_at: 5 })
|
|
.unwrap();
|
|
let gone = Uuid::new_v4();
|
|
s.put_local(v1, &record(gone, 0, 40)).unwrap();
|
|
s.remove(v1, gone).unwrap();
|
|
assert!(s.has_changes());
|
|
apply(&mut db, s.take_changes());
|
|
assert!(!s.has_changes() && s.take_changes().is_empty());
|
|
assert!(!db.contains_key(&item_key(v1, gone)));
|
|
|
|
let r = WebStore::load(db.clone()).unwrap();
|
|
assert_eq!(r.vaults().unwrap().iter().map(|v| v.vault_id).collect::<Vec<_>>(), vec![v1, v2]);
|
|
assert_eq!(r.vault(v1).unwrap(), s.vault(v1).unwrap());
|
|
let li = r.get(v1, id).unwrap().unwrap();
|
|
assert_eq!(li.state, SyncState::Pending);
|
|
assert_eq!(li.record.payload, Some(env(21)));
|
|
assert_eq!(li.base.unwrap().revision, 4);
|
|
assert_eq!(r.history(v1, id).unwrap(), s.history(v1, id).unwrap());
|
|
assert_eq!(r.get_meta("account").unwrap().as_deref(), Some(&b"{}"[..]));
|
|
let mut a = r.entries();
|
|
a.sort();
|
|
assert_eq!(a, db.clone().into_iter().collect::<Vec<_>>());
|
|
|
|
// A vault added after the reload goes last.
|
|
let v3 = Uuid::from_u128(0);
|
|
r.put_vault(&VaultRow::new(v3, env(5), None)).unwrap();
|
|
r.delete_meta("account").unwrap();
|
|
apply(&mut db, r.take_changes());
|
|
let r2 = WebStore::load(db.clone()).unwrap();
|
|
assert_eq!(r2.vaults().unwrap().last().unwrap().vault_id, v3);
|
|
assert!(r2.get_meta("account").unwrap().is_none());
|
|
|
|
r2.wipe().unwrap();
|
|
r2.put_meta("server", b"x").unwrap();
|
|
let ch = r2.take_changes();
|
|
assert_eq!(ch[0], Change::Clear);
|
|
apply(&mut db, ch);
|
|
assert_eq!(db.keys().collect::<Vec<_>>(), vec!["meta/server"]);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_corrupt_entries_and_ignores_unknown_ones() {
|
|
assert!(WebStore::load([("vault/x".to_string(), b"nope".to_vec())]).is_err());
|
|
assert!(WebStore::load([("item/a/b".to_string(), b"{}".to_vec())]).is_err());
|
|
assert!(WebStore::load([("future/x".to_string(), b"?".to_vec())]).unwrap().vaults().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn never_holds_plaintext_fields() {
|
|
let s = WebStore::new();
|
|
let v = Uuid::new_v4();
|
|
s.put_vault(&VaultRow::new(v, env(1), None)).unwrap();
|
|
s.put_local(v, &record(Uuid::new_v4(), 0, 3)).unwrap();
|
|
for (_, value) in s.entries() {
|
|
let text = String::from_utf8(value).unwrap();
|
|
for f in ["title", "username", "password", "\"url", "notes"] {
|
|
assert!(!text.contains(f), "{text}");
|
|
}
|
|
}
|
|
}
|
|
}
|