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:
@@ -52,6 +52,14 @@ pub struct Item {
|
||||
pub android_apps: Vec<String>,
|
||||
pub created_at: i64,
|
||||
pub modified_at: i64,
|
||||
/// Per-item edit counter, bound inside the ciphertext: every new version has a larger `seq` than the
|
||||
/// versions it derives from (docs/02 "Item plaintext", docs/03 "Per-item replay protection").
|
||||
#[serde(default)]
|
||||
pub seq: u64,
|
||||
/// Server revision of the tombstone this version was written over (edit beats delete). Only set on that
|
||||
/// one version.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub restores: Option<u64>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
@@ -71,6 +79,8 @@ impl Item {
|
||||
android_apps: vec![],
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
seq: 1,
|
||||
restores: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user