kv-wasm: onboarding (setup codes, enrollment tickets, scan-to-join) for the web vault

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]>
This commit is contained in:
2026-10-18 06:58:13 +09:00
co-authored by Claude Opus 5.5
parent d70beef92c
commit 2e2c8cd26f
11 changed files with 779 additions and 14 deletions
+197
View File
@@ -163,3 +163,200 @@ async fn concurrent_sync_is_refused_not_interleaved() {
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 { .. })));
}