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
|
||||
}
|
||||
Reference in New Issue
Block a user