One encrypted settings item per account (a secure note carrying app_settings, at a fixed per-account id) holds the theme and light/dark appearance, so it syncs like any item and the server can't read it. Lists hide it; older apps see a note that explains itself. app_theme / set_app_theme in kv-ffi and kv-wasm. Also fixes a flaky test that checked random ciphertext for the letters K1. Co-Authored-By: Claude Opus 5.5 <[email protected]>
939 lines
37 KiB
Rust
939 lines
37 KiB
Rust
//! `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<T: Serialize>(v: &T) -> std::result::Result<JsValue, JsValue> {
|
|
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<T: DeserializeOwned>(v: JsValue) -> Result<T> {
|
|
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::<js_sys::Error>().map(|e| String::from(e.message())))
|
|
.or_else(|| e.dyn_ref::<web_sys::DomException>().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<Platform> {
|
|
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<WebCryptoSigner>,
|
|
private: CryptoKey,
|
|
name: String,
|
|
platform: Platform,
|
|
}
|
|
|
|
struct Shared {
|
|
core: WebCore,
|
|
db: Option<IdbDatabase>,
|
|
device: RefCell<DeviceState>,
|
|
}
|
|
|
|
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<Shared>,
|
|
}
|
|
|
|
const DEFAULT_DB: &str = "kestrove";
|
|
const DEFAULT_DEVICE_NAME: &str = "Web vault";
|
|
|
|
async fn load_device(db: Option<&IdbDatabase>) -> std::result::Result<Option<DeviceState>, 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::<Uint8Array>()?.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<DeviceState, JsValue> {
|
|
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<IdbDatabase>) -> Result<VaultCore> {
|
|
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<IdbDatabase>) -> Result<VaultCore> {
|
|
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<T, F, Fut>(&self, op: F) -> Promise
|
|
where
|
|
T: Serialize,
|
|
F: FnOnce(Rc<Shared>) -> Fut + 'static,
|
|
Fut: Future<Output = Result<T>> + '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<T, F, Fut>(&self, op: F) -> Promise
|
|
where
|
|
T: Serialize,
|
|
F: FnOnce(Rc<Shared>) -> Fut + 'static,
|
|
Fut: Future<Output = Result<T>> + '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<VaultCore>")]
|
|
pub fn open(#[wasm_bindgen(js_name = "dbName")] db_name: Option<String>) -> 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<VaultCore>")]
|
|
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<string>")]
|
|
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<void>")]
|
|
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<void>")]
|
|
pub fn lock(&self) -> Promise {
|
|
self.run(|s| async move {
|
|
s.core.lock();
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
#[wasm_bindgen(js_name = isLocked, unchecked_return_type = "Promise<boolean>")]
|
|
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<boolean>")]
|
|
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<string>")]
|
|
pub fn account_id(&self) -> Promise {
|
|
self.run(|s| async move { s.core.account_id() })
|
|
}
|
|
|
|
#[wasm_bindgen(js_name = accountFingerprint, unchecked_return_type = "Promise<string>")]
|
|
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<void>")]
|
|
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<ItemSummary[]>")]
|
|
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<AppThemeDto>")]
|
|
pub fn app_theme(&self) -> Promise {
|
|
self.run(|s| async move { s.core.app_theme() })
|
|
}
|
|
|
|
#[wasm_bindgen(js_name = setAppTheme, unchecked_return_type = "Promise<void>")]
|
|
pub fn set_app_theme(&self, theme: Option<String>, appearance: Option<String>) -> Promise {
|
|
self.run(move |s| async move { s.core.set_app_theme(theme, appearance) })
|
|
}
|
|
|
|
#[wasm_bindgen(js_name = getItem, unchecked_return_type = "Promise<ItemDto>")]
|
|
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<string>")]
|
|
pub fn save_item(&self, #[wasm_bindgen(unchecked_param_type = "ItemDto")] item: JsValue) -> Promise {
|
|
let item = from_js::<ItemDto>(item);
|
|
self.write(move |s| async move { s.core.save_item(item?) })
|
|
}
|
|
|
|
#[wasm_bindgen(js_name = deleteItem, unchecked_return_type = "Promise<void>")]
|
|
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<ItemDto[]>")]
|
|
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<ItemSummary[]>")]
|
|
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<PasskeySummary[]>")]
|
|
pub fn list_passkeys(&self) -> Promise {
|
|
self.run(|s| async move { s.core.list_passkeys() })
|
|
}
|
|
|
|
#[wasm_bindgen(js_name = deletePasskey, unchecked_return_type = "Promise<void>")]
|
|
pub fn delete_passkey(&self, #[wasm_bindgen(js_name = "credentialId")] credential_id: Vec<u8>) -> Promise {
|
|
self.write(move |s| async move { s.core.delete_passkey(&credential_id) })
|
|
}
|
|
|
|
// ------------------------------------------------------------ import / export
|
|
|
|
#[wasm_bindgen(js_name = importPreview, unchecked_return_type = "Promise<ImportPreview>")]
|
|
pub fn import_preview(
|
|
&self,
|
|
data: Vec<u8>,
|
|
#[wasm_bindgen(unchecked_param_type = "ImportFormat")] format: JsValue,
|
|
) -> Promise {
|
|
let (data, format) = (zeroize::Zeroizing::new(data), from_js::<ImportFormat>(format));
|
|
self.run(move |s| async move { s.core.import_preview(&data, format?) })
|
|
}
|
|
|
|
#[wasm_bindgen(js_name = importCommit, unchecked_return_type = "Promise<ImportReport>")]
|
|
pub fn import_commit(
|
|
&self,
|
|
data: Vec<u8>,
|
|
#[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::<ImportFormat>(format), from_js::<ImportOptions>(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<Uint8Array>")]
|
|
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<void>")]
|
|
pub fn configure_server(
|
|
&self,
|
|
url: String,
|
|
#[wasm_bindgen(js_name = "spkiPins")] spki_pins: Option<Vec<String>>,
|
|
) -> 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<void>")]
|
|
pub fn set_device(
|
|
&self,
|
|
name: String,
|
|
#[wasm_bindgen(unchecked_optional_param_type = "Platform")] platform: Option<String>,
|
|
) -> 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<void>")]
|
|
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<string>")]
|
|
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<string>")]
|
|
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<ServerInfoDto>")]
|
|
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<void>")]
|
|
pub fn register_account(
|
|
&self,
|
|
#[wasm_bindgen(js_name = "setupToken")] setup_token: String,
|
|
email: Option<String>,
|
|
) -> 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<LoginResult>")]
|
|
pub fn login(
|
|
&self,
|
|
#[wasm_bindgen(js_name = "masterPassword")] master_password: String,
|
|
#[wasm_bindgen(js_name = "secretKey")] secret_key: String,
|
|
totp: Option<String>,
|
|
email: Option<String>,
|
|
) -> 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<AccountDetailsDto>")]
|
|
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<void>")]
|
|
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<AdminUserDto[]>")]
|
|
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<void>")]
|
|
pub fn admin_update_user(
|
|
&self,
|
|
#[wasm_bindgen(js_name = "accountId")] account_id: String,
|
|
disabled: Option<bool>,
|
|
#[wasm_bindgen(js_name = "isAdmin")] is_admin: Option<bool>,
|
|
) -> 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<void>")]
|
|
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<InviteDto[]>")]
|
|
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<InviteCreatedDto>")]
|
|
pub fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Promise {
|
|
self.run(move |s| async move { s.core.admin_create_invite(email, days).await })
|
|
}
|
|
|
|
#[wasm_bindgen(js_name = adminRevokeInvite, unchecked_return_type = "Promise<void>")]
|
|
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<AdminSettingsDto>")]
|
|
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<AdminSettingsDto>")]
|
|
pub fn admin_put_settings(
|
|
&self,
|
|
registration: String,
|
|
#[wasm_bindgen(js_name = "allowedDomains")] allowed_domains: Vec<String>,
|
|
) -> 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<SyncReport>")]
|
|
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<DeviceDto[]>")]
|
|
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<void>")]
|
|
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<void>")]
|
|
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<void>")]
|
|
pub fn logout(&self) -> Promise {
|
|
self.write(|s| async move { s.core.logout().await })
|
|
}
|
|
|
|
#[wasm_bindgen(js_name = isLoggedIn, unchecked_return_type = "Promise<boolean>")]
|
|
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<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?);
|
|
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<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)]
|
|
pub fn replica_entries(&self) -> Promise {
|
|
self.run(|s| async move {
|
|
let entries: std::collections::BTreeMap<String, String> = 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<JsValue, JsValue> {
|
|
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<JsValue, JsValue> {
|
|
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<JsValue, JsValue> {
|
|
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;
|
|
|
|
#[wasm_bindgen(js_name = coreVersion)]
|
|
pub fn core_version() -> String {
|
|
env!("CARGO_PKG_VERSION").to_string()
|
|
}
|