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"));
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"masterPassword": "native fixture master password",
|
||||
"secretKey": "K1-0JSWZQ-71PJT-HDDP5-W5R5A-S543N-A",
|
||||
"entries": {
|
||||
"history/09bc9fac-9097-49d3-a027-ac1b12289315/61170456-ac16-4423-b2b6-0bf2547a7b0f": "[{\"revision\":0,\"wrapped_item_key\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"5cf1e174-64f9-4ad0-ac4c-c68b0841f392\",\"n\":\"h0g0YkMpqSAq0_f80dzNsmMhxVoxr6EU\",\"ct\":\"vKZuEoDiQXFb0psz9lUlywflLhy9sL0brz8Iddnf2s71nONknLLxL06GOfTsBaOTvV4zZdiiU9QuqbFoT1Uqvw\"},\"payload\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"6a2268d0-14f7-4d7b-86ae-afe2b858825b\",\"n\":\"RCD8IcJMCc7Mxy9yfnYtlekRzb9rZlRe\",\"ct\":\"gdTqTakbO2cvUazkIRPP80zkI6giI-lSX4fOGfgKtVDpT8Wu5b2NU0CkynEYVLIgHKHzMDhGvnuLouoz8liuAXGXJr-1o1HoByY5_-UYhuhMiNKDg82p6cRqBgYgUTUo0_W9BdJv33EDUPEMFT44QeqoWlkzDAmN0ROfpH0NGoxyrt31vNlwIiWI_2ddYadLtwT3oMPWvkkZhNBDtoPnkcVYbGrU74ZPBqQkODPuqXSJqDUKP_NPXFAsG--LEdmdNJ5phBoENTg7NpADtSbnB3chYcvJQW73_rm3vrG-zk78-K7PmZvjB9eMO3X8Q5WpHbA_q6qwikTj6as2QGz2UzjqmPczMEVRdTT26gjwTSc-xX8UOibJUYpZQU8Bl3tKEEQwcp8nvf8Fa_9WhGl1vRGSnjzWv_csvzDxmLDs77BsXCDZzef4f77Xk9LTUo2n0XiNUDB-P4rUHvUtcaEma9CB6fugPwyUaXNXToFnQl397mTGSlzRna895sbarasr0bbY4zS5mlpS2aaqGRDzQgMRON4NHdYXC6T4cqJfZ2pUHRCQqAkad2PgySCc9HzazCC66Boxf8YGQybeDSLQtR0O0tJcEoCcVq7Hepk6ZGru-2O_BqIqLFXDEx7eFmwlcwiMSy0CzgxR20S5ZmO2KZVlL6lcXPx_Rux0XfP4MBM2pSj9aUYX_7PCOlRc9iUK\"},\"saved_at\":1792248470}]",
|
||||
"item/09bc9fac-9097-49d3-a027-ac1b12289315/61170456-ac16-4423-b2b6-0bf2547a7b0f": "{\"record\":{\"item_id\":\"61170456-ac16-4423-b2b6-0bf2547a7b0f\",\"revision\":0,\"deleted\":false,\"wrapped_item_key\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"5cf1e174-64f9-4ad0-ac4c-c68b0841f392\",\"n\":\"dQEJouOYVrAGZo_gjw7n-ueKZDAM9Vcg\",\"ct\":\"jLVb2Qv0HKtvFePieovP95tQ2ZYfmiIm_N-vo5oP9LAdJy69TAi7R1iFotHOqC0yKgjhfoug8uzcAnWZgyhBtA\"},\"payload\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"57c966d8-d411-439b-a10a-87c109b7b242\",\"n\":\"voEowviUuPIwSdAHvXWwX63s6uVPIZQ2\",\"ct\":\"qxvaBMYpqGYe1uVH-Ac3DtWGBE6JJJ3eg_A1Zv04cFf-mTqHlSSEc3hcW3HVsqp3urIZMMgndY3diI-nDIlWo1I7tlREPfYt4k6OomgOYjKoaQsN7WTgoBZtbn8L4CvUWSq6EzfcTWaSFYkUZPVn1qu2ftLjnduDEURF_-TvFmjXA1cRzgwDShKi9j_tFatS-JUYLxKvAKEej9Nw0tGnfY2vyDDBr4x86zWRoHXsnXuNDSpPVU9kkLkG12QM3TiJ6VeM2ccMerFr5w0hZ6v3EBhZNSJbRDYRmh-j9Atd1t1Ho4i0XiThCOeW4bK_kUG4M_rnDPDEAb0os06S8dzrVZWkHoWPXydIwxdtk8RqZLWZOja_o57uyNB41S30WCXhUR6H-rXsbc6sOlRKsh_SHs4z6D5q947nFGA7wB9HrXa-cr1t1hLMwp11QzSksDKtg23Kks0wT9yaOrPQRK_IVE-8XLvOU9HBjXsJGCXkIBBdghGeFQDQNPNizPWdlIGgdHjLaqmdiXbTYBieASmKosyYO9s--edXavhq3pqgvZd40pk9p5SZJLxmDgZ13_QSXDPnvuxdOa-_w3FvfV8pSjaE8fh2nRYCpxzdU1PFynXu7zgRBMcEVedz9bkKryXTO6OAt0kht6XdbGTTLzQm5ySy2UrRAu1jkG8wLAvvFywILpdkbPabdmHv6G1U7b4w\"},\"updated_by\":\"00000000-0000-0000-0000-000000000000\",\"updated_at\":\"\"},\"pending\":true,\"base\":null}",
|
||||
"item/09bc9fac-9097-49d3-a027-ac1b12289315/8ef000ff-0408-4112-bde7-d71ac42ab11a": "{\"record\":{\"item_id\":\"8ef000ff-0408-4112-bde7-d71ac42ab11a\",\"revision\":0,\"deleted\":false,\"wrapped_item_key\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"5cf1e174-64f9-4ad0-ac4c-c68b0841f392\",\"n\":\"Pw3nJki2b8OV_9Y3yOJ7-i_W501biHv7\",\"ct\":\"4kUtn9S1Ix6DZwrlTovYlEiROW_D_GE4QzJLeKdKdqEedRK-76zDRyYxS6lw5uQ-a__5F2vtZdd08wiu7OqxtA\"},\"payload\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"017484e0-cb67-4626-9e44-4ff105381284\",\"n\":\"gDpm8Xwv5WuM1t_3_u2yVFASzvobN5Rv\",\"ct\":\"XheIvhovpfi2MDWGef7qJnpNOvYZ1c3_4nd5l9PYsX_PW0gX3Q9vVZlC0BX-TAfTAK40oW7_yWVnFriwtzZM3Zt-AC3LxlD7JLU5O295tEJ9BXXsdF_2dcHtgzY6pPoFSmptHYnO9tKaLpqmpmhVIVzEvCWYhsYOWemPIRHtfd1SDE3n_akDQT2XiyhUpAfGmZzXmfY80ws0w3ySAJCjnLKfUVGxPVdKFQfYZQwpLqILSB4mT6kytv69CkJJNS092p7d5_k5xDOTNCs4jV1-G7QTR9KqZVflXl5rRai8OwVRMFjXSjhplnOkuULKRNlMYlNsuT4gYMTYCFLr2REOTJ-TNCQIKf9sEFJCHD7i1ei04Gn5lPWP-qH1z4ITfVWcckQ1Ua6ZVfLG9FPMc8RQ9m4ufwI7TxVIb3hPNIYG69Jhrso7NDu8HOCvmIBbl-THM7baXn2A7qDqwpfqPK5BFffEWAEQxDBaYshl0vhmgJic92oGkutJkKhgzrKT2CxC9PC68EkqW8WzPaM8e4S4KnN6TolVKOhE0_x2aciZdvhczA3XuKbue4_dTmZwJMalR_4UxYye6m2lEcfl0q49cLQvF6aQKcZ4dzhMahIlSBypp1E7q2zDh3pImWWfJ72h7wfz6oRaEVW34ZhZ4ig-tclbsaqM5y2e_JzSktZNn0886Y77KX35ZCFau2Lb_BhO\"},\"updated_by\":\"00000000-0000-0000-0000-000000000000\",\"updated_at\":\"\"},\"pending\":true,\"base\":null}",
|
||||
"meta/account": "{\"account_id\":\"a714f389-eb58-439a-8279-1ed4a4979c74\",\"kdf\":{\"alg\":\"argon2id\",\"m_kib\":65536,\"t\":3,\"p\":4,\"salt\":\"uEVvTFReXc97eGv3TnA62Q\"},\"wrapped_keyset\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"00000000-0000-0000-0000-000000000000\",\"n\":\"mzoSaCwceqqTDxSMxKe0zmoARPB66xoJ\",\"ct\":\"fwR718RcRj2i7sFLmAmR6AdMzpXCzYMRzS7lR1ql8m-zcsafEpLxYTSlX6Q3spJCZC0OhIUMPpymrUwtl2dBNhQilZ82ApJlUzG83InrumKJyUmNXZ0p6-HSnaximaJh\"},\"enc_pk\":\"gXPhqX70l2c99hXgK1mCAS-h1NB95RDwm_DYBH-55WQ\",\"sig_pk\":\"Yi6sDydfkyYKc6mWA_BwLxrguV8mZuJo62ojenlWRQ4\",\"primary_vault\":\"09bc9fac-9097-49d3-a027-ac1b12289315\",\"local_unlock\":null}",
|
||||
"vault/09bc9fac-9097-49d3-a027-ac1b12289315": "{\"order\":0,\"vault_id\":\"09bc9fac-9097-49d3-a027-ac1b12289315\",\"sealed_key\":{\"v\":1,\"alg\":\"hpke-x25519-chacha\",\"kid\":\"c4fcad32-d960-43ea-b6e3-12ba55446630\",\"ct\":\"khzLBMbHxTLB6I34O8nLMzAYnMDGlUtjfEqL-5JpG6MCldyAYXaAXQT3q2TY-8seE7zTMxyuKuxFtPxZi7n_PA\",\"enc\":\"gxEK0S49G9vdZfveY6f8rdGhLFzZpusugGVgnpVcqiw\"},\"meta\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"5cf1e174-64f9-4ad0-ac4c-c68b0841f392\",\"n\":\"zOXWfjwdhCGkYsxT2V1N7y4PJ8tLJJek\",\"ct\":\"YIvV9YIOO4HrYMYhrvSQesV3LO40pgzoDlT8VskS2wOGntM\"},\"cursor\":0,\"manifest_version\":0,\"manifest_hash\":null,\"integrity_alarm\":false}"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "61170456-ac16-4423-b2b6-0bf2547a7b0f",
|
||||
"kind": "login",
|
||||
"title": "Bank",
|
||||
"username": "[email protected]",
|
||||
"password": "second password",
|
||||
"urls": [
|
||||
"https://bank.example.com/login"
|
||||
],
|
||||
"totp": "otpauth://totp/Bank:ana?secret=JBSWY3DPEHPK3PXP&issuer=Bank",
|
||||
"notes": null,
|
||||
"favorite": true,
|
||||
"tags": [
|
||||
"finance",
|
||||
"ünïcode ✓"
|
||||
],
|
||||
"modifiedAt": 1792248471
|
||||
},
|
||||
{
|
||||
"id": "8ef000ff-0408-4112-bde7-d71ac42ab11a",
|
||||
"kind": "secureNote",
|
||||
"title": "Recovery codes",
|
||||
"username": null,
|
||||
"password": null,
|
||||
"urls": [],
|
||||
"totp": null,
|
||||
"notes": "multi\nline\nnote with emoji 🔐",
|
||||
"favorite": false,
|
||||
"tags": [],
|
||||
"modifiedAt": 1792248470
|
||||
}
|
||||
]
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"masterPassword": "wasm fixture master password",
|
||||
"secretKey": "K1-6GNZNR-FQ720-HQEA6-BANYY-9P3JB-3",
|
||||
"entries": {
|
||||
"history/77bf9af1-8425-4f3a-b020-b50398e84c4a/bde76585-2df5-4a60-9c7c-1da5388ea1d7": "[{\"revision\":0,\"wrapped_item_key\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"87860105-0dae-41e6-acdc-de5d27328150\",\"n\":\"zAbO4kpoXLfeUOlIxvo42N9QB7X6ej2b\",\"ct\":\"OYerG-HYJvp-BFirouL0gH5PLF1qM9XjZ3F_VCFy4ALjwypl1i2KvlXAItbD5R7xsAMFihKB-J8GR1iAyRolkQ\"},\"payload\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"6b367eeb-379c-47b7-93bc-b8d13834e919\",\"n\":\"yGKnW3p62ZbvGsKfgxHZH1lVO2Y_b5mZ\",\"ct\":\"6m52soFGxtwrG4vWvHVsH3sMrYvVxk2rV0KzYgfnLMYIOnUmo9X17L8l3pGfYI5KlVGr3g1mCdUjE8fbzSMsP1WrAG9X4jWwOvQztnvlAWGl58g2PL-tXmdmEnmAHvymkDA6BED5LSDjxaDbLxROn_uRw0rfYr-cCF-ydRrrRreqSgSeEQFJyXP2JZg5pboV8B3vxiDVxz7-k9ROJ4Chl2D4Ob5sFMdzZTr7XDLDgAR0QFQyZb9SweyRM4kcFkbSAsb4tWms1V8nPlGl4cJcmT5GMgxpO4JRQP4BTt6Ansbxx7TsRw4tfE6aRUyhDSao_sbPFMveTqCeAXeJ6y0r98PzfIWaXHNyizHrBSG3dvbU-R47LFh6f-lFwNrACmrGynmCamsZq1T8poTI9Uij4BFYxGZfOS1wYu1r9YgQJ0DgLqyQLgiMxt-eryvyia9jY0cgSCPubmcMiZxRCeIA4kYeD2xwzAl_x2JYOZCsjMUBtxefunmTMLVb-kAijWH7fVUi3BVl_ZHGKiRWFA4za7Cw1sdEVKyy9vNhWnyIDCTW3kCpPKezu8yu1LRKayKbUQtUWDPMwBglX1nAYEUC87QdO7YYpTMh6-zIg2qqxd4ZXXNJ6p8dKBNFC9N-Okkkt-OA3Rq8UAIyjkC6oWtF9G7GcjiVnuH1ZV-KFZDk8KXoCid4D8edWtSpBdnMXilv\"},\"saved_at\":1792248736}]",
|
||||
"item/77bf9af1-8425-4f3a-b020-b50398e84c4a/bde76585-2df5-4a60-9c7c-1da5388ea1d7": "{\"record\":{\"item_id\":\"bde76585-2df5-4a60-9c7c-1da5388ea1d7\",\"revision\":0,\"deleted\":false,\"wrapped_item_key\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"87860105-0dae-41e6-acdc-de5d27328150\",\"n\":\"e0asucuWiR5PPnhaedwkFrsx298veqn0\",\"ct\":\"70o8Z96lsKyHXqT5XYBSzhN97uxqDkwwXnQAfMOchm0k2Quy0BoSZjXD2x5onL1clO-NAnKYq74ClMaVJiVJrA\"},\"payload\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"39f2b3d0-0aa4-4a5d-b9fa-6f33f6317d36\",\"n\":\"d44oekvGNJ0vfTv1chcHKfG5x8CybPpI\",\"ct\":\"biJng_k6ficUap0Ah_0QrfOq_lRyUxd8SL2kBeOB1r0YLiC4yCh0zsiiuC3xm-kN6q3mK0OFONnxv0egWY2dR8Krt3RyADRQWsyZki34UKoB9B39AQwWGb2NsAZb32ZK7MHiuP9aIk2-eXpEv03xLIfj46W_RDjhxfeYsuoq4ecN4XqkeZ_spafr6FBM6j_wGP_XJ-7D9QKkN2p7yYdu7a2qJThNchHL-Fq1kM4lLDqbUl4_ZGFqvNVIqt2t8BwiayfEpjFM6thItoTpcqOzuiBJKsdD9EwLCUnq-OIq-luK1BIvXb-LESHrYALEVCFMDVtZACyEe40m1DrOsPkX42RYEDTY23ac2rH5uHRw6U_TdRJehAcf1SXkr_Tx63LLSDyIpsT8K3HG4hxtrQuK_9glNkOZdTe4zNNngcAb9MpYmj-xvGB-znR7O77L_XbuuljaWg2kT20tJZMI7TQJElETdv9SJW36nr1sg-p6kqCYta-cGWBffBAjxveEUEBBahvldmQwtZAvUMSGsl15MCFWxD6kuKlJ_pXgkqZGIiEs7ZX4LPKMocowp5kwOfb4YZyksGt3qUGAy6Ums4brwuzQLaxsbYdS8kDh_lYa2_MZRRmjxag64rD39tyEvNyo7K-XWA7Kq9c8HklR45Gllv2SEy1OJYlQ6XLpQOw6AY3Vfw2tVuHuroQjmqYR6p95\"},\"updated_by\":\"5e1a2bb8-96bf-4f1b-bc78-d42e64242324\",\"updated_at\":\"\"},\"pending\":true,\"base\":null}",
|
||||
"item/77bf9af1-8425-4f3a-b020-b50398e84c4a/c359f9cb-b6bd-4dc1-ba4d-65f20c8c09f2": "{\"record\":{\"item_id\":\"c359f9cb-b6bd-4dc1-ba4d-65f20c8c09f2\",\"revision\":0,\"deleted\":false,\"wrapped_item_key\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"87860105-0dae-41e6-acdc-de5d27328150\",\"n\":\"fFnoERKlD5iiHVnGSNd-VCs3lX4V8E94\",\"ct\":\"WyypQdTxHin3kuxANJiTfnp8FxkaDGFgcfvxD-pCykP5qJzT-wBRm4c98yRRb4dWWFxZzbKzGIOJdjVcOyrEXg\"},\"payload\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"5176bdbf-e432-470b-a18e-5c5f9cb749cc\",\"n\":\"lnQE8ko6PV8IjPXxD8leSgafKzuw3knn\",\"ct\":\"Fl8dbTKVVU3wkKsL4Y_gFQGtrezYxTJOb4EvZozFZttElzw2ZGwYVOtVV8slBOrv91YriOkQNctbh1pUf_KqTuH39ApIFuLsCjIlDeLcUaUwmHG4_PiKsHYb1YRUeoVai-GeEqHBG2UKv3ZY2lW6afGmK0GqZ07d8cJfznN4dYjjwbG_unmzLSK1W93nXe2qytd4VgZDWNMSEpBgY0FLiuQ2dA7wHNp06BiNFjqlfsQX6996jT5L1x59AYgWEzj8XseMG0qtS538R5441f3g5v1CF69wkrZFvwhiVozYz5E9An35dGFCQ_KJ2OvGiGieg0skInBI9t1BUhvQvn1qqh4f_YXa7G-DZSkUQHWBgU2EBvU5imuXhkc3BORJhEOVoKWIppMZAG_NwB6shsexZt2rKDGywS7NXKGZOc0G0PQE2kBoKNiUrjccd5SEfCiB4IQOsIUO4M1OB2RQgXbHUgrv0qsv1Vc99-ilxdmTcKcgM0SN78vbWWanYjFbMioye1jDxV-97z8qoQIGdTF4dX4nS3d1er0CEYnM4KAgFqkGOTIVO-JdsL55ZN5SmlIDU0Jf47K6WyDtB5dTTInSB_p32uMybZRyl26Scnu98xrGnN7TpsedtnolHSpwlRnlOLN0L0CTuil0cBrWM5VmMpWCLk9GZ1j15REiCceLK_tAn92OaVS8qN37OpCYyTO0\"},\"updated_by\":\"5e1a2bb8-96bf-4f1b-bc78-d42e64242324\",\"updated_at\":\"\"},\"pending\":true,\"base\":null}",
|
||||
"meta/account": "{\"account_id\":\"129c6f80-0158-4a13-989c-29a5b2abe694\",\"kdf\":{\"alg\":\"argon2id\",\"m_kib\":65536,\"t\":3,\"p\":4,\"salt\":\"oqb0I2509nnbJ0Qq5TgPcw\"},\"wrapped_keyset\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"00000000-0000-0000-0000-000000000000\",\"n\":\"hq-x85RmOPjOt1wvnFLud607znrd1mCW\",\"ct\":\"io9yERv5aZjOVHeSTIOoADDuQk3uLIFy1dmKaJxlB1NGubg2c_1p8TX9QKc7oNHUjXAhr00Sb2lGsSH-u3wQxE3SO_bFsn8mMrF1T32Tn83O3qfr8AYGZY5M8Zxe6k4k\"},\"enc_pk\":\"kiIK8-JcITBogJ_gaKoi_A4crq4MdHe0VKXEn9JPvgQ\",\"sig_pk\":\"KTg9NI4TVqD1j7jI-QrEFgtXB9LQCAbShUUUGEVZXg0\",\"primary_vault\":\"77bf9af1-8425-4f3a-b020-b50398e84c4a\"}",
|
||||
"vault/77bf9af1-8425-4f3a-b020-b50398e84c4a": "{\"order\":0,\"vault_id\":\"77bf9af1-8425-4f3a-b020-b50398e84c4a\",\"sealed_key\":{\"v\":1,\"alg\":\"hpke-x25519-chacha\",\"kid\":\"425c4baf-3212-4051-8ac6-33c67bbffbbc\",\"ct\":\"Tqb_MLv36AugKkt62YqO6LnPMszQZAR1bfsX3795SvUYuX26lP6t7bQKGLrlOk7rtqpvaWBLwfZI6fAjfl1rkA\",\"enc\":\"kZFb9JM0km6_lhgsyt1kATs8kEOTRQ6n-3AS62m9KFQ\"},\"meta\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"87860105-0dae-41e6-acdc-de5d27328150\",\"n\":\"WmSg8g8d6XQCfMMlxfzuaHsYOd_P1ERZ\",\"ct\":\"8cGuse-26ebJBQ_ormfZA3vvVPE9zFImZtfpZYVTga7LBMI\"},\"cursor\":0,\"manifest_version\":0,\"manifest_hash\":null,\"integrity_alarm\":false}"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "bde76585-2df5-4a60-9c7c-1da5388ea1d7",
|
||||
"kind": "login",
|
||||
"title": "Bank",
|
||||
"username": "[email protected]",
|
||||
"password": "second password",
|
||||
"urls": [
|
||||
"https://example.com/login"
|
||||
],
|
||||
"totp": "otpauth://totp/Bank:ana?secret=JBSWY3DPEHPK3PXP&issuer=Bank",
|
||||
"notes": null,
|
||||
"favorite": true,
|
||||
"tags": [
|
||||
"finance",
|
||||
"ünïcode ✓"
|
||||
],
|
||||
"modifiedAt": 1792248737
|
||||
},
|
||||
{
|
||||
"id": "c359f9cb-b6bd-4dc1-ba4d-65f20c8c09f2",
|
||||
"kind": "secureNote",
|
||||
"title": "Recovery codes",
|
||||
"username": null,
|
||||
"password": null,
|
||||
"urls": [],
|
||||
"totp": null,
|
||||
"notes": "multi\nline 🔐",
|
||||
"favorite": false,
|
||||
"tags": [],
|
||||
"modifiedAt": 1792248736
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! The web core (compiled natively here, same code as in the browser) syncing with an app device
|
||||
//! (kv-ffi) through kv-sync's in-memory test server: enrollment by approval, both directions of
|
||||
//! edits, deletes, and a restart from the persisted replica.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kv_ffi::{DeviceSigner, PlatformDto};
|
||||
use kv_proto::Platform;
|
||||
use kv_sync::{
|
||||
signing::SoftwareSigner,
|
||||
test_server::{TestServer, SETUP_TOKEN},
|
||||
};
|
||||
use kv_wasm::{
|
||||
dto::{ItemDto, ItemKindDto, LoginResult},
|
||||
store::WebStore,
|
||||
CoreError, WebCore,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
const PW: &str = "a long master password";
|
||||
|
||||
struct Signer(SoftwareSigner);
|
||||
|
||||
impl DeviceSigner for Signer {
|
||||
fn device_id(&self) -> String {
|
||||
kv_sync::DeviceSigner::device_id(&self.0).to_string()
|
||||
}
|
||||
fn public_key(&self) -> Vec<u8> {
|
||||
kv_sync::DeviceSigner::public_key(&self.0)
|
||||
}
|
||||
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, kv_ffi::CoreError> {
|
||||
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| kv_ffi::CoreError::Internal { msg: e.to_string() })
|
||||
}
|
||||
}
|
||||
|
||||
fn web_item(title: &str, password: &str) -> ItemDto {
|
||||
ItemDto {
|
||||
id: String::new(),
|
||||
kind: ItemKindDto::Login,
|
||||
title: title.into(),
|
||||
username: Some("me".into()),
|
||||
password: Some(password.into()),
|
||||
urls: vec!["https://example.com".into()],
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn web_core(store: Arc<WebStore>, url: &str, signer: Arc<SoftwareSigner>) -> WebCore {
|
||||
let core = WebCore::new(store).unwrap();
|
||||
core.configure_server(url, vec![]).unwrap();
|
||||
core.set_device_signer(signer, "Web vault", Platform::Macos);
|
||||
core
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_vault_enrolls_and_syncs_with_an_app_device() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
// The app (kv-ffi) is the first device.
|
||||
let app = kv_ffi::VaultCore::new_in_memory();
|
||||
let sk = app.create_account(PW.into()).unwrap();
|
||||
app.configure_server(server.url(), vec![]).unwrap();
|
||||
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
|
||||
.unwrap();
|
||||
app.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
let mut on_app = kv_ffi::ItemDto {
|
||||
id: String::new(),
|
||||
kind: kv_ffi::ItemKindDto::Login,
|
||||
title: "Bank".into(),
|
||||
username: Some("me".into()),
|
||||
password: Some("from the app".into()),
|
||||
urls: vec![],
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
};
|
||||
let bank = app.save_item(on_app.clone()).unwrap();
|
||||
app.sync_now().await.unwrap();
|
||||
|
||||
// The browser logs in as a new device and waits for approval.
|
||||
server.state().require_approval = true;
|
||||
let store = Arc::new(WebStore::new());
|
||||
let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4()));
|
||||
let web = web_core(store.clone(), &server.url(), signer.clone());
|
||||
assert!(matches!(web.sync_now().await, Err(CoreError::NoAccount | CoreError::Locked)));
|
||||
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::PendingApproval);
|
||||
assert!(!web.has_account());
|
||||
let pending = app.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
|
||||
assert_eq!(pending.key_fingerprint, web.device_key_fingerprint().unwrap());
|
||||
app.approve_device(pending.device_id).await.unwrap();
|
||||
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::Ok);
|
||||
let r = web.sync_now().await.unwrap();
|
||||
assert_eq!((r.pulled, r.vaults_synced), (1, 1));
|
||||
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
|
||||
|
||||
// Browser edit and new item reach the app.
|
||||
let mut edit = web.get_item(&bank).unwrap();
|
||||
edit.notes = Some("edited in the browser".into());
|
||||
web.save_item(edit).unwrap();
|
||||
let new_id = web.save_item(web_item("Mail", "from the web")).unwrap();
|
||||
assert_eq!(web.sync_now().await.unwrap().pushed, 2);
|
||||
app.sync_now().await.unwrap();
|
||||
assert_eq!(app.get_item(bank.clone()).unwrap().notes.as_deref(), Some("edited in the browser"));
|
||||
assert_eq!(app.get_item(new_id.clone()).unwrap().password.as_deref(), Some("from the web"));
|
||||
|
||||
// Concurrent edits of different fields merge.
|
||||
on_app = app.get_item(bank.clone()).unwrap();
|
||||
on_app.password = Some("rotated on the app".into());
|
||||
app.save_item(on_app).unwrap();
|
||||
let mut edit = web.get_item(&bank).unwrap();
|
||||
edit.tags = vec!["web".into()];
|
||||
web.save_item(edit).unwrap();
|
||||
app.sync_now().await.unwrap();
|
||||
let r = web.sync_now().await.unwrap();
|
||||
assert_eq!(r.merged, 1);
|
||||
app.sync_now().await.unwrap();
|
||||
for item in [web.get_item(&bank).unwrap(), {
|
||||
let d = app.get_item(bank.clone()).unwrap();
|
||||
ItemDto { password: d.password, tags: d.tags, ..web.get_item(&bank).unwrap() }
|
||||
}] {
|
||||
assert_eq!(item.password.as_deref(), Some("rotated on the app"));
|
||||
assert_eq!(item.tags, vec!["web".to_string()]);
|
||||
}
|
||||
|
||||
// Delete in the browser; the app sees it.
|
||||
web.delete_item(&new_id).unwrap();
|
||||
web.sync_now().await.unwrap();
|
||||
app.sync_now().await.unwrap();
|
||||
assert!(matches!(app.get_item(new_id), Err(kv_ffi::CoreError::NotFound)));
|
||||
|
||||
// A page reload: the replica comes back from its persisted entries, locked, session kept.
|
||||
let reloaded = Arc::new(WebStore::load(store.entries()).unwrap());
|
||||
let web = web_core(reloaded, &server.url(), signer);
|
||||
assert!(web.has_account() && web.is_locked() && web.is_logged_in());
|
||||
assert!(matches!(web.list_items(), Err(CoreError::Locked)));
|
||||
web.unlock(PW, &sk).unwrap();
|
||||
assert_eq!(web.sync_now().await.unwrap().pulled, 0);
|
||||
assert_eq!(web.list_items().unwrap().len(), 1);
|
||||
|
||||
// Revoked by the app: the browser can no longer sync.
|
||||
let me = web.device_id().unwrap().to_string();
|
||||
app.revoke_device(me).await.unwrap();
|
||||
assert!(web.sync_now().await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_sync_is_refused_not_interleaved() {
|
||||
let server = TestServer::start().await;
|
||||
let web = web_core(Arc::new(WebStore::new()), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4())));
|
||||
let sk = web.create_account(PW).unwrap();
|
||||
web.register_account(SETUP_TOKEN).await.unwrap();
|
||||
web.save_item(web_item("A", "1")).unwrap();
|
||||
let (a, b) = tokio::join!(web.sync_now(), web.sync_now());
|
||||
assert!(matches!((&a, &b), (Ok(_), Err(CoreError::Busy)) | (Err(CoreError::Busy), Ok(_))), "{a:?} {b:?}");
|
||||
assert!(web.sync_now().await.is_ok());
|
||||
web.lock();
|
||||
web.unlock(PW, &sk).unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user