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
+11 -4
View File
@@ -21,10 +21,6 @@ rand_core.workspace = true
zeroize.workspace = true
opaque-ke.workspace = true
reqwest.workspace = true
rustls.workspace = true
webpki-roots.workspace = true
rustls-webpki.workspace = true
tokio.workspace = true
ciborium.workspace = true
blake3.workspace = true
p256.workspace = true
@@ -32,6 +28,17 @@ time.workspace = true
url.workspace = true
wiremock = { workspace = true, optional = true }
# Native transport: rustls with SPKI pinning. In the browser (wasm32) reqwest uses fetch and the
# browser's TLS, so none of this applies there.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustls.workspace = true
webpki-roots.workspace = true
rustls-webpki.workspace = true
tokio.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
[features]
# In-memory fake of kestrove-server for tests (also used by kv-ffi's tests).
test-server = ["dep:wiremock"]
+10 -9
View File
@@ -1,9 +1,6 @@
//! Typed HTTP client for the sync API (docs/03, api/openapi.yaml).
use std::{
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
use std::sync::{Arc, Mutex};
use kv_proto::{
AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, ItemPut, ItemRecord, KdfParams, LoginFinish,
@@ -39,10 +36,6 @@ pub struct ApiClient {
session: Mutex<Option<String>>,
}
fn now_unix() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
impl ApiClient {
pub fn new(cfg: &ServerConfig, signer: Arc<dyn DeviceSigner>) -> Result<Self, SyncError> {
Ok(Self {
@@ -88,7 +81,15 @@ impl ApiClient {
}
if signed {
let session = self.session().ok_or(SyncError::NotAuthenticated)?;
let h = signing::sign_request(self.signer.as_ref(), method.as_str(), &url, &body, &session, now_unix())?;
let h = signing::sign_request_async(
self.signer.as_ref(),
method.as_str(),
&url,
&body,
&session,
crate::unix_now(),
)
.await?;
for (k, v) in h.pairs() {
req = req.header(k, v);
}
+6 -4
View File
@@ -4,8 +4,6 @@
//! `If-Match` (on 412: pull, merge, retry), pull again, then publish a new manifest. Every pulled record is
//! checked against the local base for per-item replay ([`crate::replay`]).
use std::time::{SystemTime, UNIX_EPOCH};
use kv_crypto::{
aead::SymKey,
keys::{open_item, seal_item, AccountKeyset, SealedItem},
@@ -48,11 +46,15 @@ pub struct SyncContext<'a> {
}
fn now() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
crate::unix_now() as i64
}
fn rfc3339_now() -> String {
time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339).unwrap_or_default()
// Not `OffsetDateTime::now_utc()`: it panics on wasm32-unknown-unknown.
time::OffsetDateTime::from_unix_timestamp(now())
.ok()
.and_then(|t| t.format(&time::format_description::well_known::Rfc3339).ok())
.unwrap_or_default()
}
fn crypto(e: kv_crypto::CryptoError) -> SyncError {
+31 -2
View File
@@ -1,7 +1,8 @@
//! Sync engine (milestone M1, kestrove-spec/docs/03).
//!
//! Responsibilities:
//! * [`transport`]: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin).
//! * [`transport`]: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin). On wasm32 (the web
//! vault) reqwest uses the browser's `fetch` and TLS instead, and pins do not apply.
//! * [`opaque`]: OPAQUE login via `opaque-ke` (ristretto255 / SHA-512, identity KSF because the input is already
//! Argon2id-stretched).
//! * [`signing`]: RFC 9421 request signing with the device key. The key lives in the platform keystore, so signing is
@@ -24,6 +25,8 @@ pub mod wire;
pub use client::{ApiClient, LoginOutcome};
pub use engine::{SyncContext, SyncReport};
use std::{future::Future, pin::Pin};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
@@ -71,13 +74,39 @@ impl From<kv_store::StoreError> for SyncError {
}
}
/// Implemented by each platform: Secure Enclave, StrongBox, TPM or software fallback.
/// Future returned by [`DeviceSigner::sign_async`]. `Send` on native targets; not on wasm32, where
/// it wraps a WebCrypto promise (the browser runs the core on one thread).
#[cfg(not(target_arch = "wasm32"))]
pub type SignFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<u8>, SyncError>> + Send + 'a>>;
#[cfg(target_arch = "wasm32")]
pub type SignFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<u8>, SyncError>> + 'a>>;
/// Implemented by each platform: Secure Enclave, StrongBox, TPM, WebCrypto or software fallback.
pub trait DeviceSigner: Send + Sync {
fn device_id(&self) -> Uuid;
/// SEC1 uncompressed P-256 public key.
fn public_key(&self) -> Vec<u8>;
/// ECDSA P-256 / SHA-256 signature (IEEE P1363 r||s) over `message`.
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, SyncError>;
/// Asynchronous signing, used by [`ApiClient`]. Defaults to [`DeviceSigner::sign`]; the web vault
/// overrides it because WebCrypto only signs asynchronously.
fn sign_async<'a>(&'a self, message: &'a [u8]) -> SignFuture<'a> {
Box::pin(std::future::ready(self.sign(message)))
}
}
/// Seconds since the Unix epoch. `SystemTime::now()` panics on wasm32-unknown-unknown, so the
/// browser build asks JavaScript's clock.
pub fn unix_now() -> u64 {
#[cfg(target_arch = "wasm32")]
{
(js_sys::Date::now() / 1000.0) as u64
}
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
}
/// Server endpoint plus pinned SPKI SHA-256 hashes (base64).
+57 -14
View File
@@ -53,6 +53,21 @@ pub fn sign_request(
sign_request_with_nonce(signer, method, target_uri, body, session, now_unix, &nonce)
}
/// [`sign_request`] through [`DeviceSigner::sign_async`] (the path [`crate::ApiClient`] uses).
pub async fn sign_request_async(
signer: &dyn DeviceSigner,
method: &str,
target_uri: &str,
body: &[u8],
session: &str,
now_unix: u64,
) -> Result<SignedHeaders, SyncError> {
let nonce = URL_SAFE_NO_PAD.encode(kv_crypto::random_bytes::<16>());
let unsigned = Unsigned::new(signer, method, target_uri, body, session, now_unix, &nonce);
let sig = signer.sign_async(unsigned.base.as_bytes()).await?;
unsigned.finish(&sig)
}
fn sign_request_with_nonce(
signer: &dyn DeviceSigner,
method: &str,
@@ -62,21 +77,49 @@ fn sign_request_with_nonce(
now_unix: u64,
nonce: &str,
) -> Result<SignedHeaders, SyncError> {
let digest = content_digest(body);
let ts = now_unix.to_string();
let params = signature_params(now_unix as i64, signer.device_id(), nonce);
let base = signature_base(&method.to_ascii_uppercase(), target_uri, &digest, session, &ts, &params);
let sig = signer.sign(base.as_bytes())?;
if sig.len() != 64 {
return Err(SyncError::Signer(format!("device signature must be 64 bytes (r||s), got {}", sig.len())));
let unsigned = Unsigned::new(signer, method, target_uri, body, session, now_unix, nonce);
let sig = signer.sign(unsigned.base.as_bytes())?;
unsigned.finish(&sig)
}
/// Everything but the signature: the signature base and the header values around it.
struct Unsigned {
digest: String,
session: String,
ts: String,
params: String,
base: String,
}
impl Unsigned {
fn new(
signer: &dyn DeviceSigner,
method: &str,
target_uri: &str,
body: &[u8],
session: &str,
now_unix: u64,
nonce: &str,
) -> Self {
let digest = content_digest(body);
let ts = now_unix.to_string();
let params = signature_params(now_unix as i64, signer.device_id(), nonce);
let base = signature_base(&method.to_ascii_uppercase(), target_uri, &digest, session, &ts, &params);
Self { digest, session: session.to_string(), ts, params, base }
}
fn finish(self, sig: &[u8]) -> Result<SignedHeaders, SyncError> {
if sig.len() != 64 {
return Err(SyncError::Signer(format!("device signature must be 64 bytes (r||s), got {}", sig.len())));
}
Ok(SignedHeaders {
content_digest: self.digest,
session: self.session,
timestamp: self.ts,
signature_input: signature_input_header(&self.params),
signature: signature_header(sig),
})
}
Ok(SignedHeaders {
content_digest: digest,
session: session.to_string(),
timestamp: ts,
signature_input: signature_input_header(&params),
signature: signature_header(&sig),
})
}
/// Software P-256 device key: for tests and as the desktop fallback when no TPM is available.
+24
View File
@@ -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);