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