//! `wasm-bindgen` surface: the `VaultCore` class and free functions, mirroring kv-ffi in camelCase. //! //! Every `VaultCore` method returns a `Promise` (writes are flushed to IndexedDB before it //! resolves). Errors reject with an `Error` whose `name` is `"CoreError"` and `code` is the variant. mod idb; mod signer; use std::{cell::RefCell, future::Future, rc::Rc, sync::Arc}; use js_sys::{Promise, Reflect, Uint8Array}; use kv_proto::Platform; use serde::{de::DeserializeOwned, Serialize}; use uuid::Uuid; use wasm_bindgen::{prelude::*, JsCast}; use wasm_bindgen_futures::future_to_promise; use web_sys::{CryptoKey, IdbDatabase}; use self::signer::WebCryptoSigner; use crate::{ core::{self, WebCore}, dto::*, error::{internal, CoreError, Result}, store::WebStore, }; #[wasm_bindgen(typescript_custom_section)] const TS_TYPES: &'static str = r#" export type ItemKind = "login" | "secureNote" | "card" | "identity"; export type MatchLevel = "none" | "domain" | "host" | "exact"; export type Platform = "ios" | "macos" | "android" | "windows" | "linux"; export type LoginResult = "ok" | "pendingApproval"; export interface ItemSummary { id: string; kind: ItemKind; title: string; username: string | null; urls: string[]; favorite: boolean; hasTotp: boolean; tags: string[]; /** "Sign in with Google…" for the site (docs/05 "Sign in with…"). */ signInWith: SignInWith | null; /** Unix seconds. */ modifiedAt: number; } /** `provider` is an id from `signInProviders()` (`google`, `apple`…); `account` the provider account used. */ export interface SignInWith { provider: string; account?: string | null; } export interface SignInProvider { id: string; name: string; } /** Synced theme (docs/02 "App settings"): a theme id such as "ocean", and "system" | "light" | "dark". */ export interface AppThemeDto { theme: string | null; appearance: string | null; } /** `id: ""` creates a new item. Omitted optional fields default to null / [] / false / 0. */ export interface ItemDto { id: string; kind: ItemKind; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null; favorite: boolean; tags: string[]; signInWith: SignInWith | null; /** Unix seconds; set by the core on save. */ modifiedAt: number; } export interface TotpCode { code: string; secondsRemaining: number; period: number; } export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; registration: "invite" | "open" | null; } export interface AccountDetailsDto { email: string | null; isAdmin: boolean; } export interface AdminUserDto { accountId: string; email: string | null; isAdmin: boolean; disabled: boolean; createdAt: string; lastSeen: number | null; devices: number; items: number; storageBytes: number; } export interface InviteDto { inviteId: string; email: string | null; createdAt: number; expiresAt: number; used: boolean; revoked: boolean; } export interface InviteCreatedDto { inviteId: string; code: string; expiresAt: number; } export interface AdminSettingsDto { registration: "invite" | "open"; allowedDomains: string[]; } export interface SyncReport { pulled: number; pushed: number; merged: number; /** Items an edit brought back after a delete elsewhere. Tell the user. */ restoredItemIds: string[]; vaultsSynced: number; vaultsSkipped: number; } export interface DeviceDto { deviceId: string; name: string; platform: Platform; approved: boolean; revoked: boolean; current: boolean; createdAt: string; /** Hex of the first 16 bytes of SHA-256(P-256 public key). Compare on both screens before approving. */ keyFingerprint: string; } export interface PasskeySummary { itemId: string; credentialId: Uint8Array; rpId: string; userHandle: Uint8Array; userName: string; createdAt: number; } export type ImportFormat = | { type: "onePux" } | { type: "csv" } | { type: "kestroveEncrypted"; passphrase: string }; export type DuplicateHandling = "skip" | "addSuffix"; export interface ImportOptions { duplicates: DuplicateHandling; } export interface ImportPreview { total: number; logins: number; secureNotes: number; cards: number; identities: number; duplicates: number; attachmentsSkipped: number; warnings: string[]; } export interface ImportReport { imported: number; skippedDuplicates: number; renamedDuplicates: number; attachmentsSkipped: number; warnings: string[]; 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" | "DeviceRevoked" | "Network" | "PinMismatch" | "Server" | "IntegrityAlarm" | "Busy"; /** What every rejected promise carries. `status` is set for "Server"; `vaultId`/`reason` for "IntegrityAlarm". */ export interface CoreError extends Error { name: "CoreError"; code: CoreErrorCode; status?: number; vaultId?: string; reason?: string; } "#; // ---------------------------------------------------------------- conversions fn to_js(v: &T) -> std::result::Result { let ser = serde_wasm_bindgen::Serializer::new().serialize_missing_as_null(true).serialize_maps_as_objects(true); v.serialize(&ser).map_err(|e| core_error(&internal(e))) } fn from_js(v: JsValue) -> Result { serde_wasm_bindgen::from_value(v).map_err(|e| CoreError::Invalid { msg: e.to_string() }) } fn core_error(e: &CoreError) -> JsValue { let err = js_sys::Error::new(&e.to_string()); err.set_name("CoreError"); let set = |k: &str, v: JsValue| { let _ = Reflect::set(&err, &JsValue::from_str(k), &v); }; set("code", e.code().into()); match e { CoreError::Server { status, .. } => set("status", (*status).into()), CoreError::IntegrityAlarm { vault_id, reason } => { set("vaultId", vault_id.as_str().into()); set("reason", reason.as_str().into()); } _ => {} } err.into() } fn js_internal(e: JsValue) -> CoreError { let msg = e .as_string() .or_else(|| e.dyn_ref::().map(|e| String::from(e.message()))) .or_else(|| e.dyn_ref::().map(|e| e.message())) .unwrap_or_else(|| format!("{e:?}")); CoreError::Internal { msg } } fn platform_name(p: Platform) -> &'static str { match p { Platform::Ios => "ios", Platform::Macos => "macos", Platform::Android => "android", Platform::Windows => "windows", Platform::Linux => "linux", } } fn platform_from_name(s: &str) -> Option { Some(match s { "ios" => Platform::Ios, "macos" => Platform::Macos, "android" => Platform::Android, "windows" => Platform::Windows, "linux" => Platform::Linux, _ => return None, }) } /// The server's platform enum has no "web" yet, so report the OS the browser runs on. fn guess_platform() -> Platform { let ua = Reflect::get(&js_sys::global(), &"navigator".into()) .and_then(|n| Reflect::get(&n, &"userAgent".into())) .ok() .and_then(|u| u.as_string()) .unwrap_or_default(); if ua.contains("iPhone") || ua.contains("iPad") { Platform::Ios } else if ua.contains("Android") { Platform::Android } else if ua.contains("Mac") { Platform::Macos } else if ua.contains("Windows") { Platform::Windows } else { Platform::Linux } } // ---------------------------------------------------------------- VaultCore struct DeviceState { signer: Arc, private: CryptoKey, name: String, platform: Platform, } struct Shared { core: WebCore, db: Option, device: RefCell, } impl Drop for Shared { fn drop(&mut self) { // `free()` from JS: drop the signing key too. Account keys are zeroized by `WebCore`'s drop. self.device.borrow().signer.forget(); if let Some(db) = &self.db { db.close(); } } } impl Shared { /// Writes everything the last operation changed to IndexedDB (no-op for in-memory vaults). async fn persist(&self) -> Result<()> { let changes = self.core.store().take_changes(); match &self.db { Some(db) => idb::apply(db, changes).await.map_err(js_internal), None => Ok(()), } } async fn save_device(&self) -> Result<()> { let Some(db) = &self.db else { return Ok(()) }; let record = { let d = self.device.borrow(); d.signer.record(&d.private, &d.name, platform_name(d.platform)).map_err(js_internal)? }; idb::put_device(db, &record).await.map_err(js_internal) } fn install_device(&self) { let d = self.device.borrow(); self.core.set_device_signer(d.signer.clone(), &d.name, d.platform); } /// docs/03 "Removed devices": the server removed this browser. Replace the WebCrypto key and the device id /// (same name and platform) in IndexedDB, forget the session, keep the vault data. async fn reset_device(&self) -> Result<()> { self.core.forget_revoked_device()?; let mut fresh = new_device().await.map_err(js_internal)?; { let old = self.device.borrow(); old.signer.forget(); fresh.name = old.name.clone(); fresh.platform = old.platform; } *self.device.borrow_mut() = fresh; self.install_device(); self.save_device().await?; self.persist().await } } /// The vault in this browser. Create it with `VaultCore.open()` (IndexedDB) or /// `VaultCore.newInMemory()` (nothing persisted). Run it in a Web Worker: `createAccount`, /// `unlock`, `login` and the encrypted export/import run Argon2id (64 MiB) and block their thread. #[wasm_bindgen] pub struct VaultCore { shared: Rc, } const DEFAULT_DB: &str = "kestrove"; const DEFAULT_DEVICE_NAME: &str = "Web vault"; async fn load_device(db: Option<&IdbDatabase>) -> std::result::Result, JsValue> { let Some(db) = db else { return Ok(None) }; let rec = idb::get_device(db).await?; if rec.is_undefined() || rec.is_null() { return Ok(None); } let get = |k: &str| Reflect::get(&rec, &JsValue::from_str(k)); let id = get("deviceId")?.as_string().and_then(|s| Uuid::parse_str(&s).ok()); let name = get("name")?.as_string().unwrap_or_else(|| DEFAULT_DEVICE_NAME.into()); let platform = get("platform")?.as_string().and_then(|p| platform_from_name(&p)).unwrap_or_else(guess_platform); let private: CryptoKey = get("privateKey")?.dyn_into()?; let public = get("publicKey")?.dyn_into::()?.to_vec(); let id = id.ok_or_else(|| JsValue::from_str("stored device id is not a UUID"))?; let signer = WebCryptoSigner::restore(id, public, private.clone())?; Ok(Some(DeviceState { signer: Arc::new(signer), private, name, platform })) } async fn new_device() -> std::result::Result { let (signer, private) = WebCryptoSigner::generate().await?; Ok(DeviceState { signer: Arc::new(signer), private, name: DEFAULT_DEVICE_NAME.into(), platform: guess_platform() }) } impl VaultCore { async fn build(db: Option) -> Result { let store = match &db { Some(db) => WebStore::load(idb::load_entries(db).await.map_err(js_internal)?)?, None => WebStore::new(), }; Self::build_with(store, db).await } async fn build_with(store: WebStore, db: Option) -> Result { let core = WebCore::new(Arc::new(store))?; let (device, fresh) = match load_device(db.as_ref()).await.map_err(js_internal)? { Some(d) => (d, false), None => (new_device().await.map_err(js_internal)?, true), }; let shared = Rc::new(Shared { core, db, device: RefCell::new(device) }); shared.install_device(); if fresh { shared.save_device().await?; } Ok(VaultCore { shared }) } /// Runs `op` and resolves with its result serialized to JS. fn run(&self, op: F) -> Promise where T: Serialize, F: FnOnce(Rc) -> Fut + 'static, Fut: Future> + 'static, { let s = self.shared.clone(); future_to_promise(async move { let r = op(s.clone()).await; // Any call the server refused as a removed device starts this browser over as a new one. if let Err(CoreError::DeviceRevoked) = r { s.reset_device().await.map_err(|e| core_error(&e))?; } r.map_err(|e| core_error(&e)).and_then(|v| to_js(&v)) }) } /// Like `run`, then flushes changed entries to IndexedDB, even when `op` failed part-way /// (a sync that pulled some changes before failing keeps them). fn write(&self, op: F) -> Promise where T: Serialize, F: FnOnce(Rc) -> Fut + 'static, Fut: Future> + 'static, { self.run(move |s| async move { let r = op(s.clone()).await; let p = s.persist().await; let v = r?; p?; Ok(v) }) } } #[wasm_bindgen] impl VaultCore { /// Opens (or creates) the vault stored in IndexedDB database `dbName` (default `"kestrove"`). /// Creates this browser's device key on first use. #[wasm_bindgen(js_name = open, unchecked_return_type = "Promise")] pub fn open(#[wasm_bindgen(js_name = "dbName")] db_name: Option) -> Promise { future_to_promise(async move { let name = db_name.unwrap_or_else(|| DEFAULT_DB.into()); let db = idb::open(&name).await.map_err(|e| core_error(&js_internal(e)))?; VaultCore::build(Some(db)).await.map(JsValue::from).map_err(|e| core_error(&e)) }) } /// A vault that persists nothing (previews, tests). Its device key lasts until the page closes. #[wasm_bindgen(js_name = newInMemory, unchecked_return_type = "Promise")] pub fn new_in_memory() -> Promise { future_to_promise(async move { VaultCore::build(None).await.map(JsValue::from).map_err(|e| core_error(&e)) }) } // ------------------------------------------------------------ account /// Creates the account and its first vault, leaving it unlocked. Resolves with the Secret Key /// for the Emergency Kit. Runs Argon2id. #[wasm_bindgen(js_name = createAccount, unchecked_return_type = "Promise")] pub fn create_account(&self, #[wasm_bindgen(js_name = "masterPassword")] master_password: String) -> Promise { let pw = zeroize::Zeroizing::new(master_password); self.write(move |s| async move { s.core.create_account(&pw) }) } /// Runs Argon2id. #[wasm_bindgen(unchecked_return_type = "Promise")] pub fn unlock( &self, #[wasm_bindgen(js_name = "masterPassword")] master_password: String, #[wasm_bindgen(js_name = "secretKey")] secret_key: String, ) -> Promise { let (pw, sk) = (zeroize::Zeroizing::new(master_password), zeroize::Zeroizing::new(secret_key)); self.run(move |s| async move { s.core.unlock(&pw, &sk) }) } /// Zeroizes the keyset, vault key and OPAQUE input. Nothing secret was ever persisted. #[wasm_bindgen(unchecked_return_type = "Promise")] pub fn lock(&self) -> Promise { self.run(|s| async move { s.core.lock(); Ok(()) }) } #[wasm_bindgen(js_name = isLocked, unchecked_return_type = "Promise")] pub fn is_locked(&self) -> Promise { self.run(|s| async move { Ok(s.core.is_locked()) }) } #[wasm_bindgen(js_name = hasAccount, unchecked_return_type = "Promise")] pub fn has_account(&self) -> Promise { self.run(|s| async move { Ok(s.core.has_account()) }) } #[wasm_bindgen(js_name = accountId, unchecked_return_type = "Promise")] pub fn account_id(&self) -> Promise { self.run(|s| async move { s.core.account_id() }) } #[wasm_bindgen(js_name = accountFingerprint, unchecked_return_type = "Promise")] pub fn account_fingerprint(&self) -> Promise { self.run(|s| async move { s.core.account_fingerprint() }) } /// Removes this browser's account data, replica, session and device key. The browser becomes a /// new device. Log out first if the server session should end too. #[wasm_bindgen(unchecked_return_type = "Promise")] pub fn wipe(&self) -> Promise { self.write(|s| async move { s.core.wipe()?; s.device.borrow().signer.forget(); let fresh = new_device().await.map_err(js_internal)?; *s.device.borrow_mut() = fresh; s.install_device(); s.save_device().await }) } // ------------------------------------------------------------ items #[wasm_bindgen(js_name = listItems, unchecked_return_type = "Promise")] pub fn list_items(&self) -> Promise { self.run(|s| async move { s.core.list_items() }) } /// The colour theme and appearance synced across this account's devices. #[wasm_bindgen(js_name = appTheme, unchecked_return_type = "Promise")] pub fn app_theme(&self) -> Promise { self.run(|s| async move { s.core.app_theme() }) } #[wasm_bindgen(js_name = setAppTheme, unchecked_return_type = "Promise")] pub fn set_app_theme(&self, theme: Option, appearance: Option) -> Promise { self.run(move |s| async move { s.core.set_app_theme(theme, appearance) }) } #[wasm_bindgen(js_name = getItem, unchecked_return_type = "Promise")] pub fn get_item(&self, id: String) -> Promise { self.run(move |s| async move { s.core.get_item(&id) }) } /// Inserts (`id: ""`) or updates an item; resolves with its id. #[wasm_bindgen(js_name = saveItem, unchecked_return_type = "Promise")] pub fn save_item(&self, #[wasm_bindgen(unchecked_param_type = "ItemDto")] item: JsValue) -> Promise { let item = from_js::(item); self.write(move |s| async move { s.core.save_item(item?) }) } #[wasm_bindgen(js_name = deleteItem, unchecked_return_type = "Promise")] pub fn delete_item(&self, id: String) -> Promise { self.write(move |s| async move { s.core.delete_item(&id) }) } #[wasm_bindgen(js_name = itemHistory, unchecked_return_type = "Promise")] pub fn item_history(&self, id: String) -> Promise { self.run(move |s| async move { s.core.item_history(&id) }) } #[wasm_bindgen(js_name = itemsForUrl, unchecked_return_type = "Promise")] pub fn items_for_url(&self, #[wasm_bindgen(js_name = "pageUrl")] page_url: String) -> Promise { self.run(move |s| async move { s.core.items_for_url(&page_url) }) } // ------------------------------------------------------------ passkeys #[wasm_bindgen(js_name = listPasskeys, unchecked_return_type = "Promise")] pub fn list_passkeys(&self) -> Promise { self.run(|s| async move { s.core.list_passkeys() }) } #[wasm_bindgen(js_name = deletePasskey, unchecked_return_type = "Promise")] pub fn delete_passkey(&self, #[wasm_bindgen(js_name = "credentialId")] credential_id: Vec) -> Promise { self.write(move |s| async move { s.core.delete_passkey(&credential_id) }) } // ------------------------------------------------------------ import / export #[wasm_bindgen(js_name = importPreview, unchecked_return_type = "Promise")] pub fn import_preview( &self, data: Vec, #[wasm_bindgen(unchecked_param_type = "ImportFormat")] format: JsValue, ) -> Promise { let (data, format) = (zeroize::Zeroizing::new(data), from_js::(format)); self.run(move |s| async move { s.core.import_preview(&data, format?) }) } #[wasm_bindgen(js_name = importCommit, unchecked_return_type = "Promise")] pub fn import_commit( &self, data: Vec, #[wasm_bindgen(unchecked_param_type = "ImportFormat")] format: JsValue, #[wasm_bindgen(unchecked_param_type = "ImportOptions")] options: JsValue, ) -> Promise { let data = zeroize::Zeroizing::new(data); let (format, options) = (from_js::(format), from_js::(options)); self.write(move |s| async move { s.core.import_commit(&data, format?, options?) }) } /// Resolves with the `.kvex` file bytes. Runs Argon2id. #[wasm_bindgen(js_name = exportEncrypted, unchecked_return_type = "Promise")] pub fn export_encrypted(&self, passphrase: String) -> Promise { let passphrase = zeroize::Zeroizing::new(passphrase); let s = self.shared.clone(); future_to_promise(async move { let bytes = s.core.export_encrypted(&passphrase).map_err(|e| core_error(&e))?; Ok(Uint8Array::from(bytes.as_slice()).into()) }) } // ------------------------------------------------------------ server and devices /// Use the web vault's own origin (`location.origin`). Pins are accepted for API parity but the /// browser's TLS is what protects the connection. #[wasm_bindgen(js_name = configureServer, unchecked_return_type = "Promise")] pub fn configure_server( &self, url: String, #[wasm_bindgen(js_name = "spkiPins")] spki_pins: Option>, ) -> Promise { self.write(move |s| async move { s.core.configure_server(&url, spki_pins.unwrap_or_default()) }) } /// Name (and optionally platform) the server shows for this browser. Takes effect at the next /// login/enrollment. Default: "Web vault" and the OS from the user agent. #[wasm_bindgen(js_name = setDevice, unchecked_return_type = "Promise")] pub fn set_device( &self, name: String, #[wasm_bindgen(unchecked_optional_param_type = "Platform")] platform: Option, ) -> Promise { self.run(move |s| async move { let platform = match platform { Some(p) => Some(platform_from_name(&p).ok_or(CoreError::Invalid { msg: "unknown platform".into() })?), None => None, }; { let mut d = s.device.borrow_mut(); d.name = name; if let Some(p) = platform { d.platform = p; } } s.install_device(); s.save_device().await }) } /// Replaces this browser's device key and id (the vault does it by itself on `DeviceRevoked`). Local data /// stays; the next login enrols a new device. #[wasm_bindgen(js_name = resetDeviceIdentity, unchecked_return_type = "Promise")] pub fn reset_device_identity(&self) -> Promise { self.run(|s| async move { s.reset_device().await }) } #[wasm_bindgen(js_name = deviceId, unchecked_return_type = "Promise")] pub fn device_id(&self) -> Promise { self.run(|s| async move { s.core.device_id().map(|d| d.to_string()).ok_or(CoreError::NotConfigured) }) } #[wasm_bindgen(js_name = deviceKeyFingerprint, unchecked_return_type = "Promise")] pub fn device_key_fingerprint(&self) -> Promise { self.run(|s| async move { s.core.device_key_fingerprint() }) } #[wasm_bindgen(js_name = serverInfo, unchecked_return_type = "Promise")] pub fn server_info(&self) -> Promise { self.run(|s| async move { s.core.server_info().await }) } /// First device only (the web vault is rarely that): registers the account with a fresh server. #[wasm_bindgen(js_name = registerAccount, unchecked_return_type = "Promise")] pub fn register_account( &self, #[wasm_bindgen(js_name = "setupToken")] setup_token: String, email: Option, ) -> Promise { self.write(move |s| async move { s.core.register_account(&setup_token, email).await }) } /// Logs in (and unlocks). Resolves `"pendingApproval"` until an enrolled device approves this /// browser; then call it again. Runs Argon2id. #[wasm_bindgen(unchecked_return_type = "Promise")] pub fn login( &self, #[wasm_bindgen(js_name = "masterPassword")] master_password: String, #[wasm_bindgen(js_name = "secretKey")] secret_key: String, totp: Option, email: Option, ) -> Promise { let (pw, sk) = (zeroize::Zeroizing::new(master_password), zeroize::Zeroizing::new(secret_key)); self.write(move |s| async move { s.core.login(&pw, &sk, totp, email).await }) } #[wasm_bindgen(js_name = accountDetails, unchecked_return_type = "Promise")] pub fn account_details(&self) -> Promise { self.run(|s| async move { s.core.account_details().await }) } #[wasm_bindgen(js_name = setAccountEmail, unchecked_return_type = "Promise")] pub fn set_account_email(&self, email: String) -> Promise { self.run(move |s| async move { s.core.set_account_email(&email).await }) } #[wasm_bindgen(js_name = adminUsers, unchecked_return_type = "Promise")] pub fn admin_users(&self) -> Promise { self.run(|s| async move { s.core.admin_users().await }) } #[wasm_bindgen(js_name = adminUpdateUser, unchecked_return_type = "Promise")] pub fn admin_update_user( &self, #[wasm_bindgen(js_name = "accountId")] account_id: String, disabled: Option, #[wasm_bindgen(js_name = "isAdmin")] is_admin: Option, ) -> Promise { self.run(move |s| async move { s.core.admin_update_user(&account_id, disabled, is_admin).await }) } #[wasm_bindgen(js_name = adminDeleteUser, unchecked_return_type = "Promise")] pub fn admin_delete_user(&self, #[wasm_bindgen(js_name = "accountId")] account_id: String) -> Promise { self.run(move |s| async move { s.core.admin_delete_user(&account_id).await }) } #[wasm_bindgen(js_name = adminInvites, unchecked_return_type = "Promise")] pub fn admin_invites(&self) -> Promise { self.run(|s| async move { s.core.admin_invites().await }) } #[wasm_bindgen(js_name = adminCreateInvite, unchecked_return_type = "Promise")] pub fn admin_create_invite(&self, email: Option, days: Option) -> Promise { self.run(move |s| async move { s.core.admin_create_invite(email, days).await }) } #[wasm_bindgen(js_name = adminRevokeInvite, unchecked_return_type = "Promise")] pub fn admin_revoke_invite(&self, #[wasm_bindgen(js_name = "inviteId")] invite_id: String) -> Promise { self.run(move |s| async move { s.core.admin_revoke_invite(&invite_id).await }) } #[wasm_bindgen(js_name = adminSettings, unchecked_return_type = "Promise")] pub fn admin_settings(&self) -> Promise { self.run(|s| async move { s.core.admin_settings().await }) } #[wasm_bindgen(js_name = adminPutSettings, unchecked_return_type = "Promise")] pub fn admin_put_settings( &self, registration: String, #[wasm_bindgen(js_name = "allowedDomains")] allowed_domains: Vec, ) -> Promise { self.run(move |s| async move { s.core.admin_put_settings(®istration, allowed_domains).await }) } #[wasm_bindgen(js_name = syncNow, unchecked_return_type = "Promise")] pub fn sync_now(&self) -> Promise { self.write(|s| async move { s.core.sync_now().await }) } #[wasm_bindgen(js_name = listDevices, unchecked_return_type = "Promise")] pub fn list_devices(&self) -> Promise { self.run(|s| async move { s.core.list_devices().await }) } #[wasm_bindgen(js_name = approveDevice, unchecked_return_type = "Promise")] pub fn approve_device(&self, #[wasm_bindgen(js_name = "deviceId")] device_id: String) -> Promise { self.run(move |s| async move { s.core.approve_device(&device_id).await }) } #[wasm_bindgen(js_name = revokeDevice, unchecked_return_type = "Promise")] pub fn revoke_device(&self, #[wasm_bindgen(js_name = "deviceId")] device_id: String) -> Promise { self.run(move |s| async move { s.core.revoke_device(&device_id).await }) } #[wasm_bindgen(unchecked_return_type = "Promise")] pub fn logout(&self) -> Promise { self.write(|s| async move { s.core.logout().await }) } #[wasm_bindgen(js_name = isLoggedIn, unchecked_return_type = "Promise")] pub fn is_logged_in(&self) -> Promise { 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")] 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")] 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")] 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")] 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::(setup), zeroize::Zeroizing::new(master_password)); self.write(move |s| async move { let setup = zeroize::Zeroizing::new(setup?); match s.core.login_with_setup(&setup, &pw).await { // A removed browser holding a ticket: new key and id, then the same login once more, as that new // device (the refused login did not use the ticket up). Err(CoreError::DeviceRevoked) if setup.ticket.is_some() => { s.reset_device().await?; s.core.login_with_setup(&setup, &pw).await } other => other, } }) } /// 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")] 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")] 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")] 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")] 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")] 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)] pub fn replica_entries(&self) -> Promise { self.run(|s| async move { let entries: std::collections::BTreeMap = s .core .store() .entries() .into_iter() .map(|(k, v)| (k, String::from_utf8_lossy(&v).into_owned())) .collect(); Ok(entries) }) } } // ---------------------------------------------------------------- free functions /// The identity providers offered for "Sign in with…", in display order. #[wasm_bindgen(js_name = signInProviders, unchecked_return_type = "SignInProvider[]")] pub fn sign_in_providers() -> std::result::Result { let list: Vec<_> = kv_model::PROVIDERS.iter().map(|(id, name, _)| serde_json::json!({ "id": id, "name": name })).collect(); to_js(&list) } #[wasm_bindgen(js_name = generatePassword)] pub fn generate_password(length: u32, symbols: bool) -> String { core::generate_password(length, symbols) } /// `unixTime` in seconds (e.g. `Math.floor(Date.now() / 1000)`). #[wasm_bindgen(js_name = totpCode, unchecked_return_type = "TotpCode")] pub fn totp_code( #[wasm_bindgen(js_name = "secretOrUri")] secret_or_uri: String, #[wasm_bindgen(js_name = "unixTime")] unix_time: f64, ) -> std::result::Result { if !(unix_time.is_finite() && unix_time >= 0.0) { return Err(core_error(&CoreError::Invalid { msg: "unixTime".into() })); } let code = core::totp_code(&secret_or_uri, unix_time as u64).map_err(|e| core_error(&e))?; to_js(&code) } #[wasm_bindgen(js_name = matchUrl, unchecked_return_type = "MatchLevel")] pub fn match_url( #[wasm_bindgen(js_name = "itemUrl")] item_url: String, #[wasm_bindgen(js_name = "pageUrl")] page_url: String, ) -> std::result::Result { 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 { 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; #[wasm_bindgen(js_name = coreVersion)] pub fn core_version() -> String { env!("CARGO_PKG_VERSION").to_string() }