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
+6 -4
View File
@@ -563,8 +563,7 @@ impl VaultCore {
model.favorite = item.favorite;
model.tags = item.tags;
model.modified_at = now.max(previous.as_ref().map_or(0, |p| p.modified_at + 1));
(model.seq, model.restores) =
kv_sync::engine::next_local_version(self.store.as_ref(), acct.account_id, vault, &u.vault_key, id)?;
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)?;
@@ -602,14 +601,17 @@ impl VaultCore {
pub fn delete_item(&self, id: String) -> Result<()> {
let id = parse_id(&id)?;
let (acct, _u) = self.unlocked_snapshot()?;
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 tomb = ItemRecord { deleted: true, wrapped_item_key: None, payload: None, ..li.record };
// The tombstone is sealed with the next `seq` (docs/03 "Per-item replay protection").
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)?)
}
+33
View File
@@ -322,3 +322,36 @@ async fn save_item_bumps_seq_and_replays_are_refused() {
}
assert_eq!(b.get_item(id).unwrap().password.as_deref(), Some("pw3"));
}
#[tokio::test]
async fn delete_item_seals_the_tombstone_and_forged_deletes_are_refused() {
let server = TestServer::start().await;
let a = VaultCore::new_in_memory();
let sk = a.create_account(PW.into()).unwrap();
connect(&a, &server);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
let gone = a.save_item(login_item("Old", "pw0")).unwrap();
let kept = a.save_item(login_item("Bank", "pw1")).unwrap();
a.sync_now().await.unwrap();
a.delete_item(gone.clone()).unwrap();
a.sync_now().await.unwrap();
let vid = *server.state().vaults.keys().next().unwrap();
let (gone_id, kept_id) = (Uuid::parse_str(&gone).unwrap(), Uuid::parse_str(&kept).unwrap());
let tomb = server.state().vaults[&vid].items[&gone_id].clone();
assert!(tomb.deleted && tomb.payload.is_some(), "DELETE carried the sealed tombstone");
let b = VaultCore::new_in_memory();
connect(&b, &server);
b.login(PW.into(), sk, None).await.unwrap();
b.sync_now().await.unwrap();
assert_eq!(b.list_items().unwrap().len(), 1);
// The server forges a delete of the kept item.
let live = server.state().vaults[&vid].items[&kept_id].clone();
server.state().write_item(vid, ItemRecord { deleted: true, wrapped_item_key: None, payload: None, ..live });
match b.sync_now().await {
Err(CoreError::IntegrityAlarm { reason, .. }) => assert!(reason.contains("unauthenticated"), "{reason}"),
other => panic!("expected integrity alarm, got {other:?}"),
}
assert_eq!(b.get_item(kept).unwrap().password.as_deref(), Some("pw1"));
}