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:
@@ -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)?)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user