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
+78
View File
@@ -181,3 +181,81 @@ pub struct ImportReport {
pub warnings: Vec<String>,
pub item_ids: Vec<String>,
}
// ---------------------------------------------------------------- onboarding (docs/10)
/// A parsed, validated setup payload: everything a new device needs except the master password.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupInfo {
/// Normalized server URL.
pub server_url: String,
/// Standard base64 SPKI pins, the format `configure_server` takes. Empty = WebPKI only.
#[serde(default)]
pub spki_pins: Vec<String>,
pub account_id: String,
/// Display form `K1-…`.
pub secret_key: String,
/// Enrollment ticket; `login_with_setup` presents it so no manual approval is needed.
#[serde(default)]
pub ticket: Option<String>,
}
impl zeroize::Zeroize for SetupInfo {
fn zeroize(&mut self) {
self.secret_key.zeroize();
self.ticket.zeroize();
}
}
impl std::fmt::Debug for SetupInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SetupInfo")
.field("server_url", &self.server_url)
.field("spki_pins", &self.spki_pins)
.field("account_id", &self.account_id)
.field("secret_key", &"<redacted>")
.field("ticket", &self.ticket.as_ref().map(|_| "<redacted>"))
.finish()
}
}
/// The "Add a device" QR: a setup payload with a fresh, unbound ticket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddDevicePayload {
pub payload: String,
/// For `revoke_enrollment_ticket` when the user cancels.
pub ticket_id: String,
/// Unix seconds; hide the QR after this.
pub expires_at: i64,
}
/// Shown by the joining device: a QR for a signed-in phone to scan.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JoinSession {
pub qr: String,
/// Unix seconds.
pub expires_at: i64,
}
/// Result of `join_poll`: `code` once a phone has scanned the QR, `setup` once it sent the bundle.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JoinProgress {
pub code: Option<String>,
pub setup: Option<SetupInfo>,
}
/// Shown by the approving device after scanning: the code to compare and who is asking.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JoinConfirm {
/// `"123 456"`. The joining device shows the same code.
pub code: String,
pub device_name: String,
pub platform: PlatformDto,
/// Same format as `DeviceDto.key_fingerprint`.
pub key_fingerprint: String,
}