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
+128
View File
@@ -123,6 +123,52 @@ export interface ImportReport {
itemIds: string[];
}
/** A parsed setup payload (`kestrove://setup/v1?…`): everything a new device needs but the master password. */
export interface SetupInfo {
/** Normalized server URL. */
serverUrl: string;
/** Standard base64 SPKI SHA-256 pins. Stored but not enforced in the browser. */
spkiPins: string[];
accountId: string;
/** Display form `K1-…`. A secret: do not keep it in long-lived state. */
secretKey: string;
/** Enrollment ticket ("Add a device", scan-to-join); null on the Emergency Kit. */
ticket: string | null;
}
export interface AddDevicePayload {
/** Text for the QR (a secret view): the setup payload with a fresh 10-minute, single-use ticket. */
payload: string;
/** For `revokeEnrollmentTicket` when the user cancels. */
ticketId: string;
/** Unix seconds. */
expiresAt: number;
}
/** Joining side of scan-to-join: the QR to show (`kestrove://join/v1?…`). */
export interface JoinSession {
qr: string;
/** Unix seconds. */
expiresAt: number;
}
export interface JoinProgress {
/** `"123 456"`, once the approving device scanned the QR. Show it for comparison. */
code: string | null;
/** Set once the bundle arrived: ask for the master password and call `loginWithSetup`. */
setup: SetupInfo | null;
}
/** Approving side of scan-to-join: what the user checks before confirming. */
export interface JoinConfirm {
/** `"123 456"`; the joining device shows the same code. */
code: string;
deviceName: string;
platform: Platform;
/** Same format as `DeviceDto.keyFingerprint`. */
keyFingerprint: string;
}
export type CoreErrorCode =
| "Locked" | "NoAccount" | "BadCredentials" | "Invalid" | "NotFound" | "Internal" | "NotConfigured"
| "NotAuthenticated" | "PendingApproval" | "Network" | "PinMismatch" | "Server" | "IntegrityAlarm" | "Busy";
@@ -617,6 +663,80 @@ impl VaultCore {
self.run(|s| async move { Ok(s.core.is_logged_in()) })
}
// ------------------------------------------------------------ onboarding (docs/10)
/// The Emergency Kit's Setup Code (server, account id, Secret Key; no ticket). Needs an unlocked
/// vault. Show it as a secret.
#[wasm_bindgen(js_name = setupPayloadForKit, unchecked_return_type = "Promise<string>")]
pub fn setup_payload_for_kit(&self) -> Promise {
self.run(|s| async move { s.core.setup_payload_for_kit() })
}
/// The "Add a device" QR: a setup payload with a fresh 10-minute, single-use ticket, so the new
/// device needs only the master password and no approval. On Cancel call `revokeEnrollmentTicket`.
#[wasm_bindgen(js_name = addDevicePayload, unchecked_return_type = "Promise<AddDevicePayload>")]
pub fn add_device_payload(&self) -> Promise {
self.run(|s| async move { s.core.add_device_payload().await })
}
#[wasm_bindgen(js_name = revokeEnrollmentTicket, unchecked_return_type = "Promise<void>")]
pub fn revoke_enrollment_ticket(&self, #[wasm_bindgen(js_name = "ticketId")] ticket_id: String) -> Promise {
self.run(move |s| async move { s.core.revoke_enrollment_ticket(&ticket_id).await })
}
/// Logs in with a setup code (from `parseSetupPayload` or `joinPoll`) and the master password,
/// presenting its ticket so no approval is needed. Refuses a code for another server than this
/// web vault's. Then call `syncNow`. Runs Argon2id.
#[wasm_bindgen(js_name = loginWithSetup, unchecked_return_type = "Promise<LoginResult>")]
pub fn login_with_setup(
&self,
#[wasm_bindgen(unchecked_param_type = "SetupInfo")] setup: JsValue,
#[wasm_bindgen(js_name = "masterPassword")] master_password: String,
) -> Promise {
let (setup, pw) = (from_js::<SetupInfo>(setup), zeroize::Zeroizing::new(master_password));
self.write(move |s| async move {
let setup = zeroize::Zeroizing::new(setup?);
s.core.login_with_setup(&setup, &pw).await
})
}
/// Joining browser (no account yet): opens a rendezvous and resolves with the QR to show. Then
/// call `joinPoll` every 2 seconds.
#[wasm_bindgen(js_name = joinStart, unchecked_return_type = "Promise<JoinSession>")]
pub fn join_start(&self, #[wasm_bindgen(js_name = "serverUrl")] server_url: String) -> Promise {
self.write(move |s| async move { s.core.join_start(&server_url).await })
}
/// Joining browser: `code` appears once a phone scanned the QR, `setup` once it sent the bundle.
/// Rejects with `NotFound` when the rendezvous expired (start again).
#[wasm_bindgen(js_name = joinPoll, unchecked_return_type = "Promise<JoinProgress>")]
pub fn join_poll(&self) -> Promise {
self.run(|s| async move { s.core.join_poll().await })
}
/// Forgets the local join state on either side.
#[wasm_bindgen(js_name = joinCancel, unchecked_return_type = "Promise<void>")]
pub fn join_cancel(&self) -> Promise {
self.run(|s| async move {
s.core.join_cancel();
Ok(())
})
}
/// Approving browser (signed in, unlocked): answers a join QR and resolves with the code to
/// compare. Nothing secret is sent until `joinSend`.
#[wasm_bindgen(js_name = joinApprove, unchecked_return_type = "Promise<JoinConfirm>")]
pub fn join_approve(&self, qr: String) -> Promise {
self.run(move |s| async move { s.core.join_approve(&qr).await })
}
/// Approving browser, after the user confirmed the code: sends the sealed setup bundle with a
/// ticket bound to the joining device's key.
#[wasm_bindgen(js_name = joinSend, unchecked_return_type = "Promise<void>")]
pub fn join_send(&self) -> Promise {
self.run(|s| async move { s.core.join_send().await })
}
/// Diagnostics and the cross-platform fixture (`js/smoke.mjs`): the replica entries exactly as
/// IndexedDB holds them (ciphertext, ids, bookkeeping). Not part of the typed API.
#[wasm_bindgen(js_name = __replicaEntries, skip_typescript)]
@@ -662,6 +782,14 @@ pub fn match_url(
to_js(&core::match_url(&item_url, &page_url))
}
/// Validates a scanned or pasted Setup Code (`kestrove://setup/v1?…`). Throws `CoreError` `Invalid`.
#[wasm_bindgen(js_name = parseSetupPayload, unchecked_return_type = "SetupInfo")]
pub fn parse_setup_payload(payload: String) -> std::result::Result<JsValue, JsValue> {
let payload = zeroize::Zeroizing::new(payload);
let info = zeroize::Zeroizing::new(core::parse_setup_payload(&payload).map_err(|e| core_error(&e))?);
to_js(&*info)
}
#[cfg(test)]
mod tests;