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:
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user