Files
samiandClaude Opus 5.5 7c8a5b668f
ci / test (push) Waiting to run
ci / wasm (push) Waiting to run
ci / deny (push) Waiting to run
ci / fuzz (push) Waiting to run
Synced app settings: colour theme and appearance (beta)
One encrypted settings item per account (a secure note carrying
app_settings, at a fixed per-account id) holds the theme and light/dark
appearance, so it syncs like any item and the server can't read it. Lists
hide it; older apps see a note that explains itself. app_theme /
set_app_theme in kv-ffi and kv-wasm. Also fixes a flaky test that checked
random ciphertext for the letters K1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 00:12:08 +07:00

446 lines
20 KiB
Rust

//! The web core (compiled natively here, same code as in the browser) syncing with an app device
//! (kv-ffi) through kv-sync's in-memory test server: enrollment by approval, both directions of
//! edits, deletes, and a restart from the persisted replica.
#![cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
use kv_ffi::{DeviceSigner, PlatformDto};
use kv_proto::Platform;
use kv_sync::{
signing::SoftwareSigner,
test_server::{TestServer, SETUP_TOKEN},
};
use kv_wasm::{
dto::{ItemDto, ItemKindDto, LoginResult},
store::WebStore,
CoreError, WebCore,
};
use uuid::Uuid;
const PW: &str = "a long master password";
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>, kv_ffi::CoreError> {
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| kv_ffi::CoreError::Internal { msg: e.to_string() })
}
fn reset(&self) -> Result<(), kv_ffi::CoreError> {
Err(kv_ffi::CoreError::Internal { msg: "not supported in this test".into() })
}
}
fn web_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![],
sign_in_with: None,
modified_at: 0,
}
}
fn web_core(store: Arc<WebStore>, url: &str, signer: Arc<SoftwareSigner>) -> WebCore {
let core = WebCore::new(store).unwrap();
core.configure_server(url, vec![]).unwrap();
core.set_device_signer(signer, "Web vault", Platform::Macos);
core
}
#[tokio::test]
async fn web_vault_enrolls_and_syncs_with_an_app_device() {
let server = TestServer::start().await;
// The app (kv-ffi) is the first device.
let app = kv_ffi::VaultCore::new_in_memory();
let sk = app.create_account(PW.into()).unwrap();
app.configure_server(server.url(), vec![]).unwrap();
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
.unwrap();
app.register_account(SETUP_TOKEN.into(), None).await.unwrap();
let mut on_app = kv_ffi::ItemDto {
id: String::new(),
kind: kv_ffi::ItemKindDto::Login,
title: "Bank".into(),
username: Some("me".into()),
password: Some("from the app".into()),
urls: vec![],
totp: None,
notes: None,
favorite: false,
tags: vec![],
sign_in_with: None,
modified_at: 0,
};
let bank = app.save_item(on_app.clone()).unwrap();
app.sync_now().await.unwrap();
// The browser logs in as a new device and waits for approval.
server.state().require_approval = true;
let store = Arc::new(WebStore::new());
let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4()));
let web = web_core(store.clone(), &server.url(), signer.clone());
assert!(matches!(web.sync_now().await, Err(CoreError::NoAccount | CoreError::Locked)));
assert_eq!(web.login(PW, &sk, None, None).await.unwrap(), LoginResult::PendingApproval);
assert!(!web.has_account());
let pending = app.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
assert_eq!(pending.key_fingerprint, web.device_key_fingerprint().unwrap());
app.approve_device(pending.device_id).await.unwrap();
assert_eq!(web.login(PW, &sk, None, None).await.unwrap(), LoginResult::Ok);
let r = web.sync_now().await.unwrap();
assert_eq!((r.pulled, r.vaults_synced), (1, 1));
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
// Browser edit and new item reach the app.
let mut edit = web.get_item(&bank).unwrap();
edit.notes = Some("edited in the browser".into());
web.save_item(edit).unwrap();
let new_id = web.save_item(web_item("Mail", "from the web")).unwrap();
assert_eq!(web.sync_now().await.unwrap().pushed, 2);
app.sync_now().await.unwrap();
assert_eq!(app.get_item(bank.clone()).unwrap().notes.as_deref(), Some("edited in the browser"));
assert_eq!(app.get_item(new_id.clone()).unwrap().password.as_deref(), Some("from the web"));
// Concurrent edits of different fields merge.
on_app = app.get_item(bank.clone()).unwrap();
on_app.password = Some("rotated on the app".into());
app.save_item(on_app).unwrap();
let mut edit = web.get_item(&bank).unwrap();
edit.tags = vec!["web".into()];
web.save_item(edit).unwrap();
app.sync_now().await.unwrap();
let r = web.sync_now().await.unwrap();
assert_eq!(r.merged, 1);
app.sync_now().await.unwrap();
for item in [web.get_item(&bank).unwrap(), {
let d = app.get_item(bank.clone()).unwrap();
ItemDto { password: d.password, tags: d.tags, ..web.get_item(&bank).unwrap() }
}] {
assert_eq!(item.password.as_deref(), Some("rotated on the app"));
assert_eq!(item.tags, vec!["web".to_string()]);
}
// Delete in the browser; the app sees it.
web.delete_item(&new_id).unwrap();
web.sync_now().await.unwrap();
app.sync_now().await.unwrap();
assert!(matches!(app.get_item(new_id), Err(kv_ffi::CoreError::NotFound)));
// A page reload: the replica comes back from its persisted entries, locked, session kept.
let reloaded = Arc::new(WebStore::load(store.entries()).unwrap());
let web = web_core(reloaded, &server.url(), signer);
assert!(web.has_account() && web.is_locked() && web.is_logged_in());
assert!(matches!(web.list_items(), Err(CoreError::Locked)));
web.unlock(PW, &sk).unwrap();
assert_eq!(web.sync_now().await.unwrap().pulled, 0);
assert_eq!(web.list_items().unwrap().len(), 1);
// Revoked by the app: the browser can no longer sync.
let me = web.device_id().unwrap().to_string();
app.revoke_device(me).await.unwrap();
assert!(web.sync_now().await.is_err());
}
#[tokio::test]
async fn concurrent_sync_is_refused_not_interleaved() {
let server = TestServer::start().await;
let web = web_core(Arc::new(WebStore::new()), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4())));
let sk = web.create_account(PW).unwrap();
web.register_account(SETUP_TOKEN, None).await.unwrap();
web.save_item(web_item("A", "1")).unwrap();
let (a, b) = tokio::join!(web.sync_now(), web.sync_now());
assert!(matches!((&a, &b), (Ok(_), Err(CoreError::Busy)) | (Err(CoreError::Busy), Ok(_))), "{a:?} {b:?}");
assert!(web.sync_now().await.is_ok());
web.lock();
web.unlock(PW, &sk).unwrap();
}
// ---------------------------------------------------------------- onboarding (docs/10)
/// An app device (kv-ffi) signed in with one item, on a server that requires approval for new devices.
async fn app_with_item(server: &TestServer) -> (Arc<kv_ffi::VaultCore>, String, String) {
let app = kv_ffi::VaultCore::new_in_memory();
let sk = app.create_account(PW.into()).unwrap();
app.configure_server(server.url(), vec![]).unwrap();
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
.unwrap();
app.register_account(SETUP_TOKEN.into(), None).await.unwrap();
let id = app
.save_item(kv_ffi::ItemDto {
id: String::new(),
kind: kv_ffi::ItemKindDto::Login,
title: "Bank".into(),
username: None,
password: Some("from the app".into()),
urls: vec![],
totp: None,
notes: None,
favorite: false,
tags: vec![],
sign_in_with: None,
modified_at: 0,
})
.unwrap();
app.sync_now().await.unwrap();
server.state().require_approval = true;
(app, sk, id)
}
fn new_web(server: &TestServer) -> (Arc<WebStore>, WebCore) {
let store = Arc::new(WebStore::new());
let web = web_core(store.clone(), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4())));
(store, web)
}
/// Nothing secret reaches the persisted replica: not the Secret Key in either form.
fn assert_no_secret_key(store: &WebStore, sk: &str) {
let compact: String = sk.chars().filter(|c| *c != '-').collect();
for (k, v) in store.entries() {
let v = String::from_utf8_lossy(&v);
assert!(!v.contains(sk) && !v.contains(&compact[2..]), "Secret Key persisted under {k}");
}
}
#[tokio::test]
async fn web_vault_enrolls_by_ticket_both_ways() {
let server = TestServer::start().await;
let (app, sk, bank) = app_with_item(&server).await;
// App -> browser: the "Add a device" QR plus the master password, no approval.
let add = app.add_device_payload().await.unwrap();
let setup = kv_wasm::core::parse_setup_payload(&add.payload).unwrap();
assert_eq!((setup.secret_key.as_str(), setup.server_url.as_str()), (sk.as_str(), server.url().as_str()));
assert!(setup.ticket.is_some() && !format!("{setup:?}").contains(&sk));
// A code for another server is refused before anything is sent (the browser only talks to its origin).
let (_, other) = new_web(&server);
other.configure_server("https://vault.example.org", vec![]).unwrap();
assert!(matches!(other.login_with_setup(&setup, PW).await, Err(CoreError::Invalid { .. })));
let (store, web) = new_web(&server);
assert!(matches!(web.login_with_setup(&setup, "wrong password!!").await, Err(CoreError::BadCredentials)));
assert_eq!(web.login_with_setup(&setup, PW).await.unwrap(), LoginResult::Ok);
web.sync_now().await.unwrap();
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
assert!(app.list_devices().await.unwrap().iter().all(|d| d.approved));
// The browser's Kit payload matches the app's; it lives only in memory.
let kit = web.setup_payload_for_kit().unwrap();
assert_eq!(kit, app.setup_payload_for_kit().unwrap());
assert_eq!(kv_wasm::core::parse_setup_payload(&kit).unwrap().ticket, None);
assert_no_secret_key(&store, &sk);
web.lock();
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
assert!(matches!(web.add_device_payload().await, Err(CoreError::Locked)));
// A reload: unlocking (which takes the Secret Key) brings it back.
let reloaded = web_core(Arc::new(WebStore::load(store.entries()).unwrap()), &server.url(), {
Arc::new(SoftwareSigner::generate(Uuid::new_v4()))
});
reloaded.unlock(PW, &sk).unwrap();
assert_eq!(reloaded.setup_payload_for_kit().unwrap(), kit);
web.unlock(PW, &sk).unwrap();
// A failed unlock (or login) forgets it until the next one that succeeds.
assert!(matches!(web.unlock("wrong password!!", &sk), Err(CoreError::BadCredentials)));
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
web.unlock(PW, &sk).unwrap();
assert!(web.login("wrong password!!", &sk, None, None).await.is_err());
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
web.unlock(PW, &sk).unwrap();
// Single use: another browser with the same QR waits for approval.
let (_, third) = new_web(&server);
assert_eq!(third.login_with_setup(&setup, PW).await.unwrap(), LoginResult::PendingApproval);
// Browser -> app: the web vault's "Add a device" QR enrolls a new app device.
let add = web.add_device_payload().await.unwrap();
let tablet = kv_ffi::VaultCore::new_in_memory();
tablet
.set_device_signer(
Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))),
"tablet".into(),
PlatformDto::Android,
)
.unwrap();
let info = kv_ffi::parse_setup_payload(add.payload).unwrap();
assert_eq!(tablet.login_with_setup(info, PW.into()).await.unwrap(), kv_ffi::LoginResult::Ok);
tablet.sync_now().await.unwrap();
assert_eq!(tablet.get_item(bank).unwrap().password.as_deref(), Some("from the app"));
// A cancelled QR is revoked.
let add = web.add_device_payload().await.unwrap();
web.revoke_enrollment_ticket(&add.ticket_id).await.unwrap();
let (_, late) = new_web(&server);
let setup = kv_wasm::core::parse_setup_payload(&add.payload).unwrap();
assert_eq!(late.login_with_setup(&setup, PW).await.unwrap(), LoginResult::PendingApproval);
// Logging out forgets the Secret Key too.
web.logout().await.unwrap();
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
}
#[tokio::test]
async fn web_vault_scan_to_join_both_roles() {
let server = TestServer::start().await;
let (phone, sk, bank) = app_with_item(&server).await;
// The browser joins: it shows the QR, the phone scans it.
let (store, web) = new_web(&server);
assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })));
let session = web.join_start(&server.url()).await.unwrap();
assert!(session.qr.starts_with("kestrove://join/v1?"));
assert_eq!(web.join_poll().await.unwrap().code, None);
let confirm = phone.join_approve(session.qr.clone()).await.unwrap();
assert_eq!(confirm.key_fingerprint, web.device_key_fingerprint().unwrap());
assert_eq!(confirm.device_name, "Web vault");
let progress = web.join_poll().await.unwrap();
assert_eq!((progress.code.as_deref(), progress.setup.as_ref()), (Some(confirm.code.as_str()), None));
phone.join_send().await.unwrap();
let setup = web.join_poll().await.unwrap().setup.expect("bundle arrived");
assert_eq!(setup.secret_key, sk);
assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })), "one read");
assert_eq!(web.login_with_setup(&setup, PW).await.unwrap(), LoginResult::Ok);
web.sync_now().await.unwrap();
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
assert_no_secret_key(&store, &sk);
assert!(matches!(web.join_start(&server.url()).await, Err(CoreError::Invalid { .. })));
// The browser approves: an app desktop joins through it.
let desktop = kv_ffi::VaultCore::new_in_memory();
desktop
.set_device_signer(
Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))),
"desktop".into(),
PlatformDto::Linux,
)
.unwrap();
let session = desktop.join_start(server.url()).await.unwrap();
assert!(matches!(web.join_send().await, Err(CoreError::Invalid { .. })));
let elsewhere = session.qr.replace("127.0.0.1", "localhost");
assert!(matches!(web.join_approve(&elsewhere).await, Err(CoreError::Invalid { .. })));
let confirm = web.join_approve(&session.qr).await.unwrap();
assert_eq!((confirm.device_name.as_str(), confirm.platform), ("desktop", kv_wasm::dto::PlatformDto::Linux));
assert_eq!(confirm.key_fingerprint, desktop.device_key_fingerprint().unwrap());
assert_eq!(desktop.join_poll().await.unwrap().code.as_deref(), Some(confirm.code.as_str()));
web.join_send().await.unwrap();
assert!(matches!(web.join_send().await, Err(CoreError::Invalid { .. })), "one send per approval");
{
let st = server.state();
let raw = serde_json::to_string(st.joins.values().next().unwrap().bundle.as_ref().unwrap()).unwrap();
assert!(!raw.contains("K1") && !raw.contains("kestrove://"));
}
let setup = desktop.join_poll().await.unwrap().setup.unwrap();
assert_eq!(desktop.login_with_setup(setup, PW.into()).await.unwrap(), kv_ffi::LoginResult::Ok);
desktop.sync_now().await.unwrap();
assert_eq!(desktop.get_item(bank).unwrap().password.as_deref(), Some("from the app"));
}
#[tokio::test]
async fn web_vault_join_refuses_a_swapped_approver() {
let server = TestServer::start().await;
let (phone, _, _) = app_with_item(&server).await;
let (_, web) = new_web(&server);
let session = web.join_start(&server.url()).await.unwrap();
phone.join_approve(session.qr.clone()).await.unwrap();
web.join_poll().await.unwrap();
// The server swaps the hello key and bundle for its own (it never had the QR secret).
let qr = kv_sync::onboarding::JoinQr::parse(&session.qr).unwrap();
let rid = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &qr.rendezvous_id).unwrap();
let forger = kv_crypto::pairing::PairingKeypair::generate();
let forged =
kv_crypto::pairing::seal(&forger, &qr.public_key, &[0u8; 32], &rid, b"kestrove://setup/v1?evil").unwrap();
{
let mut st = server.state();
let rv = st.joins.values_mut().next().unwrap();
rv.hello = Some((Uuid::new_v4(), forger.public_bytes().to_vec()));
rv.bundle = Some(forged);
}
assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })));
assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })), "state cleared");
// Cancelled on the joining side; a fresh start works.
web.join_start(&server.url()).await.unwrap();
web.join_cancel();
assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })));
}
/// docs/03 "Removed devices" for the web core: a removed browser gets `DeviceRevoked` (only with the right
/// password), forgets its session, and the same "Add a device" ticket gets it back in under a new key and id
/// (what the browser binding does: `forget_revoked_device`, new WebCrypto key, one retry).
#[tokio::test]
async fn removed_web_vault_reenrols_with_the_same_ticket() {
let server = TestServer::start().await;
let (app, _sk, id) = app_with_item(&server).await;
let add = app.add_device_payload().await.unwrap();
let setup: kv_wasm::dto::SetupInfo = {
let i = kv_ffi::parse_setup_payload(add.payload.clone()).unwrap();
kv_wasm::dto::SetupInfo {
server_url: i.server_url,
spki_pins: i.spki_pins,
account_id: i.account_id,
secret_key: i.secret_key,
ticket: i.ticket,
}
};
// The browser enrolled once (pending), then was denied.
let (store, web) = new_web(&server);
let old = web.device_id().unwrap();
let no_ticket = kv_wasm::dto::SetupInfo { ticket: None, ..setup.clone() };
assert_eq!(web.login_with_setup(&no_ticket, PW).await.unwrap(), LoginResult::PendingApproval);
app.revoke_device(old.to_string()).await.unwrap();
assert!(matches!(web.login_with_setup(&setup, "wrong password!!").await, Err(CoreError::BadCredentials)));
assert!(matches!(web.login_with_setup(&setup, PW).await, Err(CoreError::DeviceRevoked)));
web.forget_revoked_device().unwrap();
web.set_device_signer(Arc::new(SoftwareSigner::generate(Uuid::new_v4())), "Web vault", Platform::Macos);
assert_ne!(web.device_id().unwrap(), old);
assert_eq!(web.login_with_setup(&setup, PW).await.unwrap(), LoginResult::Ok);
web.sync_now().await.unwrap();
assert_eq!(web.get_item(&id).unwrap().password.as_deref(), Some("from the app"));
drop(store);
// Signed in, then removed: sync says so; the old id never comes back.
app.revoke_device(web.device_id().unwrap().to_string()).await.unwrap();
assert!(matches!(web.sync_now().await, Err(CoreError::DeviceRevoked)));
web.forget_revoked_device().unwrap();
assert!(matches!(app.approve_device(old.to_string()).await, Err(kv_ffi::CoreError::NotFound)));
}
#[tokio::test]
async fn theme_set_in_the_app_reaches_the_web_vault_and_back() {
let server = TestServer::start().await;
let (app, _sk, _) = app_with_item(&server).await;
app.set_app_theme(Some("amber".into()), Some("light".into())).unwrap();
app.sync_now().await.unwrap();
let add = app.add_device_payload().await.unwrap();
let setup = kv_wasm::core::parse_setup_payload(&add.payload).unwrap();
let (_store, web) = new_web(&server);
web.login_with_setup(&setup, PW).await.unwrap();
web.sync_now().await.unwrap();
let t = web.app_theme().unwrap();
assert_eq!((t.theme.as_deref(), t.appearance.as_deref()), (Some("amber"), Some("light")));
assert_eq!(web.list_items().unwrap().len(), 1, "only Bank; the settings item is hidden");
web.set_app_theme(Some("rose".into()), Some("dark".into())).unwrap();
web.sync_now().await.unwrap();
app.sync_now().await.unwrap();
assert_eq!(app.app_theme().unwrap().theme.as_deref(), Some("rose"));
assert_eq!(app.list_items().unwrap().len(), 1);
}