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,165 @@
|
||||
//! The web core (compiled natively here, same code as in the browser) syncing with an app device
|
||||
//! (kv-ffi) through kv-sync's in-memory test server: enrollment by approval, both directions of
|
||||
//! edits, deletes, and a restart from the persisted replica.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kv_ffi::{DeviceSigner, PlatformDto};
|
||||
use kv_proto::Platform;
|
||||
use kv_sync::{
|
||||
signing::SoftwareSigner,
|
||||
test_server::{TestServer, SETUP_TOKEN},
|
||||
};
|
||||
use kv_wasm::{
|
||||
dto::{ItemDto, ItemKindDto, LoginResult},
|
||||
store::WebStore,
|
||||
CoreError, WebCore,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
const PW: &str = "a long master password";
|
||||
|
||||
struct Signer(SoftwareSigner);
|
||||
|
||||
impl DeviceSigner for Signer {
|
||||
fn device_id(&self) -> String {
|
||||
kv_sync::DeviceSigner::device_id(&self.0).to_string()
|
||||
}
|
||||
fn public_key(&self) -> Vec<u8> {
|
||||
kv_sync::DeviceSigner::public_key(&self.0)
|
||||
}
|
||||
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, kv_ffi::CoreError> {
|
||||
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| kv_ffi::CoreError::Internal { msg: e.to_string() })
|
||||
}
|
||||
}
|
||||
|
||||
fn web_item(title: &str, password: &str) -> ItemDto {
|
||||
ItemDto {
|
||||
id: String::new(),
|
||||
kind: ItemKindDto::Login,
|
||||
title: title.into(),
|
||||
username: Some("me".into()),
|
||||
password: Some(password.into()),
|
||||
urls: vec!["https://example.com".into()],
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn web_core(store: Arc<WebStore>, url: &str, signer: Arc<SoftwareSigner>) -> WebCore {
|
||||
let core = WebCore::new(store).unwrap();
|
||||
core.configure_server(url, vec![]).unwrap();
|
||||
core.set_device_signer(signer, "Web vault", Platform::Macos);
|
||||
core
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_vault_enrolls_and_syncs_with_an_app_device() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
// The app (kv-ffi) is the first device.
|
||||
let app = kv_ffi::VaultCore::new_in_memory();
|
||||
let sk = app.create_account(PW.into()).unwrap();
|
||||
app.configure_server(server.url(), vec![]).unwrap();
|
||||
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
|
||||
.unwrap();
|
||||
app.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
let mut on_app = kv_ffi::ItemDto {
|
||||
id: String::new(),
|
||||
kind: kv_ffi::ItemKindDto::Login,
|
||||
title: "Bank".into(),
|
||||
username: Some("me".into()),
|
||||
password: Some("from the app".into()),
|
||||
urls: vec![],
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
};
|
||||
let bank = app.save_item(on_app.clone()).unwrap();
|
||||
app.sync_now().await.unwrap();
|
||||
|
||||
// The browser logs in as a new device and waits for approval.
|
||||
server.state().require_approval = true;
|
||||
let store = Arc::new(WebStore::new());
|
||||
let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4()));
|
||||
let web = web_core(store.clone(), &server.url(), signer.clone());
|
||||
assert!(matches!(web.sync_now().await, Err(CoreError::NoAccount | CoreError::Locked)));
|
||||
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::PendingApproval);
|
||||
assert!(!web.has_account());
|
||||
let pending = app.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
|
||||
assert_eq!(pending.key_fingerprint, web.device_key_fingerprint().unwrap());
|
||||
app.approve_device(pending.device_id).await.unwrap();
|
||||
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::Ok);
|
||||
let r = web.sync_now().await.unwrap();
|
||||
assert_eq!((r.pulled, r.vaults_synced), (1, 1));
|
||||
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
|
||||
|
||||
// Browser edit and new item reach the app.
|
||||
let mut edit = web.get_item(&bank).unwrap();
|
||||
edit.notes = Some("edited in the browser".into());
|
||||
web.save_item(edit).unwrap();
|
||||
let new_id = web.save_item(web_item("Mail", "from the web")).unwrap();
|
||||
assert_eq!(web.sync_now().await.unwrap().pushed, 2);
|
||||
app.sync_now().await.unwrap();
|
||||
assert_eq!(app.get_item(bank.clone()).unwrap().notes.as_deref(), Some("edited in the browser"));
|
||||
assert_eq!(app.get_item(new_id.clone()).unwrap().password.as_deref(), Some("from the web"));
|
||||
|
||||
// Concurrent edits of different fields merge.
|
||||
on_app = app.get_item(bank.clone()).unwrap();
|
||||
on_app.password = Some("rotated on the app".into());
|
||||
app.save_item(on_app).unwrap();
|
||||
let mut edit = web.get_item(&bank).unwrap();
|
||||
edit.tags = vec!["web".into()];
|
||||
web.save_item(edit).unwrap();
|
||||
app.sync_now().await.unwrap();
|
||||
let r = web.sync_now().await.unwrap();
|
||||
assert_eq!(r.merged, 1);
|
||||
app.sync_now().await.unwrap();
|
||||
for item in [web.get_item(&bank).unwrap(), {
|
||||
let d = app.get_item(bank.clone()).unwrap();
|
||||
ItemDto { password: d.password, tags: d.tags, ..web.get_item(&bank).unwrap() }
|
||||
}] {
|
||||
assert_eq!(item.password.as_deref(), Some("rotated on the app"));
|
||||
assert_eq!(item.tags, vec!["web".to_string()]);
|
||||
}
|
||||
|
||||
// Delete in the browser; the app sees it.
|
||||
web.delete_item(&new_id).unwrap();
|
||||
web.sync_now().await.unwrap();
|
||||
app.sync_now().await.unwrap();
|
||||
assert!(matches!(app.get_item(new_id), Err(kv_ffi::CoreError::NotFound)));
|
||||
|
||||
// A page reload: the replica comes back from its persisted entries, locked, session kept.
|
||||
let reloaded = Arc::new(WebStore::load(store.entries()).unwrap());
|
||||
let web = web_core(reloaded, &server.url(), signer);
|
||||
assert!(web.has_account() && web.is_locked() && web.is_logged_in());
|
||||
assert!(matches!(web.list_items(), Err(CoreError::Locked)));
|
||||
web.unlock(PW, &sk).unwrap();
|
||||
assert_eq!(web.sync_now().await.unwrap().pulled, 0);
|
||||
assert_eq!(web.list_items().unwrap().len(), 1);
|
||||
|
||||
// Revoked by the app: the browser can no longer sync.
|
||||
let me = web.device_id().unwrap().to_string();
|
||||
app.revoke_device(me).await.unwrap();
|
||||
assert!(web.sync_now().await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_sync_is_refused_not_interleaved() {
|
||||
let server = TestServer::start().await;
|
||||
let web = web_core(Arc::new(WebStore::new()), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4())));
|
||||
let sk = web.create_account(PW).unwrap();
|
||||
web.register_account(SETUP_TOKEN).await.unwrap();
|
||||
web.save_item(web_item("A", "1")).unwrap();
|
||||
let (a, b) = tokio::join!(web.sync_now(), web.sync_now());
|
||||
assert!(matches!((&a, &b), (Ok(_), Err(CoreError::Busy)) | (Err(CoreError::Busy), Ok(_))), "{a:?} {b:?}");
|
||||
assert!(web.sync_now().await.is_ok());
|
||||
web.lock();
|
||||
web.unlock(PW, &sk).unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user