Files
kestrove-core/crates/kv-ffi/src/tests.rs
T
samiandClaude Opus 5.5 abcb758b67 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]>
2026-10-16 11:08:42 +09:00

325 lines
13 KiB
Rust

use kv_sync::{
signing::SoftwareSigner,
test_server::{TestServer, SETUP_TOKEN},
};
use super::*;
const PW: &str = "a long master password";
fn login_item(title: &str, password: &str) -> ItemDto {
ItemDto {
id: String::new(),
kind: ItemKindDto::Login,
title: title.into(),
username: Some("me".into()),
password: Some(password.into()),
urls: vec!["https://example.com".into()],
totp: None,
notes: None,
favorite: false,
tags: vec![],
modified_at: 0,
}
}
struct TestSigner(SoftwareSigner);
impl DeviceSigner for TestSigner {
fn device_id(&self) -> String {
kv_sync::DeviceSigner::device_id(&self.0).to_string()
}
fn public_key(&self) -> Vec<u8> {
kv_sync::DeviceSigner::public_key(&self.0)
}
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>> {
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(internal)
}
}
fn connect(core: &VaultCore, server: &TestServer) {
core.configure_server(server.url(), vec![]).unwrap();
core.set_device_signer(
Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))),
"test".into(),
PlatformDto::Macos,
)
.unwrap();
}
#[test]
fn create_lock_unlock_crud() {
let core = VaultCore::new_in_memory();
let sk = core.create_account(PW.into()).unwrap();
let id = core.save_item(login_item("Example", "s3cret")).unwrap();
core.lock();
assert!(matches!(core.list_items(), Err(CoreError::Locked)));
assert!(matches!(core.unlock("wrong password!!".into(), sk.clone()), Err(CoreError::BadCredentials)));
core.unlock(PW.into(), sk).unwrap();
assert_eq!(core.get_item(id.clone()).unwrap().password.as_deref(), Some("s3cret"));
assert_eq!(core.items_for_url("https://login.example.com/x".into()).unwrap().len(), 1);
assert!(core.items_for_url("https://example.org".into()).unwrap().is_empty());
// Edits keep history; deletes hide the item.
let mut dto = core.get_item(id.clone()).unwrap();
dto.password = Some("n3w".into());
core.save_item(dto).unwrap();
assert_eq!(core.item_history(id.clone()).unwrap()[0].password.as_deref(), Some("s3cret"));
core.delete_item(id.clone()).unwrap();
assert!(matches!(core.get_item(id.clone()), Err(CoreError::NotFound)));
assert!(core.list_items().unwrap().is_empty());
assert!(matches!(core.delete_item(id), Err(CoreError::NotFound)));
assert!(matches!(core.create_account(PW.into()), Err(CoreError::Invalid { .. })));
}
#[test]
fn persistent_vault_survives_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("vault.sqlite").to_string_lossy().into_owned();
let (sk, id, account) = {
let core = VaultCore::open(path.clone()).unwrap();
assert!(!core.has_account());
let sk = core.create_account(PW.into()).unwrap();
let id = core.save_item(login_item("Bank", "pw")).unwrap();
(sk, id, core.account_id().unwrap())
};
let core = VaultCore::open(path.clone()).unwrap();
assert!(core.has_account() && core.is_locked());
assert_eq!(core.account_id().unwrap(), account);
core.unlock(PW.into(), sk).unwrap();
assert_eq!(core.get_item(id).unwrap().title, "Bank");
// Only ciphertext is on disk.
for f in [path.clone(), format!("{path}-wal")] {
let raw = std::fs::read(&f).unwrap_or_default();
let hay = String::from_utf8_lossy(&raw);
assert!(!hay.contains("Bank") && !hay.contains(PW), "{f}");
}
}
#[test]
fn local_unlock_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v.sqlite").to_string_lossy().into_owned();
let core = VaultCore::open(path.clone()).unwrap();
core.create_account(PW.into()).unwrap();
let id = core.save_item(login_item("Mail", "pw")).unwrap();
assert!(!core.has_local_unlock());
let luk = core.enable_local_unlock().unwrap();
assert_eq!(luk.len(), 32);
assert!(core.has_local_unlock());
core.lock();
assert!(matches!(core.enable_local_unlock(), Err(CoreError::Locked)));
assert!(matches!(core.unlock_with_local_key(vec![0; 32]), Err(CoreError::BadCredentials)));
assert!(matches!(core.unlock_with_local_key(vec![0; 16]), Err(CoreError::Invalid { .. })));
core.unlock_with_local_key(luk.clone()).unwrap();
assert_eq!(core.get_item(id.clone()).unwrap().title, "Mail");
drop(core);
// Persisted, and the AutoFill extension (another process) can use it.
let core = VaultCore::open(path.clone()).unwrap();
assert!(core.has_local_unlock());
core.unlock_with_local_key(luk.clone()).unwrap();
assert_eq!(core.list_items().unwrap().len(), 1);
// Re-enabling rotates the key.
let luk2 = core.enable_local_unlock().unwrap();
assert_ne!(luk, luk2);
core.lock();
assert!(core.unlock_with_local_key(luk).is_err());
core.unlock_with_local_key(luk2.clone()).unwrap();
core.disable_local_unlock().unwrap();
core.lock();
assert!(matches!(core.unlock_with_local_key(luk2), Err(CoreError::Invalid { .. })));
}
#[tokio::test]
async fn sync_needs_configuration() {
let core = VaultCore::new_in_memory();
core.create_account(PW.into()).unwrap();
assert!(matches!(core.sync_now().await, Err(CoreError::NotConfigured)));
assert!(core.configure_server("http://vault.example.com".into(), vec![]).is_err());
assert!(core.configure_server("https://vault.example.com".into(), vec!["not a pin".into()]).is_err());
core.configure_server("https://vault.example.com".into(), vec![]).unwrap();
assert!(matches!(core.sync_now().await, Err(CoreError::NotConfigured)), "no device signer yet");
}
#[tokio::test]
async fn two_devices_sync_through_server() {
let server = TestServer::start().await;
let dir = tempfile::tempdir().unwrap();
// Device A: create, register, add an item, sync.
let a = VaultCore::open(dir.path().join("a.sqlite").to_string_lossy().into_owned()).unwrap();
let sk = a.create_account(PW.into()).unwrap();
connect(&a, &server);
assert!(a.server_info().await.unwrap().setup_open);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
assert!(a.is_logged_in());
let id = a.save_item(login_item("Bank", "pw-a")).unwrap();
let r = a.sync_now().await.unwrap();
assert_eq!((r.pushed, r.vaults_synced), (1, 1));
// Device B: brand new, logs in with account id + password + Secret Key.
let b_path = dir.path().join("b.sqlite").to_string_lossy().into_owned();
let b = VaultCore::open(b_path.clone()).unwrap();
connect(&b, &server);
assert!(matches!(b.login("wrong password!!".into(), sk.clone(), None).await, Err(CoreError::BadCredentials)));
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok);
assert!(b.has_account() && !b.is_locked());
b.sync_now().await.unwrap();
assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a"));
assert_eq!(b.account_fingerprint().unwrap(), a.account_fingerprint().unwrap());
// Concurrent edits to different fields merge.
let mut on_a = a.get_item(id.clone()).unwrap();
on_a.notes = Some("from A".into());
a.save_item(on_a).unwrap();
let mut on_b = b.get_item(id.clone()).unwrap();
on_b.username = Some("from-b".into());
b.save_item(on_b).unwrap();
a.sync_now().await.unwrap();
assert_eq!(b.sync_now().await.unwrap().merged, 1);
a.sync_now().await.unwrap();
for core in [&a, &b] {
let it = core.get_item(id.clone()).unwrap();
assert_eq!(it.notes.as_deref(), Some("from A"));
assert_eq!(it.username.as_deref(), Some("from-b"));
}
// B enables biometrics, restarts, unlocks with the LUK; an expired session renews itself.
let luk = b.enable_local_unlock().unwrap();
drop(b);
let b = VaultCore::open(b_path).unwrap();
connect(&b, &server);
b.unlock_with_local_key(luk).unwrap();
server.state().sessions.clear();
b.save_item(login_item("Second", "x")).unwrap();
assert_eq!(b.sync_now().await.unwrap().pushed, 1);
a.sync_now().await.unwrap();
assert_eq!(a.list_items().unwrap().len(), 2);
// Delete propagates.
a.delete_item(id.clone()).unwrap();
a.sync_now().await.unwrap();
b.sync_now().await.unwrap();
assert!(matches!(b.get_item(id), Err(CoreError::NotFound)));
b.logout().await.unwrap();
assert!(!b.is_logged_in());
}
#[tokio::test]
async fn integrity_alarm_reaches_the_app() {
let server = TestServer::start().await;
let a = VaultCore::new_in_memory();
a.create_account(PW.into()).unwrap();
connect(&a, &server);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
for i in 0..2 {
a.save_item(login_item(&format!("item {i}"), "p")).unwrap();
a.sync_now().await.unwrap();
}
let vid = *server.state().vaults.keys().next().unwrap();
let first = server.state().vaults[&vid].manifest_history[0].clone();
server.state().serve_manifest = Some(first);
match a.sync_now().await {
Err(CoreError::IntegrityAlarm { vault_id, reason }) => {
assert_eq!(vault_id, vid.to_string());
assert!(reason.contains("rollback"));
}
other => panic!("expected integrity alarm, got {other:?}"),
}
// The vault stays readable locally.
assert_eq!(a.list_items().unwrap().len(), 2);
}
#[tokio::test]
async fn pending_approval_and_biometric_session_expiry() {
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();
a.sync_now().await.unwrap();
server.state().require_approval = true;
let b = VaultCore::new_in_memory();
connect(&b, &server);
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval);
assert!(!b.has_account());
// A approves B after comparing fingerprints, then B's next login succeeds.
let devices = a.list_devices().await.unwrap();
let pending = devices.iter().find(|d| !d.approved).unwrap();
assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap());
assert!(devices.iter().any(|d| d.current && d.approved));
a.approve_device(pending.device_id.clone()).await.unwrap();
assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
b.sync_now().await.unwrap();
// Revoking B kills its session.
a.revoke_device(pending.device_id.clone()).await.unwrap();
assert!(matches!(b.sync_now().await, Err(CoreError::NotAuthenticated | CoreError::PendingApproval)));
// A biometric unlock without a stored OPAQUE input cannot renew the session: NotAuthenticated.
let c = VaultCore::new_in_memory();
let sk_c = c.create_account(PW.into()).unwrap();
let luk = {
// Simulate an old LUK record without auth input by unlocking via LUK after a fresh enable.
c.lock();
c.unlock(PW.into(), sk_c).unwrap();
let luk = c.enable_local_unlock().unwrap();
let mut inner = c.lock_inner();
inner.account.as_mut().unwrap().local_unlock.as_mut().unwrap().auth_input = None;
luk
};
c.lock();
c.unlock_with_local_key(luk).unwrap();
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"));
}