* 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]>
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]>
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]>