Files
kestrove-core/crates/kv-wasm/src/wasm/idb.rs
T
samiandClaude Opus 5.5 b5ced32be3 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]>
2026-10-17 23:58:07 +09:00

142 lines
5.7 KiB
Rust

//! 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
}