kv-sync: authenticated tombstones (sealed {id, seq, deleted: true})

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]>
This commit is contained in:
2026-10-16 11:34:08 +09:00
co-authored by Claude Opus 5.5
parent abcb758b67
commit cfab3ab543
15 changed files with 397 additions and 166 deletions
+4 -3
View File
@@ -123,11 +123,12 @@ pub fn envelope_cbor(e: &Envelope) -> Vec<u8> {
out
}
/// `blake3(envelope)` for one item record.
/// `blake3(envelope)` for one item record. A sealed tombstone hashes its envelope like a live record; only
/// a legacy tombstone without ciphertext hashes the empty array.
pub fn content_hash(record: &ItemRecord) -> [u8; 32] {
let mut out = Vec::new();
match (&record.wrapped_item_key, &record.payload, record.deleted) {
(Some(k), Some(p), false) => {
match (&record.wrapped_item_key, &record.payload) {
(Some(k), Some(p)) => {
cbor::array(&mut out, 2);
out.extend_from_slice(&envelope_cbor(k));
out.extend_from_slice(&envelope_cbor(p));