kv-sync: refuse per-item replays (seq/restores in the item plaintext)
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]>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
//! 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 serde_json::Value;
|
||||
|
||||
fn vectors() -> Value {
|
||||
serde_json::from_str(include_str!("vectors/item_replay.json")).unwrap()
|
||||
}
|
||||
|
||||
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 }
|
||||
} else {
|
||||
Base::Live { revision, item: &v["item"] }
|
||||
}
|
||||
}
|
||||
|
||||
#[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 want = case["result"].as_str().unwrap();
|
||||
assert_eq!(if got.is_ok() { "accept" } else { "alarm" }, want, "{}", case["name"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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"]));
|
||||
assert_eq!(Some(seq), case["seq"].as_u64(), "{}", case["name"]);
|
||||
assert_eq!(restores, case["restores"].as_u64(), "{}", case["name"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_copy_is_identical() {
|
||||
let spec = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../kestrove-spec/vectors/item_replay.json");
|
||||
if let Ok(s) = std::fs::read_to_string(spec) {
|
||||
assert_eq!(s, include_str!("vectors/item_replay.json"), "kestrove-spec copy differs");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user