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:
@@ -563,8 +563,7 @@ impl VaultCore {
|
|||||||
model.favorite = item.favorite;
|
model.favorite = item.favorite;
|
||||||
model.tags = item.tags;
|
model.tags = item.tags;
|
||||||
model.modified_at = now.max(previous.as_ref().map_or(0, |p| p.modified_at + 1));
|
model.modified_at = now.max(previous.as_ref().map_or(0, |p| p.modified_at + 1));
|
||||||
(model.seq, model.restores) =
|
model.seq = kv_sync::engine::next_local_version(self.store.as_ref(), acct.account_id, vault, &u.vault_key, id)?;
|
||||||
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 json = Zeroizing::new(serde_json::to_vec(&model).map_err(internal)?);
|
||||||
let sealed = seal_item(acct.account_id, vault, id, &u.vault_key, &json)?;
|
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<()> {
|
pub fn delete_item(&self, id: String) -> Result<()> {
|
||||||
let id = parse_id(&id)?;
|
let id = parse_id(&id)?;
|
||||||
let (acct, _u) = self.unlocked_snapshot()?;
|
let (acct, u) = self.unlocked_snapshot()?;
|
||||||
let vault = acct.primary_vault;
|
let vault = acct.primary_vault;
|
||||||
let li = self.store.get(vault, id)?.filter(|li| !li.record.deleted).ok_or(CoreError::NotFound)?;
|
let li = self.store.get(vault, id)?.filter(|li| !li.record.deleted).ok_or(CoreError::NotFound)?;
|
||||||
if li.base.is_none() {
|
if li.base.is_none() {
|
||||||
// Never reached the server: just forget it.
|
// Never reached the server: just forget it.
|
||||||
return Ok(self.store.remove(vault, id)?);
|
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)?)
|
Ok(self.store.put_local(vault, &tomb)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
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"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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!((ia.notes.as_deref(), ia.username.as_deref()), (Some("A"), Some("b")));
|
||||||
assert_eq!((ib.notes, ib.username), (ia.notes, ia.username));
|
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();
|
b.logout().await.unwrap();
|
||||||
println!("live sync OK against {url}");
|
println!("live sync OK against {url}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,10 +56,6 @@ pub struct Item {
|
|||||||
/// versions it derives from (docs/02 "Item plaintext", docs/03 "Per-item replay protection").
|
/// versions it derives from (docs/02 "Item plaintext", docs/03 "Per-item replay protection").
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub seq: u64,
|
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 {
|
impl Item {
|
||||||
@@ -80,7 +76,6 @@ impl Item {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
modified_at: now,
|
modified_at: now,
|
||||||
seq: 1,
|
seq: 1,
|
||||||
restores: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ pub struct Vault {
|
|||||||
pub meta: Envelope,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ItemPut {
|
pub struct ItemPut {
|
||||||
pub wrapped_item_key: Envelope,
|
pub wrapped_item_key: Envelope,
|
||||||
@@ -275,6 +277,7 @@ pub struct ItemRecord {
|
|||||||
pub item_id: Uuid,
|
pub item_id: Uuid,
|
||||||
pub revision: u64,
|
pub revision: u64,
|
||||||
pub deleted: bool,
|
pub deleted: bool,
|
||||||
|
/// Present on live records and sealed tombstones. Absent only on legacy tombstones, which clients refuse.
|
||||||
pub wrapped_item_key: Option<Envelope>,
|
pub wrapped_item_key: Option<Envelope>,
|
||||||
pub payload: Option<Envelope>,
|
pub payload: Option<Envelope>,
|
||||||
pub updated_by: Uuid,
|
pub updated_by: Uuid,
|
||||||
|
|||||||
@@ -252,8 +252,15 @@ impl ApiClient {
|
|||||||
self.json(Method::PUT, &format!("{PATH_VAULTS}/{vault}/items/{item}"), Some(body), Some(base), true).await
|
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<ItemRecord, SyncError> {
|
/// Tombstones an item. `body` is the sealed tombstone `{id, seq, deleted: true}` (docs/03).
|
||||||
self.json(Method::DELETE, &format!("{PATH_VAULTS}/{vault}/items/{item}"), None::<&()>, Some(base), true).await
|
pub async fn delete_item(
|
||||||
|
&self,
|
||||||
|
vault: Uuid,
|
||||||
|
item: Uuid,
|
||||||
|
base: u64,
|
||||||
|
body: &ItemPut,
|
||||||
|
) -> Result<ItemRecord, SyncError> {
|
||||||
|
self.json(Method::DELETE, &format!("{PATH_VAULTS}/{vault}/items/{item}"), Some(body), Some(base), true).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `None` when the vault has no manifest yet.
|
/// `None` when the vault has no manifest yet.
|
||||||
|
|||||||
@@ -59,17 +59,28 @@ fn crypto(e: kv_crypto::CryptoError) -> SyncError {
|
|||||||
SyncError::Crypto(e.to_string())
|
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(
|
pub fn open_record(
|
||||||
account_id: Uuid,
|
account_id: Uuid,
|
||||||
vault_id: Uuid,
|
vault_id: Uuid,
|
||||||
vk: &SymKey,
|
vk: &SymKey,
|
||||||
record: &ItemRecord,
|
record: &ItemRecord,
|
||||||
) -> Result<Option<Value>, SyncError> {
|
) -> Result<Option<Value>, SyncError> {
|
||||||
let (Some(k), Some(p)) = (&record.wrapped_item_key, &record.payload) else { return Ok(None) };
|
|
||||||
if record.deleted {
|
if record.deleted {
|
||||||
return Ok(None);
|
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<Option<Value>, 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 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)?;
|
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}")))
|
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<ItemRecord, SyncError> {
|
||||||
|
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<HistoryEntry> {
|
fn history_entry(r: &ItemRecord) -> Option<HistoryEntry> {
|
||||||
Some(HistoryEntry {
|
Some(HistoryEntry {
|
||||||
revision: r.revision,
|
revision: r.revision,
|
||||||
@@ -106,20 +131,20 @@ fn history_entry(r: &ItemRecord) -> Option<HistoryEntry> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
enum OpenedBase {
|
||||||
None,
|
None,
|
||||||
Tombstone(u64),
|
LegacyTombstone,
|
||||||
Live(u64, Value),
|
Sealed(Value),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenedBase {
|
impl OpenedBase {
|
||||||
fn open(account_id: Uuid, vault_id: Uuid, vk: &SymKey, base: Option<&ItemRecord>) -> Result<Self, SyncError> {
|
fn open(account_id: Uuid, vault_id: Uuid, vk: &SymKey, base: Option<&ItemRecord>) -> Result<Self, SyncError> {
|
||||||
Ok(match base {
|
Ok(match base {
|
||||||
None => Self::None,
|
None => Self::None,
|
||||||
Some(b) if b.deleted => Self::Tombstone(b.revision),
|
Some(b) => match open_sealed(account_id, vault_id, vk, b)? {
|
||||||
Some(b) => match open_record(account_id, vault_id, vk, b)? {
|
Some(v) => Self::Sealed(v),
|
||||||
Some(v) => Self::Live(b.revision, v),
|
None if b.deleted => Self::LegacyTombstone,
|
||||||
None => Self::None,
|
None => Self::None,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -128,23 +153,23 @@ impl OpenedBase {
|
|||||||
fn as_base(&self) -> Base<'_> {
|
fn as_base(&self) -> Base<'_> {
|
||||||
match self {
|
match self {
|
||||||
Self::None => Base::None,
|
Self::None => Base::None,
|
||||||
Self::Tombstone(revision) => Base::Tombstone { revision: *revision },
|
Self::LegacyTombstone => Base::LegacyTombstone,
|
||||||
Self::Live(revision, item) => Base::Live { revision: *revision, item },
|
Self::Sealed(v) => Base::Sealed(v),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `seq` and `restores` a new local version of `item_id` must carry (docs/03 "Per-item replay
|
/// 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 and stamp the result into the plaintext.
|
/// protection"). Call it before sealing a local edit or delete and stamp the result into the plaintext.
|
||||||
pub fn next_local_version(
|
pub fn next_local_version(
|
||||||
store: &dyn Store,
|
store: &dyn Store,
|
||||||
account_id: Uuid,
|
account_id: Uuid,
|
||||||
vault_id: Uuid,
|
vault_id: Uuid,
|
||||||
vk: &SymKey,
|
vk: &SymKey,
|
||||||
item_id: Uuid,
|
item_id: Uuid,
|
||||||
) -> Result<(u64, Option<u64>), SyncError> {
|
) -> Result<u64, SyncError> {
|
||||||
let Some(local) = store.get(vault_id, item_id)? else { return Ok((1, None)) };
|
let Some(local) = store.get(vault_id, item_id)? else { return Ok(1) };
|
||||||
let working = open_record(account_id, vault_id, vk, &local.record)?;
|
let working = open_sealed(account_id, vault_id, vk, &local.record)?;
|
||||||
let base = OpenedBase::open(account_id, vault_id, vk, local.base.as_ref())?;
|
let base = OpenedBase::open(account_id, vault_id, vk, local.base.as_ref())?;
|
||||||
Ok(replay::next_version(working.as_ref(), base.as_base()))
|
Ok(replay::next_version(working.as_ref(), base.as_base()))
|
||||||
}
|
}
|
||||||
@@ -306,8 +331,8 @@ impl SyncContext<'_> {
|
|||||||
self.resolve_conflict(vault, vk, local, remote, report)
|
self.resolve_conflict(vault, vk, local, remote, report)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Integrity alarm unless `remote` authenticates and may follow `base` (docs/03 "Per-item replay
|
/// Integrity alarm unless `remote`, live or tombstone, authenticates, is what its `deleted` flag says
|
||||||
/// protection"). Tombstones carry no ciphertext and pass.
|
/// it is, and may follow `base` (docs/03 "Per-item replay protection").
|
||||||
fn check_replay(
|
fn check_replay(
|
||||||
&self,
|
&self,
|
||||||
vault: Uuid,
|
vault: Uuid,
|
||||||
@@ -315,17 +340,12 @@ impl SyncContext<'_> {
|
|||||||
base: Option<&ItemRecord>,
|
base: Option<&ItemRecord>,
|
||||||
remote: &ItemRecord,
|
remote: &ItemRecord,
|
||||||
) -> Result<(), SyncError> {
|
) -> Result<(), SyncError> {
|
||||||
if remote.deleted {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let id = remote.item_id;
|
let id = remote.item_id;
|
||||||
let incoming = match open_record(self.account_id(), vault, vk, remote) {
|
let Ok(incoming) = open_sealed(self.account_id(), vault, vk, remote) else {
|
||||||
Ok(Some(v)) => v,
|
return Err(self.alarm(vault, format!("item {id}: envelope does not authenticate")));
|
||||||
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 base = OpenedBase::open(self.account_id(), vault, vk, base)?;
|
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}")))
|
.map_err(|e| self.alarm(vault, format!("item {id}: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,13 +372,15 @@ impl SyncContext<'_> {
|
|||||||
report.restored.push(id);
|
report.restored.push(id);
|
||||||
}
|
}
|
||||||
(false, true) => {
|
(false, true) => {
|
||||||
// Our edit beats their delete: keep ours and push it on top of the tombstone, marked with
|
// Our edit beats their delete: keep ours and push it on top of the tombstone with a `seq`
|
||||||
// the tombstone's revision so devices that never saw the delete accept a lower `seq`.
|
// above both, so every device sees `seq` grow (docs/03 "Per-item replay protection").
|
||||||
let acct = self.account_id();
|
let acct = self.account_id();
|
||||||
let mut mine =
|
let mut mine =
|
||||||
open_record(acct, vault, vk, &local.record)?.ok_or(SyncError::Protocol("empty item".into()))?;
|
open_record(acct, vault, vk, &local.record)?.ok_or(SyncError::Protocol("empty item".into()))?;
|
||||||
let seq = replay::seq_of(&mine);
|
let tomb =
|
||||||
replay::stamp(&mut mine, seq, Some(remote.revision));
|
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)?;
|
let sealed = seal_record(acct, vault, vk, id, &mine, self.device_id)?;
|
||||||
self.store.put(vault, &rebased(sealed))?;
|
self.store.put(vault, &rebased(sealed))?;
|
||||||
report.restored.push(id);
|
report.restored.push(id);
|
||||||
@@ -387,7 +409,7 @@ impl SyncContext<'_> {
|
|||||||
self.store.put_remote(vault, remote)?;
|
self.store.put_remote(vault, remote)?;
|
||||||
} else {
|
} else {
|
||||||
let seq = replay::seq_of(&mine).max(replay::seq_of(&theirs)) + 1;
|
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)?;
|
let sealed = seal_record(acct, vault, vk, id, &out.merged, self.device_id)?;
|
||||||
self.store.put(vault, &rebased(sealed))?;
|
self.store.put(vault, &rebased(sealed))?;
|
||||||
}
|
}
|
||||||
@@ -475,17 +497,22 @@ impl SyncContext<'_> {
|
|||||||
for item in pending {
|
for item in pending {
|
||||||
let base = item.base_revision();
|
let base = item.base_revision();
|
||||||
let id = item.record.item_id;
|
let id = item.record.item_id;
|
||||||
let result = if item.record.deleted {
|
if item.record.deleted && base == 0 {
|
||||||
if base == 0 {
|
self.store.remove(vault, id)?;
|
||||||
self.store.remove(vault, id)?;
|
continue;
|
||||||
continue;
|
}
|
||||||
}
|
let item = if item.record.deleted && item.record.payload.is_none() {
|
||||||
self.api.delete_item(vault, id, base).await
|
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 {
|
} 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
|
self.api.put_item(vault, id, base, &body).await
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
@@ -507,6 +534,15 @@ impl SyncContext<'_> {
|
|||||||
Ok(pushed)
|
Ok(pushed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seals a pending delete stored before tombstones were sealed.
|
||||||
|
fn seal_legacy_delete(&self, vault: Uuid, vk: &SymKey, item: LocalItem) -> Result<LocalItem, SyncError> {
|
||||||
|
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.
|
/// 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> {
|
fn confirm(&self, vault: Uuid, pushed: &LocalItem, confirmed: ItemRecord) -> Result<(), SyncError> {
|
||||||
let current = self.store.get(vault, confirmed.item_id)?;
|
let current = self.store.get(vault, confirmed.item_id)?;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! a callback into platform code ([`DeviceSigner`]).
|
//! a callback into platform code ([`DeviceSigner`]).
|
||||||
//! * [`engine`]: pull, then push with `If-Match`, then three-way field merge on 412 ([`merge`]), then manifest verify
|
//! * [`engine`]: pull, then push with `If-Match`, then three-way field merge on 412 ([`merge`]), then manifest verify
|
||||||
//! and publish ([`manifest`]).
|
//! 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 client;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
|
|||||||
@@ -123,11 +123,12 @@ pub fn envelope_cbor(e: &Envelope) -> Vec<u8> {
|
|||||||
out
|
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] {
|
pub fn content_hash(record: &ItemRecord) -> [u8; 32] {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
match (&record.wrapped_item_key, &record.payload, record.deleted) {
|
match (&record.wrapped_item_key, &record.payload) {
|
||||||
(Some(k), Some(p), false) => {
|
(Some(k), Some(p)) => {
|
||||||
cbor::array(&mut out, 2);
|
cbor::array(&mut out, 2);
|
||||||
out.extend_from_slice(&envelope_cbor(k));
|
out.extend_from_slice(&envelope_cbor(k));
|
||||||
out.extend_from_slice(&envelope_cbor(p));
|
out.extend_from_slice(&envelope_cbor(p));
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
//!
|
//!
|
||||||
//! Items are merged as JSON objects so every top-level field of `kv_model::Item` (and any field a
|
//! 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
|
//! 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
|
//! `seq` are the maximum of both sides. The caller stamps the next `seq` if it writes the result as a new
|
||||||
//! writes the result as a new version ([`crate::replay`]).
|
//! version ([`crate::replay`]).
|
||||||
|
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
use crate::replay::{seq_of, RESTORES, SEQ};
|
use crate::replay::{seq_of, SEQ};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Side {
|
pub enum Side {
|
||||||
@@ -25,7 +25,7 @@ pub struct MergeOutcome {
|
|||||||
pub conflicting_fields: Vec<String>,
|
pub conflicting_fields: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
fn modified_at(v: &Value) -> i64 {
|
||||||
v.get("modified_at").and_then(Value::as_i64).unwrap_or(0)
|
v.get("modified_at").and_then(Value::as_i64).unwrap_or(0)
|
||||||
|
|||||||
@@ -1,43 +1,46 @@
|
|||||||
//! Per-item replay protection (docs/03 "Per-item replay protection").
|
//! 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
|
//! 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
|
//! old, authentic envelope under a new revision. Every version of an item, live or deleted, is sealed
|
||||||
//! counter that grows along every honest chain of versions, and `restores`, the revision of the
|
//! and carries `seq`, an edit counter that grows along every honest chain of versions. A tombstone's
|
||||||
//! tombstone a version was written over (tombstones carry no `seq`). Pulled records are checked
|
//! plaintext is `{id, seq, deleted: true}`. Pulled records are checked against the local base, the
|
||||||
//! against the local base, the last version the server confirmed to this device.
|
//! 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.
|
/// The plaintext field holding the per-item edit counter. Absent means 0.
|
||||||
pub const SEQ: &str = "seq";
|
pub const SEQ: &str = "seq";
|
||||||
/// The plaintext field holding the revision of the tombstone a version was written over.
|
/// The plaintext field that marks a tombstone.
|
||||||
pub const RESTORES: &str = "restores";
|
pub const DELETED: &str = "deleted";
|
||||||
|
|
||||||
pub fn seq_of(item: &Value) -> u64 {
|
pub fn seq_of(item: &Value) -> u64 {
|
||||||
item.get(SEQ).and_then(Value::as_u64).unwrap_or(0)
|
item.get(SEQ).and_then(Value::as_u64).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn restores_of(item: &Value) -> Option<u64> {
|
/// Sets `seq`.
|
||||||
item.get(RESTORES).and_then(Value::as_u64)
|
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`).
|
/// The plaintext of a tombstone for item `id`.
|
||||||
pub fn stamp(item: &mut Value, seq: u64, restores: Option<u64>) {
|
pub fn tombstone(id: Uuid, seq: u64) -> Value {
|
||||||
let Some(obj) = item.as_object_mut() else { return };
|
json!({ "id": id, SEQ: seq, DELETED: true })
|
||||||
obj.insert(SEQ.into(), Value::from(seq));
|
|
||||||
match restores {
|
|
||||||
Some(t) => obj.insert(RESTORES.into(), Value::from(t)),
|
|
||||||
None => obj.remove(RESTORES),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
pub fn same_content(a: &Value, b: &Value) -> bool {
|
||||||
let strip = |v: &Value| {
|
let strip = |v: &Value| {
|
||||||
let mut v = v.clone();
|
let mut v = v.clone();
|
||||||
if let Some(o) = v.as_object_mut() {
|
if let Some(o) = v.as_object_mut() {
|
||||||
o.remove(SEQ);
|
o.remove(SEQ);
|
||||||
o.remove(RESTORES);
|
|
||||||
}
|
}
|
||||||
v
|
v
|
||||||
};
|
};
|
||||||
@@ -49,13 +52,10 @@ pub fn same_content(a: &Value, b: &Value) -> bool {
|
|||||||
pub enum Base<'a> {
|
pub enum Base<'a> {
|
||||||
/// This device holds no server version of the item.
|
/// This device holds no server version of the item.
|
||||||
None,
|
None,
|
||||||
Tombstone {
|
/// A tombstone without ciphertext, accepted before tombstones were sealed. Its `seq` is unknown.
|
||||||
revision: u64,
|
LegacyTombstone,
|
||||||
},
|
/// The decrypted plaintext of a live version or a sealed tombstone.
|
||||||
Live {
|
Sealed(&'a Value),
|
||||||
revision: u64,
|
|
||||||
item: &'a Value,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
@@ -65,17 +65,41 @@ pub struct Replay {
|
|||||||
pub incoming: u64,
|
pub incoming: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks that `incoming` (a live record at server `revision`) may follow `base`.
|
/// Why a pulled record is refused. Every variant is an integrity alarm.
|
||||||
pub fn check_successor(base: Base<'_>, revision: u64, incoming: &Value) -> Result<(), Replay> {
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
let Base::Live { revision: base_revision, item: held } = base else { return Ok(()) };
|
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));
|
let (held_seq, seq) = (seq_of(held), seq_of(incoming));
|
||||||
if seq > held_seq {
|
if seq > held_seq {
|
||||||
return Ok(());
|
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.
|
// The same content again is not a rollback.
|
||||||
if seq == held_seq && incoming == held {
|
if seq == held_seq && incoming == held {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -83,18 +107,13 @@ pub fn check_successor(base: Base<'_>, revision: u64, incoming: &Value) -> Resul
|
|||||||
Err(Replay { held: held_seq, incoming: seq })
|
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).
|
/// `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, Option<u64>) {
|
pub fn next_version(local: Option<&Value>, base: Base<'_>) -> u64 {
|
||||||
let held = match base {
|
let held = match base {
|
||||||
Base::Live { item, .. } => seq_of(item),
|
Base::Sealed(v) => seq_of(v),
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
let seq = local.map_or(0, seq_of).max(held) + 1;
|
local.map_or(0, seq_of).max(held) + 1
|
||||||
let restores = match base {
|
|
||||||
Base::Tombstone { revision } => Some(revision),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
(seq, restores)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -105,25 +124,35 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stamp_and_same_content() {
|
fn stamp_and_same_content() {
|
||||||
let mut v = json!({"title": "a", "restores": 4});
|
let mut v = json!({"title": "a"});
|
||||||
stamp(&mut v, 7, None);
|
stamp(&mut v, 7);
|
||||||
assert_eq!(v, json!({"title": "a", "seq": 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": "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]
|
#[test]
|
||||||
fn next_version_rules() {
|
fn next_version_rules() {
|
||||||
let base = json!({"seq": 5});
|
let base = json!({"seq": 5});
|
||||||
let local = json!({"seq": 6});
|
let local = json!({"seq": 6});
|
||||||
assert_eq!(next_version(None, Base::None), (1, None));
|
let tomb = tombstone(Uuid::nil(), 9);
|
||||||
assert_eq!(next_version(Some(&base), Base::Live { revision: 3, item: &base }), (6, None));
|
assert_eq!(next_version(None, Base::None), 1);
|
||||||
assert_eq!(next_version(Some(&local), Base::Live { revision: 3, item: &base }), (7, None));
|
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.
|
// 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::Sealed(&base)), 6);
|
||||||
assert_eq!(next_version(None, Base::Tombstone { revision: 9 }), (1, Some(9)));
|
assert_eq!(next_version(None, Base::Sealed(&tomb)), 10);
|
||||||
assert_eq!(next_version(Some(&local), Base::Tombstone { revision: 9 }), (7, Some(9)));
|
assert_eq!(next_version(Some(&local), Base::Sealed(&tomb)), 10);
|
||||||
|
assert_eq!(next_version(None, Base::LegacyTombstone), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,7 +387,9 @@ fn authed(
|
|||||||
let vid = Uuid::parse_str(vid).map_err(|_| err(404, "not_found"))?;
|
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 iid = Uuid::parse_str(iid).map_err(|_| err(404, "not_found"))?;
|
||||||
let base = if_match(req)?;
|
let base = if_match(req)?;
|
||||||
let body: Option<ItemPut> = 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 {
|
if st.force_412 > 0 {
|
||||||
st.force_412 -= 1;
|
st.force_412 -= 1;
|
||||||
return Err(err(412, "revision_conflict"));
|
return Err(err(412, "revision_conflict"));
|
||||||
@@ -397,16 +399,16 @@ fn authed(
|
|||||||
if current != base {
|
if current != base {
|
||||||
return Err(err(412, "revision_conflict"));
|
return Err(err(412, "revision_conflict"));
|
||||||
}
|
}
|
||||||
if body.is_none() && current == 0 {
|
if deleted && current == 0 {
|
||||||
return Err(err(404, "not_found"));
|
return Err(err(404, "not_found"));
|
||||||
}
|
}
|
||||||
v.head += 1;
|
v.head += 1;
|
||||||
let rec = ItemRecord {
|
let rec = ItemRecord {
|
||||||
item_id: iid,
|
item_id: iid,
|
||||||
revision: v.head,
|
revision: v.head,
|
||||||
deleted: body.is_none(),
|
deleted,
|
||||||
wrapped_item_key: body.as_ref().map(|b| b.wrapped_item_key.clone()),
|
wrapped_item_key: Some(body.wrapped_item_key),
|
||||||
payload: body.map(|b| b.payload),
|
payload: Some(body.payload),
|
||||||
updated_by: device,
|
updated_by: device,
|
||||||
updated_at: now(),
|
updated_at: now(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Checks `kv_sync::replay` against the shared decision table (kestrove-spec/vectors/item_replay.json).
|
//! 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 serde_json::Value;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn vectors() -> Value {
|
fn vectors() -> Value {
|
||||||
serde_json::from_str(include_str!("vectors/item_replay.json")).unwrap()
|
serde_json::from_str(include_str!("vectors/item_replay.json")).unwrap()
|
||||||
@@ -9,22 +10,24 @@ fn vectors() -> Value {
|
|||||||
|
|
||||||
fn base(v: &Value) -> Base<'_> {
|
fn base(v: &Value) -> Base<'_> {
|
||||||
if v.is_null() {
|
if v.is_null() {
|
||||||
return Base::None;
|
Base::None
|
||||||
}
|
} else if v["legacy_tombstone"] == Value::Bool(true) {
|
||||||
let revision = v["revision"].as_u64().unwrap();
|
Base::LegacyTombstone
|
||||||
if v["tombstone"] == Value::Bool(true) {
|
|
||||||
Base::Tombstone { revision }
|
|
||||||
} else {
|
} else {
|
||||||
Base::Live { revision, item: &v["item"] }
|
Base::Sealed(&v["plaintext"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_table() {
|
fn check_table() {
|
||||||
for case in vectors()["check"].as_array().unwrap() {
|
let v = vectors();
|
||||||
let got = check_successor(base(&case["base"]), case["revision"].as_u64().unwrap(), &case["incoming"]);
|
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();
|
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() {
|
fn next_version_table() {
|
||||||
for case in vectors()["next_version"].as_array().unwrap() {
|
for case in vectors()["next_version"].as_array().unwrap() {
|
||||||
let local = Some(&case["local"]).filter(|v| !v.is_null());
|
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!(Some(seq), case["seq"].as_u64(), "{}", case["name"]);
|
||||||
assert_eq!(restores, case["restores"].as_u64(), "{}", case["name"]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+115
-16
@@ -10,7 +10,7 @@ use kv_crypto::{
|
|||||||
use kv_proto::{DeviceEnroll, ItemRecord, Platform};
|
use kv_proto::{DeviceEnroll, ItemRecord, Platform};
|
||||||
use kv_store::{MemoryStore, Store, SyncState, VaultRow};
|
use kv_store::{MemoryStore, Store, SyncState, VaultRow};
|
||||||
use kv_sync::{
|
use kv_sync::{
|
||||||
engine::{next_local_version, open_record, seal_record},
|
engine::{next_local_version, open_record, open_sealed, seal_record, seal_tombstone},
|
||||||
replay,
|
replay,
|
||||||
signing::SoftwareSigner,
|
signing::SoftwareSigner,
|
||||||
test_server::{TestServer, SETUP_TOKEN},
|
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()
|
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) {
|
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();
|
let seq = next_local_version(&self.store, self.keyset.account_id, vid, &self.vk(vid), id).unwrap();
|
||||||
replay::stamp(&mut item, seq, restores);
|
replay::stamp(&mut item, seq);
|
||||||
self.save_raw(vid, id, item);
|
self.save_raw(vid, id, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,16 @@ impl Device {
|
|||||||
self.store.put_local(vid, &rec).unwrap();
|
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) {
|
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;
|
let mut cur = self.store.get(vid, id).unwrap().unwrap().record;
|
||||||
cur.deleted = true;
|
cur.deleted = true;
|
||||||
cur.wrapped_item_key = None;
|
cur.wrapped_item_key = None;
|
||||||
@@ -124,6 +133,12 @@ impl Device {
|
|||||||
self.store.put_local(vid, &cur).unwrap();
|
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<Value> {
|
||||||
|
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<Value> {
|
fn read(&self, vid: Uuid, id: Uuid) -> Option<Value> {
|
||||||
let item = self.store.get(vid, id).unwrap()?;
|
let item = self.store.get(vid, id).unwrap()?;
|
||||||
open_record(self.keyset.account_id, vid, &self.vk(vid), &item.record).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.save(vid, local_only, item(local_only, "L", "l", 1));
|
||||||
a.delete(vid, local_only);
|
a.delete(vid, local_only);
|
||||||
a.sync().await.unwrap();
|
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!(!server.state().vaults[&vid].items.contains_key(&local_only));
|
||||||
assert!(a.store.get(vid, local_only).unwrap().is_none());
|
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]
|
#[tokio::test]
|
||||||
@@ -634,7 +654,6 @@ async fn concurrent_equal_seq_edits_are_not_an_alarm() {
|
|||||||
assert_eq!(b.sync().await.unwrap().merged, 1);
|
assert_eq!(b.sync().await.unwrap().merged, 1);
|
||||||
let merged = b.read(vid, id).unwrap();
|
let merged = b.read(vid, id).unwrap();
|
||||||
assert_eq!(merged["seq"], 3);
|
assert_eq!(merged["seq"], 3);
|
||||||
assert!(merged.get("restores").is_none());
|
|
||||||
for x in [&a, &c, &d] {
|
for x in [&a, &c, &d] {
|
||||||
x.sync().await.unwrap(); // d jumps from seq 1 straight to 3
|
x.sync().await.unwrap(); // d jumps from seq 1 straight to 3
|
||||||
assert_eq!(x.read(vid, id).unwrap(), merged);
|
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());
|
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.
|
/// An edit that beats a delete is written above the tombstone's `seq`, so every device sees `seq` grow,
|
||||||
/// `restores` lets a device that never saw the tombstone accept it, and only directly after that delete.
|
/// including one that never saw the tombstone. Replays of the tombstone or of the restore are refused.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn edit_beats_delete_is_not_an_alarm_but_its_replay_is() {
|
async fn edit_beats_delete_is_not_an_alarm_but_its_replay_is() {
|
||||||
let server = TestServer::start().await;
|
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
|
c.sync().await.unwrap(); // c holds seq 4 and will not see the tombstone
|
||||||
a.delete(vid, id);
|
a.delete(vid, id);
|
||||||
a.sync().await.unwrap();
|
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;
|
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));
|
b.save(vid, id, item(id, "X", "x-b", 9));
|
||||||
let r = b.sync().await.unwrap();
|
let r = b.sync().await.unwrap();
|
||||||
assert_eq!(r.restored, vec![id]);
|
assert_eq!(r.restored, vec![id]);
|
||||||
let restored = b.read(vid, id).unwrap();
|
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();
|
let restore_envelope = server.state().vaults[&vid].items[&id].clone();
|
||||||
|
|
||||||
for x in [&a, &c, &d] {
|
for x in [&a, &c, &d] {
|
||||||
x.sync().await.unwrap();
|
x.sync().await.unwrap();
|
||||||
assert_eq!(x.read(vid, id).unwrap()["password"], "x-b");
|
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));
|
c.save(vid, id, item(id, "X", "x-c", 10));
|
||||||
assert_eq!(c.read(vid, id).unwrap()["seq"], 3);
|
assert_eq!(c.read(vid, id).unwrap()["seq"], 7);
|
||||||
assert!(c.read(vid, id).unwrap().get("restores").is_none());
|
|
||||||
c.sync().await.unwrap();
|
c.sync().await.unwrap();
|
||||||
a.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.
|
// Re-serving the tombstone or the restore later is a rollback.
|
||||||
server.state().write_item(vid, restore_envelope);
|
server.state().write_item(vid, tombstone);
|
||||||
assert_replay_alarm(a.sync().await, vid);
|
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.
|
/// Items written before `seq` existed read as seq 0; the first edit writes seq 1.
|
||||||
|
|||||||
@@ -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": [
|
"check": [
|
||||||
{ "name": "no base: first sight of the item", "base": null, "revision": 7, "incoming": {"title": "a", "seq": 1}, "result": "accept" },
|
{"name": "no base: first sight of the item", "base": null, "record": {"deleted": false, "plaintext": {"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": "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": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "b", "seq": 4}, "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": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "b", "seq": 9}, "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": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "old", "seq": 2}, "result": "alarm" },
|
{"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": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "b", "seq": 3}, "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": {"revision": 5, "item": {"title": "a", "seq": 3}}, "revision": 7, "incoming": {"title": "a", "seq": 3}, "result": "accept" },
|
{"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": {"revision": 5, "item": {"title": "a"}}, "revision": 7, "incoming": {"title": "b", "seq": 0}, "result": "alarm" },
|
{"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": {"revision": 5, "item": {"title": "a"}}, "revision": 7, "incoming": {"title": "b", "seq": 1}, "result": "accept" },
|
{"name": "legacy base, first seq-aware edit", "base": {"plaintext": {"title": "a"}}, "record": {"deleted": false, "plaintext": {"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": "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": "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": "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 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 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": "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": "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": [
|
"next_version": [
|
||||||
{ "name": "new item", "local": null, "base": null, "seq": 1, "restores": null },
|
{"name": "new item", "local": null, "base": null, "seq": 1},
|
||||||
{ "name": "edit of a clean item", "local": {"seq": 3}, "base": {"revision": 5, "item": {"seq": 3}}, "seq": 4, "restores": null },
|
{"name": "edit of a clean item", "local": {"seq": 3}, "base": {"plaintext": {"seq": 3}}, "seq": 4},
|
||||||
{ "name": "second edit before syncing", "local": {"seq": 4}, "base": {"revision": 5, "item": {"seq": 3}}, "seq": 5, "restores": null },
|
{"name": "second edit before syncing", "local": {"seq": 4}, "base": {"plaintext": {"seq": 3}}, "seq": 5},
|
||||||
{ "name": "re-save after a pending local delete", "local": null, "base": {"revision": 5, "item": {"seq": 3}}, "seq": 4, "restores": null },
|
{"name": "delete of a clean item (the tombstone's seq)", "local": {"seq": 3}, "base": {"plaintext": {"seq": 3}}, "seq": 4},
|
||||||
{ "name": "legacy item", "local": {}, "base": {"revision": 5, "item": {}}, "seq": 1, "restores": null },
|
{"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": "save over a tombstone", "local": null, "base": {"tombstone": true, "revision": 8}, "seq": 1, "restores": 8 },
|
{"name": "legacy item", "local": {}, "base": {"plaintext": {}}, "seq": 1},
|
||||||
{ "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": "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