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,27 +1,39 @@
|
||||
{
|
||||
"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}.",
|
||||
"description": "Per-item replay protection (docs/03 'Per-item replay protection'). Plaintexts are shown as JSON; only seq, the deleted marker, the tombstone id and content equality matter. Every case is for item_id. 'base' is the last server-confirmed version this device holds: null (none), {legacy_tombstone: true} (a tombstone without ciphertext from before tombstones were sealed) or {plaintext} (a live item or a sealed tombstone {id, seq, deleted: true}). 'record' is a pulled record: its deleted flag and its authenticated plaintext, null when it carries no ciphertext. 'local' is the device's working copy.",
|
||||
"item_id": "11111111-1111-1111-1111-111111111111",
|
||||
"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" }
|
||||
{"name": "no base: first sight of the item", "base": null, "record": {"deleted": false, "plaintext": {"title": "a", "seq": 1}}, "result": "accept"},
|
||||
{"name": "no base: first sight is a sealed tombstone", "base": null, "record": {"deleted": true, "plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 2, "deleted": true}}, "result": "accept"},
|
||||
{"name": "higher seq", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": false, "plaintext": {"title": "b", "seq": 4}}, "result": "accept"},
|
||||
{"name": "seq jumps by more than one", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": false, "plaintext": {"title": "b", "seq": 9}}, "result": "accept"},
|
||||
{"name": "older seq: replay of an old envelope", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": false, "plaintext": {"title": "old", "seq": 2}}, "result": "alarm"},
|
||||
{"name": "same seq, different content", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": false, "plaintext": {"title": "b", "seq": 3}}, "result": "alarm"},
|
||||
{"name": "same seq, identical content", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": false, "plaintext": {"title": "a", "seq": 3}}, "result": "accept"},
|
||||
{"name": "legacy items: absent seq is 0", "base": {"plaintext": {"title": "a"}}, "record": {"deleted": false, "plaintext": {"title": "b", "seq": 0}}, "result": "alarm"},
|
||||
{"name": "legacy base, first seq-aware edit", "base": {"plaintext": {"title": "a"}}, "record": {"deleted": false, "plaintext": {"title": "b", "seq": 1}}, "result": "accept"},
|
||||
{"name": "delete after a live version", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": true, "plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}}, "result": "accept"},
|
||||
{"name": "old tombstone replayed after a newer edit", "base": {"plaintext": {"title": "a", "seq": 6}}, "record": {"deleted": true, "plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 5, "deleted": true}}, "result": "alarm"},
|
||||
{"name": "restore after a delete (edit beats delete)", "base": {"plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}}, "record": {"deleted": false, "plaintext": {"title": "r", "seq": 5}}, "result": "accept"},
|
||||
{"name": "delete-then-replay: old live version after a tombstone", "base": {"plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}}, "record": {"deleted": false, "plaintext": {"title": "old", "seq": 3}}, "result": "alarm"},
|
||||
{"name": "same tombstone again", "base": {"plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}}, "record": {"deleted": true, "plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}}, "result": "accept"},
|
||||
{"name": "restore seen by a device that missed the delete", "base": {"plaintext": {"title": "a", "seq": 5}}, "record": {"deleted": false, "plaintext": {"title": "r", "seq": 7}}, "result": "accept"},
|
||||
{"name": "unsealed tombstone after a live version", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": true, "plaintext": null}, "result": "alarm"},
|
||||
{"name": "unsealed tombstone, no base", "base": null, "record": {"deleted": true, "plaintext": null}, "result": "alarm"},
|
||||
{"name": "live record without ciphertext", "base": null, "record": {"deleted": false, "plaintext": null}, "result": "alarm"},
|
||||
{"name": "tombstone envelope served as a live record", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": false, "plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}}, "result": "alarm"},
|
||||
{"name": "live envelope served as a tombstone", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": true, "plaintext": {"title": "b", "seq": 4}}, "result": "alarm"},
|
||||
{"name": "tombstone naming another item", "base": {"plaintext": {"title": "a", "seq": 3}}, "record": {"deleted": true, "plaintext": {"id": "22222222-2222-2222-2222-222222222222", "seq": 4, "deleted": true}}, "result": "alarm"},
|
||||
{"name": "legacy tombstone base: seq unknown", "base": {"legacy_tombstone": true}, "record": {"deleted": false, "plaintext": {"title": "r", "seq": 1}}, "result": "accept"}
|
||||
],
|
||||
"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 }
|
||||
{"name": "new item", "local": null, "base": null, "seq": 1},
|
||||
{"name": "edit of a clean item", "local": {"seq": 3}, "base": {"plaintext": {"seq": 3}}, "seq": 4},
|
||||
{"name": "second edit before syncing", "local": {"seq": 4}, "base": {"plaintext": {"seq": 3}}, "seq": 5},
|
||||
{"name": "delete of a clean item (the tombstone's seq)", "local": {"seq": 3}, "base": {"plaintext": {"seq": 3}}, "seq": 4},
|
||||
{"name": "re-save after a pending local delete", "local": {"id": "11111111-1111-1111-1111-111111111111", "seq": 4, "deleted": true}, "base": {"plaintext": {"seq": 3}}, "seq": 5},
|
||||
{"name": "legacy item", "local": {}, "base": {"plaintext": {}}, "seq": 1},
|
||||
{"name": "save over a pulled tombstone", "local": {"id": "11111111-1111-1111-1111-111111111111", "seq": 8, "deleted": true}, "base": {"plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 8, "deleted": true}}, "seq": 9},
|
||||
{"name": "pending edit rebased over a pulled tombstone (edit beats delete)", "local": {"seq": 4}, "base": {"plaintext": {"id": "11111111-1111-1111-1111-111111111111", "seq": 6, "deleted": true}}, "seq": 7},
|
||||
{"name": "legacy tombstone base", "local": null, "base": {"legacy_tombstone": true}, "seq": 1}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user