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");
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ use kv_crypto::{
|
||||
use kv_proto::{DeviceEnroll, ItemRecord, Platform};
|
||||
use kv_store::{MemoryStore, Store, SyncState, VaultRow};
|
||||
use kv_sync::{
|
||||
engine::{open_record, seal_record},
|
||||
engine::{next_local_version, open_record, seal_record},
|
||||
replay,
|
||||
signing::SoftwareSigner,
|
||||
test_server::{TestServer, SETUP_TOKEN},
|
||||
ApiClient, DeviceSigner, LoginOutcome, ServerConfig, SyncContext, SyncError, SyncReport,
|
||||
@@ -102,7 +103,15 @@ impl Device {
|
||||
self.keyset.open_vault_key(vid, &self.store.vault(vid).unwrap().unwrap().sealed_key).unwrap()
|
||||
}
|
||||
|
||||
fn save(&self, vid: Uuid, id: Uuid, item: Value) {
|
||||
/// Saves a local edit the way a client does: with the next `seq` (and `restores`) stamped in.
|
||||
fn save(&self, vid: Uuid, id: Uuid, mut item: Value) {
|
||||
let (seq, restores) = next_local_version(&self.store, self.keyset.account_id, vid, &self.vk(vid), id).unwrap();
|
||||
replay::stamp(&mut item, seq, restores);
|
||||
self.save_raw(vid, id, item);
|
||||
}
|
||||
|
||||
/// Saves exactly `item`, e.g. a legacy item without `seq`.
|
||||
fn save_raw(&self, vid: Uuid, id: Uuid, item: Value) {
|
||||
let rec = seal_record(self.keyset.account_id, vid, &self.vk(vid), id, &item, self.signer.device_id()).unwrap();
|
||||
self.store.put_local(vid, &rec).unwrap();
|
||||
}
|
||||
@@ -522,3 +531,204 @@ mod tls {
|
||||
assert!(matches!(client(&p, port, vec![pin]).server_info().await, Err(SyncError::Network(_))));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- per-item replay (docs/03)
|
||||
|
||||
fn assert_replay_alarm(r: Result<SyncReport, SyncError>, vid: Uuid) {
|
||||
let err = r.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, SyncError::Integrity { vault_id, ref reason } if vault_id == vid && reason.contains("older version")),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The server re-serves an old, authentic envelope of an item under a new, higher revision.
|
||||
#[tokio::test]
|
||||
async fn replayed_old_envelope_is_an_integrity_alarm() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
a.save(vid, id, item(id, "Bank", "pw1", 1));
|
||||
a.sync().await.unwrap();
|
||||
let old = server.state().vaults[&vid].items[&id].clone();
|
||||
for (i, pw) in ["pw2", "pw3"].iter().enumerate() {
|
||||
a.save(vid, id, item(id, "Bank", pw, 2 + i as i64));
|
||||
a.sync().await.unwrap();
|
||||
}
|
||||
let b = Device::second(&server, &a).await;
|
||||
b.sync().await.unwrap();
|
||||
assert_eq!(b.read(vid, id).unwrap()["seq"], 3);
|
||||
let c = Device::second(&server, &a).await;
|
||||
c.sync().await.unwrap();
|
||||
let manifest_version = server.state().vaults[&vid].manifest.as_ref().unwrap().version;
|
||||
|
||||
let replayed_at = server.state().write_item(vid, old);
|
||||
// A clean device and a device with a pending edit of the item both refuse it.
|
||||
c.save(vid, id, item(id, "Bank", "pw-c", 9));
|
||||
assert_replay_alarm(b.sync().await, vid);
|
||||
assert_replay_alarm(c.sync().await, vid);
|
||||
assert_replay_alarm(a.sync().await, vid);
|
||||
for d in [&a, &b] {
|
||||
assert_eq!(d.read(vid, id).unwrap()["password"], "pw3", "not rolled back");
|
||||
assert!(d.store.vault(vid).unwrap().unwrap().integrity_alarm);
|
||||
}
|
||||
// Nobody pushed or signed over the rolled-back state.
|
||||
let st = server.state();
|
||||
assert_eq!(st.vaults[&vid].items[&id].revision, replayed_at);
|
||||
assert_eq!(st.vaults[&vid].manifest.as_ref().unwrap().version, manifest_version);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_of_the_current_version_is_harmless() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
a.save(vid, id, item(id, "Bank", "pw1", 1));
|
||||
a.sync().await.unwrap();
|
||||
let b = Device::second(&server, &a).await;
|
||||
b.sync().await.unwrap();
|
||||
let current = server.state().vaults[&vid].items[&id].clone();
|
||||
server.state().write_item(vid, current);
|
||||
a.sync().await.unwrap();
|
||||
b.sync().await.unwrap();
|
||||
assert_eq!(b.read(vid, id).unwrap()["password"], "pw1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tampered_envelope_is_an_integrity_alarm() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
a.save(vid, id, item(id, "Bank", "pw1", 1));
|
||||
a.sync().await.unwrap();
|
||||
let b = Device::second(&server, &a).await;
|
||||
let mut rec = server.state().vaults[&vid].items[&id].clone();
|
||||
rec.payload.as_mut().unwrap().ct[0] ^= 1;
|
||||
server.state().write_item(vid, rec);
|
||||
let err = b.sync().await.unwrap_err();
|
||||
assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("authenticate")), "{err}");
|
||||
}
|
||||
|
||||
/// Concurrent edits from the same base carry the same `seq`; that is a merge, not an alarm, on the two
|
||||
/// writers and on a third device that sees the versions one by one or only the last.
|
||||
#[tokio::test]
|
||||
async fn concurrent_equal_seq_edits_are_not_an_alarm() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
a.save(vid, id, item(id, "Mail", "p0", 1));
|
||||
a.sync().await.unwrap();
|
||||
let b = Device::second(&server, &a).await;
|
||||
let c = Device::second(&server, &a).await;
|
||||
let d = Device::second(&server, &a).await;
|
||||
for x in [&b, &c, &d] {
|
||||
x.sync().await.unwrap();
|
||||
}
|
||||
|
||||
a.save(vid, id, item(id, "Mail", "pA", 30));
|
||||
b.save(vid, id, json!({"id": id, "kind": "login", "title": "Mail", "password": "p0", "notes": "nB", "created_at": 1, "modified_at": 20}));
|
||||
assert_eq!(a.read(vid, id).unwrap()["seq"], 2);
|
||||
assert_eq!(b.read(vid, id).unwrap()["seq"], 2);
|
||||
a.sync().await.unwrap();
|
||||
c.sync().await.unwrap(); // sees A's seq 2
|
||||
assert_eq!(b.sync().await.unwrap().merged, 1);
|
||||
let merged = b.read(vid, id).unwrap();
|
||||
assert_eq!(merged["seq"], 3);
|
||||
assert!(merged.get("restores").is_none());
|
||||
for x in [&a, &c, &d] {
|
||||
x.sync().await.unwrap(); // d jumps from seq 1 straight to 3
|
||||
assert_eq!(x.read(vid, id).unwrap(), merged);
|
||||
}
|
||||
// Merging into exactly the remote content adopts it without writing a new version.
|
||||
a.save(vid, id, item(id, "Mail", "same", 40));
|
||||
c.save(vid, id, item(id, "Mail", "same", 40));
|
||||
a.sync().await.unwrap();
|
||||
let head = server.state().vaults[&vid].head;
|
||||
c.sync().await.unwrap();
|
||||
assert_eq!(server.state().vaults[&vid].head, head, "c adopted a's version");
|
||||
assert!(c.store.pending(vid).unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// An edit that beats a delete may carry a lower `seq` than versions other devices saw before the delete.
|
||||
/// `restores` lets a device that never saw the tombstone accept it, and only directly after that delete.
|
||||
#[tokio::test]
|
||||
async fn edit_beats_delete_is_not_an_alarm_but_its_replay_is() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
a.save(vid, id, item(id, "X", "x1", 1));
|
||||
a.sync().await.unwrap();
|
||||
let b = Device::second(&server, &a).await;
|
||||
b.sync().await.unwrap(); // b holds seq 1
|
||||
for (i, pw) in ["x2", "x3", "x4"].iter().enumerate() {
|
||||
a.save(vid, id, item(id, "X", pw, 2 + i as i64));
|
||||
a.sync().await.unwrap();
|
||||
}
|
||||
let c = Device::second(&server, &a).await;
|
||||
c.sync().await.unwrap(); // c holds seq 4 and will not see the tombstone
|
||||
a.delete(vid, id);
|
||||
a.sync().await.unwrap();
|
||||
let tombstone_rev = server.state().vaults[&vid].items[&id].revision;
|
||||
let d = Device::second(&server, &a).await;
|
||||
d.sync().await.unwrap(); // d holds the tombstone
|
||||
|
||||
b.save(vid, id, item(id, "X", "x-b", 9));
|
||||
let r = b.sync().await.unwrap();
|
||||
assert_eq!(r.restored, vec![id]);
|
||||
let restored = b.read(vid, id).unwrap();
|
||||
assert_eq!((restored["seq"].as_u64(), restored["restores"].as_u64()), (Some(2), Some(tombstone_rev)));
|
||||
let restore_envelope = server.state().vaults[&vid].items[&id].clone();
|
||||
|
||||
for x in [&a, &c, &d] {
|
||||
x.sync().await.unwrap();
|
||||
assert_eq!(x.read(vid, id).unwrap()["password"], "x-b");
|
||||
}
|
||||
// The next edit clears `restores` and continues from the restored `seq`.
|
||||
c.save(vid, id, item(id, "X", "x-c", 10));
|
||||
assert_eq!(c.read(vid, id).unwrap()["seq"], 3);
|
||||
assert!(c.read(vid, id).unwrap().get("restores").is_none());
|
||||
c.sync().await.unwrap();
|
||||
a.sync().await.unwrap();
|
||||
|
||||
// Re-serving the restore later is a rollback: the receiver's base is past the tombstone now.
|
||||
server.state().write_item(vid, restore_envelope);
|
||||
assert_replay_alarm(a.sync().await, vid);
|
||||
}
|
||||
|
||||
/// Items written before `seq` existed read as seq 0; the first edit writes seq 1.
|
||||
#[tokio::test]
|
||||
async fn legacy_items_without_seq() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
a.save_raw(vid, id, item(id, "Old", "p", 1));
|
||||
a.sync().await.unwrap();
|
||||
let b = Device::second(&server, &a).await;
|
||||
b.sync().await.unwrap();
|
||||
assert!(b.read(vid, id).unwrap().get("seq").is_none());
|
||||
b.save(vid, id, item(id, "Old", "p2", 2));
|
||||
assert_eq!(b.read(vid, id).unwrap()["seq"], 1);
|
||||
b.sync().await.unwrap();
|
||||
a.sync().await.unwrap();
|
||||
assert_eq!(a.read(vid, id).unwrap()["password"], "p2");
|
||||
}
|
||||
|
||||
/// The Merkle root is also checked when the cursor is past the manifest's head but nothing held is newer.
|
||||
#[tokio::test]
|
||||
async fn hidden_item_is_caught_when_cursor_is_past_manifest_head() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let (x, y) = (Uuid::new_v4(), Uuid::new_v4());
|
||||
a.save(vid, x, item(x, "X", "x", 1));
|
||||
a.save(vid, y, item(y, "Y", "y", 1));
|
||||
a.sync().await.unwrap();
|
||||
// Another device writes y without publishing a manifest yet, and the server hides y.
|
||||
let mut rec = server.state().vaults[&vid].items[&y].clone();
|
||||
rec.updated_by = Uuid::nil();
|
||||
server.state().write_item(vid, rec);
|
||||
server.state().hide_items = vec![y];
|
||||
|
||||
let b = Device::second(&server, &a).await;
|
||||
let err = b.sync().await.unwrap_err();
|
||||
assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("Merkle")), "{err}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"description": "Per-item replay protection (docs/03 'Per-item replay protection'). Item plaintexts are shown as JSON; only seq, restores and content equality matter. 'base' is the last server-confirmed version this device holds: null (none), {tombstone, revision} or {revision, item}.",
|
||||
"check": [
|
||||
{ "name": "no base: first sight of the item", "base": null, "revision": 7, "incoming": {"title": "a", "seq": 1}, "result": "accept" },
|
||||
{ "name": "tombstone base: anything may follow a delete", "base": {"tombstone": true, "revision": 5}, "revision": 7, "incoming": {"title": "a", "seq": 1}, "result": "accept" },
|
||||
{ "name": "higher seq", "base": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "b", "seq": 4}, "result": "accept" },
|
||||
{ "name": "seq jumps by more than one", "base": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "b", "seq": 9}, "result": "accept" },
|
||||
{ "name": "older seq: replay of an old envelope", "base": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "old", "seq": 2}, "result": "alarm" },
|
||||
{ "name": "same seq, different content", "base": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "b", "seq": 3}, "result": "alarm" },
|
||||
{ "name": "same seq, identical content", "base": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "a", "seq": 3}, "result": "accept" },
|
||||
{ "name": "legacy items: absent seq is 0", "base": {"revision": 5, "item": {"title": "a"}}, "revision": 7, "incoming": {"title": "b", "seq": 0}, "result": "alarm" },
|
||||
{ "name": "legacy base, first seq-aware edit", "base": {"revision": 5, "item": {"title": "a"}}, "revision": 7, "incoming": {"title": "b", "seq": 1}, "result": "accept" },
|
||||
{ "name": "restore over a tombstone this device did not see", "base": {"revision": 5, "item": {"title": "a", "seq": 6}}, "revision": 9, "incoming": {"title": "r", "seq": 2, "restores": 8}, "result": "accept" },
|
||||
{ "name": "restore claim older than the base", "base": {"revision": 8, "item": {"title": "a", "seq": 6}}, "revision": 9, "incoming": {"title": "r", "seq": 2, "restores": 7}, "result": "alarm" },
|
||||
{ "name": "restore claim equal to the base revision", "base": {"revision": 8, "item": {"title": "a", "seq": 6}}, "revision": 9, "incoming": {"title": "r", "seq": 2, "restores": 8}, "result": "alarm" },
|
||||
{ "name": "restore claim not below the record's own revision", "base": {"revision": 5, "item": {"title": "a", "seq": 6}}, "revision": 9, "incoming": {"title": "r", "seq": 2, "restores": 9}, "result": "alarm" }
|
||||
],
|
||||
"next_version": [
|
||||
{ "name": "new item", "local": null, "base": null, "seq": 1, "restores": null },
|
||||
{ "name": "edit of a clean item", "local": {"seq": 3}, "base": {"revision": 5, "item": {"seq": 3}}, "seq": 4, "restores": null },
|
||||
{ "name": "second edit before syncing", "local": {"seq": 4}, "base": {"revision": 5, "item": {"seq": 3}}, "seq": 5, "restores": null },
|
||||
{ "name": "re-save after a pending local delete", "local": null, "base": {"revision": 5, "item": {"seq": 3}}, "seq": 4, "restores": null },
|
||||
{ "name": "legacy item", "local": {}, "base": {"revision": 5, "item": {}}, "seq": 1, "restores": null },
|
||||
{ "name": "save over a tombstone", "local": null, "base": {"tombstone": true, "revision": 8}, "seq": 1, "restores": 8 },
|
||||
{ "name": "edit after a restore clears restores", "local": {"seq": 2, "restores": 8}, "base": {"revision": 9, "item": {"seq": 2, "restores": 8}}, "seq": 3, "restores": null }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user