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:
@@ -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 { .. })));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user