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