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]>
113 lines
4.6 KiB
Rust
113 lines
4.6 KiB
Rust
//! Two devices against a real, freshly started kestrove-server. Ignored by default.
|
|
//!
|
|
//! ```sh
|
|
//! # in kestrove-server (empty database, so it prints SETUP_TOKEN=… on boot):
|
|
//! KV_DATABASE=sqlite::memory: KV_BIND=127.0.0.1:8080 KV_PUBLIC_URL=http://127.0.0.1:8080 cargo run
|
|
//! # here:
|
|
//! KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=<token> \
|
|
//! cargo test -p kv-ffi --test live_server -- --ignored --nocapture
|
|
//! ```
|
|
//! The setup token works once, so restart the server with an empty database for every run.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use kv_ffi::{CoreError, DeviceSigner, ItemDto, ItemKindDto, LoginResult, PlatformDto, VaultCore};
|
|
use kv_sync::signing::SoftwareSigner;
|
|
use uuid::Uuid;
|
|
|
|
struct Signer(SoftwareSigner);
|
|
|
|
impl DeviceSigner for Signer {
|
|
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>, CoreError> {
|
|
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| CoreError::Internal { msg: e.to_string() })
|
|
}
|
|
}
|
|
|
|
fn connect(core: &VaultCore, url: &str, name: &str) {
|
|
core.configure_server(url.into(), vec![]).unwrap();
|
|
core.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), name.into(), PlatformDto::Linux)
|
|
.unwrap();
|
|
}
|
|
|
|
fn 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,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "needs a fresh local kestrove-server; see the module docs"]
|
|
async fn two_devices_against_local_server() {
|
|
let url = std::env::var("KESTROVE_SERVER_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into());
|
|
let token =
|
|
std::env::var("KESTROVE_SETUP_TOKEN").expect("set KESTROVE_SETUP_TOKEN to the token the server printed");
|
|
let pw = "a long master password for the live test";
|
|
|
|
let a = VaultCore::new_in_memory();
|
|
let sk = a.create_account(pw.into()).unwrap();
|
|
connect(&a, &url, "device A");
|
|
assert!(a.server_info().await.unwrap().setup_open, "server must be fresh (setup open)");
|
|
a.register_account(token).await.unwrap();
|
|
let id = a.save_item(item("Bank", "pw-a")).unwrap();
|
|
let r = a.sync_now().await.unwrap();
|
|
assert_eq!(r.pushed, 1);
|
|
|
|
// The server allows 5 unauthenticated auth calls per minute per IP and both devices share one.
|
|
tokio::time::sleep(std::time::Duration::from_secs(61)).await;
|
|
let b = VaultCore::new_in_memory();
|
|
connect(&b, &url, "device B");
|
|
if b.login(pw.into(), sk.clone(), None).await.unwrap() == LoginResult::PendingApproval {
|
|
let devices = a.list_devices().await.unwrap();
|
|
let pending = devices.iter().find(|d| !d.approved).expect("B listed as pending");
|
|
assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap());
|
|
a.approve_device(pending.device_id.clone()).await.unwrap();
|
|
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit again
|
|
assert_eq!(b.login(pw.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok);
|
|
}
|
|
b.sync_now().await.unwrap();
|
|
assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a"));
|
|
|
|
// Conflicting edits merge.
|
|
let mut on_a = a.get_item(id.clone()).unwrap();
|
|
on_a.notes = Some("A".into());
|
|
a.save_item(on_a).unwrap();
|
|
let mut on_b = b.get_item(id.clone()).unwrap();
|
|
on_b.username = Some("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();
|
|
let (ia, ib) = (a.get_item(id.clone()).unwrap(), b.get_item(id).unwrap());
|
|
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}");
|
|
}
|