From cfab3ab543a3a8daf29f873e8de673077c7e0ad5 Mon Sep 17 00:00:00 2001 From: samialabri Date: Fri, 16 Oct 2026 11:34:08 +0900 Subject: [PATCH] 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 --- crates/kv-ffi/src/lib.rs | 10 +- crates/kv-ffi/src/tests.rs | 33 +++++ crates/kv-ffi/tests/live_server.rs | 10 ++ crates/kv-model/src/item.rs | 5 - crates/kv-proto/src/lib.rs | 3 + crates/kv-sync/src/client.rs | 11 +- crates/kv-sync/src/engine.rs | 116 +++++++++------ crates/kv-sync/src/lib.rs | 2 +- crates/kv-sync/src/manifest.rs | 7 +- crates/kv-sync/src/merge.rs | 8 +- crates/kv-sync/src/replay.rs | 135 +++++++++++------- crates/kv-sync/src/test_server.rs | 12 +- crates/kv-sync/tests/replay_vectors.rs | 26 ++-- crates/kv-sync/tests/sync.rs | 131 ++++++++++++++--- crates/kv-sync/tests/vectors/item_replay.json | 54 ++++--- 15 files changed, 397 insertions(+), 166 deletions(-) diff --git a/crates/kv-ffi/src/lib.rs b/crates/kv-ffi/src/lib.rs index 846a4e9..4721e1d 100644 --- a/crates/kv-ffi/src/lib.rs +++ b/crates/kv-ffi/src/lib.rs @@ -563,8 +563,7 @@ impl VaultCore { model.favorite = item.favorite; model.tags = item.tags; model.modified_at = now.max(previous.as_ref().map_or(0, |p| p.modified_at + 1)); - (model.seq, model.restores) = - kv_sync::engine::next_local_version(self.store.as_ref(), acct.account_id, vault, &u.vault_key, id)?; + model.seq = kv_sync::engine::next_local_version(self.store.as_ref(), acct.account_id, vault, &u.vault_key, id)?; let json = Zeroizing::new(serde_json::to_vec(&model).map_err(internal)?); let sealed = seal_item(acct.account_id, vault, id, &u.vault_key, &json)?; @@ -602,14 +601,17 @@ impl VaultCore { pub fn delete_item(&self, id: String) -> Result<()> { let id = parse_id(&id)?; - let (acct, _u) = self.unlocked_snapshot()?; + let (acct, u) = self.unlocked_snapshot()?; let vault = acct.primary_vault; let li = self.store.get(vault, id)?.filter(|li| !li.record.deleted).ok_or(CoreError::NotFound)?; if li.base.is_none() { // Never reached the server: just forget it. return Ok(self.store.remove(vault, id)?); } - let tomb = ItemRecord { deleted: true, wrapped_item_key: None, payload: None, ..li.record }; + // The tombstone is sealed with the next `seq` (docs/03 "Per-item replay protection"). + let seq = kv_sync::engine::next_local_version(self.store.as_ref(), acct.account_id, vault, &u.vault_key, id)?; + let tomb = + kv_sync::engine::seal_tombstone(acct.account_id, vault, &u.vault_key, id, seq, li.record.updated_by)?; Ok(self.store.put_local(vault, &tomb)?) } diff --git a/crates/kv-ffi/src/tests.rs b/crates/kv-ffi/src/tests.rs index e01ff31..e75306f 100644 --- a/crates/kv-ffi/src/tests.rs +++ b/crates/kv-ffi/src/tests.rs @@ -322,3 +322,36 @@ async fn save_item_bumps_seq_and_replays_are_refused() { } assert_eq!(b.get_item(id).unwrap().password.as_deref(), Some("pw3")); } + +#[tokio::test] +async fn delete_item_seals_the_tombstone_and_forged_deletes_are_refused() { + let server = TestServer::start().await; + let a = VaultCore::new_in_memory(); + let sk = a.create_account(PW.into()).unwrap(); + connect(&a, &server); + a.register_account(SETUP_TOKEN.into()).await.unwrap(); + let gone = a.save_item(login_item("Old", "pw0")).unwrap(); + let kept = a.save_item(login_item("Bank", "pw1")).unwrap(); + a.sync_now().await.unwrap(); + a.delete_item(gone.clone()).unwrap(); + a.sync_now().await.unwrap(); + let vid = *server.state().vaults.keys().next().unwrap(); + let (gone_id, kept_id) = (Uuid::parse_str(&gone).unwrap(), Uuid::parse_str(&kept).unwrap()); + let tomb = server.state().vaults[&vid].items[&gone_id].clone(); + assert!(tomb.deleted && tomb.payload.is_some(), "DELETE carried the sealed tombstone"); + + let b = VaultCore::new_in_memory(); + connect(&b, &server); + b.login(PW.into(), sk, None).await.unwrap(); + b.sync_now().await.unwrap(); + assert_eq!(b.list_items().unwrap().len(), 1); + + // The server forges a delete of the kept item. + let live = server.state().vaults[&vid].items[&kept_id].clone(); + server.state().write_item(vid, ItemRecord { deleted: true, wrapped_item_key: None, payload: None, ..live }); + match b.sync_now().await { + Err(CoreError::IntegrityAlarm { reason, .. }) => assert!(reason.contains("unauthenticated"), "{reason}"), + other => panic!("expected integrity alarm, got {other:?}"), + } + assert_eq!(b.get_item(kept).unwrap().password.as_deref(), Some("pw1")); +} diff --git a/crates/kv-ffi/tests/live_server.rs b/crates/kv-ffi/tests/live_server.rs index 771df53..85060f1 100644 --- a/crates/kv-ffi/tests/live_server.rs +++ b/crates/kv-ffi/tests/live_server.rs @@ -97,6 +97,16 @@ async fn two_devices_against_local_server() { assert_eq!((ia.notes.as_deref(), ia.username.as_deref()), (Some("A"), Some("b"))); assert_eq!((ib.notes, ib.username), (ia.notes, ia.username)); + // A delete carries the sealed tombstone, and B authenticates it. + let temp = a.save_item(item("Temp", "pw-t")).unwrap(); + a.sync_now().await.unwrap(); + b.sync_now().await.unwrap(); + assert!(b.get_item(temp.clone()).is_ok()); + a.delete_item(temp.clone()).unwrap(); + assert_eq!(a.sync_now().await.unwrap().pushed, 1); + b.sync_now().await.unwrap(); + assert!(matches!(b.get_item(temp), Err(CoreError::NotFound))); + b.logout().await.unwrap(); println!("live sync OK against {url}"); } diff --git a/crates/kv-model/src/item.rs b/crates/kv-model/src/item.rs index 5adedfc..f0b27d6 100644 --- a/crates/kv-model/src/item.rs +++ b/crates/kv-model/src/item.rs @@ -56,10 +56,6 @@ pub struct Item { /// 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, } impl Item { @@ -80,7 +76,6 @@ impl Item { created_at: now, modified_at: now, seq: 1, - restores: None, } } } diff --git a/crates/kv-proto/src/lib.rs b/crates/kv-proto/src/lib.rs index e495cf1..d607128 100644 --- a/crates/kv-proto/src/lib.rs +++ b/crates/kv-proto/src/lib.rs @@ -264,6 +264,8 @@ pub struct Vault { pub meta: Envelope, } +/// Body of `PUT /v1/vaults/{vid}/items/{iid}` (the sealed item) and of `DELETE` on the same path (the sealed +/// tombstone `{id, seq, deleted: true}`, same AAD as the item; docs/03 "Per-item replay protection"). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ItemPut { pub wrapped_item_key: Envelope, @@ -275,6 +277,7 @@ pub struct ItemRecord { pub item_id: Uuid, pub revision: u64, pub deleted: bool, + /// Present on live records and sealed tombstones. Absent only on legacy tombstones, which clients refuse. pub wrapped_item_key: Option, pub payload: Option, pub updated_by: Uuid, diff --git a/crates/kv-sync/src/client.rs b/crates/kv-sync/src/client.rs index e452dba..ca239e3 100644 --- a/crates/kv-sync/src/client.rs +++ b/crates/kv-sync/src/client.rs @@ -252,8 +252,15 @@ impl ApiClient { self.json(Method::PUT, &format!("{PATH_VAULTS}/{vault}/items/{item}"), Some(body), Some(base), true).await } - pub async fn delete_item(&self, vault: Uuid, item: Uuid, base: u64) -> Result { - self.json(Method::DELETE, &format!("{PATH_VAULTS}/{vault}/items/{item}"), None::<&()>, Some(base), true).await + /// Tombstones an item. `body` is the sealed tombstone `{id, seq, deleted: true}` (docs/03). + pub async fn delete_item( + &self, + vault: Uuid, + item: Uuid, + base: u64, + body: &ItemPut, + ) -> Result { + self.json(Method::DELETE, &format!("{PATH_VAULTS}/{vault}/items/{item}"), Some(body), Some(base), true).await } /// `None` when the vault has no manifest yet. diff --git a/crates/kv-sync/src/engine.rs b/crates/kv-sync/src/engine.rs index 616543c..d89f7f4 100644 --- a/crates/kv-sync/src/engine.rs +++ b/crates/kv-sync/src/engine.rs @@ -59,17 +59,28 @@ fn crypto(e: kv_crypto::CryptoError) -> SyncError { SyncError::Crypto(e.to_string()) } -/// Decrypts an item record's payload into JSON. +/// Decrypts a live item record's payload into JSON. `None` for a tombstone. pub fn open_record( account_id: Uuid, vault_id: Uuid, vk: &SymKey, record: &ItemRecord, ) -> Result, SyncError> { - let (Some(k), Some(p)) = (&record.wrapped_item_key, &record.payload) else { return Ok(None) }; if record.deleted { return Ok(None); } + open_sealed(account_id, vault_id, vk, record) +} + +/// Decrypts the plaintext of a live record or a sealed tombstone. `None` when the record carries no +/// ciphertext (a legacy tombstone). +pub fn open_sealed( + account_id: Uuid, + vault_id: Uuid, + vk: &SymKey, + record: &ItemRecord, +) -> Result, SyncError> { + let (Some(k), Some(p)) = (&record.wrapped_item_key, &record.payload) else { return Ok(None) }; let sealed = SealedItem { wrapped_item_key: k.clone(), payload: p.clone() }; let pt = open_item(account_id, vault_id, record.item_id, vk, &sealed).map_err(crypto)?; serde_json::from_slice(&pt).map(Some).map_err(|e| SyncError::Crypto(format!("item JSON: {e}"))) @@ -97,6 +108,20 @@ pub fn seal_record( }) } +/// Seals a tombstone `{id, seq, deleted: true}` into a new (local) deleted record. `seq` comes from +/// [`next_local_version`] (docs/03 "Per-item replay protection"). +pub fn seal_tombstone( + account_id: Uuid, + vault_id: Uuid, + vk: &SymKey, + item_id: Uuid, + seq: u64, + device_id: Uuid, +) -> Result { + let record = seal_record(account_id, vault_id, vk, item_id, &replay::tombstone(item_id, seq), device_id)?; + Ok(ItemRecord { deleted: true, ..record }) +} + fn history_entry(r: &ItemRecord) -> Option { Some(HistoryEntry { revision: r.revision, @@ -106,20 +131,20 @@ fn history_entry(r: &ItemRecord) -> Option { }) } -/// The decrypted form of a local base, for [`replay`]. Owns the plaintext a [`Base::Live`] borrows. +/// The decrypted form of a local base, for [`replay`]. Owns the plaintext a [`Base::Sealed`] borrows. enum OpenedBase { None, - Tombstone(u64), - Live(u64, Value), + LegacyTombstone, + Sealed(Value), } impl OpenedBase { fn open(account_id: Uuid, vault_id: Uuid, vk: &SymKey, base: Option<&ItemRecord>) -> Result { Ok(match base { None => Self::None, - Some(b) if b.deleted => Self::Tombstone(b.revision), - Some(b) => match open_record(account_id, vault_id, vk, b)? { - Some(v) => Self::Live(b.revision, v), + Some(b) => match open_sealed(account_id, vault_id, vk, b)? { + Some(v) => Self::Sealed(v), + None if b.deleted => Self::LegacyTombstone, None => Self::None, }, }) @@ -128,23 +153,23 @@ impl OpenedBase { fn as_base(&self) -> Base<'_> { match self { Self::None => Base::None, - Self::Tombstone(revision) => Base::Tombstone { revision: *revision }, - Self::Live(revision, item) => Base::Live { revision: *revision, item }, + Self::LegacyTombstone => Base::LegacyTombstone, + Self::Sealed(v) => Base::Sealed(v), } } } -/// The `seq` and `restores` a new local version of `item_id` must carry (docs/03 "Per-item replay -/// protection"). Call it before sealing a local edit and stamp the result into the plaintext. +/// The `seq` a new local version of `item_id`, live or tombstone, must carry (docs/03 "Per-item replay +/// protection"). Call it before sealing a local edit or delete and stamp the result into the plaintext. pub fn next_local_version( store: &dyn Store, account_id: Uuid, vault_id: Uuid, vk: &SymKey, item_id: Uuid, -) -> Result<(u64, Option), SyncError> { - let Some(local) = store.get(vault_id, item_id)? else { return Ok((1, None)) }; - let working = open_record(account_id, vault_id, vk, &local.record)?; +) -> Result { + let Some(local) = store.get(vault_id, item_id)? else { return Ok(1) }; + let working = open_sealed(account_id, vault_id, vk, &local.record)?; let base = OpenedBase::open(account_id, vault_id, vk, local.base.as_ref())?; Ok(replay::next_version(working.as_ref(), base.as_base())) } @@ -306,8 +331,8 @@ impl SyncContext<'_> { self.resolve_conflict(vault, vk, local, remote, report) } - /// Integrity alarm unless `remote` authenticates and may follow `base` (docs/03 "Per-item replay - /// protection"). Tombstones carry no ciphertext and pass. + /// Integrity alarm unless `remote`, live or tombstone, authenticates, is what its `deleted` flag says + /// it is, and may follow `base` (docs/03 "Per-item replay protection"). fn check_replay( &self, vault: Uuid, @@ -315,17 +340,12 @@ impl SyncContext<'_> { base: Option<&ItemRecord>, remote: &ItemRecord, ) -> Result<(), SyncError> { - if remote.deleted { - return Ok(()); - } let id = remote.item_id; - let incoming = match open_record(self.account_id(), vault, vk, remote) { - Ok(Some(v)) => v, - Ok(None) => return Err(self.alarm(vault, format!("item {id}: live record without ciphertext"))), - Err(_) => return Err(self.alarm(vault, format!("item {id}: envelope does not authenticate"))), + let Ok(incoming) = open_sealed(self.account_id(), vault, vk, remote) else { + return Err(self.alarm(vault, format!("item {id}: envelope does not authenticate"))); }; let base = OpenedBase::open(self.account_id(), vault, vk, base)?; - replay::check_successor(base.as_base(), remote.revision, &incoming) + replay::check_pulled(base.as_base(), id, remote.deleted, incoming.as_ref()) .map_err(|e| self.alarm(vault, format!("item {id}: {e}"))) } @@ -352,13 +372,15 @@ impl SyncContext<'_> { report.restored.push(id); } (false, true) => { - // Our edit beats their delete: keep ours and push it on top of the tombstone, marked with - // the tombstone's revision so devices that never saw the delete accept a lower `seq`. + // Our edit beats their delete: keep ours and push it on top of the tombstone with a `seq` + // above both, so every device sees `seq` grow (docs/03 "Per-item replay protection"). let acct = self.account_id(); let mut mine = open_record(acct, vault, vk, &local.record)?.ok_or(SyncError::Protocol("empty item".into()))?; - let seq = replay::seq_of(&mine); - replay::stamp(&mut mine, seq, Some(remote.revision)); + let tomb = + open_sealed(acct, vault, vk, remote)?.ok_or(SyncError::Protocol("empty tombstone".into()))?; + let seq = replay::seq_of(&mine).max(replay::seq_of(&tomb)) + 1; + replay::stamp(&mut mine, seq); let sealed = seal_record(acct, vault, vk, id, &mine, self.device_id)?; self.store.put(vault, &rebased(sealed))?; report.restored.push(id); @@ -387,7 +409,7 @@ impl SyncContext<'_> { self.store.put_remote(vault, remote)?; } else { let seq = replay::seq_of(&mine).max(replay::seq_of(&theirs)) + 1; - replay::stamp(&mut out.merged, seq, None); + replay::stamp(&mut out.merged, seq); let sealed = seal_record(acct, vault, vk, id, &out.merged, self.device_id)?; self.store.put(vault, &rebased(sealed))?; } @@ -475,17 +497,22 @@ impl SyncContext<'_> { for item in pending { let base = item.base_revision(); let id = item.record.item_id; - let result = if item.record.deleted { - if base == 0 { - self.store.remove(vault, id)?; - continue; - } - self.api.delete_item(vault, id, base).await + if item.record.deleted && base == 0 { + self.store.remove(vault, id)?; + continue; + } + let item = if item.record.deleted && item.record.payload.is_none() { + self.seal_legacy_delete(vault, vk, item)? + } else { + item + }; + let (Some(k), Some(p)) = (&item.record.wrapped_item_key, &item.record.payload) else { + return Err(SyncError::Protocol(format!("pending item {id} has no ciphertext"))); + }; + let body = ItemPut { wrapped_item_key: k.clone(), payload: p.clone() }; + let result = if item.record.deleted { + self.api.delete_item(vault, id, base, &body).await } else { - let (Some(k), Some(p)) = (&item.record.wrapped_item_key, &item.record.payload) else { - return Err(SyncError::Protocol(format!("pending item {id} has no ciphertext"))); - }; - let body = ItemPut { wrapped_item_key: k.clone(), payload: p.clone() }; self.api.put_item(vault, id, base, &body).await }; match result { @@ -507,6 +534,15 @@ impl SyncContext<'_> { Ok(pushed) } + /// Seals a pending delete stored before tombstones were sealed. + fn seal_legacy_delete(&self, vault: Uuid, vk: &SymKey, item: LocalItem) -> Result { + let id = item.record.item_id; + let seq = next_local_version(self.store, self.account_id(), vault, vk, id)?; + let tomb = seal_tombstone(self.account_id(), vault, vk, id, seq, self.device_id)?; + self.store.put_local(vault, &tomb)?; + Ok(self.store.get(vault, id)?.ok_or(kv_store::StoreError::NotFound)?) + } + /// Records the server's acceptance, unless the item was edited again while the request was in flight. fn confirm(&self, vault: Uuid, pushed: &LocalItem, confirmed: ItemRecord) -> Result<(), SyncError> { let current = self.store.get(vault, confirmed.item_id)?; diff --git a/crates/kv-sync/src/lib.rs b/crates/kv-sync/src/lib.rs index 4137a99..c21ff33 100644 --- a/crates/kv-sync/src/lib.rs +++ b/crates/kv-sync/src/lib.rs @@ -8,7 +8,7 @@ //! a callback into platform code ([`DeviceSigner`]). //! * [`engine`]: pull, then push with `If-Match`, then three-way field merge on 412 ([`merge`]), then manifest verify //! and publish ([`manifest`]). -//! * [`replay`]: per-item replay check on pull (the `seq` / `restores` fields of the item plaintext). +//! * [`replay`]: per-item replay check on pull (the `seq` field sealed into every item version and tombstone). pub mod client; pub mod engine; diff --git a/crates/kv-sync/src/manifest.rs b/crates/kv-sync/src/manifest.rs index 5f9ffbb..c982855 100644 --- a/crates/kv-sync/src/manifest.rs +++ b/crates/kv-sync/src/manifest.rs @@ -123,11 +123,12 @@ pub fn envelope_cbor(e: &Envelope) -> Vec { out } -/// `blake3(envelope)` for one item record. +/// `blake3(envelope)` for one item record. A sealed tombstone hashes its envelope like a live record; only +/// a legacy tombstone without ciphertext hashes the empty array. pub fn content_hash(record: &ItemRecord) -> [u8; 32] { let mut out = Vec::new(); - match (&record.wrapped_item_key, &record.payload, record.deleted) { - (Some(k), Some(p), false) => { + match (&record.wrapped_item_key, &record.payload) { + (Some(k), Some(p)) => { cbor::array(&mut out, 2); out.extend_from_slice(&envelope_cbor(k)); out.extend_from_slice(&envelope_cbor(p)); diff --git a/crates/kv-sync/src/merge.rs b/crates/kv-sync/src/merge.rs index f3fccf2..513db8c 100644 --- a/crates/kv-sync/src/merge.rs +++ b/crates/kv-sync/src/merge.rs @@ -2,12 +2,12 @@ //! //! Items are merged as JSON objects so every top-level field of `kv_model::Item` (and any field a //! newer client adds) is a merge unit. `id` and `created_at` come from the base, `modified_at` and -//! `seq` are the maximum of both sides, and `restores` is dropped. The caller stamps the next `seq` if it -//! writes the result as a new version ([`crate::replay`]). +//! `seq` are the maximum of both sides. The caller stamps the next `seq` if it writes the result as a new +//! version ([`crate::replay`]). use serde_json::{Map, Value}; -use crate::replay::{seq_of, RESTORES, SEQ}; +use crate::replay::{seq_of, SEQ}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Side { @@ -25,7 +25,7 @@ pub struct MergeOutcome { pub conflicting_fields: Vec, } -const FIXED: [&str; 5] = ["id", "created_at", "modified_at", SEQ, RESTORES]; +const FIXED: [&str; 4] = ["id", "created_at", "modified_at", SEQ]; fn modified_at(v: &Value) -> i64 { v.get("modified_at").and_then(Value::as_i64).unwrap_or(0) diff --git a/crates/kv-sync/src/replay.rs b/crates/kv-sync/src/replay.rs index 7a1f92b..f1a579d 100644 --- a/crates/kv-sync/src/replay.rs +++ b/crates/kv-sync/src/replay.rs @@ -1,43 +1,46 @@ //! Per-item replay protection (docs/03 "Per-item replay protection"). //! //! The AAD binds an envelope to its item but not to a server revision, so a server could re-serve an -//! old, authentic envelope under a new revision. Every item plaintext therefore carries `seq`, an edit -//! counter that grows along every honest chain of versions, and `restores`, the revision of the -//! tombstone a version was written over (tombstones carry no `seq`). Pulled records are checked -//! against the local base, the last version the server confirmed to this device. +//! old, authentic envelope under a new revision. Every version of an item, live or deleted, is sealed +//! and carries `seq`, an edit counter that grows along every honest chain of versions. A tombstone's +//! plaintext is `{id, seq, deleted: true}`. Pulled records are checked against the local base, the +//! last version the server confirmed to this device. -use serde_json::Value; +use serde_json::{json, Value}; +use uuid::Uuid; /// The plaintext field holding the per-item edit counter. Absent means 0. pub const SEQ: &str = "seq"; -/// The plaintext field holding the revision of the tombstone a version was written over. -pub const RESTORES: &str = "restores"; +/// The plaintext field that marks a tombstone. +pub const DELETED: &str = "deleted"; pub fn seq_of(item: &Value) -> u64 { item.get(SEQ).and_then(Value::as_u64).unwrap_or(0) } -pub fn restores_of(item: &Value) -> Option { - item.get(RESTORES).and_then(Value::as_u64) +/// Sets `seq`. +pub fn stamp(item: &mut Value, seq: u64) { + if let Some(obj) = item.as_object_mut() { + obj.insert(SEQ.into(), Value::from(seq)); + } } -/// Sets `seq` and `restores` (removing `restores` when `None`). -pub fn stamp(item: &mut Value, seq: u64, restores: Option) { - let Some(obj) = item.as_object_mut() else { return }; - obj.insert(SEQ.into(), Value::from(seq)); - match restores { - Some(t) => obj.insert(RESTORES.into(), Value::from(t)), - None => obj.remove(RESTORES), - }; +/// The plaintext of a tombstone for item `id`. +pub fn tombstone(id: Uuid, seq: u64) -> Value { + json!({ "id": id, SEQ: seq, DELETED: true }) } -/// Whether two item plaintexts have the same content, ignoring `seq` and `restores`. +/// Whether a plaintext is a tombstone (`deleted: true`). +pub fn is_tombstone(plaintext: &Value) -> bool { + plaintext.get(DELETED) == Some(&Value::Bool(true)) +} + +/// Whether two item plaintexts have the same content, ignoring `seq`. pub fn same_content(a: &Value, b: &Value) -> bool { let strip = |v: &Value| { let mut v = v.clone(); if let Some(o) = v.as_object_mut() { o.remove(SEQ); - o.remove(RESTORES); } v }; @@ -49,13 +52,10 @@ pub fn same_content(a: &Value, b: &Value) -> bool { pub enum Base<'a> { /// This device holds no server version of the item. None, - Tombstone { - revision: u64, - }, - Live { - revision: u64, - item: &'a Value, - }, + /// A tombstone without ciphertext, accepted before tombstones were sealed. Its `seq` is unknown. + LegacyTombstone, + /// The decrypted plaintext of a live version or a sealed tombstone. + Sealed(&'a Value), } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] @@ -65,17 +65,41 @@ pub struct Replay { pub incoming: u64, } -/// Checks that `incoming` (a live record at server `revision`) may follow `base`. -pub fn check_successor(base: Base<'_>, revision: u64, incoming: &Value) -> Result<(), Replay> { - let Base::Live { revision: base_revision, item: held } = base else { return Ok(()) }; +/// Why a pulled record is refused. Every variant is an integrity alarm. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Rejected { + #[error("tombstone without ciphertext (unauthenticated delete)")] + UnsealedTombstone, + #[error("live record without ciphertext")] + UnsealedItem, + #[error("deleted flag does not match the sealed plaintext")] + KindMismatch, + #[error("tombstone names another item")] + WrongItem, + #[error(transparent)] + Replay(#[from] Replay), +} + +/// Checks a pulled record of item `id`: its `deleted` flag and its authenticated plaintext (`None` when it +/// carries no ciphertext) against the local base. +pub fn check_pulled(base: Base<'_>, id: Uuid, deleted: bool, plaintext: Option<&Value>) -> Result<(), Rejected> { + let incoming = plaintext.ok_or(if deleted { Rejected::UnsealedTombstone } else { Rejected::UnsealedItem })?; + if is_tombstone(incoming) != deleted { + return Err(Rejected::KindMismatch); + } + if deleted && incoming.get("id") != Some(&Value::from(id.to_string())) { + return Err(Rejected::WrongItem); + } + Ok(check_successor(base, incoming)?) +} + +/// Checks that `incoming` (the plaintext of a pulled live record or tombstone) may follow `base`. +pub fn check_successor(base: Base<'_>, incoming: &Value) -> Result<(), Replay> { + let Base::Sealed(held) = base else { return Ok(()) }; let (held_seq, seq) = (seq_of(held), seq_of(incoming)); if seq > held_seq { return Ok(()); } - // A delete this device never saw came between its base and `incoming`. - if restores_of(incoming).is_some_and(|t| base_revision < t && t < revision) { - return Ok(()); - } // The same content again is not a rollback. if seq == held_seq && incoming == held { return Ok(()); @@ -83,18 +107,13 @@ pub fn check_successor(base: Base<'_>, revision: u64, incoming: &Value) -> Resul Err(Replay { held: held_seq, incoming: seq }) } -/// `seq` and `restores` for a new local version of an item whose working copy is `local` (if live). -pub fn next_version(local: Option<&Value>, base: Base<'_>) -> (u64, Option) { +/// `seq` for a new local version (live or tombstone) of an item whose working copy is `local`. +pub fn next_version(local: Option<&Value>, base: Base<'_>) -> u64 { let held = match base { - Base::Live { item, .. } => seq_of(item), + Base::Sealed(v) => seq_of(v), _ => 0, }; - let seq = local.map_or(0, seq_of).max(held) + 1; - let restores = match base { - Base::Tombstone { revision } => Some(revision), - _ => None, - }; - (seq, restores) + local.map_or(0, seq_of).max(held) + 1 } #[cfg(test)] @@ -105,25 +124,35 @@ mod tests { #[test] fn stamp_and_same_content() { - let mut v = json!({"title": "a", "restores": 4}); - stamp(&mut v, 7, None); + let mut v = json!({"title": "a"}); + stamp(&mut v, 7); assert_eq!(v, json!({"title": "a", "seq": 7})); - stamp(&mut v, 8, Some(3)); - assert_eq!(v, json!({"title": "a", "seq": 8, "restores": 3})); assert!(same_content(&v, &json!({"title": "a"}))); - assert!(!same_content(&v, &json!({"title": "b", "seq": 8}))); + assert!(!same_content(&v, &json!({"title": "b", "seq": 7}))); + } + + #[test] + fn tombstone_plaintext() { + let id = Uuid::nil(); + let t = tombstone(id, 4); + assert_eq!(t, json!({"id": "00000000-0000-0000-0000-000000000000", "seq": 4, "deleted": true})); + assert!(is_tombstone(&t)); + assert!(!is_tombstone(&json!({"title": "a", "deleted": false}))); + assert_eq!(seq_of(&t), 4); } #[test] fn next_version_rules() { let base = json!({"seq": 5}); let local = json!({"seq": 6}); - assert_eq!(next_version(None, Base::None), (1, None)); - assert_eq!(next_version(Some(&base), Base::Live { revision: 3, item: &base }), (6, None)); - assert_eq!(next_version(Some(&local), Base::Live { revision: 3, item: &base }), (7, None)); + let tomb = tombstone(Uuid::nil(), 9); + assert_eq!(next_version(None, Base::None), 1); + assert_eq!(next_version(Some(&base), Base::Sealed(&base)), 6); + assert_eq!(next_version(Some(&local), Base::Sealed(&base)), 7); // A pending delete being re-saved continues from the base. - assert_eq!(next_version(None, Base::Live { revision: 3, item: &base }), (6, None)); - assert_eq!(next_version(None, Base::Tombstone { revision: 9 }), (1, Some(9))); - assert_eq!(next_version(Some(&local), Base::Tombstone { revision: 9 }), (7, Some(9))); + assert_eq!(next_version(None, Base::Sealed(&base)), 6); + assert_eq!(next_version(None, Base::Sealed(&tomb)), 10); + assert_eq!(next_version(Some(&local), Base::Sealed(&tomb)), 10); + assert_eq!(next_version(None, Base::LegacyTombstone), 1); } } diff --git a/crates/kv-sync/src/test_server.rs b/crates/kv-sync/src/test_server.rs index e6a0f99..fe4159c 100644 --- a/crates/kv-sync/src/test_server.rs +++ b/crates/kv-sync/src/test_server.rs @@ -387,7 +387,9 @@ fn authed( let vid = Uuid::parse_str(vid).map_err(|_| err(404, "not_found"))?; let iid = Uuid::parse_str(iid).map_err(|_| err(404, "not_found"))?; let base = if_match(req)?; - let body: Option = if method == "PUT" { Some(parse(req)?) } else { None }; + // PUT carries the item, DELETE the sealed tombstone; both have the same shape. + let body: ItemPut = parse(req)?; + let deleted = method == "DELETE"; if st.force_412 > 0 { st.force_412 -= 1; return Err(err(412, "revision_conflict")); @@ -397,16 +399,16 @@ fn authed( if current != base { return Err(err(412, "revision_conflict")); } - if body.is_none() && current == 0 { + if deleted && current == 0 { return Err(err(404, "not_found")); } v.head += 1; let rec = ItemRecord { item_id: iid, revision: v.head, - deleted: body.is_none(), - wrapped_item_key: body.as_ref().map(|b| b.wrapped_item_key.clone()), - payload: body.map(|b| b.payload), + deleted, + wrapped_item_key: Some(body.wrapped_item_key), + payload: Some(body.payload), updated_by: device, updated_at: now(), }; diff --git a/crates/kv-sync/tests/replay_vectors.rs b/crates/kv-sync/tests/replay_vectors.rs index 78e2257..3542f84 100644 --- a/crates/kv-sync/tests/replay_vectors.rs +++ b/crates/kv-sync/tests/replay_vectors.rs @@ -1,7 +1,8 @@ //! 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 kv_sync::replay::{check_pulled, next_version, Base}; use serde_json::Value; +use uuid::Uuid; fn vectors() -> Value { serde_json::from_str(include_str!("vectors/item_replay.json")).unwrap() @@ -9,22 +10,24 @@ fn vectors() -> Value { 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 } + Base::None + } else if v["legacy_tombstone"] == Value::Bool(true) { + Base::LegacyTombstone } else { - Base::Live { revision, item: &v["item"] } + Base::Sealed(&v["plaintext"]) } } #[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 v = vectors(); + let id = Uuid::parse_str(v["item_id"].as_str().unwrap()).unwrap(); + for case in v["check"].as_array().unwrap() { + let record = &case["record"]; + let plaintext = Some(&record["plaintext"]).filter(|p| !p.is_null()); + let got = check_pulled(base(&case["base"]), id, record["deleted"].as_bool().unwrap(), plaintext); let want = case["result"].as_str().unwrap(); - assert_eq!(if got.is_ok() { "accept" } else { "alarm" }, want, "{}", case["name"]); + assert_eq!(if got.is_ok() { "accept" } else { "alarm" }, want, "{}: {got:?}", case["name"]); } } @@ -32,9 +35,8 @@ fn check_table() { 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"])); + let seq = 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"]); } } diff --git a/crates/kv-sync/tests/sync.rs b/crates/kv-sync/tests/sync.rs index 72f7f2a..9568828 100644 --- a/crates/kv-sync/tests/sync.rs +++ b/crates/kv-sync/tests/sync.rs @@ -10,7 +10,7 @@ use kv_crypto::{ use kv_proto::{DeviceEnroll, ItemRecord, Platform}; use kv_store::{MemoryStore, Store, SyncState, VaultRow}; use kv_sync::{ - engine::{next_local_version, open_record, seal_record}, + engine::{next_local_version, open_record, open_sealed, seal_record, seal_tombstone}, replay, signing::SoftwareSigner, test_server::{TestServer, SETUP_TOKEN}, @@ -103,10 +103,10 @@ impl Device { self.keyset.open_vault_key(vid, &self.store.vault(vid).unwrap().unwrap().sealed_key).unwrap() } - /// Saves a local edit the way a client does: with the next `seq` (and `restores`) stamped in. + /// Saves a local edit the way a client does: with the next `seq` 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); + let seq = next_local_version(&self.store, self.keyset.account_id, vid, &self.vk(vid), id).unwrap(); + replay::stamp(&mut item, seq); self.save_raw(vid, id, item); } @@ -116,7 +116,16 @@ impl Device { self.store.put_local(vid, &rec).unwrap(); } + /// Deletes the way a client does: a sealed tombstone with the next `seq`. fn delete(&self, vid: Uuid, id: Uuid) { + let (acct, vk) = (self.keyset.account_id, self.vk(vid)); + let seq = next_local_version(&self.store, acct, vid, &vk, id).unwrap(); + let tomb = seal_tombstone(acct, vid, &vk, id, seq, self.signer.device_id()).unwrap(); + self.store.put_local(vid, &tomb).unwrap(); + } + + /// A pending delete as stored before tombstones were sealed (no ciphertext). + fn delete_legacy(&self, vid: Uuid, id: Uuid) { let mut cur = self.store.get(vid, id).unwrap().unwrap().record; cur.deleted = true; cur.wrapped_item_key = None; @@ -124,6 +133,12 @@ impl Device { self.store.put_local(vid, &cur).unwrap(); } + /// The sealed plaintext of the item's base (live or tombstone). + fn read_base(&self, vid: Uuid, id: Uuid) -> Option { + let base = self.store.get(vid, id).unwrap()?.base?; + open_sealed(self.keyset.account_id, vid, &self.vk(vid), &base).unwrap() + } + fn read(&self, vid: Uuid, id: Uuid) -> Option { let item = self.store.get(vid, id).unwrap()?; open_record(self.keyset.account_id, vid, &self.vk(vid), &item.record).unwrap() @@ -257,9 +272,14 @@ async fn deleting_and_never_synced_items() { a.save(vid, local_only, item(local_only, "L", "l", 1)); a.delete(vid, local_only); a.sync().await.unwrap(); - assert!(server.state().vaults[&vid].items[&x].deleted); + let tomb = server.state().vaults[&vid].items[&x].clone(); + assert!(tomb.deleted && tomb.payload.is_some(), "the server stores the sealed tombstone"); assert!(!server.state().vaults[&vid].items.contains_key(&local_only)); assert!(a.store.get(vid, local_only).unwrap().is_none()); + let b = Device::second(&server, &a).await; + b.sync().await.unwrap(); + assert_eq!(b.read_base(vid, x).unwrap(), json!({"id": x, "seq": 2, "deleted": true})); + assert!(b.read(vid, x).is_none()); } #[tokio::test] @@ -634,7 +654,6 @@ async fn concurrent_equal_seq_edits_are_not_an_alarm() { 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); @@ -649,8 +668,8 @@ async fn concurrent_equal_seq_edits_are_not_an_alarm() { 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. +/// An edit that beats a delete is written above the tombstone's `seq`, so every device sees `seq` grow, +/// including one that never saw the tombstone. Replays of the tombstone or of the restore are refused. #[tokio::test] async fn edit_beats_delete_is_not_an_alarm_but_its_replay_is() { let server = TestServer::start().await; @@ -668,31 +687,111 @@ async fn edit_beats_delete_is_not_an_alarm_but_its_replay_is() { 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 tombstone = server.state().vaults[&vid].items[&id].clone(); let d = Device::second(&server, &a).await; - d.sync().await.unwrap(); // d holds the tombstone + d.sync().await.unwrap(); // d holds the tombstone (seq 5) + assert_eq!(d.read_base(vid, id).unwrap()["seq"], 5); 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))); + assert_eq!(restored["seq"], 6, "above the tombstone's seq"); 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()); + assert_eq!(c.read(vid, id).unwrap()["seq"], 7); c.sync().await.unwrap(); a.sync().await.unwrap(); + d.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); + // Re-serving the tombstone or the restore later is a rollback. + server.state().write_item(vid, tombstone); assert_replay_alarm(a.sync().await, vid); + server.state().write_item(vid, restore_envelope); + assert_replay_alarm(d.sync().await, vid); +} + +/// The server forges a delete. A tombstone without ciphertext, or an item envelope relabelled as deleted, is an +/// integrity alarm; the item stays visible. +#[tokio::test] +async fn forged_tombstone_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; + b.sync().await.unwrap(); + let live = server.state().vaults[&vid].items[&id].clone(); + let manifest_version = server.state().vaults[&vid].manifest.as_ref().unwrap().version; + + // An unauthenticated tombstone, as the server could always write before. + let forged = ItemRecord { deleted: true, wrapped_item_key: None, payload: None, ..live.clone() }; + server.state().write_item(vid, forged); + let err = b.sync().await.unwrap_err(); + assert!( + matches!(err, SyncError::Integrity { vault_id, ref reason } if vault_id == vid && reason.contains("unauthenticated delete")), + "{err}" + ); + assert_eq!(b.read(vid, id).unwrap()["password"], "pw1", "not hidden"); + assert!(b.store.vault(vid).unwrap().unwrap().integrity_alarm); + // A device that never held the item refuses it too. + let c = Device::second(&server, &a).await; + assert!(matches!(c.sync().await, Err(SyncError::Integrity { .. }))); + + // An authentic item envelope re-served with the deleted flag set. + let relabelled = ItemRecord { deleted: true, ..live }; + server.state().write_item(vid, relabelled); + let err = a.sync().await.unwrap_err(); + assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("deleted flag")), "{err}"); + assert_eq!(a.read(vid, id).unwrap()["password"], "pw1"); + assert_eq!(server.state().vaults[&vid].manifest.as_ref().unwrap().version, manifest_version); +} + +/// Delete-then-replay: after a device pulls a (genuine) tombstone, an old live envelope re-served on top of it +/// is refused, because the tombstone's `seq` is authenticated. +#[tokio::test] +async fn replay_after_a_tombstone_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(); + a.save(vid, id, item(id, "Bank", "pw2", 2)); + a.sync().await.unwrap(); + let b = Device::second(&server, &a).await; + b.sync().await.unwrap(); + a.delete(vid, id); + a.sync().await.unwrap(); + b.sync().await.unwrap(); + assert!(b.read(vid, id).is_none()); + + server.state().write_item(vid, old); + assert_replay_alarm(b.sync().await, vid); + assert!(b.read(vid, id).is_none(), "the deleted item did not come back"); +} + +/// A pending delete stored before tombstones were sealed is sealed when it is pushed. +#[tokio::test] +async fn legacy_pending_delete_is_sealed_on_push() { + let server = TestServer::start().await; + let (a, vid) = Device::first(&server).await; + let id = Uuid::new_v4(); + a.save(vid, id, item(id, "X", "x", 1)); + a.sync().await.unwrap(); + a.delete_legacy(vid, id); + a.sync().await.unwrap(); + let tomb = server.state().vaults[&vid].items[&id].clone(); + assert!(tomb.deleted && tomb.payload.is_some()); + let b = Device::second(&server, &a).await; + b.sync().await.unwrap(); + assert_eq!(b.read_base(vid, id).unwrap(), json!({"id": id, "seq": 2, "deleted": true})); } /// Items written before `seq` existed read as seq 0; the first edit writes seq 1. diff --git a/crates/kv-sync/tests/vectors/item_replay.json b/crates/kv-sync/tests/vectors/item_replay.json index 837f4f5..b7368bd 100644 --- a/crates/kv-sync/tests/vectors/item_replay.json +++ b/crates/kv-sync/tests/vectors/item_replay.json @@ -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} ] }