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:
2026-10-17 23:58:07 +09:00
co-authored by Claude Opus 5.5
parent f20380d293
commit b5ced32be3
35 changed files with 4488 additions and 363 deletions
+110
View File
@@ -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(&params, 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(&params, &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) })
}
}