Files
kestrove-core/crates/kv-ffi/tests/live_server.rs
T
samiandClaude Opus 5.5 83d56d43e2 Removed devices: DeviceRevoked, identity reset and one retry with the ticket
kv-sync: SyncError::DeviceRevoked for 403 device_revoked (drops the session);
the test server answers it after OPAQUE (before the ticket) and for valid
signatures by a revoked device's key.

kv-ffi: CoreError::DeviceRevoked; the foreign DeviceSigner gains reset() (new key
and id). On device_revoked the core resets the identity, forgets the session
and the kept OPAQUE input (no silent re-enrolment) and keeps the vault data; a
login carrying a ticket retries once as the new device. reset_device_identity()
runs the reset on demand. Signed calls (sync, devices, tickets, join) reset too.

kv-wasm: DeviceRevoked; the browser binding replaces the WebCrypto key and id in
IndexedDB (same name and platform), loginWithSetup retries once with a ticket;
resetDeviceIdentity().

Tests: end to end revoked -> reset -> re-enrol with the same ticket (kv-ffi and
web core), no-ticket path, in-browser IndexedDB reset.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 22:25:03 +09:00

134 lines
5.9 KiB
Rust

//! Two devices against a real, freshly started kestrove-server. Ignored by default.
//!
//! ```sh
//! # in kestrove-server (empty database; without KV_SETUP_CODE it prints SETUP_TOKEN=… on boot instead):
//! KV_DATABASE=sqlite::memory: KV_BIND=127.0.0.1:8080 KV_PUBLIC_URL=http://127.0.0.1:8080 \
//! KV_SETUP_CODE=live-test-setup-code cargo run
//! # here:
//! KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=live-test-setup-code \
//! cargo test -p kv-ffi --test live_server -- --ignored --nocapture
//! ```
//! The setup code works once, so restart the server with an empty database for every run. The run takes about
//! four minutes because it waits out the server's rate limit on the login endpoints.
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 reset(&self) -> Result<(), CoreError> {
Err(CoreError::Internal { msg: "not supported in this test".into() })
}
}
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)));
// Scan-to-join (docs/10 §4): a third device that knows only the server URL needs no approval.
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit again
let c = VaultCore::new_in_memory();
connect(&c, &url, "device C");
let session = c.join_start(url.clone()).await.unwrap();
let confirm = a.join_approve(session.qr).await.unwrap();
assert_eq!(confirm.device_name, "device C");
assert_eq!(c.join_poll().await.unwrap().code.as_deref(), Some(confirm.code.as_str()));
a.join_send().await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1100)).await; // the server allows one poll per second
let setup = c.join_poll().await.unwrap().setup.expect("bundle");
assert_eq!(setup.secret_key, sk);
assert_eq!(c.login_with_setup(setup, pw.into()).await.unwrap(), LoginResult::Ok);
c.sync_now().await.unwrap();
assert!(c.list_items().unwrap().iter().any(|i| i.title == "Bank"));
b.logout().await.unwrap();
println!("live sync OK against {url}");
}