The browser bridge answers passkey_query/get/create/result/cancel: the page's
RP ID is checked against the tab origin (no public suffixes), every request
waits for the user to approve it in the app, and the extension polls for the
result. Tags are normalised on save and included in item summaries.
confirm_master_password verifies the user where there are no biometrics.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
New crate crates/kv-wasm runs the same crypto, item model and sync engine in the browser:
- WebStore: in-memory replica of kv-store's Store, each changed entry written to IndexedDB
(ciphertext only) before the JS call resolves.
- Device key: non-extractable WebCrypto ECDSA P-256 key in IndexedDB (raw r||s signatures).
- WebCore mirrors kv-ffi's VaultCore; wasm-bindgen surface in camelCase with TypeScript types,
plus a Web Worker wrapper (connectVault) so Argon2id never blocks the page.
- Tests: Chrome (IndexedDB, WebCrypto, RFC 9421 verify), shared envelope fixtures both ways
with kv-ffi, native sync with kv-ffi via the test server, Node smoke of the built package.
Small changes to shared crates so they build for wasm32:
- kv-store: SqliteStore moved to a native-only module.
- kv-sync: rustls/pinning/tokio native only (fetch on wasm); DeviceSigner::sign_async (default
wraps sign) used by ApiClient; unix_now() instead of SystemTime/now_utc.
- kv-model: Public Suffix List behind a default `psl` feature (kv-ffi keeps it; the web build
drops ~0.9 MB of code and matches same-host only).
- wasm-release profile (opt-level z, Argon2 at 3) and a wasm CI job.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Tombstones carried no ciphertext, so the server could forge a delete (hiding
an item), and a device that pulled one accepted the next live version without
a seq comparison (delete-then-replay rollback).
* A delete is sealed like an item (same AAD and padding) with plaintext
{id, seq, deleted: true}; seq = working/base seq + 1. DELETE carries it as
the ItemPut body; the test server stores and serves it.
* replay: Base is None | LegacyTombstone | Sealed(plaintext). check_pulled
refuses a record without ciphertext, a deleted flag that disagrees with the
plaintext, or a tombstone naming another item, then requires seq to grow
for tombstones too. The `restores` exception is gone: edit beats delete
re-seals with max(edit, tombstone) + 1. next_version returns just seq.
* Legacy: a pulled tombstone without ciphertext is an integrity alarm (cannot
be told apart from a forgery). A pending legacy delete is sealed on push; a
legacy tombstone already held as base is accepted as unknown seq.
* engine: open_sealed, seal_tombstone; next_local_version -> u64. Merkle leaf
hashes a sealed tombstone's envelope like a live record's.
* kv-model Item: `restores` removed. kv-ffi delete_item seals the tombstone;
FFI surface unchanged.
* Tests: forged (unsealed and relabelled) tombstones raise an integrity alarm
(kv-sync and kv-ffi), replay after a tombstone, replays of the tombstone and
of the restore, legacy pending delete, shared item_replay.json table; the
live test now deletes an item through the real server.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A malicious server could re-serve an old but valid envelope of an item under
a new, higher revision: the AAD does not bind the revision and the Merkle
root was only checked when head_revision == cursor. The client took it as
the latest version, rolled the item back and signed over it in its next
manifest.
* kv-model Item: `seq: u64` (serde default 0) and `restores: Option<u64>`.
* kv-sync replay module: pure decision rules (check_successor,
next_version, stamp, same_content). Every pulled live record must
authenticate and have seq > the base's seq, unless it carries
restores = t with base_revision < t < revision (edit beat a delete this
device never saw) or is identical to the base. Otherwise: Integrity alarm.
Tombstones and items without a base pass.
* engine: check on every pulled record; edit-beats-delete re-seals with
restores = tombstone revision; merges write seq = max + 1 and adopt the
remote version when only seq/restores differ. Public
engine::next_local_version gives the seq/restores for a local save
(used by kv-ffi save_item and the tests).
* verify_manifest checks the Merkle root whenever no held item is newer
than head_revision (previously only at head == cursor).
* Tests: replay alarm (clean and pending receivers, plus kv-ffi), harmless
replay of the current version, tampered envelope, concurrent equal-seq
edits across three devices, edit-beats-delete with a device that skipped
the tombstone and a later replay of the restore, legacy items without seq,
hidden item with cursor past the manifest head, and the shared
item_replay.json decision table.
* gen-vectors: envelope vector item payload now includes seq.
FFI surface unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The first base32 symbol carries 3 data bits (26 symbols = 130 bits, 2 spare),
but decode only accepted values 0-3, so ~half of generated keys failed to parse.
Adds an edge-case test, rustfmt config, Cargo.lock, and on-demand cross targets
instead of installing all seven in rust-toolchain.toml.
Co-Authored-By: Claude Opus 5.5 <[email protected]>