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:
@@ -7,13 +7,18 @@
|
||||
//! a primary and a backup pin so a key rotation never locks the client out.
|
||||
//!
|
||||
//! Plain `http://` is accepted only for loopback hosts (local development and tests).
|
||||
//!
|
||||
//! On wasm32 (the web vault) reqwest sends through the browser's `fetch`: the browser does TLS and the
|
||||
//! page is served from the same origin as the API, so pinning and the rustls config are native only.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::{
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
Engine,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use rustls::{
|
||||
client::{
|
||||
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||
@@ -22,6 +27,7 @@ use rustls::{
|
||||
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||
CertificateError, DigitallySignedStruct, OtherError, RootCertStore, SignatureScheme,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{ServerConfig, SyncError};
|
||||
@@ -48,22 +54,26 @@ pub fn parse_pin(pin: &str) -> Result<[u8; 32], SyncError> {
|
||||
raw.try_into().map_err(|_| SyncError::Config(format!("pin is not a SHA-256 hash: {pin}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// `base64(SHA-256(SPKI))` of a DER certificate. Useful for tooling and tests.
|
||||
pub fn spki_pin_of(cert_der: &[u8]) -> Result<String, SyncError> {
|
||||
Ok(STANDARD.encode(spki_hash(&CertificateDer::from(cert_der))?))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spki_hash(cert: &CertificateDer<'_>) -> Result<[u8; 32], SyncError> {
|
||||
let parsed = webpki::EndEntityCert::try_from(cert).map_err(|e| SyncError::Config(format!("certificate: {e}")))?;
|
||||
Ok(Sha256::digest(parsed.subject_public_key_info().as_ref()).into())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Debug)]
|
||||
struct PinningVerifier {
|
||||
inner: Arc<WebPkiServerVerifier>,
|
||||
pins: Vec<[u8; 32]>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl ServerCertVerifier for PinningVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
@@ -112,6 +122,7 @@ impl ServerCertVerifier for PinningVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Builds the rustls config: TLS 1.3 only, ring provider, Mozilla roots plus `extra_roots`, pin check.
|
||||
pub fn tls_config(pins: &[String], extra_roots: &[Vec<u8>]) -> Result<rustls::ClientConfig, SyncError> {
|
||||
let pins = pins.iter().map(|p| parse_pin(p)).collect::<Result<Vec<_>, _>>()?;
|
||||
@@ -156,6 +167,7 @@ pub fn normalize_base_url(raw: &str) -> Result<String, SyncError> {
|
||||
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn http_client(cfg: &ServerConfig) -> Result<reqwest::Client, SyncError> {
|
||||
reqwest::Client::builder()
|
||||
.use_preconfigured_tls(tls_config(&cfg.spki_pins, &cfg.extra_roots_der)?)
|
||||
@@ -167,6 +179,18 @@ pub fn http_client(cfg: &ServerConfig) -> Result<reqwest::Client, SyncError> {
|
||||
.map_err(|e| SyncError::Config(e.to_string()))
|
||||
}
|
||||
|
||||
/// Browser `fetch`: TLS, timeouts and certificate checks belong to the browser. Pins are ignored.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn http_client(_cfg: &ServerConfig) -> Result<reqwest::Client, SyncError> {
|
||||
reqwest::Client::builder().build().map_err(|e| SyncError::Config(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn classify(e: reqwest::Error) -> SyncError {
|
||||
SyncError::Network(e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Maps a reqwest error to `PinMismatch` when the handshake failed on our pin check.
|
||||
pub(crate) fn classify(e: reqwest::Error) -> SyncError {
|
||||
let mut src: Option<&(dyn std::error::Error + 'static)> = Some(&e);
|
||||
|
||||
Reference in New Issue
Block a user