Mirrors kv-ffi's docs/10 §5 API in WebCore and binds it under the names the web app codes against: setupPayloadForKit, addDevicePayload, revokeEnrollmentTicket, loginWithSetup, joinStart/joinPoll/joinCancel, joinApprove, joinSend, and the free function parseSetupPayload (also callable through the worker client). Unlike kv-ffi, the Secret Key is not persisted: every unlock on the web takes it, so it is kept zeroized in the unlocked state and dropped on lock. loginWithSetup refuses a code for a different server than the configured origin (the browser only talks to its own origin). Tests: ticket enrollment and scan-to-join in both roles against kv-ffi via the kv-sync test server, a swapped-approver rejection, a Chrome test (IndexedDB never holds the Secret Key, JS shapes, error codes) and a Node smoke check of the built package. Co-Authored-By: Claude Opus 5.5 <[email protected]>
363 lines
16 KiB
Rust
363 lines
16 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 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![],
|
|
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()).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![],
|
|
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).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).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).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()).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![],
|
|
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();
|
|
|
|
// 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);
|
|
}
|
|
|
|
#[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 { .. })));
|
|
}
|