Add kv-wasm: the core compiled to WebAssembly for the web vault
New crate crates/kv-wasm runs the same crypto, item model and sync engine in the browser: - WebStore: in-memory replica of kv-store's Store, each changed entry written to IndexedDB (ciphertext only) before the JS call resolves. - Device key: non-extractable WebCrypto ECDSA P-256 key in IndexedDB (raw r||s signatures). - WebCore mirrors kv-ffi's VaultCore; wasm-bindgen surface in camelCase with TypeScript types, plus a Web Worker wrapper (connectVault) so Argon2id never blocks the page. - Tests: Chrome (IndexedDB, WebCrypto, RFC 9421 verify), shared envelope fixtures both ways with kv-ffi, native sync with kv-ffi via the test server, Node smoke of the built package. Small changes to shared crates so they build for wasm32: - kv-store: SqliteStore moved to a native-only module. - kv-sync: rustls/pinning/tokio native only (fetch on wasm); DeviceSigner::sign_async (default wraps sign) used by ApiClient; unix_now() instead of SystemTime/now_utc. - kv-model: Public Suffix List behind a default `psl` feature (kv-ffi keeps it; the web build drops ~0.9 MB of code and matches same-host only). - wasm-release profile (opt-level z, Argon2 at 3) and a wasm CI job. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
//! Minimal IndexedDB access: open, read every replica entry, apply a batch of changes in one
|
||||
//! transaction, and keep the device key record.
|
||||
//!
|
||||
//! Database `kestrove` (or the name passed to `VaultCore.open`), version 1:
|
||||
//! * `kv`: replica entries, string key → `Uint8Array` (ciphertext envelopes and bookkeeping only);
|
||||
//! * `device`: one record `"device"` → `{ deviceId, name, platform, privateKey: CryptoKey, publicKey }`.
|
||||
//! The private key is a non-extractable WebCrypto key: the browser stores it, script never sees its bytes.
|
||||
|
||||
use js_sys::{Array, Promise, Reflect, Uint8Array};
|
||||
use wasm_bindgen::{prelude::*, JsCast};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{
|
||||
IdbDatabase, IdbFactory, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransaction, IdbTransactionMode,
|
||||
};
|
||||
|
||||
use crate::store::Change;
|
||||
|
||||
const VERSION: u32 = 1;
|
||||
pub const KV: &str = "kv";
|
||||
pub const DEVICE: &str = "device";
|
||||
pub const DEVICE_KEY: &str = "device";
|
||||
|
||||
fn factory() -> Result<IdbFactory, JsValue> {
|
||||
let f = Reflect::get(&js_sys::global(), &JsValue::from_str("indexedDB"))?;
|
||||
if f.is_undefined() || f.is_null() {
|
||||
return Err(JsValue::from_str("IndexedDB is not available in this context"));
|
||||
}
|
||||
f.dyn_into::<IdbFactory>()
|
||||
}
|
||||
|
||||
/// Resolves with `request.result` on success, rejects with `request.error`.
|
||||
async fn request(r: &IdbRequest) -> Result<JsValue, JsValue> {
|
||||
let promise = Promise::new(&mut |resolve, reject| {
|
||||
let ok_req = r.clone();
|
||||
let on_ok = Closure::once_into_js(move |_: web_sys::Event| {
|
||||
let _ = resolve.call1(&JsValue::NULL, &ok_req.result().unwrap_or(JsValue::UNDEFINED));
|
||||
});
|
||||
let err_req = r.clone();
|
||||
let on_err = Closure::once_into_js(move |_: web_sys::Event| {
|
||||
let e = err_req.error().ok().flatten().map(JsValue::from).unwrap_or_else(|| "IndexedDB error".into());
|
||||
let _ = reject.call1(&JsValue::NULL, &e);
|
||||
});
|
||||
r.set_onsuccess(Some(on_ok.unchecked_ref()));
|
||||
r.set_onerror(Some(on_err.unchecked_ref()));
|
||||
});
|
||||
JsFuture::from(promise).await
|
||||
}
|
||||
|
||||
/// Resolves when the transaction commits; rejects when it aborts or fails.
|
||||
async fn done(tx: &IdbTransaction) -> Result<(), JsValue> {
|
||||
let promise = Promise::new(&mut |resolve, reject| {
|
||||
let on_ok = Closure::once_into_js(move |_: web_sys::Event| {
|
||||
let _ = resolve.call0(&JsValue::NULL);
|
||||
});
|
||||
let err_tx = tx.clone();
|
||||
let reject2 = reject.clone();
|
||||
let on_err = Closure::once_into_js(move |_: web_sys::Event| {
|
||||
let e = err_tx.error().map(JsValue::from).unwrap_or_else(|| "IndexedDB transaction failed".into());
|
||||
let _ = reject.call1(&JsValue::NULL, &e);
|
||||
});
|
||||
let on_abort = Closure::once_into_js(move |_: web_sys::Event| {
|
||||
let _ = reject2.call1(&JsValue::NULL, &"IndexedDB transaction aborted".into());
|
||||
});
|
||||
tx.set_oncomplete(Some(on_ok.unchecked_ref()));
|
||||
tx.set_onerror(Some(on_err.unchecked_ref()));
|
||||
tx.set_onabort(Some(on_abort.unchecked_ref()));
|
||||
});
|
||||
JsFuture::from(promise).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn open(name: &str) -> Result<IdbDatabase, JsValue> {
|
||||
let req: IdbOpenDbRequest = factory()?.open_with_u32(name, VERSION)?;
|
||||
let upgrade_req = req.clone();
|
||||
let on_upgrade = Closure::once_into_js(move |_: web_sys::Event| {
|
||||
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>()) {
|
||||
let names = db.object_store_names();
|
||||
for store in [KV, DEVICE] {
|
||||
if !names.contains(store) {
|
||||
let _ = db.create_object_store(store);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
req.set_onupgradeneeded(Some(on_upgrade.unchecked_ref()));
|
||||
request(&req).await?.dyn_into::<IdbDatabase>()
|
||||
}
|
||||
|
||||
fn store(db: &IdbDatabase, name: &str, mode: IdbTransactionMode) -> Result<(IdbTransaction, IdbObjectStore), JsValue> {
|
||||
let tx = db.transaction_with_str_and_mode(name, mode)?;
|
||||
let s = tx.object_store(name)?;
|
||||
Ok((tx, s))
|
||||
}
|
||||
|
||||
/// Every replica entry.
|
||||
pub async fn load_entries(db: &IdbDatabase) -> Result<Vec<(String, Vec<u8>)>, JsValue> {
|
||||
let (_tx, s) = store(db, KV, IdbTransactionMode::Readonly)?;
|
||||
let keys: Array = request(&s.get_all_keys()?).await?.dyn_into()?;
|
||||
let values: Array = request(&s.get_all()?).await?.dyn_into()?;
|
||||
if keys.length() != values.length() {
|
||||
return Err(JsValue::from_str("IndexedDB changed while loading"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(keys.length() as usize);
|
||||
for (k, v) in keys.iter().zip(values.iter()) {
|
||||
let k = k.as_string().ok_or_else(|| JsValue::from_str("non-string key in IndexedDB"))?;
|
||||
out.push((k, v.dyn_into::<Uint8Array>()?.to_vec()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Applies changes atomically (one readwrite transaction).
|
||||
pub async fn apply(db: &IdbDatabase, changes: Vec<Change>) -> Result<(), JsValue> {
|
||||
if changes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (tx, s) = store(db, KV, IdbTransactionMode::Readwrite)?;
|
||||
for c in changes {
|
||||
match c {
|
||||
Change::Clear => {
|
||||
s.clear()?;
|
||||
}
|
||||
Change::Put(k, v) => {
|
||||
s.put_with_key(&Uint8Array::from(v.as_slice()), &JsValue::from_str(&k))?;
|
||||
}
|
||||
Change::Delete(k) => {
|
||||
s.delete(&JsValue::from_str(&k))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
done(&tx).await
|
||||
}
|
||||
|
||||
pub async fn get_device(db: &IdbDatabase) -> Result<JsValue, JsValue> {
|
||||
let (_tx, s) = store(db, DEVICE, IdbTransactionMode::Readonly)?;
|
||||
request(&s.get(&JsValue::from_str(DEVICE_KEY))?).await
|
||||
}
|
||||
|
||||
pub async fn put_device(db: &IdbDatabase, record: &JsValue) -> Result<(), JsValue> {
|
||||
let (tx, s) = store(db, DEVICE, IdbTransactionMode::Readwrite)?;
|
||||
s.put_with_key(record, &JsValue::from_str(DEVICE_KEY))?;
|
||||
done(&tx).await
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
//! `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;
|
||||
}
|
||||
|
||||
/** `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[];
|
||||
/** 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; }
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
export type CoreErrorCode =
|
||||
| "Locked" | "NoAccount" | "BadCredentials" | "Invalid" | "NotFound" | "Internal" | "NotConfigured"
|
||||
| "NotAuthenticated" | "PendingApproval" | "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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 { op(s).await.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() })
|
||||
}
|
||||
|
||||
#[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
|
||||
})
|
||||
}
|
||||
|
||||
#[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) -> Promise {
|
||||
self.write(move |s| async move { s.core.register_account(&setup_token).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>,
|
||||
) -> 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).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()) })
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
#[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))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[wasm_bindgen(js_name = coreVersion)]
|
||||
pub fn core_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! The browser's device key: a non-extractable WebCrypto ECDSA P-256 key.
|
||||
//!
|
||||
//! WebCrypto's ECDSA signature is already IEEE P1363 `r || s` (64 bytes), the format the RFC 9421
|
||||
//! profile uses, so no DER conversion is needed. The `CryptoKey` is a JS object, so it stays in a
|
||||
//! thread-local registry keyed by device id (the wasm module runs on one thread; several vaults may
|
||||
//! share it) and the signer itself is plain data, which keeps it `Send + Sync` as
|
||||
//! `kv_sync::DeviceSigner` requires.
|
||||
|
||||
use std::{cell::RefCell, collections::HashMap};
|
||||
|
||||
use js_sys::{Array, Object, Reflect, Uint8Array};
|
||||
use kv_sync::{DeviceSigner, SignFuture, SyncError};
|
||||
use uuid::Uuid;
|
||||
use wasm_bindgen::{prelude::*, JsCast};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Crypto, CryptoKey, EcKeyGenParams, EcdsaParams, SubtleCrypto};
|
||||
|
||||
thread_local! {
|
||||
static PRIVATE_KEYS: RefCell<HashMap<Uuid, CryptoKey>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
fn subtle() -> Result<SubtleCrypto, JsValue> {
|
||||
let crypto: Crypto = Reflect::get(&js_sys::global(), &JsValue::from_str("crypto"))?.dyn_into()?;
|
||||
Ok(crypto.subtle())
|
||||
}
|
||||
|
||||
pub struct WebCryptoSigner {
|
||||
id: Uuid,
|
||||
public_key: Vec<u8>,
|
||||
}
|
||||
|
||||
impl WebCryptoSigner {
|
||||
/// Generates a fresh non-extractable key pair. Returns the signer and the private `CryptoKey`
|
||||
/// (to store in IndexedDB; structured clone keeps it non-extractable).
|
||||
pub async fn generate() -> Result<(Self, CryptoKey), JsValue> {
|
||||
let subtle = subtle()?;
|
||||
let params = EcKeyGenParams::new("ECDSA", "P-256");
|
||||
let usages = Array::of1(&JsValue::from_str("sign"));
|
||||
let pair = JsFuture::from(subtle.generate_key_with_object(¶ms, false, &usages)?).await?;
|
||||
let private: CryptoKey = Reflect::get(&pair, &JsValue::from_str("privateKey"))?.dyn_into()?;
|
||||
let public: CryptoKey = Reflect::get(&pair, &JsValue::from_str("publicKey"))?.dyn_into()?;
|
||||
// Public keys are always exportable; "raw" is the 65-byte SEC1 uncompressed point.
|
||||
let raw = JsFuture::from(subtle.export_key("raw", &public)?).await?;
|
||||
let public_key = Uint8Array::new(&raw).to_vec();
|
||||
let signer = Self { id: Uuid::new_v4(), public_key };
|
||||
signer.activate(private.clone());
|
||||
Ok((signer, private))
|
||||
}
|
||||
|
||||
/// Restores a stored key.
|
||||
pub fn restore(id: Uuid, public_key: Vec<u8>, private: CryptoKey) -> Result<Self, JsValue> {
|
||||
if public_key.len() != 65 || public_key[0] != 4 {
|
||||
return Err(JsValue::from_str("stored device public key is not an uncompressed P-256 point"));
|
||||
}
|
||||
let signer = Self { id, public_key };
|
||||
signer.activate(private);
|
||||
Ok(signer)
|
||||
}
|
||||
|
||||
fn activate(&self, private: CryptoKey) {
|
||||
PRIVATE_KEYS.with(|k| k.borrow_mut().insert(self.id, private));
|
||||
}
|
||||
|
||||
/// Drops this device's key from the registry (wipe, or the vault was freed).
|
||||
pub fn forget(&self) {
|
||||
PRIVATE_KEYS.with(|k| k.borrow_mut().remove(&self.id));
|
||||
}
|
||||
|
||||
/// The IndexedDB record for this key.
|
||||
pub fn record(&self, private: &CryptoKey, name: &str, platform: &str) -> Result<JsValue, JsValue> {
|
||||
let o = Object::new();
|
||||
Reflect::set(&o, &"deviceId".into(), &self.id.to_string().into())?;
|
||||
Reflect::set(&o, &"name".into(), &name.into())?;
|
||||
Reflect::set(&o, &"platform".into(), &platform.into())?;
|
||||
Reflect::set(&o, &"privateKey".into(), private)?;
|
||||
Reflect::set(&o, &"publicKey".into(), &Uint8Array::from(self.public_key.as_slice()))?;
|
||||
Ok(o.into())
|
||||
}
|
||||
|
||||
async fn sign_js(&self, message: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
let key = PRIVATE_KEYS
|
||||
.with(|k| k.borrow().get(&self.id).cloned())
|
||||
.ok_or_else(|| JsValue::from_str("device key is not loaded"))?;
|
||||
let params = EcdsaParams::new("ECDSA", &JsValue::from_str("SHA-256"));
|
||||
let sig = JsFuture::from(subtle()?.sign_with_object_and_u8_array(¶ms, &key, message)?).await?;
|
||||
Ok(Uint8Array::new(&sig).to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
fn js_err(e: JsValue) -> SyncError {
|
||||
SyncError::Signer(e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
|
||||
impl DeviceSigner for WebCryptoSigner {
|
||||
fn device_id(&self) -> Uuid {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn public_key(&self) -> Vec<u8> {
|
||||
self.public_key.clone()
|
||||
}
|
||||
|
||||
fn sign(&self, _message: &[u8]) -> Result<Vec<u8>, SyncError> {
|
||||
Err(SyncError::Signer("WebCrypto signs asynchronously; use sign_async".into()))
|
||||
}
|
||||
|
||||
fn sign_async<'a>(&'a self, message: &'a [u8]) -> SignFuture<'a> {
|
||||
Box::pin(async move { self.sign_js(message).await.map_err(js_err) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! In-browser tests (`wasm-pack test --headless --chrome crates/kv-wasm`). They drive the same
|
||||
//! `VaultCore` methods JavaScript calls (promises, serde conversion, IndexedDB, WebCrypto).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use js_sys::{Promise, Reflect};
|
||||
use serde_json::{json, Value};
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
|
||||
const PW: &str = "correct horse battery staple";
|
||||
|
||||
async fn ok(p: Promise) -> JsValue {
|
||||
match JsFuture::from(p).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("promise rejected: {:?}", Reflect::get(&e, &"message".into()).unwrap_or(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn err_code(p: Promise) -> String {
|
||||
let e = JsFuture::from(p).await.expect_err("expected a rejection");
|
||||
assert_eq!(Reflect::get(&e, &"name".into()).unwrap().as_string().as_deref(), Some("CoreError"));
|
||||
Reflect::get(&e, &"code".into()).unwrap().as_string().unwrap()
|
||||
}
|
||||
|
||||
fn js(v: Value) -> JsValue {
|
||||
to_js(&v).unwrap()
|
||||
}
|
||||
|
||||
fn de<T: DeserializeOwned>(v: JsValue) -> T {
|
||||
serde_wasm_bindgen::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
fn now_ms() -> f64 {
|
||||
js_sys::Date::now()
|
||||
}
|
||||
|
||||
async fn open_db(name: &str) -> VaultCore {
|
||||
VaultCore::build(Some(idb::open(name).await.unwrap())).await.unwrap()
|
||||
}
|
||||
|
||||
fn login(title: &str, password: &str) -> Value {
|
||||
json!({
|
||||
"id": "", "kind": "login", "title": title, "username": "ana", "password": password,
|
||||
"urls": ["https://example.com/login"], "totp": null, "notes": null, "favorite": false, "tags": [],
|
||||
"modifiedAt": 0
|
||||
})
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn account_lifecycle_persists_in_indexeddb() {
|
||||
let db_name = format!("kv-test-{}", Uuid::new_v4());
|
||||
let core = open_db(&db_name).await;
|
||||
assert!(!ok(core.has_account()).await.as_bool().unwrap());
|
||||
let device_id = ok(core.device_id()).await.as_string().unwrap();
|
||||
|
||||
let t = now_ms();
|
||||
let secret_key = ok(core.create_account(PW.into())).await.as_string().unwrap();
|
||||
console_log!("createAccount (Argon2id 64 MiB, t=3, p=4): {:.0} ms", now_ms() - t);
|
||||
assert!(secret_key.starts_with("K1-"));
|
||||
|
||||
let id = ok(core.save_item(js(login("Bank", "hunter2-secret")))).await.as_string().unwrap();
|
||||
// Minimal input: omitted optional fields default.
|
||||
let note = ok(core.save_item(js(json!({"id": "", "kind": "secureNote", "title": "Note", "notes": "n"}))))
|
||||
.await
|
||||
.as_string()
|
||||
.unwrap();
|
||||
let list: Vec<ItemSummary> = de(ok(core.list_items()).await);
|
||||
assert_eq!(list.iter().map(|i| i.title.as_str()).collect::<Vec<_>>(), ["Bank", "Note"]);
|
||||
let mut item: ItemDto = de(ok(core.get_item(id.clone())).await);
|
||||
assert_eq!(item.password.as_deref(), Some("hunter2-secret"));
|
||||
item.password = Some("hunter3-secret".into());
|
||||
ok(core.save_item(to_js(&item).unwrap())).await;
|
||||
let history: Vec<ItemDto> = de(ok(core.item_history(id.clone())).await);
|
||||
assert_eq!(history[0].password.as_deref(), Some("hunter2-secret"));
|
||||
ok(core.delete_item(note)).await;
|
||||
let for_url: Vec<ItemSummary> = de(ok(core.items_for_url("https://example.com/other".into())).await);
|
||||
assert_eq!(for_url.len(), 1);
|
||||
|
||||
// Lock: every read needs the keys again.
|
||||
ok(core.lock()).await;
|
||||
assert!(ok(core.is_locked()).await.as_bool().unwrap());
|
||||
assert_eq!(err_code(core.list_items()).await, "Locked");
|
||||
assert_eq!(err_code(core.unlock("wrong password!".into(), secret_key.clone())).await, "BadCredentials");
|
||||
assert_eq!(err_code(core.save_item(js(json!({"kind": "nope"})))).await, "Invalid");
|
||||
|
||||
// IndexedDB holds ciphertext only.
|
||||
let db = core.shared.db.clone().unwrap();
|
||||
for (key, value) in idb::load_entries(&db).await.unwrap() {
|
||||
let text = String::from_utf8_lossy(&value);
|
||||
for secret in ["hunter2", "hunter3", "Bank", "ana", "example.com", PW] {
|
||||
assert!(!text.contains(secret), "{key} contains {secret}");
|
||||
}
|
||||
}
|
||||
db.close();
|
||||
drop(core);
|
||||
|
||||
// A new page load: same device key, account still there, locked.
|
||||
let core = open_db(&db_name).await;
|
||||
assert_eq!(ok(core.device_id()).await.as_string().unwrap(), device_id);
|
||||
assert!(ok(core.has_account()).await.as_bool().unwrap());
|
||||
assert!(ok(core.is_locked()).await.as_bool().unwrap());
|
||||
let t = now_ms();
|
||||
ok(core.unlock(PW.into(), secret_key.clone())).await;
|
||||
console_log!("unlock (Argon2id 64 MiB): {:.0} ms", now_ms() - t);
|
||||
let item: ItemDto = de(ok(core.get_item(id.clone())).await);
|
||||
assert_eq!(item.password.as_deref(), Some("hunter3-secret"));
|
||||
let list: Vec<ItemSummary> = de(ok(core.list_items()).await);
|
||||
assert_eq!(list.len(), 1);
|
||||
|
||||
// Wipe: a new device, nothing left.
|
||||
ok(core.wipe()).await;
|
||||
assert!(!ok(core.has_account()).await.as_bool().unwrap());
|
||||
assert_ne!(ok(core.device_id()).await.as_string().unwrap(), device_id);
|
||||
let db = core.shared.db.clone().unwrap();
|
||||
assert!(idb::load_entries(&db).await.unwrap().is_empty());
|
||||
db.close();
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn webcrypto_device_key_signs_rfc9421_requests() {
|
||||
let (signer, _private) = WebCryptoSigner::generate().await.unwrap();
|
||||
let pk = kv_sync::DeviceSigner::public_key(&signer);
|
||||
assert_eq!((pk.len(), pk[0]), (65, 4));
|
||||
let url = "https://vault.example.com/v1/vaults";
|
||||
let h = kv_sync::signing::sign_request_async(&signer, "PUT", url, b"{\"a\":1}", "session", 1_700_000_000)
|
||||
.await
|
||||
.unwrap();
|
||||
let lookup = |name: &str| h.pairs().iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string());
|
||||
// The server-side verifier (p256) accepts the WebCrypto signature as is (raw r||s).
|
||||
let id = kv_sync::signing::verify_request(&pk, "PUT", url, b"{\"a\":1}", &lookup).unwrap();
|
||||
assert_eq!(id, kv_sync::DeviceSigner::device_id(&signer));
|
||||
assert!(kv_sync::signing::verify_request(&pk, "PUT", url, b"{}", &lookup).is_err());
|
||||
}
|
||||
|
||||
/// Items written by kv-ffi (native, SQLite) open in the browser.
|
||||
#[wasm_bindgen_test]
|
||||
async fn native_fixture_opens_in_wasm() {
|
||||
let f: Value = serde_json::from_str(include_str!("../../tests/fixtures/native-vault.json")).unwrap();
|
||||
let entries: BTreeMap<String, String> = serde_json::from_value(f["entries"].clone()).unwrap();
|
||||
let store = WebStore::load(entries.into_iter().map(|(k, v)| (k, v.into_bytes()))).unwrap();
|
||||
let core = VaultCore::build_with(store, None).await.unwrap();
|
||||
let (pw, sk) = (f["masterPassword"].as_str().unwrap(), f["secretKey"].as_str().unwrap());
|
||||
ok(core.unlock(pw.into(), sk.into())).await;
|
||||
let expected: Vec<ItemDto> = serde_json::from_value(f["items"].clone()).unwrap();
|
||||
for e in &expected {
|
||||
let got: ItemDto = de(ok(core.get_item(e.id.clone())).await);
|
||||
assert_eq!(&got, e);
|
||||
}
|
||||
let bank = expected.iter().find(|i| i.title == "Bank").unwrap();
|
||||
let history: Vec<ItemDto> = de(ok(core.item_history(bank.id.clone())).await);
|
||||
assert_eq!(history[0].password.as_deref(), Some("first password"));
|
||||
// Edits made here keep working with the native-made keys.
|
||||
let mut edit = bank.clone();
|
||||
edit.notes = Some("edited in the browser".into());
|
||||
ok(core.save_item(to_js(&edit).unwrap())).await;
|
||||
let got: ItemDto = de(ok(core.get_item(bank.id.clone())).await);
|
||||
assert_eq!(got.notes.as_deref(), Some("edited in the browser"));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn in_memory_vault_import_export_and_helpers() {
|
||||
let core: VaultCore = VaultCore::build(None).await.unwrap();
|
||||
assert_eq!(err_code(core.list_items()).await, "NoAccount");
|
||||
assert_eq!(err_code(core.create_account("short".into())).await, "Invalid");
|
||||
ok(core.create_account(PW.into())).await;
|
||||
let csv = b"title,url,username,password,otp,notes\nGit,https://git.example.com,me,pw1,,\n".to_vec();
|
||||
let preview: ImportPreview = de(ok(core.import_preview(csv.clone(), js(json!({"type": "csv"})))).await);
|
||||
assert_eq!((preview.total, preview.logins), (1, 1));
|
||||
let report: ImportReport =
|
||||
de(ok(core.import_commit(csv, js(json!({"type": "csv"})), js(json!({"duplicates": "skip"})))).await);
|
||||
assert_eq!(report.imported, 1);
|
||||
let t = now_ms();
|
||||
let blob = ok(core.export_encrypted("export passphrase".into())).await;
|
||||
console_log!("exportEncrypted (Argon2id): {:.0} ms", now_ms() - t);
|
||||
let blob = js_sys::Uint8Array::new(&blob).to_vec();
|
||||
let fmt = js(json!({"type": "kestroveEncrypted", "passphrase": "export passphrase"}));
|
||||
let preview: ImportPreview = de(ok(core.import_preview(blob, fmt)).await);
|
||||
assert_eq!((preview.total, preview.duplicates), (1, 1));
|
||||
let passkeys: Vec<PasskeySummary> = de(ok(core.list_passkeys()).await);
|
||||
assert!(passkeys.is_empty());
|
||||
assert_eq!(err_code(core.delete_passkey(vec![1, 2, 3])).await, "NotFound");
|
||||
// Not configured yet: network calls fail cleanly.
|
||||
assert_eq!(err_code(core.sync_now()).await, "NotConfigured");
|
||||
assert_eq!(err_code(core.configure_server("http://example.com".into(), None)).await, "Invalid");
|
||||
|
||||
assert_eq!(generate_password(24, true).chars().count(), 24);
|
||||
let code: TotpCode = de(totp_code("JBSWY3DPEHPK3PXP".into(), 59.0).unwrap());
|
||||
assert_eq!((code.code.len(), code.period, code.seconds_remaining), (6, 30, 1));
|
||||
assert_eq!(match_url("https://a.example.com".into(), "https://a.example.com/x".into()).unwrap(), "exact");
|
||||
// No Public Suffix List in the web build: different hosts never match.
|
||||
assert_eq!(match_url("https://a.example.com".into(), "https://b.example.com".into()).unwrap(), "none");
|
||||
}
|
||||
Reference in New Issue
Block a user