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:
2026-10-16 11:08:42 +09:00
co-authored by Claude Opus 5.5
parent 9da4472ad8
commit abcb758b67
12 changed files with 570 additions and 16 deletions
@@ -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 }
]
}