From b5ced32be33c8e3e7a099f8686ab0f639865872c Mon Sep 17 00:00:00 2001 From: samialabri Date: Sat, 17 Oct 2026 23:58:07 +0900 Subject: [PATCH 1/2] 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 --- .github/workflows/ci.yml | 16 + Cargo.lock | 176 ++++ Cargo.toml | 13 +- crates/kv-ffi/Cargo.toml | 2 +- crates/kv-model/Cargo.toml | 8 +- crates/kv-model/src/matching.rs | 14 +- crates/kv-store/Cargo.toml | 3 + crates/kv-store/src/lib.rs | 333 +------ crates/kv-store/src/sqlite.rs | 331 +++++++ crates/kv-sync/Cargo.toml | 15 +- crates/kv-sync/src/client.rs | 19 +- crates/kv-sync/src/engine.rs | 10 +- crates/kv-sync/src/lib.rs | 33 +- crates/kv-sync/src/signing.rs | 71 +- crates/kv-sync/src/transport.rs | 24 + crates/kv-wasm/Cargo.toml | 64 ++ crates/kv-wasm/README.md | 120 +++ crates/kv-wasm/build.sh | 29 + crates/kv-wasm/js/client.d.ts | 41 + crates/kv-wasm/js/client.js | 60 ++ crates/kv-wasm/js/smoke.mjs | 149 +++ crates/kv-wasm/js/worker.js | 84 ++ crates/kv-wasm/src/core.rs | 865 ++++++++++++++++++ crates/kv-wasm/src/dto.rs | 180 ++++ crates/kv-wasm/src/error.rs | 132 +++ crates/kv-wasm/src/lib.rs | 21 + crates/kv-wasm/src/store.rs | 445 +++++++++ crates/kv-wasm/src/wasm/idb.rs | 141 +++ crates/kv-wasm/src/wasm/mod.rs | 668 ++++++++++++++ crates/kv-wasm/src/wasm/signer.rs | 110 +++ crates/kv-wasm/src/wasm/tests.rs | 198 ++++ crates/kv-wasm/tests/fixture.rs | 223 +++++ .../kv-wasm/tests/fixtures/native-vault.json | 44 + crates/kv-wasm/tests/fixtures/wasm-vault.json | 44 + crates/kv-wasm/tests/sync.rs | 165 ++++ 35 files changed, 4488 insertions(+), 363 deletions(-) create mode 100644 crates/kv-store/src/sqlite.rs create mode 100644 crates/kv-wasm/Cargo.toml create mode 100644 crates/kv-wasm/README.md create mode 100755 crates/kv-wasm/build.sh create mode 100644 crates/kv-wasm/js/client.d.ts create mode 100644 crates/kv-wasm/js/client.js create mode 100644 crates/kv-wasm/js/smoke.mjs create mode 100644 crates/kv-wasm/js/worker.js create mode 100644 crates/kv-wasm/src/core.rs create mode 100644 crates/kv-wasm/src/dto.rs create mode 100644 crates/kv-wasm/src/error.rs create mode 100644 crates/kv-wasm/src/lib.rs create mode 100644 crates/kv-wasm/src/store.rs create mode 100644 crates/kv-wasm/src/wasm/idb.rs create mode 100644 crates/kv-wasm/src/wasm/mod.rs create mode 100644 crates/kv-wasm/src/wasm/signer.rs create mode 100644 crates/kv-wasm/src/wasm/tests.rs create mode 100644 crates/kv-wasm/tests/fixture.rs create mode 100644 crates/kv-wasm/tests/fixtures/native-vault.json create mode 100644 crates/kv-wasm/tests/fixtures/wasm-vault.json create mode 100644 crates/kv-wasm/tests/sync.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 359f1ab..7a616f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,22 @@ jobs: - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace + # The web vault's core (crates/kv-wasm/README.md): wasm32 lints, in-browser tests (IndexedDB, + # WebCrypto), the package build and a Node smoke test of the built package. + wasm: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: { persist-credentials: false } + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, 2026-10 + with: { toolchain: stable, components: clippy, targets: wasm32-unknown-unknown } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - run: cargo install wasm-pack --locked + - run: cargo clippy -p kv-wasm --target wasm32-unknown-unknown --all-targets -- -D warnings + - run: wasm-pack test --headless --chrome crates/kv-wasm + - run: crates/kv-wasm/build.sh + - run: node crates/kv-wasm/js/smoke.mjs + deny: runs-on: ubuntu-24.04 steps: diff --git a/Cargo.lock b/Cargo.lock index 3d04d69..a04dd27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,6 +202,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -337,6 +348,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.8.0" @@ -1440,6 +1457,7 @@ dependencies = [ "base64 0.22.1", "blake3", "ciborium", + "js-sys", "kv-crypto", "kv-model", "kv-proto", @@ -1467,6 +1485,35 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kv-wasm" +version = "0.1.0" +dependencies = [ + "getrandom 0.2.17", + "js-sys", + "kv-crypto", + "kv-ffi", + "kv-import", + "kv-model", + "kv-proto", + "kv-store", + "kv-sync", + "serde", + "serde-wasm-bindgen", + "serde_bytes", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.21", + "tokio", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.1" @@ -1479,6 +1526,12 @@ version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -1530,6 +1583,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1579,12 +1642,31 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + [[package]] name = "num_cpus" version = "1.17.0" @@ -1607,6 +1689,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -2028,6 +2116,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scroll" version = "0.12.0" @@ -2082,6 +2179,27 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -2760,6 +2878,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.2" @@ -2831,6 +2959,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe" + [[package]] name = "web-sys" version = "0.3.106" @@ -2868,6 +3035,15 @@ dependencies = [ "nom", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 441a952..91eb5ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,8 @@ publish = false [workspace.dependencies] kv-proto = { path = "crates/kv-proto" } kv-crypto = { path = "crates/kv-crypto" } -kv-model = { path = "crates/kv-model" } +# The `psl` feature (eTLD+1 matching, ~0.9 MB of code) is enabled by kv-ffi; the web build leaves it out. +kv-model = { path = "crates/kv-model", default-features = false } kv-store = { path = "crates/kv-store" } kv-sync = { path = "crates/kv-sync" } kv-import = { path = "crates/kv-import" } @@ -65,3 +66,13 @@ strip = true opt-level = 3 [profile.dev.package.blake2] opt-level = 3 + +# Web vault (`wasm-pack build crates/kv-wasm --profile wasm-release`): optimize for download size, +# but keep Argon2id's hot loop at full speed. +[profile.wasm-release] +inherits = "release" +opt-level = "z" +[profile.wasm-release.package.argon2] +opt-level = 3 +[profile.wasm-release.package.blake2] +opt-level = 3 diff --git a/crates/kv-ffi/Cargo.toml b/crates/kv-ffi/Cargo.toml index fb2d959..478ecbc 100644 --- a/crates/kv-ffi/Cargo.toml +++ b/crates/kv-ffi/Cargo.toml @@ -17,7 +17,7 @@ path = "src/bin/uniffi-bindgen.rs" [dependencies] kv-proto.workspace = true kv-crypto.workspace = true -kv-model.workspace = true +kv-model = { workspace = true, features = ["psl"] } kv-store.workspace = true kv-sync.workspace = true kv-import.workspace = true diff --git a/crates/kv-model/Cargo.toml b/crates/kv-model/Cargo.toml index 0b21acb..2b29423 100644 --- a/crates/kv-model/Cargo.toml +++ b/crates/kv-model/Cargo.toml @@ -18,8 +18,14 @@ sha1.workspace = true sha2.workspace = true data-encoding.workspace = true url.workspace = true -psl.workspace = true +psl = { workspace = true, optional = true } p256.workspace = true +[features] +# Public Suffix List for `MatchLevel::Domain`. Without it only same-host URLs match (the web vault, +# where the list would be a third of the download and nothing autofills). +default = ["psl"] +psl = ["dep:psl"] + [dev-dependencies] ciborium.workspace = true diff --git a/crates/kv-model/src/matching.rs b/crates/kv-model/src/matching.rs index 59e511a..8ae672f 100644 --- a/crates/kv-model/src/matching.rs +++ b/crates/kv-model/src/matching.rs @@ -34,12 +34,24 @@ pub fn match_url(item_url: &str, page_url: &str) -> MatchLevel { MatchLevel::Host }; } - match (psl::domain_str(&ih), psl::domain_str(&ph)) { + same_registrable_domain(&ih, &ph) +} + +#[cfg(feature = "psl")] +fn same_registrable_domain(a: &str, b: &str) -> MatchLevel { + match (psl::domain_str(a), psl::domain_str(b)) { (Some(a), Some(b)) if a == b => MatchLevel::Domain, _ => MatchLevel::None, } } +/// Without the Public Suffix List there is no safe way to tell `a.example.com` / `b.example.com` +/// (same site) from `a.github.io` / `b.github.io` (different sites), so different hosts never match. +#[cfg(not(feature = "psl"))] +fn same_registrable_domain(_: &str, _: &str) -> MatchLevel { + MatchLevel::None +} + fn parse(s: &str) -> Option { let s = s.trim(); // Saved URLs are often stored without a scheme ("example.com/login"). diff --git a/crates/kv-store/Cargo.toml b/crates/kv-store/Cargo.toml index 7e55bbb..a29c6b1 100644 --- a/crates/kv-store/Cargo.toml +++ b/crates/kv-store/Cargo.toml @@ -11,6 +11,9 @@ kv-proto.workspace = true uuid.workspace = true thiserror.workspace = true serde_json.workspace = true + +# SQLite does not build for wasm32; the web vault (kv-wasm) brings its own IndexedDB-backed store. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] rusqlite.workspace = true [dev-dependencies] diff --git a/crates/kv-store/src/lib.rs b/crates/kv-store/src/lib.rs index 3cc2fd1..ad20e09 100644 --- a/crates/kv-store/src/lib.rs +++ b/crates/kv-store/src/lib.rs @@ -1,5 +1,7 @@ //! Local encrypted replica (milestone M1, kestrove-spec/docs/07). //! +//! [`SqliteStore`] is native only; on wasm32 the web vault (`kv-wasm`) implements [`Store`] over IndexedDB. +//! //! The store holds the same `Envelope` bytes the server holds, plus sync //! bookkeeping. It never sees plaintext. Schema (rusqlite, bundled): //! @@ -22,14 +24,17 @@ use std::{ collections::{BTreeMap, HashMap}, - path::Path, sync::Mutex, }; use kv_proto::{Envelope, ItemRecord}; -use rusqlite::{params, Connection, OptionalExtension}; use uuid::Uuid; +#[cfg(not(target_arch = "wasm32"))] +mod sqlite; +#[cfg(not(target_arch = "wasm32"))] +pub use sqlite::SqliteStore; + /// Versions of an item kept in history. pub const HISTORY_LIMIT: usize = 10; @@ -41,12 +46,6 @@ pub enum StoreError { NotFound, } -impl From for StoreError { - fn from(e: rusqlite::Error) -> Self { - StoreError::Io(e.to_string()) - } -} - impl From for StoreError { fn from(e: serde_json::Error) -> Self { StoreError::Io(format!("corrupt envelope: {e}")) @@ -164,324 +163,6 @@ pub trait Store: Send + Sync { } } -// ---------------------------------------------------------------- SQLite - -const SCHEMA: &str = " -CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value BLOB NOT NULL); -CREATE TABLE IF NOT EXISTS vault ( - vault_id BLOB PRIMARY KEY, sealed_key TEXT NOT NULL, meta TEXT, - cursor INTEGER NOT NULL DEFAULT 0, manifest_version INTEGER NOT NULL DEFAULT 0, manifest_hash BLOB, - integrity_alarm INTEGER NOT NULL DEFAULT 0); -CREATE TABLE IF NOT EXISTS item ( - vault_id BLOB NOT NULL, item_id BLOB NOT NULL, revision INTEGER NOT NULL, deleted INTEGER NOT NULL, - wrapped_item_key TEXT, payload TEXT, updated_by BLOB NOT NULL, updated_at TEXT NOT NULL, - state TEXT NOT NULL CHECK(state IN ('clean','pending')), - base_revision INTEGER NOT NULL DEFAULT 0, base_deleted INTEGER, base_wrapped_item_key TEXT, base_payload TEXT, - base_updated_by BLOB, base_updated_at TEXT, - PRIMARY KEY (vault_id, item_id)); -CREATE TABLE IF NOT EXISTS history ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, vault_id BLOB NOT NULL, item_id BLOB NOT NULL, revision INTEGER NOT NULL, - wrapped_item_key TEXT NOT NULL, payload TEXT NOT NULL, saved_at INTEGER NOT NULL); -CREATE INDEX IF NOT EXISTS history_by_item ON history (vault_id, item_id, seq); -"; - -const SCHEMA_VERSION: i64 = 1; - -pub struct SqliteStore { - conn: Mutex, -} - -impl SqliteStore { - pub fn open(path: impl AsRef) -> Result { - Self::init(Connection::open(path)?) - } - - pub fn open_in_memory() -> Result { - Self::init(Connection::open_in_memory()?) - } - - fn init(conn: Connection) -> Result { - conn.pragma_update(None, "journal_mode", "WAL")?; - conn.pragma_update(None, "synchronous", "FULL")?; - conn.pragma_update(None, "secure_delete", "ON")?; - let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; - if version > SCHEMA_VERSION { - return Err(StoreError::Io(format!("database schema {version} is newer than this app"))); - } - conn.execute_batch(SCHEMA)?; - conn.pragma_update(None, "user_version", SCHEMA_VERSION)?; - Ok(Self { conn: Mutex::new(conn) }) - } - - fn conn(&self) -> std::sync::MutexGuard<'_, Connection> { - self.conn.lock().unwrap_or_else(|p| p.into_inner()) - } -} - -fn env_to_sql(e: &Option) -> Result> { - e.as_ref().map(serde_json::to_string).transpose().map_err(Into::into) -} - -fn env_from_sql(s: Option) -> Result> { - s.as_deref().map(serde_json::from_str).transpose().map_err(Into::into) -} - -fn uuid_from_sql(b: Vec) -> Result { - Uuid::from_slice(&b).map_err(|e| StoreError::Io(e.to_string())) -} - -fn hash_from_sql(b: Option>) -> Result> { - b.map(|b| b.try_into().map_err(|_| StoreError::Io("bad manifest hash".into()))).transpose() -} - -const ITEM_COLS: &str = "item_id, revision, deleted, wrapped_item_key, payload, updated_by, updated_at, state, - base_revision, base_deleted, base_wrapped_item_key, base_payload, base_updated_by, base_updated_at"; - -type RawItem = ( - Vec, - i64, - bool, - Option, - Option, - Vec, - String, - String, - i64, - Option, - Option, - Option, - Option>, - Option, -); - -fn raw_item(r: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(( - r.get(0)?, - r.get(1)?, - r.get(2)?, - r.get(3)?, - r.get(4)?, - r.get(5)?, - r.get(6)?, - r.get(7)?, - r.get(8)?, - r.get(9)?, - r.get(10)?, - r.get(11)?, - r.get(12)?, - r.get(13)?, - )) -} - -fn item_from_raw(raw: RawItem) -> Result { - let (id, rev, deleted, wik, payload, by, at, state, brev, bdel, bwik, bpayload, bby, bat) = raw; - let item_id = uuid_from_sql(id)?; - let record = ItemRecord { - item_id, - revision: rev as u64, - deleted, - wrapped_item_key: env_from_sql(wik)?, - payload: env_from_sql(payload)?, - updated_by: uuid_from_sql(by)?, - updated_at: at, - }; - let base = if brev > 0 { - Some(ItemRecord { - item_id, - revision: brev as u64, - deleted: bdel.unwrap_or(false), - wrapped_item_key: env_from_sql(bwik)?, - payload: env_from_sql(bpayload)?, - updated_by: bby.map(uuid_from_sql).transpose()?.unwrap_or_default(), - updated_at: bat.unwrap_or_default(), - }) - } else { - None - }; - let state = if state == "pending" { SyncState::Pending } else { SyncState::Clean }; - Ok(LocalItem { record, state, base }) -} - -fn vault_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)) -} - -type RawVault = (Vec, String, Option, i64, i64, Option>, bool); - -fn vault_from_raw(raw: RawVault) -> Result { - let (id, sealed, meta, cursor, mver, mhash, alarm) = raw; - Ok(VaultRow { - vault_id: uuid_from_sql(id)?, - sealed_key: serde_json::from_str(&sealed)?, - meta: env_from_sql(meta)?, - cursor: cursor as u64, - manifest_version: mver as u64, - manifest_hash: hash_from_sql(mhash)?, - integrity_alarm: alarm, - }) -} - -const VAULT_COLS: &str = "vault_id, sealed_key, meta, cursor, manifest_version, manifest_hash, integrity_alarm"; - -impl Store for SqliteStore { - fn get_meta(&self, key: &str) -> Result>> { - Ok(self.conn().query_row("SELECT value FROM meta WHERE key = ?", [key], |r| r.get(0)).optional()?) - } - - fn put_meta(&self, key: &str, value: &[u8]) -> Result<()> { - self.conn().execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", params![key, value])?; - Ok(()) - } - - fn delete_meta(&self, key: &str) -> Result<()> { - self.conn().execute("DELETE FROM meta WHERE key = ?", [key])?; - Ok(()) - } - - fn put_vault(&self, v: &VaultRow) -> Result<()> { - self.conn().execute( - &format!("INSERT OR REPLACE INTO vault ({VAULT_COLS}) VALUES (?,?,?,?,?,?,?)"), - params![ - v.vault_id.as_bytes().as_slice(), - serde_json::to_string(&v.sealed_key)?, - env_to_sql(&v.meta)?, - v.cursor as i64, - v.manifest_version as i64, - v.manifest_hash.as_ref().map(|h| h.as_slice()), - v.integrity_alarm, - ], - )?; - Ok(()) - } - - fn vault(&self, vault: Uuid) -> Result> { - let raw = self - .conn() - .query_row( - &format!("SELECT {VAULT_COLS} FROM vault WHERE vault_id = ?"), - [vault.as_bytes().as_slice()], - vault_from_row, - ) - .optional()?; - raw.map(vault_from_raw).transpose() - } - - fn vaults(&self) -> Result> { - let conn = self.conn(); - let mut stmt = conn.prepare(&format!("SELECT {VAULT_COLS} FROM vault ORDER BY rowid"))?; - let raws = stmt.query_map([], vault_from_row)?.collect::>>()?; - raws.into_iter().map(vault_from_raw).collect() - } - - fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()> { - let r = &item.record; - let b = item.base.as_ref(); - self.conn().execute( - &format!("INSERT OR REPLACE INTO item (vault_id, {ITEM_COLS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"), - params![ - vault.as_bytes().as_slice(), - r.item_id.as_bytes().as_slice(), - r.revision as i64, - r.deleted, - env_to_sql(&r.wrapped_item_key)?, - env_to_sql(&r.payload)?, - r.updated_by.as_bytes().as_slice(), - r.updated_at, - match item.state { - SyncState::Clean => "clean", - SyncState::Pending => "pending", - }, - b.map_or(0, |b| b.revision as i64), - b.map(|b| b.deleted), - env_to_sql(&b.and_then(|b| b.wrapped_item_key.clone()))?, - env_to_sql(&b.and_then(|b| b.payload.clone()))?, - b.map(|b| b.updated_by.as_bytes().to_vec()), - b.map(|b| b.updated_at.clone()), - ], - )?; - Ok(()) - } - - fn get(&self, vault: Uuid, item: Uuid) -> Result> { - let raw = self - .conn() - .query_row( - &format!("SELECT {ITEM_COLS} FROM item WHERE vault_id = ? AND item_id = ?"), - [vault.as_bytes().as_slice(), item.as_bytes().as_slice()], - raw_item, - ) - .optional()?; - raw.map(item_from_raw).transpose() - } - - fn list(&self, vault: Uuid) -> Result> { - let conn = self.conn(); - let mut stmt = conn.prepare(&format!("SELECT {ITEM_COLS} FROM item WHERE vault_id = ? ORDER BY item_id"))?; - let raws = stmt.query_map([vault.as_bytes().as_slice()], raw_item)?.collect::>>()?; - raws.into_iter().map(item_from_raw).collect() - } - - fn remove(&self, vault: Uuid, item: Uuid) -> Result<()> { - self.conn().execute( - "DELETE FROM item WHERE vault_id = ? AND item_id = ?", - [vault.as_bytes().as_slice(), item.as_bytes().as_slice()], - )?; - Ok(()) - } - - fn push_history(&self, vault: Uuid, item: Uuid, e: &HistoryEntry) -> Result<()> { - let mut conn = self.conn(); - let tx = conn.transaction()?; - let (v, i) = (vault.as_bytes().as_slice(), item.as_bytes().as_slice()); - tx.execute( - "INSERT INTO history (vault_id, item_id, revision, wrapped_item_key, payload, saved_at) VALUES (?,?,?,?,?,?)", - params![ - v, - i, - e.revision as i64, - serde_json::to_string(&e.wrapped_item_key)?, - serde_json::to_string(&e.payload)?, - e.saved_at - ], - )?; - tx.execute( - "DELETE FROM history WHERE vault_id = ?1 AND item_id = ?2 AND seq NOT IN - (SELECT seq FROM history WHERE vault_id = ?1 AND item_id = ?2 ORDER BY seq DESC LIMIT ?3)", - params![v, i, HISTORY_LIMIT as i64], - )?; - tx.commit()?; - Ok(()) - } - - fn history(&self, vault: Uuid, item: Uuid) -> Result> { - let conn = self.conn(); - let mut stmt = conn.prepare( - "SELECT revision, wrapped_item_key, payload, saved_at FROM history - WHERE vault_id = ? AND item_id = ? ORDER BY seq DESC", - )?; - let raws = stmt - .query_map([vault.as_bytes().as_slice(), item.as_bytes().as_slice()], |r| { - Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, i64>(3)?)) - })? - .collect::>>()?; - raws.into_iter() - .map(|(rev, wik, payload, at)| { - Ok(HistoryEntry { - revision: rev as u64, - wrapped_item_key: serde_json::from_str(&wik)?, - payload: serde_json::from_str(&payload)?, - saved_at: at, - }) - }) - .collect() - } - - fn wipe(&self) -> Result<()> { - self.conn().execute_batch("DELETE FROM history; DELETE FROM item; DELETE FROM vault; DELETE FROM meta;")?; - Ok(()) - } -} - // ---------------------------------------------------------------- in-memory #[derive(Default)] diff --git a/crates/kv-store/src/sqlite.rs b/crates/kv-store/src/sqlite.rs new file mode 100644 index 0000000..57625eb --- /dev/null +++ b/crates/kv-store/src/sqlite.rs @@ -0,0 +1,331 @@ +//! SQLite backend (devices). Not built for wasm32. + +use std::{path::Path, sync::Mutex}; + +use kv_proto::{Envelope, ItemRecord}; +use rusqlite::{params, Connection, OptionalExtension}; +use uuid::Uuid; + +use crate::{HistoryEntry, LocalItem, Result, Store, StoreError, SyncState, VaultRow, HISTORY_LIMIT}; + +impl From for StoreError { + fn from(e: rusqlite::Error) -> Self { + StoreError::Io(e.to_string()) + } +} + +const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value BLOB NOT NULL); +CREATE TABLE IF NOT EXISTS vault ( + vault_id BLOB PRIMARY KEY, sealed_key TEXT NOT NULL, meta TEXT, + cursor INTEGER NOT NULL DEFAULT 0, manifest_version INTEGER NOT NULL DEFAULT 0, manifest_hash BLOB, + integrity_alarm INTEGER NOT NULL DEFAULT 0); +CREATE TABLE IF NOT EXISTS item ( + vault_id BLOB NOT NULL, item_id BLOB NOT NULL, revision INTEGER NOT NULL, deleted INTEGER NOT NULL, + wrapped_item_key TEXT, payload TEXT, updated_by BLOB NOT NULL, updated_at TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('clean','pending')), + base_revision INTEGER NOT NULL DEFAULT 0, base_deleted INTEGER, base_wrapped_item_key TEXT, base_payload TEXT, + base_updated_by BLOB, base_updated_at TEXT, + PRIMARY KEY (vault_id, item_id)); +CREATE TABLE IF NOT EXISTS history ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, vault_id BLOB NOT NULL, item_id BLOB NOT NULL, revision INTEGER NOT NULL, + wrapped_item_key TEXT NOT NULL, payload TEXT NOT NULL, saved_at INTEGER NOT NULL); +CREATE INDEX IF NOT EXISTS history_by_item ON history (vault_id, item_id, seq); +"; + +const SCHEMA_VERSION: i64 = 1; + +pub struct SqliteStore { + conn: Mutex, +} + +impl SqliteStore { + pub fn open(path: impl AsRef) -> Result { + Self::init(Connection::open(path)?) + } + + pub fn open_in_memory() -> Result { + Self::init(Connection::open_in_memory()?) + } + + fn init(conn: Connection) -> Result { + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "synchronous", "FULL")?; + conn.pragma_update(None, "secure_delete", "ON")?; + let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; + if version > SCHEMA_VERSION { + return Err(StoreError::Io(format!("database schema {version} is newer than this app"))); + } + conn.execute_batch(SCHEMA)?; + conn.pragma_update(None, "user_version", SCHEMA_VERSION)?; + Ok(Self { conn: Mutex::new(conn) }) + } + + pub(crate) fn conn(&self) -> std::sync::MutexGuard<'_, Connection> { + self.conn.lock().unwrap_or_else(|p| p.into_inner()) + } +} + +fn env_to_sql(e: &Option) -> Result> { + e.as_ref().map(serde_json::to_string).transpose().map_err(Into::into) +} + +fn env_from_sql(s: Option) -> Result> { + s.as_deref().map(serde_json::from_str).transpose().map_err(Into::into) +} + +fn uuid_from_sql(b: Vec) -> Result { + Uuid::from_slice(&b).map_err(|e| StoreError::Io(e.to_string())) +} + +fn hash_from_sql(b: Option>) -> Result> { + b.map(|b| b.try_into().map_err(|_| StoreError::Io("bad manifest hash".into()))).transpose() +} + +const ITEM_COLS: &str = "item_id, revision, deleted, wrapped_item_key, payload, updated_by, updated_at, state, + base_revision, base_deleted, base_wrapped_item_key, base_payload, base_updated_by, base_updated_at"; + +type RawItem = ( + Vec, + i64, + bool, + Option, + Option, + Vec, + String, + String, + i64, + Option, + Option, + Option, + Option>, + Option, +); + +fn raw_item(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + r.get(8)?, + r.get(9)?, + r.get(10)?, + r.get(11)?, + r.get(12)?, + r.get(13)?, + )) +} + +fn item_from_raw(raw: RawItem) -> Result { + let (id, rev, deleted, wik, payload, by, at, state, brev, bdel, bwik, bpayload, bby, bat) = raw; + let item_id = uuid_from_sql(id)?; + let record = ItemRecord { + item_id, + revision: rev as u64, + deleted, + wrapped_item_key: env_from_sql(wik)?, + payload: env_from_sql(payload)?, + updated_by: uuid_from_sql(by)?, + updated_at: at, + }; + let base = if brev > 0 { + Some(ItemRecord { + item_id, + revision: brev as u64, + deleted: bdel.unwrap_or(false), + wrapped_item_key: env_from_sql(bwik)?, + payload: env_from_sql(bpayload)?, + updated_by: bby.map(uuid_from_sql).transpose()?.unwrap_or_default(), + updated_at: bat.unwrap_or_default(), + }) + } else { + None + }; + let state = if state == "pending" { SyncState::Pending } else { SyncState::Clean }; + Ok(LocalItem { record, state, base }) +} + +fn vault_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)) +} + +type RawVault = (Vec, String, Option, i64, i64, Option>, bool); + +fn vault_from_raw(raw: RawVault) -> Result { + let (id, sealed, meta, cursor, mver, mhash, alarm) = raw; + Ok(VaultRow { + vault_id: uuid_from_sql(id)?, + sealed_key: serde_json::from_str(&sealed)?, + meta: env_from_sql(meta)?, + cursor: cursor as u64, + manifest_version: mver as u64, + manifest_hash: hash_from_sql(mhash)?, + integrity_alarm: alarm, + }) +} + +const VAULT_COLS: &str = "vault_id, sealed_key, meta, cursor, manifest_version, manifest_hash, integrity_alarm"; + +impl Store for SqliteStore { + fn get_meta(&self, key: &str) -> Result>> { + Ok(self.conn().query_row("SELECT value FROM meta WHERE key = ?", [key], |r| r.get(0)).optional()?) + } + + fn put_meta(&self, key: &str, value: &[u8]) -> Result<()> { + self.conn().execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", params![key, value])?; + Ok(()) + } + + fn delete_meta(&self, key: &str) -> Result<()> { + self.conn().execute("DELETE FROM meta WHERE key = ?", [key])?; + Ok(()) + } + + fn put_vault(&self, v: &VaultRow) -> Result<()> { + self.conn().execute( + &format!("INSERT OR REPLACE INTO vault ({VAULT_COLS}) VALUES (?,?,?,?,?,?,?)"), + params![ + v.vault_id.as_bytes().as_slice(), + serde_json::to_string(&v.sealed_key)?, + env_to_sql(&v.meta)?, + v.cursor as i64, + v.manifest_version as i64, + v.manifest_hash.as_ref().map(|h| h.as_slice()), + v.integrity_alarm, + ], + )?; + Ok(()) + } + + fn vault(&self, vault: Uuid) -> Result> { + let raw = self + .conn() + .query_row( + &format!("SELECT {VAULT_COLS} FROM vault WHERE vault_id = ?"), + [vault.as_bytes().as_slice()], + vault_from_row, + ) + .optional()?; + raw.map(vault_from_raw).transpose() + } + + fn vaults(&self) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare(&format!("SELECT {VAULT_COLS} FROM vault ORDER BY rowid"))?; + let raws = stmt.query_map([], vault_from_row)?.collect::>>()?; + raws.into_iter().map(vault_from_raw).collect() + } + + fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()> { + let r = &item.record; + let b = item.base.as_ref(); + self.conn().execute( + &format!("INSERT OR REPLACE INTO item (vault_id, {ITEM_COLS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"), + params![ + vault.as_bytes().as_slice(), + r.item_id.as_bytes().as_slice(), + r.revision as i64, + r.deleted, + env_to_sql(&r.wrapped_item_key)?, + env_to_sql(&r.payload)?, + r.updated_by.as_bytes().as_slice(), + r.updated_at, + match item.state { + SyncState::Clean => "clean", + SyncState::Pending => "pending", + }, + b.map_or(0, |b| b.revision as i64), + b.map(|b| b.deleted), + env_to_sql(&b.and_then(|b| b.wrapped_item_key.clone()))?, + env_to_sql(&b.and_then(|b| b.payload.clone()))?, + b.map(|b| b.updated_by.as_bytes().to_vec()), + b.map(|b| b.updated_at.clone()), + ], + )?; + Ok(()) + } + + fn get(&self, vault: Uuid, item: Uuid) -> Result> { + let raw = self + .conn() + .query_row( + &format!("SELECT {ITEM_COLS} FROM item WHERE vault_id = ? AND item_id = ?"), + [vault.as_bytes().as_slice(), item.as_bytes().as_slice()], + raw_item, + ) + .optional()?; + raw.map(item_from_raw).transpose() + } + + fn list(&self, vault: Uuid) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare(&format!("SELECT {ITEM_COLS} FROM item WHERE vault_id = ? ORDER BY item_id"))?; + let raws = stmt.query_map([vault.as_bytes().as_slice()], raw_item)?.collect::>>()?; + raws.into_iter().map(item_from_raw).collect() + } + + fn remove(&self, vault: Uuid, item: Uuid) -> Result<()> { + self.conn().execute( + "DELETE FROM item WHERE vault_id = ? AND item_id = ?", + [vault.as_bytes().as_slice(), item.as_bytes().as_slice()], + )?; + Ok(()) + } + + fn push_history(&self, vault: Uuid, item: Uuid, e: &HistoryEntry) -> Result<()> { + let mut conn = self.conn(); + let tx = conn.transaction()?; + let (v, i) = (vault.as_bytes().as_slice(), item.as_bytes().as_slice()); + tx.execute( + "INSERT INTO history (vault_id, item_id, revision, wrapped_item_key, payload, saved_at) VALUES (?,?,?,?,?,?)", + params![ + v, + i, + e.revision as i64, + serde_json::to_string(&e.wrapped_item_key)?, + serde_json::to_string(&e.payload)?, + e.saved_at + ], + )?; + tx.execute( + "DELETE FROM history WHERE vault_id = ?1 AND item_id = ?2 AND seq NOT IN + (SELECT seq FROM history WHERE vault_id = ?1 AND item_id = ?2 ORDER BY seq DESC LIMIT ?3)", + params![v, i, HISTORY_LIMIT as i64], + )?; + tx.commit()?; + Ok(()) + } + + fn history(&self, vault: Uuid, item: Uuid) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare( + "SELECT revision, wrapped_item_key, payload, saved_at FROM history + WHERE vault_id = ? AND item_id = ? ORDER BY seq DESC", + )?; + let raws = stmt + .query_map([vault.as_bytes().as_slice(), item.as_bytes().as_slice()], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, i64>(3)?)) + })? + .collect::>>()?; + raws.into_iter() + .map(|(rev, wik, payload, at)| { + Ok(HistoryEntry { + revision: rev as u64, + wrapped_item_key: serde_json::from_str(&wik)?, + payload: serde_json::from_str(&payload)?, + saved_at: at, + }) + }) + .collect() + } + + fn wipe(&self) -> Result<()> { + self.conn().execute_batch("DELETE FROM history; DELETE FROM item; DELETE FROM vault; DELETE FROM meta;")?; + Ok(()) + } +} diff --git a/crates/kv-sync/Cargo.toml b/crates/kv-sync/Cargo.toml index 694a583..cc81ff5 100644 --- a/crates/kv-sync/Cargo.toml +++ b/crates/kv-sync/Cargo.toml @@ -21,10 +21,6 @@ rand_core.workspace = true zeroize.workspace = true opaque-ke.workspace = true reqwest.workspace = true -rustls.workspace = true -webpki-roots.workspace = true -rustls-webpki.workspace = true -tokio.workspace = true ciborium.workspace = true blake3.workspace = true p256.workspace = true @@ -32,6 +28,17 @@ time.workspace = true url.workspace = true wiremock = { workspace = true, optional = true } +# Native transport: rustls with SPKI pinning. In the browser (wasm32) reqwest uses fetch and the +# browser's TLS, so none of this applies there. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rustls.workspace = true +webpki-roots.workspace = true +rustls-webpki.workspace = true +tokio.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" + [features] # In-memory fake of kestrove-server for tests (also used by kv-ffi's tests). test-server = ["dep:wiremock"] diff --git a/crates/kv-sync/src/client.rs b/crates/kv-sync/src/client.rs index ca239e3..4686d57 100644 --- a/crates/kv-sync/src/client.rs +++ b/crates/kv-sync/src/client.rs @@ -1,9 +1,6 @@ //! Typed HTTP client for the sync API (docs/03, api/openapi.yaml). -use std::{ - sync::{Arc, Mutex}, - time::{SystemTime, UNIX_EPOCH}, -}; +use std::sync::{Arc, Mutex}; use kv_proto::{ AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, ItemPut, ItemRecord, KdfParams, LoginFinish, @@ -39,10 +36,6 @@ pub struct ApiClient { session: Mutex>, } -fn now_unix() -> u64 { - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) -} - impl ApiClient { pub fn new(cfg: &ServerConfig, signer: Arc) -> Result { Ok(Self { @@ -88,7 +81,15 @@ impl ApiClient { } if signed { let session = self.session().ok_or(SyncError::NotAuthenticated)?; - let h = signing::sign_request(self.signer.as_ref(), method.as_str(), &url, &body, &session, now_unix())?; + let h = signing::sign_request_async( + self.signer.as_ref(), + method.as_str(), + &url, + &body, + &session, + crate::unix_now(), + ) + .await?; for (k, v) in h.pairs() { req = req.header(k, v); } diff --git a/crates/kv-sync/src/engine.rs b/crates/kv-sync/src/engine.rs index 8d4db80..13b65c9 100644 --- a/crates/kv-sync/src/engine.rs +++ b/crates/kv-sync/src/engine.rs @@ -4,8 +4,6 @@ //! `If-Match` (on 412: pull, merge, retry), pull again, then publish a new manifest. Every pulled record is //! checked against the local base for per-item replay ([`crate::replay`]). -use std::time::{SystemTime, UNIX_EPOCH}; - use kv_crypto::{ aead::SymKey, keys::{open_item, seal_item, AccountKeyset, SealedItem}, @@ -48,11 +46,15 @@ pub struct SyncContext<'a> { } fn now() -> i64 { - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0) + crate::unix_now() as i64 } fn rfc3339_now() -> String { - time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339).unwrap_or_default() + // Not `OffsetDateTime::now_utc()`: it panics on wasm32-unknown-unknown. + time::OffsetDateTime::from_unix_timestamp(now()) + .ok() + .and_then(|t| t.format(&time::format_description::well_known::Rfc3339).ok()) + .unwrap_or_default() } fn crypto(e: kv_crypto::CryptoError) -> SyncError { diff --git a/crates/kv-sync/src/lib.rs b/crates/kv-sync/src/lib.rs index c21ff33..616a00f 100644 --- a/crates/kv-sync/src/lib.rs +++ b/crates/kv-sync/src/lib.rs @@ -1,7 +1,8 @@ //! Sync engine (milestone M1, kestrove-spec/docs/03). //! //! Responsibilities: -//! * [`transport`]: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin). +//! * [`transport`]: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin). On wasm32 (the web +//! vault) reqwest uses the browser's `fetch` and TLS instead, and pins do not apply. //! * [`opaque`]: OPAQUE login via `opaque-ke` (ristretto255 / SHA-512, identity KSF because the input is already //! Argon2id-stretched). //! * [`signing`]: RFC 9421 request signing with the device key. The key lives in the platform keystore, so signing is @@ -24,6 +25,8 @@ pub mod wire; pub use client::{ApiClient, LoginOutcome}; pub use engine::{SyncContext, SyncReport}; +use std::{future::Future, pin::Pin}; + use uuid::Uuid; #[derive(Debug, thiserror::Error)] @@ -71,13 +74,39 @@ impl From for SyncError { } } -/// Implemented by each platform: Secure Enclave, StrongBox, TPM or software fallback. +/// Future returned by [`DeviceSigner::sign_async`]. `Send` on native targets; not on wasm32, where +/// it wraps a WebCrypto promise (the browser runs the core on one thread). +#[cfg(not(target_arch = "wasm32"))] +pub type SignFuture<'a> = Pin, SyncError>> + Send + 'a>>; +#[cfg(target_arch = "wasm32")] +pub type SignFuture<'a> = Pin, SyncError>> + 'a>>; + +/// Implemented by each platform: Secure Enclave, StrongBox, TPM, WebCrypto or software fallback. pub trait DeviceSigner: Send + Sync { fn device_id(&self) -> Uuid; /// SEC1 uncompressed P-256 public key. fn public_key(&self) -> Vec; /// ECDSA P-256 / SHA-256 signature (IEEE P1363 r||s) over `message`. fn sign(&self, message: &[u8]) -> Result, SyncError>; + /// Asynchronous signing, used by [`ApiClient`]. Defaults to [`DeviceSigner::sign`]; the web vault + /// overrides it because WebCrypto only signs asynchronously. + fn sign_async<'a>(&'a self, message: &'a [u8]) -> SignFuture<'a> { + Box::pin(std::future::ready(self.sign(message))) + } +} + +/// Seconds since the Unix epoch. `SystemTime::now()` panics on wasm32-unknown-unknown, so the +/// browser build asks JavaScript's clock. +pub fn unix_now() -> u64 { + #[cfg(target_arch = "wasm32")] + { + (js_sys::Date::now() / 1000.0) as u64 + } + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) + } } /// Server endpoint plus pinned SPKI SHA-256 hashes (base64). diff --git a/crates/kv-sync/src/signing.rs b/crates/kv-sync/src/signing.rs index f0cd7fa..ffcdec6 100644 --- a/crates/kv-sync/src/signing.rs +++ b/crates/kv-sync/src/signing.rs @@ -53,6 +53,21 @@ pub fn sign_request( sign_request_with_nonce(signer, method, target_uri, body, session, now_unix, &nonce) } +/// [`sign_request`] through [`DeviceSigner::sign_async`] (the path [`crate::ApiClient`] uses). +pub async fn sign_request_async( + signer: &dyn DeviceSigner, + method: &str, + target_uri: &str, + body: &[u8], + session: &str, + now_unix: u64, +) -> Result { + let nonce = URL_SAFE_NO_PAD.encode(kv_crypto::random_bytes::<16>()); + let unsigned = Unsigned::new(signer, method, target_uri, body, session, now_unix, &nonce); + let sig = signer.sign_async(unsigned.base.as_bytes()).await?; + unsigned.finish(&sig) +} + fn sign_request_with_nonce( signer: &dyn DeviceSigner, method: &str, @@ -62,21 +77,49 @@ fn sign_request_with_nonce( now_unix: u64, nonce: &str, ) -> Result { - let digest = content_digest(body); - let ts = now_unix.to_string(); - let params = signature_params(now_unix as i64, signer.device_id(), nonce); - let base = signature_base(&method.to_ascii_uppercase(), target_uri, &digest, session, &ts, ¶ms); - let sig = signer.sign(base.as_bytes())?; - if sig.len() != 64 { - return Err(SyncError::Signer(format!("device signature must be 64 bytes (r||s), got {}", sig.len()))); + let unsigned = Unsigned::new(signer, method, target_uri, body, session, now_unix, nonce); + let sig = signer.sign(unsigned.base.as_bytes())?; + unsigned.finish(&sig) +} + +/// Everything but the signature: the signature base and the header values around it. +struct Unsigned { + digest: String, + session: String, + ts: String, + params: String, + base: String, +} + +impl Unsigned { + fn new( + signer: &dyn DeviceSigner, + method: &str, + target_uri: &str, + body: &[u8], + session: &str, + now_unix: u64, + nonce: &str, + ) -> Self { + let digest = content_digest(body); + let ts = now_unix.to_string(); + let params = signature_params(now_unix as i64, signer.device_id(), nonce); + let base = signature_base(&method.to_ascii_uppercase(), target_uri, &digest, session, &ts, ¶ms); + Self { digest, session: session.to_string(), ts, params, base } + } + + fn finish(self, sig: &[u8]) -> Result { + if sig.len() != 64 { + return Err(SyncError::Signer(format!("device signature must be 64 bytes (r||s), got {}", sig.len()))); + } + Ok(SignedHeaders { + content_digest: self.digest, + session: self.session, + timestamp: self.ts, + signature_input: signature_input_header(&self.params), + signature: signature_header(sig), + }) } - Ok(SignedHeaders { - content_digest: digest, - session: session.to_string(), - timestamp: ts, - signature_input: signature_input_header(¶ms), - signature: signature_header(&sig), - }) } /// Software P-256 device key: for tests and as the desktop fallback when no TPM is available. diff --git a/crates/kv-sync/src/transport.rs b/crates/kv-sync/src/transport.rs index 014e133..8d0ac8f 100644 --- a/crates/kv-sync/src/transport.rs +++ b/crates/kv-sync/src/transport.rs @@ -7,13 +7,18 @@ //! a primary and a backup pin so a key rotation never locks the client out. //! //! Plain `http://` is accepted only for loopback hosts (local development and tests). +//! +//! On wasm32 (the web vault) reqwest sends through the browser's `fetch`: the browser does TLS and the +//! page is served from the same origin as the API, so pinning and the rustls config are native only. +#[cfg(not(target_arch = "wasm32"))] use std::sync::Arc; use base64::{ engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, Engine, }; +#[cfg(not(target_arch = "wasm32"))] use rustls::{ client::{ danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, @@ -22,6 +27,7 @@ use rustls::{ pki_types::{CertificateDer, ServerName, UnixTime}, CertificateError, DigitallySignedStruct, OtherError, RootCertStore, SignatureScheme, }; +#[cfg(not(target_arch = "wasm32"))] use sha2::{Digest, Sha256}; use crate::{ServerConfig, SyncError}; @@ -48,22 +54,26 @@ pub fn parse_pin(pin: &str) -> Result<[u8; 32], SyncError> { raw.try_into().map_err(|_| SyncError::Config(format!("pin is not a SHA-256 hash: {pin}"))) } +#[cfg(not(target_arch = "wasm32"))] /// `base64(SHA-256(SPKI))` of a DER certificate. Useful for tooling and tests. pub fn spki_pin_of(cert_der: &[u8]) -> Result { Ok(STANDARD.encode(spki_hash(&CertificateDer::from(cert_der))?)) } +#[cfg(not(target_arch = "wasm32"))] fn spki_hash(cert: &CertificateDer<'_>) -> Result<[u8; 32], SyncError> { let parsed = webpki::EndEntityCert::try_from(cert).map_err(|e| SyncError::Config(format!("certificate: {e}")))?; Ok(Sha256::digest(parsed.subject_public_key_info().as_ref()).into()) } +#[cfg(not(target_arch = "wasm32"))] #[derive(Debug)] struct PinningVerifier { inner: Arc, pins: Vec<[u8; 32]>, } +#[cfg(not(target_arch = "wasm32"))] impl ServerCertVerifier for PinningVerifier { fn verify_server_cert( &self, @@ -112,6 +122,7 @@ impl ServerCertVerifier for PinningVerifier { } } +#[cfg(not(target_arch = "wasm32"))] /// Builds the rustls config: TLS 1.3 only, ring provider, Mozilla roots plus `extra_roots`, pin check. pub fn tls_config(pins: &[String], extra_roots: &[Vec]) -> Result { let pins = pins.iter().map(|p| parse_pin(p)).collect::, _>>()?; @@ -156,6 +167,7 @@ pub fn normalize_base_url(raw: &str) -> Result { Ok(url.as_str().trim_end_matches('/').to_string()) } +#[cfg(not(target_arch = "wasm32"))] pub fn http_client(cfg: &ServerConfig) -> Result { reqwest::Client::builder() .use_preconfigured_tls(tls_config(&cfg.spki_pins, &cfg.extra_roots_der)?) @@ -167,6 +179,18 @@ pub fn http_client(cfg: &ServerConfig) -> Result { .map_err(|e| SyncError::Config(e.to_string())) } +/// Browser `fetch`: TLS, timeouts and certificate checks belong to the browser. Pins are ignored. +#[cfg(target_arch = "wasm32")] +pub fn http_client(_cfg: &ServerConfig) -> Result { + reqwest::Client::builder().build().map_err(|e| SyncError::Config(e.to_string())) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn classify(e: reqwest::Error) -> SyncError { + SyncError::Network(e.to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] /// Maps a reqwest error to `PinMismatch` when the handshake failed on our pin check. pub(crate) fn classify(e: reqwest::Error) -> SyncError { let mut src: Option<&(dyn std::error::Error + 'static)> = Some(&e); diff --git a/crates/kv-wasm/Cargo.toml b/crates/kv-wasm/Cargo.toml new file mode 100644 index 0000000..9c9ebca --- /dev/null +++ b/crates/kv-wasm/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "kv-wasm" +description = "WebAssembly build of the Kestrove core for the web vault (IndexedDB store, WebCrypto device key)." +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +kv-proto.workspace = true +kv-crypto.workspace = true +kv-model.workspace = true +kv-store.workspace = true +kv-sync.workspace = true +kv-import.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +uuid.workspace = true +zeroize.workspace = true +sha2.workspace = true +serde_bytes = "0.11" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +serde-wasm-bindgen = "0.6" +# Browser randomness (crypto.getRandomValues) for uuid and rand_core/OsRng. +uuid = { workspace = true, features = ["js"] } +getrandom = { version = "0.2", features = ["js"] } +web-sys = { version = "0.3", features = [ + "Crypto", "SubtleCrypto", "CryptoKey", "CryptoKeyPair", "EcKeyGenParams", "EcdsaParams", + "IdbFactory", "IdbDatabase", "IdbOpenDbRequest", "IdbRequest", "IdbRequestReadyState", "IdbTransaction", + "IdbTransactionMode", "IdbObjectStore", "IdbObjectStoreParameters", "IdbVersionChangeEvent", "IdbCursorWithValue", + "DomException", "DomStringList", "Window", "WorkerGlobalScope", "Event", +] } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +kv-ffi = { path = "../kv-ffi" } +kv-store.workspace = true +kv-sync = { workspace = true, features = ["test-server"] } +tokio = { workspace = true } +tempfile.workspace = true + +# rustc targets these post-MVP features by default; wasm-opt must be told it may use them. +# `custom` applies to `--profile wasm-release`. +[package.metadata.wasm-pack.profile.release] +wasm-opt = [ + "-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext", + "--enable-mutable-globals", "--enable-reference-types", "--enable-multivalue", +] + +[package.metadata.wasm-pack.profile.custom] +wasm-opt = [ + "-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext", + "--enable-mutable-globals", "--enable-reference-types", "--enable-multivalue", +] diff --git a/crates/kv-wasm/README.md b/crates/kv-wasm/README.md new file mode 100644 index 0000000..150ae1a --- /dev/null +++ b/crates/kv-wasm/README.md @@ -0,0 +1,120 @@ +# kv-wasm: the Kestrove core for the web vault + +The web vault runs the same Rust core as the apps, compiled to WebAssembly: same 2SKD (Argon2id + +Secret Key), same envelopes, same item model, same OPAQUE login, RFC 9421 request signing and sync +engine (three-way merge, signed manifests, replay checks). The server stays zero-knowledge: only +ciphertext leaves the browser. + +| Concern | Apps (kv-ffi) | Web (kv-wasm) | +|---|---|---| +| Local replica | SQLite (`SqliteStore`) | `WebStore`: in-memory, each changed entry written to IndexedDB before the call resolves | +| Transport | reqwest + rustls, TLS 1.3, SPKI pins | reqwest over `fetch`; the browser's TLS; same origin; pins accepted but not enforced | +| Device key | Secure Enclave / StrongBox / TPM | non-extractable WebCrypto ECDSA P-256 key, kept in IndexedDB | +| Randomness, clock | OS | `crypto.getRandomValues`, `Date.now()` | +| Biometric unlock (LUK) | yes | **no**: every unlock needs master password + Secret Key | +| URL matching | eTLD+1 via the Public Suffix List | same host only (the list would be a third of the download) | +| Passkeys | create, assert, list, delete | list, delete | + +## Build and test + +```sh +crates/kv-wasm/build.sh # pkg/: wasm-pack (size profile, wasm-opt -Oz) + worker files +wasm-pack test --headless --chrome crates/kv-wasm # in Chrome: IndexedDB, WebCrypto, native fixture +node crates/kv-wasm/js/smoke.mjs # the built package in Node (in-memory), Argon2 timing +cargo test -p kv-wasm # native: store, fixtures vs kv-ffi, sync with kv-ffi via test server +``` + +`wasm-pack build crates/kv-wasm --target web` alone also works (opt-level 3, a bit bigger). +`smoke.mjs` runs a two-device sync round trip against a real server when `KESTROVE_SERVER_URL` and +`KESTROVE_SETUP_TOKEN` are set (see the file header). + +**Size** (`build.sh`): `kv_wasm_bg.wasm` 1.27 MB, **572 KB gzip**, 467 KB brotli. JS glue ~60 KB. +Serve it with compression and `Content-Type: application/wasm`. + +**Argon2id** (m = 64 MiB, t = 3, p = 4, the account default): about **100–120 ms** per derivation in +Chrome and Node on an Apple Silicon Mac (native is in the same range). Expect several times that on low-end phones. +It runs on the calling thread, hence the worker below. + +**Envelope compatibility** is checked both ways with shared fixtures in `tests/fixtures/`: +`native-vault.json` is made by kv-ffi and opened in Chrome; `wasm-vault.json` is made by the wasm +package and opened by kv-ffi (`tests/fixture.rs`). + +## JS API + +The generated `pkg/kv_wasm.d.ts` is the reference. It mirrors kv-ffi's `VaultCore` in camelCase; +records are plain objects with the same fields as kv-ffi's records (`ItemDto`, `ItemSummary`, +`SyncReport`, `DeviceDto`, `PasskeySummary`, `ImportPreview`, …); enums are camelCase strings +(`"login" | "secureNote" | "card" | "identity"`, `"ok" | "pendingApproval"`); bytes are `Uint8Array`; +absent optionals come back as `null`. **Every `VaultCore` method returns a `Promise`.** + +```ts +class VaultCore { + static open(dbName?: string | null): Promise; // IndexedDB, default "kestrove" + static newInMemory(): Promise; // persists nothing + createAccount(masterPassword): Promise; // -> Secret Key. Argon2id + unlock(masterPassword, secretKey): Promise; // Argon2id + lock(): Promise; isLocked(); hasAccount(); accountId(); accountFingerprint(); + listItems(); getItem(id); saveItem(item: ItemDto): Promise; deleteItem(id); + itemHistory(id); itemsForUrl(pageUrl); listPasskeys(); deletePasskey(credentialId: Uint8Array); + importPreview(data: Uint8Array, format: ImportFormat); importCommit(data, format, options); + exportEncrypted(passphrase): Promise; // Argon2id + configureServer(url, spkiPins?); setDevice(name, platform?); deviceId(); deviceKeyFingerprint(); + serverInfo(); registerAccount(setupToken); login(masterPassword, secretKey, totp?): Promise; + syncNow(): Promise; listDevices(); approveDevice(deviceId); revokeDevice(deviceId); + logout(); isLoggedIn(); wipe(); +} +generatePassword(length, symbols): string; totpCode(secretOrUri, unixTime): TotpCode; +matchUrl(itemUrl, pageUrl): MatchLevel; coreVersion(): string; +``` + +Errors reject with an `Error` whose `name` is `"CoreError"` and whose `code` is one of kv-ffi's +`CoreError` cases (`Locked`, `NoAccount`, `BadCredentials`, `Invalid`, `NotFound`, `Internal`, +`NotConfigured`, `NotAuthenticated`, `PendingApproval`, `Network`, `Server` + `status`, +`IntegrityAlarm` + `vaultId`/`reason`), plus `Busy` (a sync is already running) and, from the worker +client, `InUse` (another tab has the vault open). + +### Use it from a Web Worker + +`createAccount`, `unlock`, `login`, `exportEncrypted` and importing a `.kvex` run Argon2id and block +their thread. The package ships a worker and a typed client: + +```ts +import { connectVault } from "@kestrove/kv-wasm/client.js"; +const vault = await connectVault(); // RemoteVault: every VaultCore method + free functions, async +await vault.configureServer(location.origin); +if (await vault.hasAccount()) await vault.unlock(password, secretKey); +const items = await vault.listItems(); +await vault.close(); // frees the core (zeroizing keys) and ends the worker +``` + +The worker holds a Web Lock per database for its lifetime, so a second tab gets `InUse` instead of +two replicas overwriting each other. + +## What the web UI must handle + +1. **Run the core in the worker** (`connectVault`), never on the main thread. +2. **Same origin.** Serve the web vault from the server's origin and call + `configureServer(location.origin)`. CSP needs `script-src 'self' 'wasm-unsafe-eval'` and + `worker-src 'self'`; `connect-src 'self'`. +3. **Enrollment.** The browser is a device. A first `login` returns `"pendingApproval"`: show + `deviceKeyFingerprint()` and ask the user to approve it on an enrolled device (which shows the same + fingerprint in `listDevices`), then call `login` again. `setDevice("Firefox on Mac")` names it; + the platform defaults to the OS from the user agent because the server has no `web` platform yet. +4. **Every unlock needs the master password and Secret Key.** There is no Local Unlock Key on the + web: nothing secret is ever written to IndexedDB, so a reload, `lock()` or closing the tab + leaves the vault locked. Lock on idle and on `visibilitychange`/`pagehide` as the UI sees fit; + `close()` drops the worker and every key in it. +5. **Secrets in JS.** Master password, Secret Key and decrypted `ItemDto`s are JS strings the + core cannot wipe. Do not keep them in long-lived state, logs or URLs. The core zeroizes its own + copies. +6. **Sync.** Call `syncNow()` after login, on an interval and on focus. Handle `Busy` (skip), + `NotAuthenticated` (ask for the password and `login` again), `PendingApproval`, + `IntegrityAlarm` (stop and alert: the server rolled back or tampered with a vault) and + `restoredItemIds` (tell the user an edit brought a deleted item back). +7. **Storage.** Call `navigator.storage.persist()`. If the browser evicts IndexedDB, unsynced edits + and the device key are lost and the browser must be approved again. +8. **Sign out of this browser:** `logout()` (ends the server session), then `wipe()` (removes the + replica and device key; the next login is a new device). +9. **Import/export:** pass `new Uint8Array(await file.arrayBuffer())`; `exportEncrypted` resolves with + the `.kvex` bytes for a `Blob` download. +10. `itemsForUrl` / `matchUrl` match the same host only (no Public Suffix List in this build). diff --git a/crates/kv-wasm/build.sh b/crates/kv-wasm/build.sh new file mode 100755 index 0000000..db44108 --- /dev/null +++ b/crates/kv-wasm/build.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Builds the web vault package: wasm-pack (size profile, wasm-opt -Oz) plus the Web Worker wrapper. +# crates/kv-wasm/build.sh -> crates/kv-wasm/pkg/ +# Then run the Node smoke test: node crates/kv-wasm/js/smoke.mjs +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +cd "$here" + +wasm-pack build . --target web --profile wasm-release --out-dir pkg --scope kestrove --out-name kv_wasm +cp js/worker.js js/client.js js/client.d.ts pkg/ + +# Ship the worker files and point the package's types at the client (it re-exports everything). +node -e ' + const fs = require("fs"); + const p = JSON.parse(fs.readFileSync("pkg/package.json", "utf8")); + p.files = Array.from(new Set([...(p.files || []), "worker.js", "client.js", "client.d.ts"])); + p.exports = { + ".": { types: "./kv_wasm.d.ts", default: "./kv_wasm.js" }, + "./client.js": { types: "./client.d.ts", default: "./client.js" }, + "./worker.js": "./worker.js", + "./kv_wasm_bg.wasm": "./kv_wasm_bg.wasm", + }; + fs.writeFileSync("pkg/package.json", JSON.stringify(p, null, 2) + "\n"); +' + +wasm="pkg/kv_wasm_bg.wasm" +printf 'kv_wasm_bg.wasm: %s bytes, gzip -9 %s, brotli %s\n' \ + "$(wc -c <"$wasm" | tr -d ' ')" "$(gzip -9 -c "$wasm" | wc -c | tr -d ' ')" \ + "$( (command -v brotli >/dev/null && brotli -c -q 11 "$wasm" | wc -c | tr -d ' ') || echo n/a)" diff --git a/crates/kv-wasm/js/client.d.ts b/crates/kv-wasm/js/client.d.ts new file mode 100644 index 0000000..e8ee320 --- /dev/null +++ b/crates/kv-wasm/js/client.d.ts @@ -0,0 +1,41 @@ +import type { + VaultCore, + CoreError, + CoreErrorCode, + generatePassword, + totpCode, + matchUrl, + coreVersion, +} from "./kv_wasm.js"; + +export * from "./kv_wasm.js"; + +type Async = F extends (...args: infer A) => infer R ? (...args: A) => Promise> : never; + +type CoreMethods = { + [K in keyof VaultCore as K extends "free" | typeof Symbol.dispose ? never : K]: Async; +}; + +/** `VaultCore` running in a Web Worker: same methods, all async, plus the free functions. */ +export type RemoteVault = CoreMethods & { + generatePassword: Async; + totpCode: Async; + matchUrl: Async; + coreVersion: Async; + /** Frees the core (zeroizing keys), releases the tab lock and terminates the worker. */ + close(): Promise; +}; + +/** `"InUse"`: another tab already has this vault open (one tab per vault). */ +export type ClientErrorCode = CoreErrorCode | "InUse"; + +export interface ConnectOptions { + /** IndexedDB database name. Default "kestrove". */ + dbName?: string; + /** Persist nothing (previews, tests). */ + inMemory?: boolean; + /** Bring your own worker (e.g. a bundler's `new Worker(new URL(...))`). It must run worker.js. */ + worker?: Worker; +} + +export function connectVault(options?: ConnectOptions): Promise; diff --git a/crates/kv-wasm/js/client.js b/crates/kv-wasm/js/client.js new file mode 100644 index 0000000..259144a --- /dev/null +++ b/crates/kv-wasm/js/client.js @@ -0,0 +1,60 @@ +// Page-side handle to the vault worker. Every VaultCore method (and the free functions) becomes an +// async call into the worker; errors come back as `CoreError`s with the same `code`s. +// +// import { connectVault } from "@kestrove/core/client.js"; +// const vault = await connectVault(); // IndexedDB "kestrove" +// if (!(await vault.hasAccount())) { ... } +// await vault.unlock(password, secretKey); // Argon2id runs in the worker +// const items = await vault.listItems(); + +export async function connectVault(options = {}) { + const worker = + options.worker ?? new Worker(new URL("./worker.js", import.meta.url), { type: "module", name: "kestrove-vault" }); + let next = 1; + const pending = new Map(); + + worker.onmessage = ({ data: { id, ok, err } }) => { + const p = pending.get(id); + if (!p) return; + pending.delete(id); + if (err) p.reject(Object.assign(new Error(err.message), err)); + else p.resolve(ok); + }; + worker.onerror = (event) => { + const e = Object.assign(new Error(event.message || "vault worker failed"), { name: "CoreError", code: "Internal" }); + for (const p of pending.values()) p.reject(e); + pending.clear(); + }; + + const call = (method, args) => + new Promise((resolve, reject) => { + const id = next++; + pending.set(id, { resolve, reject }); + worker.postMessage({ id, method, args }); + }); + + try { + await call("open", [options.dbName ?? null, Boolean(options.inMemory)]); + } catch (e) { + worker.terminate(); // e.g. "InUse": the vault is open in another tab + throw e; + } + + const handle = { + /** Frees the core, releases the tab lock and stops the worker (key material goes with it). */ + async close() { + try { + await call("close", []); + } finally { + worker.terminate(); + } + }, + }; + return new Proxy(handle, { + get(target, prop) { + if (prop in target) return target[prop]; + if (typeof prop !== "string" || prop === "then") return undefined; + return (...args) => call(prop, args); + }, + }); +} diff --git a/crates/kv-wasm/js/smoke.mjs b/crates/kv-wasm/js/smoke.mjs new file mode 100644 index 0000000..27dd9d5 --- /dev/null +++ b/crates/kv-wasm/js/smoke.mjs @@ -0,0 +1,149 @@ +// Node smoke test of the built package (pkg/, `--target web`): the generated JS glue, camelCase +// records, CoreError codes, Argon2id timing. No IndexedDB in Node, so it uses in-memory vaults +// (the IndexedDB path is covered by `wasm-pack test --headless --chrome`). +// +// node crates/kv-wasm/js/smoke.mjs # checks + timing +// node crates/kv-wasm/js/smoke.mjs --write-fixture # also writes tests/fixtures/wasm-vault.json +// +// With a fresh local kestrove-server it also runs a two-device sync round trip, both devices in wasm +// (see crates/kv-ffi/tests/live_server.rs for starting one; it takes ~2 minutes because of the +// server's auth rate limit): +// +// KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN= node crates/kv-wasm/js/smoke.mjs + +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import init, * as kv from "../pkg/kv_wasm.js"; + +const pkg = new URL("../pkg/", import.meta.url); +await init({ module_or_path: await readFile(new URL("kv_wasm_bg.wasm", pkg)) }); + +const PW = "wasm fixture master password"; +const ms = (t) => `${Math.round(performance.now() - t)} ms`; + +async function rejects(promise, code) { + try { + await promise; + } catch (e) { + assert.equal(e.name, "CoreError"); + assert.equal(e.code, code, e.message); + return e; + } + assert.fail(`expected ${code}`); +} + +function login(title, password, extra = {}) { + return { + id: "", + kind: "login", + title, + username: "ana@example.com", + password, + urls: ["https://example.com/login"], + totp: null, + notes: null, + favorite: false, + tags: [], + modifiedAt: 0, + ...extra, + }; +} + +// ------------------------------------------------------------ local checks, Argon2 timing + +const a = await kv.VaultCore.newInMemory(); +await rejects(a.unlock(PW, "K1-AAAAAA-AAAAA-AAAAA-AAAAA-AAAAA-A"), "NoAccount"); +let t = performance.now(); +const secretKey = await a.createAccount(PW); +console.log(`createAccount (Argon2id m=64 MiB t=3 p=4): ${ms(t)}`); +await a.lock(); +t = performance.now(); +await a.unlock(PW, secretKey); +console.log(`unlock (Argon2id m=64 MiB t=3 p=4): ${ms(t)}`); + +const bankId = await a.saveItem( + login("Bank", "first password", { + totp: "otpauth://totp/Bank:ana?secret=JBSWY3DPEHPK3PXP&issuer=Bank", + tags: ["finance", "ünïcode ✓"], + favorite: true, + }), +); +const bank = await a.getItem(bankId); +assert.equal(bank.password, "first password"); +assert.equal(bank.notes, null); +await a.saveItem({ ...bank, password: "second password" }); +await a.saveItem({ id: "", kind: "secureNote", title: "Recovery codes", notes: "multi\nline 🔐" }); +const list = await a.listItems(); +assert.deepEqual( + list.map((i) => [i.title, i.kind, i.hasTotp]), + [ + ["Bank", "login", true], + ["Recovery codes", "secureNote", false], + ], +); +assert.equal((await a.itemHistory(bankId))[0].password, "first password"); +await rejects(a.getItem("not-a-uuid"), "Invalid"); +const code = kv.totpCode("JBSWY3DPEHPK3PXP", Math.floor(Date.now() / 1000)); +assert.match(code.code, /^\d{6}$/); +assert.equal(kv.matchUrl("example.com", "https://example.com/x"), "exact"); +assert.equal(kv.generatePassword(20, false).length, 20); +const exported = await a.exportEncrypted("export passphrase"); +assert.ok(exported instanceof Uint8Array && exported.length > 100); + +if (process.argv.includes("--write-fixture")) { + const items = []; + for (const s of await a.listItems()) items.push(await a.getItem(s.id)); + items.sort((x, y) => (x.title < y.title ? -1 : 1)); + const fixture = { masterPassword: PW, secretKey, entries: await a.__replicaEntries(), items }; + const path = new URL("../tests/fixtures/wasm-vault.json", import.meta.url); + await writeFile(path, JSON.stringify(fixture, null, 2) + "\n"); + console.log(`wrote ${path.pathname}`); +} + +// ------------------------------------------------------------ live server round trip + +const url = process.env.KESTROVE_SERVER_URL; +const token = process.env.KESTROVE_SETUP_TOKEN; +if (url && token) { + const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000)); + await a.setDevice("wasm device A", "linux"); + await a.configureServer(url, []); + assert.equal((await a.serverInfo()).setupOpen, true, "server must be fresh"); + await a.registerAccount(token); + let r = await a.syncNow(); + assert.equal(r.pushed, 2); + console.log("A registered and pushed", r); + + console.log("waiting 61 s for the server's auth rate limit…"); + await sleep(61); + const b = await kv.VaultCore.newInMemory(); + await b.setDevice("wasm device B", "macos"); + await b.configureServer(url, []); + let res = await b.login(PW, secretKey, null); + if (res === "pendingApproval") { + const pending = (await a.listDevices()).find((d) => !d.approved); + assert.equal(pending.keyFingerprint, await b.deviceKeyFingerprint()); + await a.approveDevice(pending.deviceId); + console.log("A approved B; waiting 61 s again…"); + await sleep(61); + res = await b.login(PW, secretKey, null); + } + assert.equal(res, "ok"); + r = await b.syncNow(); + console.log("B synced", r); + const bItems = await b.listItems(); + assert.deepEqual(bItems.map((i) => i.title).sort(), ["Bank", "Recovery codes"]); + const onB = await b.getItem(bankId); + assert.equal(onB.password, "second password"); + await b.saveItem({ ...onB, notes: "edited on B" }); + assert.equal((await b.syncNow()).pushed, 1); + await a.syncNow(); + assert.equal((await a.getItem(bankId)).notes, "edited on B"); + await b.deleteItem((await b.listItems()).find((i) => i.title === "Recovery codes").id); + await b.syncNow(); + await a.syncNow(); + assert.deepEqual((await a.listItems()).map((i) => i.title), ["Bank"]); + console.log("live round trip ok"); +} + +console.log("smoke ok"); diff --git a/crates/kv-wasm/js/worker.js b/crates/kv-wasm/js/worker.js new file mode 100644 index 0000000..327b024 --- /dev/null +++ b/crates/kv-wasm/js/worker.js @@ -0,0 +1,84 @@ +// Kestrove vault worker: owns the wasm VaultCore so Argon2id (createAccount, unlock, login, +// encrypted export/import) never blocks the page. Started by `connectVault()` in client.js. +// +// Protocol (structured clone): {id, method, args} -> {id, ok: value} | {id, err: {name, message, code, ...}}. +// The first message must be {id, method: "open", args: [dbName | null, inMemory]}. + +import init, * as kv from "./kv_wasm.js"; + +let core = null; +let releaseLock = null; + +function serializeError(e) { + if (e && typeof e === "object") { + return { + name: e.name || "Error", + message: e.message || String(e), + code: e.code ?? "Internal", + status: e.status, + vaultId: e.vaultId, + reason: e.reason, + }; + } + return { name: "CoreError", message: String(e), code: "Internal" }; +} + +// One tab per vault: two in-memory replicas over the same IndexedDB database would overwrite +// each other's writes. The lock is held for the worker's lifetime. +async function acquireLock(dbName) { + if (!self.navigator?.locks) return true; // very old browsers: best effort + return new Promise((resolve) => { + self.navigator.locks.request(`kestrove-vault:${dbName}`, { ifAvailable: true }, (lock) => { + if (!lock) { + resolve(false); + return undefined; + } + resolve(true); + return new Promise((release) => (releaseLock = release)); + }); + }); +} + +async function open(dbName, inMemory) { + await init({ module_or_path: new URL("./kv_wasm_bg.wasm", import.meta.url) }); + if (inMemory) { + core = await kv.VaultCore.newInMemory(); + return null; + } + const name = dbName || "kestrove"; + if (!(await acquireLock(name))) { + const e = new Error("the vault is open in another tab"); + e.name = "CoreError"; + e.code = "InUse"; + throw e; + } + core = await kv.VaultCore.open(name); + return null; +} + +const FREE_FUNCTIONS = new Set(["generatePassword", "totpCode", "matchUrl", "coreVersion"]); + +self.onmessage = async ({ data: { id, method, args } }) => { + try { + let value; + if (method === "open") { + value = await open(...args); + } else if (method === "close") { + core?.free(); + core = null; + releaseLock?.(); + value = null; + } else if (FREE_FUNCTIONS.has(method)) { + value = kv[method](...args); + } else if (!core) { + throw Object.assign(new Error("vault is not open"), { name: "CoreError", code: "NotConfigured" }); + } else if (typeof core[method] !== "function" || method.startsWith("_") || method === "free") { + throw Object.assign(new Error(`unknown method ${method}`), { name: "CoreError", code: "Invalid" }); + } else { + value = await core[method](...args); + } + self.postMessage({ id, ok: value }); + } catch (e) { + self.postMessage({ id, err: serializeError(e) }); + } +}; diff --git a/crates/kv-wasm/src/core.rs b/crates/kv-wasm/src/core.rs new file mode 100644 index 0000000..0f65e06 --- /dev/null +++ b/crates/kv-wasm/src/core.rs @@ -0,0 +1,865 @@ +//! 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, + #[serde(with = "b64")] + pub sig_pk: Vec, + pub primary_vault: Uuid, +} + +#[derive(Clone, Serialize, Deserialize)] +struct ServerRecord { + url: String, + pins: Vec, +} + +/// Decrypted key material. Lives only in wasm memory; zeroized on drop (`lock`). +#[derive(Clone)] +struct Unlocked { + keyset: Arc, + vault_key: Arc, + /// OPAQUE password, kept while unlocked so an expired session can be renewed without prompting. + auth_input: Option>, +} + +struct Device { + signer: Arc, + name: String, + platform: Platform, +} + +#[derive(Default)] +struct Inner { + account: Option, + unlocked: Option, + server: Option, + device: Option, + api: Option>, +} + +pub struct WebCore { + store: Arc, + inner: Mutex, + 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) -> Result { + let account = store + .get_meta(META_ACCOUNT)? + .map(|b| serde_json::from_slice::(&b)) + .transpose() + .map_err(internal)?; + let server = store.get_meta(META_SERVER)?.and_then(|b| serde_json::from_slice::(&b).ok()); + Ok(Self { + store, + inner: Mutex::new(Inner { account, server, ..Default::default() }), + syncing: AtomicBool::new(false), + }) + } + + pub fn store(&self) -> &Arc { + &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 { + 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, ¶ms)?; + 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 { + 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 { + 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> { + let (acct, u) = self.unlocked_snapshot()?; + let mut out: Vec = 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 { + 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 { + 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> { + 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> { + 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> { + 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 { + 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 { + 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> { + 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) -> 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, 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 { + self.lock_inner().device.as_ref().map(|d| d.signer.device_id()) + } + + pub fn device_key_fingerprint(&self) -> Result { + 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 { + 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) -> Result { + 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 { + 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> { + 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 { + 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 { + 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> { + 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, + 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 { + 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> { + 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, + ) -> Result { + 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 { + 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> { + 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 { + 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::parse_str(s).map_err(|_| CoreError::Invalid { msg: "id must be a UUID".into() }) +} diff --git a/crates/kv-wasm/src/dto.rs b/crates/kv-wasm/src/dto.rs new file mode 100644 index 0000000..8fcd5b7 --- /dev/null +++ b/crates/kv-wasm/src/dto.rs @@ -0,0 +1,180 @@ +//! Records crossing the JS boundary. Same fields as kv-ffi's UniFFI records, in camelCase; enums are +//! camelCase strings (`"login"`, `"secureNote"`, `"pendingApproval"`); byte fields are `Uint8Array`. +//! The TypeScript declarations for these live in `wasm.rs` (`TS_TYPES`); keep both in step. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ItemKindDto { + Login, + SecureNote, + Card, + Identity, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MatchLevelDto { + None, + Domain, + Host, + Exact, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PlatformDto { + Ios, + Macos, + Android, + Windows, + Linux, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ItemSummary { + pub id: String, + pub kind: ItemKindDto, + pub title: String, + pub username: Option, + pub urls: Vec, + pub favorite: bool, + pub has_totp: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ItemDto { + /// Empty string means "new item". + #[serde(default)] + pub id: String, + pub kind: ItemKindDto, + pub title: String, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub password: Option, + #[serde(default)] + pub urls: Vec, + #[serde(default)] + pub totp: Option, + #[serde(default)] + pub notes: Option, + #[serde(default)] + pub favorite: bool, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub modified_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TotpCode { + pub code: String, + pub seconds_remaining: u64, + pub period: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerInfoDto { + pub version: String, + pub api: u32, + pub setup_open: bool, + pub min_client: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LoginResult { + /// Logged in and unlocked. + Ok, + /// An enrolled device must approve this browser (or pass `totp`); call `login` again afterwards. + PendingApproval, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncReport { + pub pulled: u32, + pub pushed: u32, + pub merged: u32, + pub restored_item_ids: Vec, + pub vaults_synced: u32, + pub vaults_skipped: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceDto { + pub device_id: String, + pub name: String, + pub platform: PlatformDto, + pub approved: bool, + pub revoked: bool, + pub current: bool, + pub created_at: String, + pub key_fingerprint: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeySummary { + pub item_id: String, + #[serde(with = "serde_bytes")] + pub credential_id: Vec, + pub rp_id: String, + #[serde(with = "serde_bytes")] + pub user_handle: Vec, + pub user_name: String, + pub created_at: i64, +} + +/// `{ type: "onePux" } | { type: "csv" } | { type: "kestroveEncrypted", passphrase }`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ImportFormat { + OnePux, + Csv, + KestroveEncrypted { passphrase: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DuplicateHandling { + Skip, + AddSuffix, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportOptions { + pub duplicates: DuplicateHandling, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportPreview { + pub total: u32, + pub logins: u32, + pub secure_notes: u32, + pub cards: u32, + pub identities: u32, + pub duplicates: u32, + pub attachments_skipped: u32, + pub warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportReport { + pub imported: u32, + pub skipped_duplicates: u32, + pub renamed_duplicates: u32, + pub attachments_skipped: u32, + pub warnings: Vec, + pub item_ids: Vec, +} diff --git a/crates/kv-wasm/src/error.rs b/crates/kv-wasm/src/error.rs new file mode 100644 index 0000000..30d76da --- /dev/null +++ b/crates/kv-wasm/src/error.rs @@ -0,0 +1,132 @@ +//! Same error cases as kv-ffi's `CoreError`. In JavaScript they arrive as a thrown `Error` whose +//! `name` is `"CoreError"` and whose `code` is the variant name (see `wasm.rs`). + +use kv_crypto::CryptoError; +use kv_store::StoreError; +use kv_sync::SyncError; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CoreError { + #[error("vault is locked")] + Locked, + #[error("no account in this browser")] + NoAccount, + #[error("wrong master password or Secret Key")] + BadCredentials, + #[error("invalid input: {msg}")] + Invalid { msg: String }, + #[error("not found")] + NotFound, + #[error("internal error: {msg}")] + Internal { msg: String }, + /// `configure_server` has not been called, or the device key is missing. + #[error("sync server is not configured")] + NotConfigured, + /// The session expired and the vault was not unlocked with the master password in this session. + #[error("not logged in to the sync server")] + NotAuthenticated, + /// This browser is waiting for approval from an enrolled device. + #[error("device approval pending")] + PendingApproval, + #[error("network error: {msg}")] + Network { msg: String }, + /// Native only (TLS pinning); never raised in the browser. Kept so the code set matches kv-ffi. + #[error("server certificate does not match the pinned keys")] + PinMismatch, + #[error("server error {status}: {msg}")] + Server { status: u16, msg: String }, + #[error("integrity alarm on vault {vault_id}: {reason}")] + IntegrityAlarm { vault_id: String, reason: String }, + /// Web only: another `sync_now` is still running in this vault. + #[error("a sync is already running")] + Busy, +} + +impl CoreError { + /// The variant name, used as `error.code` in JavaScript. + pub fn code(&self) -> &'static str { + match self { + CoreError::Locked => "Locked", + CoreError::NoAccount => "NoAccount", + CoreError::BadCredentials => "BadCredentials", + CoreError::Invalid { .. } => "Invalid", + CoreError::NotFound => "NotFound", + CoreError::Internal { .. } => "Internal", + CoreError::NotConfigured => "NotConfigured", + CoreError::NotAuthenticated => "NotAuthenticated", + CoreError::PendingApproval => "PendingApproval", + CoreError::Network { .. } => "Network", + CoreError::PinMismatch => "PinMismatch", + CoreError::Server { .. } => "Server", + CoreError::IntegrityAlarm { .. } => "IntegrityAlarm", + CoreError::Busy => "Busy", + } + } +} + +pub type Result = std::result::Result; + +pub fn internal(e: impl std::fmt::Display) -> CoreError { + CoreError::Internal { msg: e.to_string() } +} + +impl From for CoreError { + fn from(e: CryptoError) -> Self { + match e { + CryptoError::Decrypt => CoreError::BadCredentials, + CryptoError::BadSecretKey => CoreError::Invalid { msg: "Secret Key".into() }, + other => CoreError::Internal { msg: other.to_string() }, + } + } +} + +impl From for CoreError { + fn from(e: StoreError) -> Self { + match e { + StoreError::NotFound => CoreError::NotFound, + StoreError::Io(msg) => CoreError::Internal { msg }, + } + } +} + +impl From for CoreError { + fn from(e: SyncError) -> Self { + match e { + SyncError::Network(msg) => CoreError::Network { msg }, + SyncError::PinMismatch => CoreError::PinMismatch, + SyncError::Config(msg) => CoreError::Invalid { msg }, + SyncError::Server { status, code, message } => { + CoreError::Server { status, msg: format!("{code}: {message}") } + } + SyncError::Integrity { vault_id, reason } => { + CoreError::IntegrityAlarm { vault_id: vault_id.to_string(), reason } + } + SyncError::NotAuthenticated => CoreError::NotAuthenticated, + SyncError::BadCredentials => CoreError::BadCredentials, + SyncError::NotFound => CoreError::NotFound, + other => CoreError::Internal { msg: other.to_string() }, + } + } +} + +impl From for CoreError { + fn from(e: kv_import::ImportError) -> Self { + use kv_import::ImportError; + match e { + ImportError::BadPassphrase => CoreError::BadCredentials, + ImportError::Crypto => CoreError::Internal { msg: e.to_string() }, + other => CoreError::Invalid { msg: other.to_string() }, + } + } +} + +impl From for CoreError { + fn from(e: kv_model::passkey::PasskeyError) -> Self { + use kv_model::passkey::PasskeyError; + match e { + PasskeyError::RpMismatch => CoreError::NotFound, + PasskeyError::BadKey => CoreError::Internal { msg: e.to_string() }, + other => CoreError::Invalid { msg: other.to_string() }, + } + } +} diff --git a/crates/kv-wasm/src/lib.rs b/crates/kv-wasm/src/lib.rs new file mode 100644 index 0000000..0770cb6 --- /dev/null +++ b/crates/kv-wasm/src/lib.rs @@ -0,0 +1,21 @@ +//! The Kestrove core for the web vault, compiled to WebAssembly (`wasm-pack build crates/kv-wasm`). +//! +//! The browser runs the same crypto, item model and sync engine as the apps; the server stays +//! zero-knowledge. Platform pieces replaced for the browser: +//! * storage: [`store::WebStore`], an in-memory replica persisted entry by entry to IndexedDB; +//! * network: reqwest over `fetch` (the browser's TLS, same origin as the web vault; no SPKI pins); +//! * device key: a non-extractable WebCrypto ECDSA P-256 key kept in IndexedDB; +//! * randomness and time: `crypto.getRandomValues` and `Date.now()`. +//! +//! [`core::WebCore`] holds the logic and is plain Rust (tested natively too); the `wasm` module is +//! the `wasm-bindgen` surface. See `README.md` for the JS API and how the UI should call it. + +pub mod core; +pub mod dto; +pub mod error; +pub mod store; +#[cfg(target_arch = "wasm32")] +mod wasm; + +pub use crate::core::WebCore; +pub use error::CoreError; diff --git a/crates/kv-wasm/src/store.rs b/crates/kv-wasm/src/store.rs new file mode 100644 index 0000000..44b4272 --- /dev/null +++ b/crates/kv-wasm/src/store.rs @@ -0,0 +1,445 @@ +//! 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/` | raw meta bytes (account record, server, session) | +//! | `vault/` | [`VaultEntry`] | +//! | `item//` | [`ItemEntry`] (working copy + base) | +//! | `history//` | 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), + 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, + cursor: u64, + manifest_version: u64, + manifest_hash: Option<[u8; 32]>, + integrity_alarm: bool, +} + +#[derive(Serialize, Deserialize)] +struct ItemEntry { + record: ItemRecord, + pending: bool, + base: Option, +} + +#[derive(Serialize, Deserialize)] +struct HistoryEntryJson { + revision: u64, + wrapped_item_key: Envelope, + payload: Envelope, + saved_at: i64, +} + +#[derive(Default)] +struct Mem { + meta: HashMap>, + /// (order, row) + vaults: Vec<(u64, VaultRow)>, + items: HashMap>, + history: HashMap<(Uuid, Uuid), Vec>, + next_order: u64, + dirty: BTreeSet, + cleared: bool, +} + +#[derive(Default)] +pub struct WebStore { + inner: Mutex, +} + +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(v: &T) -> Vec { + // 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)>) -> Result { + 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 = 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 { + 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)> { + let m = self.mem(); + let mut keys: Vec = 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> { + 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 = 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>> { + 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> { + Ok(self.mem().vaults.iter().find(|(_, v)| v.vault_id == vault).map(|(_, v)| v.clone())) + } + + fn vaults(&self) -> Result> { + 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> { + Ok(self.mem().items.get(&vault).and_then(|v| v.get(&item)).cloned()) + } + + fn list(&self, vault: Uuid) -> Result> { + 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> { + 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>, changes: Vec) { + 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![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::>()); + + // 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!["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}"); + } + } + } +} diff --git a/crates/kv-wasm/src/wasm/idb.rs b/crates/kv-wasm/src/wasm/idb.rs new file mode 100644 index 0000000..3515c1f --- /dev/null +++ b/crates/kv-wasm/src/wasm/idb.rs @@ -0,0 +1,141 @@ +//! Minimal IndexedDB access: open, read every replica entry, apply a batch of changes in one +//! transaction, and keep the device key record. +//! +//! Database `kestrove` (or the name passed to `VaultCore.open`), version 1: +//! * `kv`: replica entries, string key → `Uint8Array` (ciphertext envelopes and bookkeeping only); +//! * `device`: one record `"device"` → `{ deviceId, name, platform, privateKey: CryptoKey, publicKey }`. +//! The private key is a non-extractable WebCrypto key: the browser stores it, script never sees its bytes. + +use js_sys::{Array, Promise, Reflect, Uint8Array}; +use wasm_bindgen::{prelude::*, JsCast}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{ + IdbDatabase, IdbFactory, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransaction, IdbTransactionMode, +}; + +use crate::store::Change; + +const VERSION: u32 = 1; +pub const KV: &str = "kv"; +pub const DEVICE: &str = "device"; +pub const DEVICE_KEY: &str = "device"; + +fn factory() -> Result { + let f = Reflect::get(&js_sys::global(), &JsValue::from_str("indexedDB"))?; + if f.is_undefined() || f.is_null() { + return Err(JsValue::from_str("IndexedDB is not available in this context")); + } + f.dyn_into::() +} + +/// Resolves with `request.result` on success, rejects with `request.error`. +async fn request(r: &IdbRequest) -> Result { + let promise = Promise::new(&mut |resolve, reject| { + let ok_req = r.clone(); + let on_ok = Closure::once_into_js(move |_: web_sys::Event| { + let _ = resolve.call1(&JsValue::NULL, &ok_req.result().unwrap_or(JsValue::UNDEFINED)); + }); + let err_req = r.clone(); + let on_err = Closure::once_into_js(move |_: web_sys::Event| { + let e = err_req.error().ok().flatten().map(JsValue::from).unwrap_or_else(|| "IndexedDB error".into()); + let _ = reject.call1(&JsValue::NULL, &e); + }); + r.set_onsuccess(Some(on_ok.unchecked_ref())); + r.set_onerror(Some(on_err.unchecked_ref())); + }); + JsFuture::from(promise).await +} + +/// Resolves when the transaction commits; rejects when it aborts or fails. +async fn done(tx: &IdbTransaction) -> Result<(), JsValue> { + let promise = Promise::new(&mut |resolve, reject| { + let on_ok = Closure::once_into_js(move |_: web_sys::Event| { + let _ = resolve.call0(&JsValue::NULL); + }); + let err_tx = tx.clone(); + let reject2 = reject.clone(); + let on_err = Closure::once_into_js(move |_: web_sys::Event| { + let e = err_tx.error().map(JsValue::from).unwrap_or_else(|| "IndexedDB transaction failed".into()); + let _ = reject.call1(&JsValue::NULL, &e); + }); + let on_abort = Closure::once_into_js(move |_: web_sys::Event| { + let _ = reject2.call1(&JsValue::NULL, &"IndexedDB transaction aborted".into()); + }); + tx.set_oncomplete(Some(on_ok.unchecked_ref())); + tx.set_onerror(Some(on_err.unchecked_ref())); + tx.set_onabort(Some(on_abort.unchecked_ref())); + }); + JsFuture::from(promise).await.map(|_| ()) +} + +pub async fn open(name: &str) -> Result { + let req: IdbOpenDbRequest = factory()?.open_with_u32(name, VERSION)?; + let upgrade_req = req.clone(); + let on_upgrade = Closure::once_into_js(move |_: web_sys::Event| { + if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::()) { + let names = db.object_store_names(); + for store in [KV, DEVICE] { + if !names.contains(store) { + let _ = db.create_object_store(store); + } + } + } + }); + req.set_onupgradeneeded(Some(on_upgrade.unchecked_ref())); + request(&req).await?.dyn_into::() +} + +fn store(db: &IdbDatabase, name: &str, mode: IdbTransactionMode) -> Result<(IdbTransaction, IdbObjectStore), JsValue> { + let tx = db.transaction_with_str_and_mode(name, mode)?; + let s = tx.object_store(name)?; + Ok((tx, s)) +} + +/// Every replica entry. +pub async fn load_entries(db: &IdbDatabase) -> Result)>, JsValue> { + let (_tx, s) = store(db, KV, IdbTransactionMode::Readonly)?; + let keys: Array = request(&s.get_all_keys()?).await?.dyn_into()?; + let values: Array = request(&s.get_all()?).await?.dyn_into()?; + if keys.length() != values.length() { + return Err(JsValue::from_str("IndexedDB changed while loading")); + } + let mut out = Vec::with_capacity(keys.length() as usize); + for (k, v) in keys.iter().zip(values.iter()) { + let k = k.as_string().ok_or_else(|| JsValue::from_str("non-string key in IndexedDB"))?; + out.push((k, v.dyn_into::()?.to_vec())); + } + Ok(out) +} + +/// Applies changes atomically (one readwrite transaction). +pub async fn apply(db: &IdbDatabase, changes: Vec) -> Result<(), JsValue> { + if changes.is_empty() { + return Ok(()); + } + let (tx, s) = store(db, KV, IdbTransactionMode::Readwrite)?; + for c in changes { + match c { + Change::Clear => { + s.clear()?; + } + Change::Put(k, v) => { + s.put_with_key(&Uint8Array::from(v.as_slice()), &JsValue::from_str(&k))?; + } + Change::Delete(k) => { + s.delete(&JsValue::from_str(&k))?; + } + } + } + done(&tx).await +} + +pub async fn get_device(db: &IdbDatabase) -> Result { + let (_tx, s) = store(db, DEVICE, IdbTransactionMode::Readonly)?; + request(&s.get(&JsValue::from_str(DEVICE_KEY))?).await +} + +pub async fn put_device(db: &IdbDatabase, record: &JsValue) -> Result<(), JsValue> { + let (tx, s) = store(db, DEVICE, IdbTransactionMode::Readwrite)?; + s.put_with_key(record, &JsValue::from_str(DEVICE_KEY))?; + done(&tx).await +} diff --git a/crates/kv-wasm/src/wasm/mod.rs b/crates/kv-wasm/src/wasm/mod.rs new file mode 100644 index 0000000..34344ae --- /dev/null +++ b/crates/kv-wasm/src/wasm/mod.rs @@ -0,0 +1,668 @@ +//! `wasm-bindgen` surface: the `VaultCore` class and free functions, mirroring kv-ffi in camelCase. +//! +//! Every `VaultCore` method returns a `Promise` (writes are flushed to IndexedDB before it +//! resolves). Errors reject with an `Error` whose `name` is `"CoreError"` and `code` is the variant. + +mod idb; +mod signer; + +use std::{cell::RefCell, future::Future, rc::Rc, sync::Arc}; + +use js_sys::{Promise, Reflect, Uint8Array}; +use kv_proto::Platform; +use serde::{de::DeserializeOwned, Serialize}; +use uuid::Uuid; +use wasm_bindgen::{prelude::*, JsCast}; +use wasm_bindgen_futures::future_to_promise; +use web_sys::{CryptoKey, IdbDatabase}; + +use self::signer::WebCryptoSigner; +use crate::{ + core::{self, WebCore}, + dto::*, + error::{internal, CoreError, Result}, + store::WebStore, +}; + +#[wasm_bindgen(typescript_custom_section)] +const TS_TYPES: &'static str = r#" +export type ItemKind = "login" | "secureNote" | "card" | "identity"; +export type MatchLevel = "none" | "domain" | "host" | "exact"; +export type Platform = "ios" | "macos" | "android" | "windows" | "linux"; +export type LoginResult = "ok" | "pendingApproval"; + +export interface ItemSummary { + id: string; + kind: ItemKind; + title: string; + username: string | null; + urls: string[]; + favorite: boolean; + hasTotp: boolean; +} + +/** `id: ""` creates a new item. Omitted optional fields default to null / [] / false / 0. */ +export interface ItemDto { + id: string; + kind: ItemKind; + title: string; + username: string | null; + password: string | null; + urls: string[]; + totp: string | null; + notes: string | null; + favorite: boolean; + tags: string[]; + /** Unix seconds; set by the core on save. */ + modifiedAt: number; +} + +export interface TotpCode { code: string; secondsRemaining: number; period: number; } + +export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; } + +export interface SyncReport { + pulled: number; + pushed: number; + merged: number; + /** Items an edit brought back after a delete elsewhere. Tell the user. */ + restoredItemIds: string[]; + vaultsSynced: number; + vaultsSkipped: number; +} + +export interface DeviceDto { + deviceId: string; + name: string; + platform: Platform; + approved: boolean; + revoked: boolean; + current: boolean; + createdAt: string; + /** Hex of the first 16 bytes of SHA-256(P-256 public key). Compare on both screens before approving. */ + keyFingerprint: string; +} + +export interface PasskeySummary { + itemId: string; + credentialId: Uint8Array; + rpId: string; + userHandle: Uint8Array; + userName: string; + createdAt: number; +} + +export type ImportFormat = + | { type: "onePux" } + | { type: "csv" } + | { type: "kestroveEncrypted"; passphrase: string }; + +export type DuplicateHandling = "skip" | "addSuffix"; +export interface ImportOptions { duplicates: DuplicateHandling; } + +export interface ImportPreview { + total: number; + logins: number; + secureNotes: number; + cards: number; + identities: number; + duplicates: number; + attachmentsSkipped: number; + warnings: string[]; +} + +export interface ImportReport { + imported: number; + skippedDuplicates: number; + renamedDuplicates: number; + attachmentsSkipped: number; + warnings: string[]; + itemIds: string[]; +} + +export type CoreErrorCode = + | "Locked" | "NoAccount" | "BadCredentials" | "Invalid" | "NotFound" | "Internal" | "NotConfigured" + | "NotAuthenticated" | "PendingApproval" | "Network" | "PinMismatch" | "Server" | "IntegrityAlarm" | "Busy"; + +/** What every rejected promise carries. `status` is set for "Server"; `vaultId`/`reason` for "IntegrityAlarm". */ +export interface CoreError extends Error { + name: "CoreError"; + code: CoreErrorCode; + status?: number; + vaultId?: string; + reason?: string; +} +"#; + +// ---------------------------------------------------------------- conversions + +fn to_js(v: &T) -> std::result::Result { + let ser = serde_wasm_bindgen::Serializer::new().serialize_missing_as_null(true).serialize_maps_as_objects(true); + v.serialize(&ser).map_err(|e| core_error(&internal(e))) +} + +fn from_js(v: JsValue) -> Result { + serde_wasm_bindgen::from_value(v).map_err(|e| CoreError::Invalid { msg: e.to_string() }) +} + +fn core_error(e: &CoreError) -> JsValue { + let err = js_sys::Error::new(&e.to_string()); + err.set_name("CoreError"); + let set = |k: &str, v: JsValue| { + let _ = Reflect::set(&err, &JsValue::from_str(k), &v); + }; + set("code", e.code().into()); + match e { + CoreError::Server { status, .. } => set("status", (*status).into()), + CoreError::IntegrityAlarm { vault_id, reason } => { + set("vaultId", vault_id.as_str().into()); + set("reason", reason.as_str().into()); + } + _ => {} + } + err.into() +} + +fn js_internal(e: JsValue) -> CoreError { + let msg = e + .as_string() + .or_else(|| e.dyn_ref::().map(|e| String::from(e.message()))) + .or_else(|| e.dyn_ref::().map(|e| e.message())) + .unwrap_or_else(|| format!("{e:?}")); + CoreError::Internal { msg } +} + +fn platform_name(p: Platform) -> &'static str { + match p { + Platform::Ios => "ios", + Platform::Macos => "macos", + Platform::Android => "android", + Platform::Windows => "windows", + Platform::Linux => "linux", + } +} + +fn platform_from_name(s: &str) -> Option { + Some(match s { + "ios" => Platform::Ios, + "macos" => Platform::Macos, + "android" => Platform::Android, + "windows" => Platform::Windows, + "linux" => Platform::Linux, + _ => return None, + }) +} + +/// The server's platform enum has no "web" yet, so report the OS the browser runs on. +fn guess_platform() -> Platform { + let ua = Reflect::get(&js_sys::global(), &"navigator".into()) + .and_then(|n| Reflect::get(&n, &"userAgent".into())) + .ok() + .and_then(|u| u.as_string()) + .unwrap_or_default(); + if ua.contains("iPhone") || ua.contains("iPad") { + Platform::Ios + } else if ua.contains("Android") { + Platform::Android + } else if ua.contains("Mac") { + Platform::Macos + } else if ua.contains("Windows") { + Platform::Windows + } else { + Platform::Linux + } +} + +// ---------------------------------------------------------------- VaultCore + +struct DeviceState { + signer: Arc, + private: CryptoKey, + name: String, + platform: Platform, +} + +struct Shared { + core: WebCore, + db: Option, + device: RefCell, +} + +impl Drop for Shared { + fn drop(&mut self) { + // `free()` from JS: drop the signing key too. Account keys are zeroized by `WebCore`'s drop. + self.device.borrow().signer.forget(); + if let Some(db) = &self.db { + db.close(); + } + } +} + +impl Shared { + /// Writes everything the last operation changed to IndexedDB (no-op for in-memory vaults). + async fn persist(&self) -> Result<()> { + let changes = self.core.store().take_changes(); + match &self.db { + Some(db) => idb::apply(db, changes).await.map_err(js_internal), + None => Ok(()), + } + } + + async fn save_device(&self) -> Result<()> { + let Some(db) = &self.db else { return Ok(()) }; + let record = { + let d = self.device.borrow(); + d.signer.record(&d.private, &d.name, platform_name(d.platform)).map_err(js_internal)? + }; + idb::put_device(db, &record).await.map_err(js_internal) + } + + fn install_device(&self) { + let d = self.device.borrow(); + self.core.set_device_signer(d.signer.clone(), &d.name, d.platform); + } +} + +/// The vault in this browser. Create it with `VaultCore.open()` (IndexedDB) or +/// `VaultCore.newInMemory()` (nothing persisted). Run it in a Web Worker: `createAccount`, +/// `unlock`, `login` and the encrypted export/import run Argon2id (64 MiB) and block their thread. +#[wasm_bindgen] +pub struct VaultCore { + shared: Rc, +} + +const DEFAULT_DB: &str = "kestrove"; +const DEFAULT_DEVICE_NAME: &str = "Web vault"; + +async fn load_device(db: Option<&IdbDatabase>) -> std::result::Result, JsValue> { + let Some(db) = db else { return Ok(None) }; + let rec = idb::get_device(db).await?; + if rec.is_undefined() || rec.is_null() { + return Ok(None); + } + let get = |k: &str| Reflect::get(&rec, &JsValue::from_str(k)); + let id = get("deviceId")?.as_string().and_then(|s| Uuid::parse_str(&s).ok()); + let name = get("name")?.as_string().unwrap_or_else(|| DEFAULT_DEVICE_NAME.into()); + let platform = get("platform")?.as_string().and_then(|p| platform_from_name(&p)).unwrap_or_else(guess_platform); + let private: CryptoKey = get("privateKey")?.dyn_into()?; + let public = get("publicKey")?.dyn_into::()?.to_vec(); + let id = id.ok_or_else(|| JsValue::from_str("stored device id is not a UUID"))?; + let signer = WebCryptoSigner::restore(id, public, private.clone())?; + Ok(Some(DeviceState { signer: Arc::new(signer), private, name, platform })) +} + +async fn new_device() -> std::result::Result { + let (signer, private) = WebCryptoSigner::generate().await?; + Ok(DeviceState { signer: Arc::new(signer), private, name: DEFAULT_DEVICE_NAME.into(), platform: guess_platform() }) +} + +impl VaultCore { + async fn build(db: Option) -> Result { + let store = match &db { + Some(db) => WebStore::load(idb::load_entries(db).await.map_err(js_internal)?)?, + None => WebStore::new(), + }; + Self::build_with(store, db).await + } + + async fn build_with(store: WebStore, db: Option) -> Result { + let core = WebCore::new(Arc::new(store))?; + let (device, fresh) = match load_device(db.as_ref()).await.map_err(js_internal)? { + Some(d) => (d, false), + None => (new_device().await.map_err(js_internal)?, true), + }; + let shared = Rc::new(Shared { core, db, device: RefCell::new(device) }); + shared.install_device(); + if fresh { + shared.save_device().await?; + } + Ok(VaultCore { shared }) + } + + /// Runs `op` and resolves with its result serialized to JS. + fn run(&self, op: F) -> Promise + where + T: Serialize, + F: FnOnce(Rc) -> Fut + 'static, + Fut: Future> + 'static, + { + let s = self.shared.clone(); + future_to_promise(async move { op(s).await.map_err(|e| core_error(&e)).and_then(|v| to_js(&v)) }) + } + + /// Like `run`, then flushes changed entries to IndexedDB, even when `op` failed part-way + /// (a sync that pulled some changes before failing keeps them). + fn write(&self, op: F) -> Promise + where + T: Serialize, + F: FnOnce(Rc) -> Fut + 'static, + Fut: Future> + 'static, + { + self.run(move |s| async move { + let r = op(s.clone()).await; + let p = s.persist().await; + let v = r?; + p?; + Ok(v) + }) + } +} + +#[wasm_bindgen] +impl VaultCore { + /// Opens (or creates) the vault stored in IndexedDB database `dbName` (default `"kestrove"`). + /// Creates this browser's device key on first use. + #[wasm_bindgen(js_name = open, unchecked_return_type = "Promise")] + pub fn open(#[wasm_bindgen(js_name = "dbName")] db_name: Option) -> Promise { + future_to_promise(async move { + let name = db_name.unwrap_or_else(|| DEFAULT_DB.into()); + let db = idb::open(&name).await.map_err(|e| core_error(&js_internal(e)))?; + VaultCore::build(Some(db)).await.map(JsValue::from).map_err(|e| core_error(&e)) + }) + } + + /// A vault that persists nothing (previews, tests). Its device key lasts until the page closes. + #[wasm_bindgen(js_name = newInMemory, unchecked_return_type = "Promise")] + pub fn new_in_memory() -> Promise { + future_to_promise(async move { VaultCore::build(None).await.map(JsValue::from).map_err(|e| core_error(&e)) }) + } + + // ------------------------------------------------------------ account + + /// Creates the account and its first vault, leaving it unlocked. Resolves with the Secret Key + /// for the Emergency Kit. Runs Argon2id. + #[wasm_bindgen(js_name = createAccount, unchecked_return_type = "Promise")] + pub fn create_account(&self, #[wasm_bindgen(js_name = "masterPassword")] master_password: String) -> Promise { + let pw = zeroize::Zeroizing::new(master_password); + self.write(move |s| async move { s.core.create_account(&pw) }) + } + + /// Runs Argon2id. + #[wasm_bindgen(unchecked_return_type = "Promise")] + pub fn unlock( + &self, + #[wasm_bindgen(js_name = "masterPassword")] master_password: String, + #[wasm_bindgen(js_name = "secretKey")] secret_key: String, + ) -> Promise { + let (pw, sk) = (zeroize::Zeroizing::new(master_password), zeroize::Zeroizing::new(secret_key)); + self.run(move |s| async move { s.core.unlock(&pw, &sk) }) + } + + /// Zeroizes the keyset, vault key and OPAQUE input. Nothing secret was ever persisted. + #[wasm_bindgen(unchecked_return_type = "Promise")] + pub fn lock(&self) -> Promise { + self.run(|s| async move { + s.core.lock(); + Ok(()) + }) + } + + #[wasm_bindgen(js_name = isLocked, unchecked_return_type = "Promise")] + pub fn is_locked(&self) -> Promise { + self.run(|s| async move { Ok(s.core.is_locked()) }) + } + + #[wasm_bindgen(js_name = hasAccount, unchecked_return_type = "Promise")] + pub fn has_account(&self) -> Promise { + self.run(|s| async move { Ok(s.core.has_account()) }) + } + + #[wasm_bindgen(js_name = accountId, unchecked_return_type = "Promise")] + pub fn account_id(&self) -> Promise { + self.run(|s| async move { s.core.account_id() }) + } + + #[wasm_bindgen(js_name = accountFingerprint, unchecked_return_type = "Promise")] + pub fn account_fingerprint(&self) -> Promise { + self.run(|s| async move { s.core.account_fingerprint() }) + } + + /// Removes this browser's account data, replica, session and device key. The browser becomes a + /// new device. Log out first if the server session should end too. + #[wasm_bindgen(unchecked_return_type = "Promise")] + pub fn wipe(&self) -> Promise { + self.write(|s| async move { + s.core.wipe()?; + s.device.borrow().signer.forget(); + let fresh = new_device().await.map_err(js_internal)?; + *s.device.borrow_mut() = fresh; + s.install_device(); + s.save_device().await + }) + } + + // ------------------------------------------------------------ items + + #[wasm_bindgen(js_name = listItems, unchecked_return_type = "Promise")] + pub fn list_items(&self) -> Promise { + self.run(|s| async move { s.core.list_items() }) + } + + #[wasm_bindgen(js_name = getItem, unchecked_return_type = "Promise")] + pub fn get_item(&self, id: String) -> Promise { + self.run(move |s| async move { s.core.get_item(&id) }) + } + + /// Inserts (`id: ""`) or updates an item; resolves with its id. + #[wasm_bindgen(js_name = saveItem, unchecked_return_type = "Promise")] + pub fn save_item(&self, #[wasm_bindgen(unchecked_param_type = "ItemDto")] item: JsValue) -> Promise { + let item = from_js::(item); + self.write(move |s| async move { s.core.save_item(item?) }) + } + + #[wasm_bindgen(js_name = deleteItem, unchecked_return_type = "Promise")] + pub fn delete_item(&self, id: String) -> Promise { + self.write(move |s| async move { s.core.delete_item(&id) }) + } + + #[wasm_bindgen(js_name = itemHistory, unchecked_return_type = "Promise")] + pub fn item_history(&self, id: String) -> Promise { + self.run(move |s| async move { s.core.item_history(&id) }) + } + + #[wasm_bindgen(js_name = itemsForUrl, unchecked_return_type = "Promise")] + pub fn items_for_url(&self, #[wasm_bindgen(js_name = "pageUrl")] page_url: String) -> Promise { + self.run(move |s| async move { s.core.items_for_url(&page_url) }) + } + + // ------------------------------------------------------------ passkeys + + #[wasm_bindgen(js_name = listPasskeys, unchecked_return_type = "Promise")] + pub fn list_passkeys(&self) -> Promise { + self.run(|s| async move { s.core.list_passkeys() }) + } + + #[wasm_bindgen(js_name = deletePasskey, unchecked_return_type = "Promise")] + pub fn delete_passkey(&self, #[wasm_bindgen(js_name = "credentialId")] credential_id: Vec) -> Promise { + self.write(move |s| async move { s.core.delete_passkey(&credential_id) }) + } + + // ------------------------------------------------------------ import / export + + #[wasm_bindgen(js_name = importPreview, unchecked_return_type = "Promise")] + pub fn import_preview( + &self, + data: Vec, + #[wasm_bindgen(unchecked_param_type = "ImportFormat")] format: JsValue, + ) -> Promise { + let (data, format) = (zeroize::Zeroizing::new(data), from_js::(format)); + self.run(move |s| async move { s.core.import_preview(&data, format?) }) + } + + #[wasm_bindgen(js_name = importCommit, unchecked_return_type = "Promise")] + pub fn import_commit( + &self, + data: Vec, + #[wasm_bindgen(unchecked_param_type = "ImportFormat")] format: JsValue, + #[wasm_bindgen(unchecked_param_type = "ImportOptions")] options: JsValue, + ) -> Promise { + let data = zeroize::Zeroizing::new(data); + let (format, options) = (from_js::(format), from_js::(options)); + self.write(move |s| async move { s.core.import_commit(&data, format?, options?) }) + } + + /// Resolves with the `.kvex` file bytes. Runs Argon2id. + #[wasm_bindgen(js_name = exportEncrypted, unchecked_return_type = "Promise")] + pub fn export_encrypted(&self, passphrase: String) -> Promise { + let passphrase = zeroize::Zeroizing::new(passphrase); + let s = self.shared.clone(); + future_to_promise(async move { + let bytes = s.core.export_encrypted(&passphrase).map_err(|e| core_error(&e))?; + Ok(Uint8Array::from(bytes.as_slice()).into()) + }) + } + + // ------------------------------------------------------------ server and devices + + /// Use the web vault's own origin (`location.origin`). Pins are accepted for API parity but the + /// browser's TLS is what protects the connection. + #[wasm_bindgen(js_name = configureServer, unchecked_return_type = "Promise")] + pub fn configure_server( + &self, + url: String, + #[wasm_bindgen(js_name = "spkiPins")] spki_pins: Option>, + ) -> Promise { + self.write(move |s| async move { s.core.configure_server(&url, spki_pins.unwrap_or_default()) }) + } + + /// Name (and optionally platform) the server shows for this browser. Takes effect at the next + /// login/enrollment. Default: "Web vault" and the OS from the user agent. + #[wasm_bindgen(js_name = setDevice, unchecked_return_type = "Promise")] + pub fn set_device( + &self, + name: String, + #[wasm_bindgen(unchecked_optional_param_type = "Platform")] platform: Option, + ) -> Promise { + self.run(move |s| async move { + let platform = match platform { + Some(p) => Some(platform_from_name(&p).ok_or(CoreError::Invalid { msg: "unknown platform".into() })?), + None => None, + }; + { + let mut d = s.device.borrow_mut(); + d.name = name; + if let Some(p) = platform { + d.platform = p; + } + } + s.install_device(); + s.save_device().await + }) + } + + #[wasm_bindgen(js_name = deviceId, unchecked_return_type = "Promise")] + pub fn device_id(&self) -> Promise { + self.run(|s| async move { s.core.device_id().map(|d| d.to_string()).ok_or(CoreError::NotConfigured) }) + } + + #[wasm_bindgen(js_name = deviceKeyFingerprint, unchecked_return_type = "Promise")] + pub fn device_key_fingerprint(&self) -> Promise { + self.run(|s| async move { s.core.device_key_fingerprint() }) + } + + #[wasm_bindgen(js_name = serverInfo, unchecked_return_type = "Promise")] + pub fn server_info(&self) -> Promise { + self.run(|s| async move { s.core.server_info().await }) + } + + /// First device only (the web vault is rarely that): registers the account with a fresh server. + #[wasm_bindgen(js_name = registerAccount, unchecked_return_type = "Promise")] + pub fn register_account(&self, #[wasm_bindgen(js_name = "setupToken")] setup_token: String) -> Promise { + self.write(move |s| async move { s.core.register_account(&setup_token).await }) + } + + /// Logs in (and unlocks). Resolves `"pendingApproval"` until an enrolled device approves this + /// browser; then call it again. Runs Argon2id. + #[wasm_bindgen(unchecked_return_type = "Promise")] + pub fn login( + &self, + #[wasm_bindgen(js_name = "masterPassword")] master_password: String, + #[wasm_bindgen(js_name = "secretKey")] secret_key: String, + totp: Option, + ) -> Promise { + let (pw, sk) = (zeroize::Zeroizing::new(master_password), zeroize::Zeroizing::new(secret_key)); + self.write(move |s| async move { s.core.login(&pw, &sk, totp).await }) + } + + #[wasm_bindgen(js_name = syncNow, unchecked_return_type = "Promise")] + pub fn sync_now(&self) -> Promise { + self.write(|s| async move { s.core.sync_now().await }) + } + + #[wasm_bindgen(js_name = listDevices, unchecked_return_type = "Promise")] + pub fn list_devices(&self) -> Promise { + self.run(|s| async move { s.core.list_devices().await }) + } + + #[wasm_bindgen(js_name = approveDevice, unchecked_return_type = "Promise")] + pub fn approve_device(&self, #[wasm_bindgen(js_name = "deviceId")] device_id: String) -> Promise { + self.run(move |s| async move { s.core.approve_device(&device_id).await }) + } + + #[wasm_bindgen(js_name = revokeDevice, unchecked_return_type = "Promise")] + pub fn revoke_device(&self, #[wasm_bindgen(js_name = "deviceId")] device_id: String) -> Promise { + self.run(move |s| async move { s.core.revoke_device(&device_id).await }) + } + + #[wasm_bindgen(unchecked_return_type = "Promise")] + pub fn logout(&self) -> Promise { + self.write(|s| async move { s.core.logout().await }) + } + + #[wasm_bindgen(js_name = isLoggedIn, unchecked_return_type = "Promise")] + pub fn is_logged_in(&self) -> Promise { + self.run(|s| async move { Ok(s.core.is_logged_in()) }) + } + + /// Diagnostics and the cross-platform fixture (`js/smoke.mjs`): the replica entries exactly as + /// IndexedDB holds them (ciphertext, ids, bookkeeping). Not part of the typed API. + #[wasm_bindgen(js_name = __replicaEntries, skip_typescript)] + pub fn replica_entries(&self) -> Promise { + self.run(|s| async move { + let entries: std::collections::BTreeMap = s + .core + .store() + .entries() + .into_iter() + .map(|(k, v)| (k, String::from_utf8_lossy(&v).into_owned())) + .collect(); + Ok(entries) + }) + } +} + +// ---------------------------------------------------------------- free functions + +#[wasm_bindgen(js_name = generatePassword)] +pub fn generate_password(length: u32, symbols: bool) -> String { + core::generate_password(length, symbols) +} + +/// `unixTime` in seconds (e.g. `Math.floor(Date.now() / 1000)`). +#[wasm_bindgen(js_name = totpCode, unchecked_return_type = "TotpCode")] +pub fn totp_code( + #[wasm_bindgen(js_name = "secretOrUri")] secret_or_uri: String, + #[wasm_bindgen(js_name = "unixTime")] unix_time: f64, +) -> std::result::Result { + if !(unix_time.is_finite() && unix_time >= 0.0) { + return Err(core_error(&CoreError::Invalid { msg: "unixTime".into() })); + } + let code = core::totp_code(&secret_or_uri, unix_time as u64).map_err(|e| core_error(&e))?; + to_js(&code) +} + +#[wasm_bindgen(js_name = matchUrl, unchecked_return_type = "MatchLevel")] +pub fn match_url( + #[wasm_bindgen(js_name = "itemUrl")] item_url: String, + #[wasm_bindgen(js_name = "pageUrl")] page_url: String, +) -> std::result::Result { + to_js(&core::match_url(&item_url, &page_url)) +} + +#[cfg(test)] +mod tests; + +#[wasm_bindgen(js_name = coreVersion)] +pub fn core_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} diff --git a/crates/kv-wasm/src/wasm/signer.rs b/crates/kv-wasm/src/wasm/signer.rs new file mode 100644 index 0000000..85f497a --- /dev/null +++ b/crates/kv-wasm/src/wasm/signer.rs @@ -0,0 +1,110 @@ +//! The browser's device key: a non-extractable WebCrypto ECDSA P-256 key. +//! +//! WebCrypto's ECDSA signature is already IEEE P1363 `r || s` (64 bytes), the format the RFC 9421 +//! profile uses, so no DER conversion is needed. The `CryptoKey` is a JS object, so it stays in a +//! thread-local registry keyed by device id (the wasm module runs on one thread; several vaults may +//! share it) and the signer itself is plain data, which keeps it `Send + Sync` as +//! `kv_sync::DeviceSigner` requires. + +use std::{cell::RefCell, collections::HashMap}; + +use js_sys::{Array, Object, Reflect, Uint8Array}; +use kv_sync::{DeviceSigner, SignFuture, SyncError}; +use uuid::Uuid; +use wasm_bindgen::{prelude::*, JsCast}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{Crypto, CryptoKey, EcKeyGenParams, EcdsaParams, SubtleCrypto}; + +thread_local! { + static PRIVATE_KEYS: RefCell> = RefCell::new(HashMap::new()); +} + +fn subtle() -> Result { + let crypto: Crypto = Reflect::get(&js_sys::global(), &JsValue::from_str("crypto"))?.dyn_into()?; + Ok(crypto.subtle()) +} + +pub struct WebCryptoSigner { + id: Uuid, + public_key: Vec, +} + +impl WebCryptoSigner { + /// Generates a fresh non-extractable key pair. Returns the signer and the private `CryptoKey` + /// (to store in IndexedDB; structured clone keeps it non-extractable). + pub async fn generate() -> Result<(Self, CryptoKey), JsValue> { + let subtle = subtle()?; + let params = EcKeyGenParams::new("ECDSA", "P-256"); + let usages = Array::of1(&JsValue::from_str("sign")); + let pair = JsFuture::from(subtle.generate_key_with_object(¶ms, false, &usages)?).await?; + let private: CryptoKey = Reflect::get(&pair, &JsValue::from_str("privateKey"))?.dyn_into()?; + let public: CryptoKey = Reflect::get(&pair, &JsValue::from_str("publicKey"))?.dyn_into()?; + // Public keys are always exportable; "raw" is the 65-byte SEC1 uncompressed point. + let raw = JsFuture::from(subtle.export_key("raw", &public)?).await?; + let public_key = Uint8Array::new(&raw).to_vec(); + let signer = Self { id: Uuid::new_v4(), public_key }; + signer.activate(private.clone()); + Ok((signer, private)) + } + + /// Restores a stored key. + pub fn restore(id: Uuid, public_key: Vec, private: CryptoKey) -> Result { + if public_key.len() != 65 || public_key[0] != 4 { + return Err(JsValue::from_str("stored device public key is not an uncompressed P-256 point")); + } + let signer = Self { id, public_key }; + signer.activate(private); + Ok(signer) + } + + fn activate(&self, private: CryptoKey) { + PRIVATE_KEYS.with(|k| k.borrow_mut().insert(self.id, private)); + } + + /// Drops this device's key from the registry (wipe, or the vault was freed). + pub fn forget(&self) { + PRIVATE_KEYS.with(|k| k.borrow_mut().remove(&self.id)); + } + + /// The IndexedDB record for this key. + pub fn record(&self, private: &CryptoKey, name: &str, platform: &str) -> Result { + let o = Object::new(); + Reflect::set(&o, &"deviceId".into(), &self.id.to_string().into())?; + Reflect::set(&o, &"name".into(), &name.into())?; + Reflect::set(&o, &"platform".into(), &platform.into())?; + Reflect::set(&o, &"privateKey".into(), private)?; + Reflect::set(&o, &"publicKey".into(), &Uint8Array::from(self.public_key.as_slice()))?; + Ok(o.into()) + } + + async fn sign_js(&self, message: &[u8]) -> Result, JsValue> { + let key = PRIVATE_KEYS + .with(|k| k.borrow().get(&self.id).cloned()) + .ok_or_else(|| JsValue::from_str("device key is not loaded"))?; + let params = EcdsaParams::new("ECDSA", &JsValue::from_str("SHA-256")); + let sig = JsFuture::from(subtle()?.sign_with_object_and_u8_array(¶ms, &key, message)?).await?; + Ok(Uint8Array::new(&sig).to_vec()) + } +} + +fn js_err(e: JsValue) -> SyncError { + SyncError::Signer(e.as_string().unwrap_or_else(|| format!("{e:?}"))) +} + +impl DeviceSigner for WebCryptoSigner { + fn device_id(&self) -> Uuid { + self.id + } + + fn public_key(&self) -> Vec { + self.public_key.clone() + } + + fn sign(&self, _message: &[u8]) -> Result, SyncError> { + Err(SyncError::Signer("WebCrypto signs asynchronously; use sign_async".into())) + } + + fn sign_async<'a>(&'a self, message: &'a [u8]) -> SignFuture<'a> { + Box::pin(async move { self.sign_js(message).await.map_err(js_err) }) + } +} diff --git a/crates/kv-wasm/src/wasm/tests.rs b/crates/kv-wasm/src/wasm/tests.rs new file mode 100644 index 0000000..d365764 --- /dev/null +++ b/crates/kv-wasm/src/wasm/tests.rs @@ -0,0 +1,198 @@ +//! In-browser tests (`wasm-pack test --headless --chrome crates/kv-wasm`). They drive the same +//! `VaultCore` methods JavaScript calls (promises, serde conversion, IndexedDB, WebCrypto). + +use std::collections::BTreeMap; + +use js_sys::{Promise, Reflect}; +use serde_json::{json, Value}; +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::JsFuture; +use wasm_bindgen_test::*; + +use super::*; + +wasm_bindgen_test_configure!(run_in_browser); + +const PW: &str = "correct horse battery staple"; + +async fn ok(p: Promise) -> JsValue { + match JsFuture::from(p).await { + Ok(v) => v, + Err(e) => panic!("promise rejected: {:?}", Reflect::get(&e, &"message".into()).unwrap_or(e)), + } +} + +async fn err_code(p: Promise) -> String { + let e = JsFuture::from(p).await.expect_err("expected a rejection"); + assert_eq!(Reflect::get(&e, &"name".into()).unwrap().as_string().as_deref(), Some("CoreError")); + Reflect::get(&e, &"code".into()).unwrap().as_string().unwrap() +} + +fn js(v: Value) -> JsValue { + to_js(&v).unwrap() +} + +fn de(v: JsValue) -> T { + serde_wasm_bindgen::from_value(v).unwrap() +} + +fn now_ms() -> f64 { + js_sys::Date::now() +} + +async fn open_db(name: &str) -> VaultCore { + VaultCore::build(Some(idb::open(name).await.unwrap())).await.unwrap() +} + +fn login(title: &str, password: &str) -> Value { + json!({ + "id": "", "kind": "login", "title": title, "username": "ana", "password": password, + "urls": ["https://example.com/login"], "totp": null, "notes": null, "favorite": false, "tags": [], + "modifiedAt": 0 + }) +} + +#[wasm_bindgen_test] +async fn account_lifecycle_persists_in_indexeddb() { + let db_name = format!("kv-test-{}", Uuid::new_v4()); + let core = open_db(&db_name).await; + assert!(!ok(core.has_account()).await.as_bool().unwrap()); + let device_id = ok(core.device_id()).await.as_string().unwrap(); + + let t = now_ms(); + let secret_key = ok(core.create_account(PW.into())).await.as_string().unwrap(); + console_log!("createAccount (Argon2id 64 MiB, t=3, p=4): {:.0} ms", now_ms() - t); + assert!(secret_key.starts_with("K1-")); + + let id = ok(core.save_item(js(login("Bank", "hunter2-secret")))).await.as_string().unwrap(); + // Minimal input: omitted optional fields default. + let note = ok(core.save_item(js(json!({"id": "", "kind": "secureNote", "title": "Note", "notes": "n"})))) + .await + .as_string() + .unwrap(); + let list: Vec = de(ok(core.list_items()).await); + assert_eq!(list.iter().map(|i| i.title.as_str()).collect::>(), ["Bank", "Note"]); + let mut item: ItemDto = de(ok(core.get_item(id.clone())).await); + assert_eq!(item.password.as_deref(), Some("hunter2-secret")); + item.password = Some("hunter3-secret".into()); + ok(core.save_item(to_js(&item).unwrap())).await; + let history: Vec = de(ok(core.item_history(id.clone())).await); + assert_eq!(history[0].password.as_deref(), Some("hunter2-secret")); + ok(core.delete_item(note)).await; + let for_url: Vec = de(ok(core.items_for_url("https://example.com/other".into())).await); + assert_eq!(for_url.len(), 1); + + // Lock: every read needs the keys again. + ok(core.lock()).await; + assert!(ok(core.is_locked()).await.as_bool().unwrap()); + assert_eq!(err_code(core.list_items()).await, "Locked"); + assert_eq!(err_code(core.unlock("wrong password!".into(), secret_key.clone())).await, "BadCredentials"); + assert_eq!(err_code(core.save_item(js(json!({"kind": "nope"})))).await, "Invalid"); + + // IndexedDB holds ciphertext only. + let db = core.shared.db.clone().unwrap(); + for (key, value) in idb::load_entries(&db).await.unwrap() { + let text = String::from_utf8_lossy(&value); + for secret in ["hunter2", "hunter3", "Bank", "ana", "example.com", PW] { + assert!(!text.contains(secret), "{key} contains {secret}"); + } + } + db.close(); + drop(core); + + // A new page load: same device key, account still there, locked. + let core = open_db(&db_name).await; + assert_eq!(ok(core.device_id()).await.as_string().unwrap(), device_id); + assert!(ok(core.has_account()).await.as_bool().unwrap()); + assert!(ok(core.is_locked()).await.as_bool().unwrap()); + let t = now_ms(); + ok(core.unlock(PW.into(), secret_key.clone())).await; + console_log!("unlock (Argon2id 64 MiB): {:.0} ms", now_ms() - t); + let item: ItemDto = de(ok(core.get_item(id.clone())).await); + assert_eq!(item.password.as_deref(), Some("hunter3-secret")); + let list: Vec = de(ok(core.list_items()).await); + assert_eq!(list.len(), 1); + + // Wipe: a new device, nothing left. + ok(core.wipe()).await; + assert!(!ok(core.has_account()).await.as_bool().unwrap()); + assert_ne!(ok(core.device_id()).await.as_string().unwrap(), device_id); + let db = core.shared.db.clone().unwrap(); + assert!(idb::load_entries(&db).await.unwrap().is_empty()); + db.close(); +} + +#[wasm_bindgen_test] +async fn webcrypto_device_key_signs_rfc9421_requests() { + let (signer, _private) = WebCryptoSigner::generate().await.unwrap(); + let pk = kv_sync::DeviceSigner::public_key(&signer); + assert_eq!((pk.len(), pk[0]), (65, 4)); + let url = "https://vault.example.com/v1/vaults"; + let h = kv_sync::signing::sign_request_async(&signer, "PUT", url, b"{\"a\":1}", "session", 1_700_000_000) + .await + .unwrap(); + let lookup = |name: &str| h.pairs().iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string()); + // The server-side verifier (p256) accepts the WebCrypto signature as is (raw r||s). + let id = kv_sync::signing::verify_request(&pk, "PUT", url, b"{\"a\":1}", &lookup).unwrap(); + assert_eq!(id, kv_sync::DeviceSigner::device_id(&signer)); + assert!(kv_sync::signing::verify_request(&pk, "PUT", url, b"{}", &lookup).is_err()); +} + +/// Items written by kv-ffi (native, SQLite) open in the browser. +#[wasm_bindgen_test] +async fn native_fixture_opens_in_wasm() { + let f: Value = serde_json::from_str(include_str!("../../tests/fixtures/native-vault.json")).unwrap(); + let entries: BTreeMap = serde_json::from_value(f["entries"].clone()).unwrap(); + let store = WebStore::load(entries.into_iter().map(|(k, v)| (k, v.into_bytes()))).unwrap(); + let core = VaultCore::build_with(store, None).await.unwrap(); + let (pw, sk) = (f["masterPassword"].as_str().unwrap(), f["secretKey"].as_str().unwrap()); + ok(core.unlock(pw.into(), sk.into())).await; + let expected: Vec = serde_json::from_value(f["items"].clone()).unwrap(); + for e in &expected { + let got: ItemDto = de(ok(core.get_item(e.id.clone())).await); + assert_eq!(&got, e); + } + let bank = expected.iter().find(|i| i.title == "Bank").unwrap(); + let history: Vec = de(ok(core.item_history(bank.id.clone())).await); + assert_eq!(history[0].password.as_deref(), Some("first password")); + // Edits made here keep working with the native-made keys. + let mut edit = bank.clone(); + edit.notes = Some("edited in the browser".into()); + ok(core.save_item(to_js(&edit).unwrap())).await; + let got: ItemDto = de(ok(core.get_item(bank.id.clone())).await); + assert_eq!(got.notes.as_deref(), Some("edited in the browser")); +} + +#[wasm_bindgen_test] +async fn in_memory_vault_import_export_and_helpers() { + let core: VaultCore = VaultCore::build(None).await.unwrap(); + assert_eq!(err_code(core.list_items()).await, "NoAccount"); + assert_eq!(err_code(core.create_account("short".into())).await, "Invalid"); + ok(core.create_account(PW.into())).await; + let csv = b"title,url,username,password,otp,notes\nGit,https://git.example.com,me,pw1,,\n".to_vec(); + let preview: ImportPreview = de(ok(core.import_preview(csv.clone(), js(json!({"type": "csv"})))).await); + assert_eq!((preview.total, preview.logins), (1, 1)); + let report: ImportReport = + de(ok(core.import_commit(csv, js(json!({"type": "csv"})), js(json!({"duplicates": "skip"})))).await); + assert_eq!(report.imported, 1); + let t = now_ms(); + let blob = ok(core.export_encrypted("export passphrase".into())).await; + console_log!("exportEncrypted (Argon2id): {:.0} ms", now_ms() - t); + let blob = js_sys::Uint8Array::new(&blob).to_vec(); + let fmt = js(json!({"type": "kestroveEncrypted", "passphrase": "export passphrase"})); + let preview: ImportPreview = de(ok(core.import_preview(blob, fmt)).await); + assert_eq!((preview.total, preview.duplicates), (1, 1)); + let passkeys: Vec = de(ok(core.list_passkeys()).await); + assert!(passkeys.is_empty()); + assert_eq!(err_code(core.delete_passkey(vec![1, 2, 3])).await, "NotFound"); + // Not configured yet: network calls fail cleanly. + assert_eq!(err_code(core.sync_now()).await, "NotConfigured"); + assert_eq!(err_code(core.configure_server("http://example.com".into(), None)).await, "Invalid"); + + assert_eq!(generate_password(24, true).chars().count(), 24); + let code: TotpCode = de(totp_code("JBSWY3DPEHPK3PXP".into(), 59.0).unwrap()); + assert_eq!((code.code.len(), code.period, code.seconds_remaining), (6, 30, 1)); + assert_eq!(match_url("https://a.example.com".into(), "https://a.example.com/x".into()).unwrap(), "exact"); + // No Public Suffix List in the web build: different hosts never match. + assert_eq!(match_url("https://a.example.com".into(), "https://b.example.com".into()).unwrap(), "none"); +} diff --git a/crates/kv-wasm/tests/fixture.rs b/crates/kv-wasm/tests/fixture.rs new file mode 100644 index 0000000..bf0c9ff --- /dev/null +++ b/crates/kv-wasm/tests/fixture.rs @@ -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, + /// Expected plaintext, sorted by title. + items: Vec, +} + +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 { + 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 = 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 { + let core = WebCore::new(Arc::new(web_store(f))).unwrap(); + core.unlock(&f.master_password, &f.secret_key).unwrap(); + let mut out: Vec = 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("ana@example.com".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")); +} diff --git a/crates/kv-wasm/tests/fixtures/native-vault.json b/crates/kv-wasm/tests/fixtures/native-vault.json new file mode 100644 index 0000000..95c48be --- /dev/null +++ b/crates/kv-wasm/tests/fixtures/native-vault.json @@ -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": "ana@example.com", + "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 + } + ] +} diff --git a/crates/kv-wasm/tests/fixtures/wasm-vault.json b/crates/kv-wasm/tests/fixtures/wasm-vault.json new file mode 100644 index 0000000..a5695bb --- /dev/null +++ b/crates/kv-wasm/tests/fixtures/wasm-vault.json @@ -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": "ana@example.com", + "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 + } + ] +} diff --git a/crates/kv-wasm/tests/sync.rs b/crates/kv-wasm/tests/sync.rs new file mode 100644 index 0000000..7b0d654 --- /dev/null +++ b/crates/kv-wasm/tests/sync.rs @@ -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 { + kv_sync::DeviceSigner::public_key(&self.0) + } + fn sign(&self, message: Vec) -> Result, 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, url: &str, signer: Arc) -> 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(); +} From f9f2d14aa2f0573e5434032e767c8773a2325f12 Mon Sep 17 00:00:00 2001 From: samialabri Date: Sat, 17 Oct 2026 23:59:58 +0900 Subject: [PATCH 2/2] kv-wasm: ItemSummary carries tags and modifiedAt for the web vault's filters and sort Co-Authored-By: Claude Opus 5.5 --- crates/kv-wasm/README.md | 2 +- crates/kv-wasm/js/smoke.mjs | 2 ++ crates/kv-wasm/src/core.rs | 2 ++ crates/kv-wasm/src/dto.rs | 3 +++ crates/kv-wasm/src/wasm/mod.rs | 3 +++ crates/kv-wasm/src/wasm/tests.rs | 1 + 6 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/kv-wasm/README.md b/crates/kv-wasm/README.md index 150ae1a..ed6c1d9 100644 --- a/crates/kv-wasm/README.md +++ b/crates/kv-wasm/README.md @@ -45,7 +45,7 @@ The generated `pkg/kv_wasm.d.ts` is the reference. It mirrors kv-ffi's `VaultCor records are plain objects with the same fields as kv-ffi's records (`ItemDto`, `ItemSummary`, `SyncReport`, `DeviceDto`, `PasskeySummary`, `ImportPreview`, …); enums are camelCase strings (`"login" | "secureNote" | "card" | "identity"`, `"ok" | "pendingApproval"`); bytes are `Uint8Array`; -absent optionals come back as `null`. **Every `VaultCore` method returns a `Promise`.** +absent optionals come back as `null`. `ItemSummary` also carries `tags` and `modifiedAt` (web only). **Every `VaultCore` method returns a `Promise`.** ```ts class VaultCore { diff --git a/crates/kv-wasm/js/smoke.mjs b/crates/kv-wasm/js/smoke.mjs index 27dd9d5..07c07f2 100644 --- a/crates/kv-wasm/js/smoke.mjs +++ b/crates/kv-wasm/js/smoke.mjs @@ -82,6 +82,8 @@ assert.deepEqual( ], ); assert.equal((await a.itemHistory(bankId))[0].password, "first password"); +assert.deepEqual(list[0].tags, ["finance", "ünïcode ✓"]); +assert.ok(list[0].modifiedAt >= bank.modifiedAt); await rejects(a.getItem("not-a-uuid"), "Invalid"); const code = kv.totpCode("JBSWY3DPEHPK3PXP", Math.floor(Date.now() / 1000)); assert.match(code.code, /^\d{6}$/); diff --git a/crates/kv-wasm/src/core.rs b/crates/kv-wasm/src/core.rs index 0f65e06..b60ea1a 100644 --- a/crates/kv-wasm/src/core.rs +++ b/crates/kv-wasm/src/core.rs @@ -796,6 +796,8 @@ fn summary(i: &Item) -> ItemSummary { urls: i.urls.clone(), favorite: i.favorite, has_totp: i.totp.is_some(), + tags: i.tags.clone(), + modified_at: i.modified_at, } } diff --git a/crates/kv-wasm/src/dto.rs b/crates/kv-wasm/src/dto.rs index 8fcd5b7..d12c501 100644 --- a/crates/kv-wasm/src/dto.rs +++ b/crates/kv-wasm/src/dto.rs @@ -42,6 +42,9 @@ pub struct ItemSummary { pub urls: Vec, pub favorite: bool, pub has_totp: bool, + /// Web only (not in kv-ffi's record): for tag filters and "recently modified" without `getItem`. + pub tags: Vec, + pub modified_at: i64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/kv-wasm/src/wasm/mod.rs b/crates/kv-wasm/src/wasm/mod.rs index 34344ae..66fadac 100644 --- a/crates/kv-wasm/src/wasm/mod.rs +++ b/crates/kv-wasm/src/wasm/mod.rs @@ -39,6 +39,9 @@ export interface ItemSummary { urls: string[]; favorite: boolean; hasTotp: boolean; + tags: string[]; + /** Unix seconds. */ + modifiedAt: number; } /** `id: ""` creates a new item. Omitted optional fields default to null / [] / false / 0. */ diff --git a/crates/kv-wasm/src/wasm/tests.rs b/crates/kv-wasm/src/wasm/tests.rs index d365764..4c23000 100644 --- a/crates/kv-wasm/src/wasm/tests.rs +++ b/crates/kv-wasm/src/wasm/tests.rs @@ -72,6 +72,7 @@ async fn account_lifecycle_persists_in_indexeddb() { .unwrap(); let list: Vec = de(ok(core.list_items()).await); assert_eq!(list.iter().map(|i| i.title.as_str()).collect::>(), ["Bank", "Note"]); + assert!(list[0].tags.is_empty() && list[0].modified_at > 1_700_000_000); let mut item: ItemDto = de(ok(core.get_item(id.clone())).await); assert_eq!(item.password.as_deref(), Some("hunter2-secret")); item.password = Some("hunter3-secret".into());