kv-sync: refuse per-item replays (seq/restores in the item plaintext)
A malicious server could re-serve an old but valid envelope of an item under a new, higher revision: the AAD does not bind the revision and the Merkle root was only checked when head_revision == cursor. The client took it as the latest version, rolled the item back and signed over it in its next manifest. * kv-model Item: `seq: u64` (serde default 0) and `restores: Option<u64>`. * kv-sync replay module: pure decision rules (check_successor, next_version, stamp, same_content). Every pulled live record must authenticate and have seq > the base's seq, unless it carries restores = t with base_revision < t < revision (edit beat a delete this device never saw) or is identical to the base. Otherwise: Integrity alarm. Tombstones and items without a base pass. * engine: check on every pulled record; edit-beats-delete re-seals with restores = tombstone revision; merges write seq = max + 1 and adopt the remote version when only seq/restores differ. Public engine::next_local_version gives the seq/restores for a local save (used by kv-ffi save_item and the tests). * verify_manifest checks the Merkle root whenever no held item is newer than head_revision (previously only at head == cursor). * Tests: replay alarm (clean and pending receivers, plus kv-ffi), harmless replay of the current version, tampered envelope, concurrent equal-seq edits across three devices, edit-beats-delete with a device that skipped the tombstone and a later replay of the restore, legacy items without seq, hidden item with cursor past the manifest head, and the shared item_replay.json decision table. * gen-vectors: envelope vector item payload now includes seq. FFI surface unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -563,6 +563,8 @@ 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)?;
|
||||
|
||||
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)?;
|
||||
|
||||
@@ -281,3 +281,44 @@ async fn pending_approval_and_biometric_session_expiry() {
|
||||
connect(&c, &server);
|
||||
assert!(matches!(c.register_account("x".into()).await, Err(CoreError::NotAuthenticated)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_item_bumps_seq_and_replays_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 id = a.save_item(login_item("Bank", "pw1")).unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
let vid = *server.state().vaults.keys().next().unwrap();
|
||||
let iid = Uuid::parse_str(&id).unwrap();
|
||||
let old = server.state().vaults[&vid].items[&iid].clone();
|
||||
|
||||
let seq_of = |core: &VaultCore| {
|
||||
let (acct, u) = core.unlocked_snapshot().unwrap();
|
||||
let li = core.store.get(acct.primary_vault, iid).unwrap().unwrap();
|
||||
decrypt(&acct, &u.vault_key, &li.record).unwrap().unwrap().seq
|
||||
};
|
||||
assert_eq!(seq_of(&a), 1);
|
||||
for pw in ["pw2", "pw3"] {
|
||||
let mut it = a.get_item(id.clone()).unwrap();
|
||||
it.password = Some(pw.into());
|
||||
a.save_item(it).unwrap();
|
||||
}
|
||||
assert_eq!(seq_of(&a), 3);
|
||||
a.sync_now().await.unwrap();
|
||||
|
||||
let b = VaultCore::new_in_memory();
|
||||
connect(&b, &server);
|
||||
b.login(PW.into(), sk, None).await.unwrap();
|
||||
b.sync_now().await.unwrap();
|
||||
assert_eq!(seq_of(&b), 3);
|
||||
|
||||
server.state().write_item(vid, old);
|
||||
match b.sync_now().await {
|
||||
Err(CoreError::IntegrityAlarm { reason, .. }) => assert!(reason.contains("older version"), "{reason}"),
|
||||
other => panic!("expected integrity alarm, got {other:?}"),
|
||||
}
|
||||
assert_eq!(b.get_item(id).unwrap().password.as_deref(), Some("pw3"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user