- kv-proto: email on setup, AccountInfo.email/is_admin, ServerInfo.registration, admin DTOs.
- kv-sync: prelogin by email or account id (setup QR / Emergency Kit), register with email,
set_email, /v1/admin/* client calls.
- kv-ffi: register_account(code, email), login(.., email), account_details, set_account_email,
admin_create_invite. kv-wasm: the same plus the full admin API for the web vault.
- Live test: two users on one server stay isolated; a new device signs in by email.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Syncs first (fails if that sync fails, unless discard_unsynced), ends the session best effort and
wipes the local store, keeping the device identity. Refused for a vault with no server, which would
be lost. Test: unsynced item survives sign-out and returns after signing in again.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
VaultCore::delete_account verifies the password against the keyset (Secret Key from its sealed copy),
calls DELETE /v1/account when a server is configured (needs a session; a failure deletes nothing),
then wipes the local store and keeps only the device identity. Unit test plus a live test against a
real server.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- crates/kv-ipc: native-messaging framing and the local IPC socket/pipe (moved from kestrove-desktop),
plus Listener::bind_in/connect_in and, on macOS, the App Group socket for the kestrove-nmh bundled
in Kestrove.app (Contents/Resources/kestrove-app-group).
- crates/kv-nmh: the native-messaging host, with an embedded Info.plist on macOS so it can run
sandboxed (KV_NMH_BUNDLE_ID).
- kv-ffi feature browser-bridge: broker, pairing channel and word list (moved from kestrove-desktop)
and BrowserBridge for Swift. build-apple.sh enables it and builds a universal kestrove-nmh.
- RUSTSEC-2026-0381/0382: rustls-webpki 0.103.16, webpki-roots 1.0.10 (distrusted emSign roots).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Ignored like the other live test: a phone denied while pending keeps its
identity over a reinstall and a fresh Add-a-device QR signs it straight in as
a new device. Passed against kestrove-server reenroll 62fe35d.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
kv-sync: SyncError::DeviceRevoked for 403 device_revoked (drops the session);
the test server answers it after OPAQUE (before the ticket) and for valid
signatures by a revoked device's key.
kv-ffi: CoreError::DeviceRevoked; the foreign DeviceSigner gains reset() (new key
and id). On device_revoked the core resets the identity, forgets the session
and the kept OPAQUE input (no silent re-enrolment) and keeps the vault data; a
login carrying a ticket retries once as the new device. reset_device_identity()
runs the reset on demand. Signed calls (sync, devices, tickets, join) reset too.
kv-wasm: DeviceRevoked; the browser binding replaces the WebCrypto key and id in
IndexedDB (same name and platform), loginWithSetup retries once with a ticket;
resetDeviceIdentity().
Tests: end to end revoked -> reset -> re-enrol with the same ticket (kv-ffi and
web core), no-ticket path, in-browser IndexedDB reset.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Mirrors kv-ffi's docs/10 §5 API in WebCore and binds it under the names the web app codes
against: setupPayloadForKit, addDevicePayload, revokeEnrollmentTicket, loginWithSetup,
joinStart/joinPoll/joinCancel, joinApprove, joinSend, and the free function parseSetupPayload
(also callable through the worker client).
Unlike kv-ffi, the Secret Key is not persisted: every unlock on the web takes it, so it is
kept zeroized in the unlocked state and dropped on lock. loginWithSetup refuses a code for a
different server than the configured origin (the browser only talks to its own origin).
Tests: ticket enrollment and scan-to-join in both roles against kv-ffi via the kv-sync test
server, a swapped-approver rejection, a Chrome test (IndexedDB never holds the Secret Key,
JS shapes, error codes) and a Node smoke check of the built package.
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]>
* kv-proto: ticket and join wire types, LoginFinish.ticket, b64_opt.
* kv-crypto: pairing.rs (HPKE AuthPSK seal/open with the QR secret as PSK, SAS code).
* kv-sync: onboarding.rs (kestrove://setup/v1 and kestrove://join/v1 codec with
strict validation), ApiClient ticket/join calls and login_with_ticket, test
server support for tickets and rendezvous.
* kv-ffi: parse/encode_setup_payload, setup_payload_for_kit, add_device_payload,
revoke_enrollment_ticket, login_with_setup, join_start/poll/cancel,
join_approve/send. The Secret Key is kept locally, HPKE-sealed to enc_pk.
* Tests: codec round trips and rejections, AuthPSK tamper cases, end-to-end
Add-device QR and scan-to-join between two clients, forged bundles, bound
tickets; live test extended with scan-to-join.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
next_version() and the two merge paths in the engine computed max(seq) + 1 on seq
values read from decrypted plaintext. A version carrying seq = u64::MAX panicked in
debug builds and wrapped to 0 in release, so the next save looked like a replay to
every other device. Found while checking fuzz/item_replay; needs an authentic
plaintext, so low severity.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
parse() checked the cleaned key's byte length and then split it at byte 26, which
panics when a multibyte character straddles that index (found by
fuzz/secret_key_parse). Under panic = "abort" this would crash the app on a
mistyped or pasted Secret Key.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
New module crates/kv-ffi/src/passkeys.rs; lib.rs only gains the mod/pub use lines.
Tests verify registrations and assertions like an RP (ciborium + p256), check RP isolation,
reopening the vault and use on a second device after a sync.
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 transport already restricts to TLS 1.3 (docs/02); dropping the tls12
feature keeps TLS 1.2 code out of the binary entirely.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
kv-store: SQLite (bundled) and in-memory Store holding only ciphertext
envelopes, with base copies for three-way merges and a 10-version history.
kv-ffi: VaultCore::open(path); persisted account record; local unlock key
(enable/unlock_with_local_key/disable); async sync API (configure_server,
set_device_signer, register_account, login, sync_now, logout, devices);
DeviceSigner foreign trait; IntegrityAlarm/PinMismatch/... error variants.
kv-sync: reqwest + rustls (TLS 1.3 only, ring) with SPKI pinning; OPAQUE
client on kv-proto's OpaqueSuite; RFC 9421 signing per kv_proto::httpsig;
pull/push with If-Match, field-level three-way merge, edit-beats-delete;
signed manifest (canonical CBOR, Merkle root, prev chain, Ed25519) with
rollback/fork/Merkle checks as hard integrity alarms. Tested against a
wiremock fake server, plus an #[ignore] test against kestrove-server.
kv-crypto: gen-vectors example and tests reproducing kestrove-spec/vectors.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A new device needs account_id and the KDF params to derive AUTH_INPUT
before it can build KE1, so they cannot come only from login/start.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Adds the OpaqueSuite (Ristretto255 OPRF, TripleDH, SHA-512, identity KSF)
shared by client and server, the httpsig signature-base builders, and the
setup/credentials/account/device/SSE types for milestone M1.
Cargo.lock changes only for kv-proto's new dependencies.
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]>