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:
@@ -1,7 +1,8 @@
|
||||
//! Checks `kv_sync::replay` against the shared decision table (kestrove-spec/vectors/item_replay.json).
|
||||
|
||||
use kv_sync::replay::{check_successor, next_version, Base};
|
||||
use kv_sync::replay::{check_pulled, next_version, Base};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn vectors() -> Value {
|
||||
serde_json::from_str(include_str!("vectors/item_replay.json")).unwrap()
|
||||
@@ -9,22 +10,24 @@ fn vectors() -> Value {
|
||||
|
||||
fn base(v: &Value) -> Base<'_> {
|
||||
if v.is_null() {
|
||||
return Base::None;
|
||||
}
|
||||
let revision = v["revision"].as_u64().unwrap();
|
||||
if v["tombstone"] == Value::Bool(true) {
|
||||
Base::Tombstone { revision }
|
||||
Base::None
|
||||
} else if v["legacy_tombstone"] == Value::Bool(true) {
|
||||
Base::LegacyTombstone
|
||||
} else {
|
||||
Base::Live { revision, item: &v["item"] }
|
||||
Base::Sealed(&v["plaintext"])
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_table() {
|
||||
for case in vectors()["check"].as_array().unwrap() {
|
||||
let got = check_successor(base(&case["base"]), case["revision"].as_u64().unwrap(), &case["incoming"]);
|
||||
let v = vectors();
|
||||
let id = Uuid::parse_str(v["item_id"].as_str().unwrap()).unwrap();
|
||||
for case in v["check"].as_array().unwrap() {
|
||||
let record = &case["record"];
|
||||
let plaintext = Some(&record["plaintext"]).filter(|p| !p.is_null());
|
||||
let got = check_pulled(base(&case["base"]), id, record["deleted"].as_bool().unwrap(), plaintext);
|
||||
let want = case["result"].as_str().unwrap();
|
||||
assert_eq!(if got.is_ok() { "accept" } else { "alarm" }, want, "{}", case["name"]);
|
||||
assert_eq!(if got.is_ok() { "accept" } else { "alarm" }, want, "{}: {got:?}", case["name"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +35,8 @@ fn check_table() {
|
||||
fn next_version_table() {
|
||||
for case in vectors()["next_version"].as_array().unwrap() {
|
||||
let local = Some(&case["local"]).filter(|v| !v.is_null());
|
||||
let (seq, restores) = next_version(local, base(&case["base"]));
|
||||
let seq = next_version(local, base(&case["base"]));
|
||||
assert_eq!(Some(seq), case["seq"].as_u64(), "{}", case["name"]);
|
||||
assert_eq!(restores, case["restores"].as_u64(), "{}", case["name"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user