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]>
This commit is contained in:
2026-10-17 23:58:07 +09:00
co-authored by Claude Opus 5.5
parent f20380d293
commit b5ced32be3
35 changed files with 4488 additions and 363 deletions
+223
View File
@@ -0,0 +1,223 @@
//! Envelope compatibility between the apps (kv-ffi) and the web vault (kv-wasm), through two shared
//! fixtures in `tests/fixtures/`:
//!
//! * `native-vault.json`: made by kv-ffi (SQLite). The wasm tests (`src/wasm/tests.rs`, in Chrome)
//! unlock it and read its items.
//! * `wasm-vault.json`: made by the real wasm package (`js/smoke.mjs`, in Node). Here kv-ffi unlocks it
//! and reads its items.
//!
//! Both hold the replica entries in the web store's format (ciphertext only), the credentials and the
//! expected plaintext. Regenerate the native one with
//! `cargo test -p kv-wasm --test fixture -- --ignored generate_native_fixture`.
#![cfg(not(target_arch = "wasm32"))]
use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
use kv_store::{SqliteStore, Store};
use kv_wasm::{
dto::{ItemDto, ItemKindDto},
store::WebStore,
WebCore,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Fixture {
master_password: String,
secret_key: String,
/// Replica entries, values as UTF-8 (they are JSON or ASCII).
entries: BTreeMap<String, String>,
/// Expected plaintext, sorted by title.
items: Vec<ItemDto>,
}
fn path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name)
}
fn load(name: &str) -> Fixture {
serde_json::from_slice(&std::fs::read(path(name)).unwrap()).unwrap()
}
/// Copies a native SQLite replica into the web store format.
fn sqlite_to_web(src: &SqliteStore) -> WebStore {
let dst = WebStore::new();
for key in ["account", "server", "session"] {
if let Some(v) = src.get_meta(key).unwrap() {
dst.put_meta(key, &v).unwrap();
}
}
for v in src.vaults().unwrap() {
dst.put_vault(&v).unwrap();
for item in src.list(v.vault_id).unwrap() {
dst.put(v.vault_id, &item).unwrap();
for h in src.history(v.vault_id, item.record.item_id).unwrap().into_iter().rev() {
dst.push_history(v.vault_id, item.record.item_id, &h).unwrap();
}
}
}
dst
}
/// The reverse: a web replica written into a fresh SQLite database kv-ffi can open.
fn web_to_sqlite(src: &WebStore, dst: &SqliteStore) {
for key in ["account", "server", "session"] {
if let Some(v) = src.get_meta(key).unwrap() {
dst.put_meta(key, &v).unwrap();
}
}
for v in src.vaults().unwrap() {
dst.put_vault(&v).unwrap();
for item in src.list(v.vault_id).unwrap() {
dst.put(v.vault_id, &item).unwrap();
for h in src.history(v.vault_id, item.record.item_id).unwrap().into_iter().rev() {
dst.push_history(v.vault_id, item.record.item_id, &h).unwrap();
}
}
}
}
fn web_store(f: &Fixture) -> WebStore {
WebStore::load(f.entries.iter().map(|(k, v)| (k.clone(), v.as_bytes().to_vec()))).unwrap()
}
/// Items as kv-ffi sees them, sorted by title, `modified_at` included.
fn read_with_kv_ffi(f: &Fixture) -> Vec<ItemDto> {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("vault.db");
web_to_sqlite(&web_store(f), &SqliteStore::open(&db).unwrap());
let core = kv_ffi::VaultCore::open(db.to_string_lossy().into_owned()).unwrap();
assert!(core.is_locked());
core.unlock(f.master_password.clone(), f.secret_key.clone()).unwrap();
let mut out: Vec<ItemDto> = core
.list_items()
.unwrap()
.into_iter()
.map(|s| {
let d = core.get_item(s.id).unwrap();
ItemDto {
id: d.id,
kind: match d.kind {
kv_ffi::ItemKindDto::Login => ItemKindDto::Login,
kv_ffi::ItemKindDto::SecureNote => ItemKindDto::SecureNote,
kv_ffi::ItemKindDto::Card => ItemKindDto::Card,
kv_ffi::ItemKindDto::Identity => ItemKindDto::Identity,
},
title: d.title,
username: d.username,
password: d.password,
urls: d.urls,
totp: d.totp,
notes: d.notes,
favorite: d.favorite,
tags: d.tags,
modified_at: d.modified_at,
}
})
.collect();
out.sort_by(|a, b| a.title.cmp(&b.title));
out
}
fn read_with_web_core(f: &Fixture) -> Vec<ItemDto> {
let core = WebCore::new(Arc::new(web_store(f))).unwrap();
core.unlock(&f.master_password, &f.secret_key).unwrap();
let mut out: Vec<ItemDto> = core.list_items().unwrap().into_iter().map(|s| core.get_item(&s.id).unwrap()).collect();
out.sort_by(|a, b| a.title.cmp(&b.title));
out
}
fn ffi_item(title: &str, kind: kv_ffi::ItemKindDto) -> kv_ffi::ItemDto {
kv_ffi::ItemDto {
id: String::new(),
kind,
title: title.into(),
username: None,
password: None,
urls: vec![],
totp: None,
notes: None,
favorite: false,
tags: vec![],
modified_at: 0,
}
}
#[test]
#[ignore = "writes tests/fixtures/native-vault.json"]
fn generate_native_fixture() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("vault.db");
let pw = "native fixture master password";
let core = kv_ffi::VaultCore::open(db.to_string_lossy().into_owned()).unwrap();
let secret_key = core.create_account(pw.into()).unwrap();
let bank = core
.save_item(kv_ffi::ItemDto {
username: Some("[email protected]".into()),
password: Some("first password".into()),
urls: vec!["https://bank.example.com/login".into()],
totp: Some("otpauth://totp/Bank:ana?secret=JBSWY3DPEHPK3PXP&issuer=Bank".into()),
tags: vec!["finance".into(), "ünïcode ✓".into()],
favorite: true,
..ffi_item("Bank", kv_ffi::ItemKindDto::Login)
})
.unwrap();
// A second version, so the history entry crosses over too.
let mut edit = core.get_item(bank.clone()).unwrap();
edit.password = Some("second password".into());
core.save_item(edit).unwrap();
core.save_item(kv_ffi::ItemDto {
notes: Some("multi\nline\nnote with emoji 🔐".into()),
..ffi_item("Recovery codes", kv_ffi::ItemKindDto::SecureNote)
})
.unwrap();
drop(core);
let web = sqlite_to_web(&SqliteStore::open(&db).unwrap());
let mut f = Fixture {
master_password: pw.into(),
secret_key,
entries: web.entries().into_iter().map(|(k, v)| (k, String::from_utf8(v).unwrap())).collect(),
items: vec![],
};
f.items = read_with_kv_ffi(&f);
std::fs::write(path("native-vault.json"), serde_json::to_string_pretty(&f).unwrap() + "\n").unwrap();
}
#[test]
fn native_fixture_opens_in_kv_ffi_and_web_core() {
let f = load("native-vault.json");
assert_eq!(f.items.len(), 2);
assert_eq!(read_with_kv_ffi(&f), f.items);
assert_eq!(read_with_web_core(&f), f.items);
// History made by kv-ffi decrypts in the web core.
let core = WebCore::new(Arc::new(web_store(&f))).unwrap();
core.unlock(&f.master_password, &f.secret_key).unwrap();
let bank = f.items.iter().find(|i| i.title == "Bank").unwrap();
let h = core.item_history(&bank.id).unwrap();
assert_eq!(h[0].password.as_deref(), Some("first password"));
}
/// Made by the wasm build (`node crates/kv-wasm/js/smoke.mjs`), opened by kv-ffi.
#[test]
fn wasm_fixture_opens_in_kv_ffi() {
let f = load("wasm-vault.json");
assert!(!f.items.is_empty());
assert_eq!(read_with_kv_ffi(&f), f.items);
// kv-ffi can keep using the vault: edit, and the web core reads the edit back.
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("vault.db");
web_to_sqlite(&web_store(&f), &SqliteStore::open(&db).unwrap());
let core = kv_ffi::VaultCore::open(db.to_string_lossy().into_owned()).unwrap();
core.unlock(f.master_password.clone(), f.secret_key.clone()).unwrap();
let first = core.list_items().unwrap().remove(0);
let mut d = core.get_item(first.id.clone()).unwrap();
d.notes = Some("edited natively".into());
core.save_item(d).unwrap();
drop(core);
let back = sqlite_to_web(&SqliteStore::open(&db).unwrap());
let web = WebCore::new(Arc::new(back)).unwrap();
web.unlock(&f.master_password, &f.secret_key).unwrap();
assert_eq!(web.get_item(&first.id).unwrap().notes.as_deref(), Some("edited natively"));
}