Files
kestrove-core/crates/kv-wasm/js/smoke.mjs
T
samiandClaude Opus 5.5 b5ced32be3 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]>
2026-10-17 23:58:07 +09:00

150 lines
5.6 KiB
JavaScript

// Node smoke test of the built package (pkg/, `--target web`): the generated JS glue, camelCase
// records, CoreError codes, Argon2id timing. No IndexedDB in Node, so it uses in-memory vaults
// (the IndexedDB path is covered by `wasm-pack test --headless --chrome`).
//
// node crates/kv-wasm/js/smoke.mjs # checks + timing
// node crates/kv-wasm/js/smoke.mjs --write-fixture # also writes tests/fixtures/wasm-vault.json
//
// With a fresh local kestrove-server it also runs a two-device sync round trip, both devices in wasm
// (see crates/kv-ffi/tests/live_server.rs for starting one; it takes ~2 minutes because of the
// server's auth rate limit):
//
// KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=<token> node crates/kv-wasm/js/smoke.mjs
import assert from "node:assert/strict";
import { readFile, writeFile } from "node:fs/promises";
import init, * as kv from "../pkg/kv_wasm.js";
const pkg = new URL("../pkg/", import.meta.url);
await init({ module_or_path: await readFile(new URL("kv_wasm_bg.wasm", pkg)) });
const PW = "wasm fixture master password";
const ms = (t) => `${Math.round(performance.now() - t)} ms`;
async function rejects(promise, code) {
try {
await promise;
} catch (e) {
assert.equal(e.name, "CoreError");
assert.equal(e.code, code, e.message);
return e;
}
assert.fail(`expected ${code}`);
}
function login(title, password, extra = {}) {
return {
id: "",
kind: "login",
title,
username: "[email protected]",
password,
urls: ["https://example.com/login"],
totp: null,
notes: null,
favorite: false,
tags: [],
modifiedAt: 0,
...extra,
};
}
// ------------------------------------------------------------ local checks, Argon2 timing
const a = await kv.VaultCore.newInMemory();
await rejects(a.unlock(PW, "K1-AAAAAA-AAAAA-AAAAA-AAAAA-AAAAA-A"), "NoAccount");
let t = performance.now();
const secretKey = await a.createAccount(PW);
console.log(`createAccount (Argon2id m=64 MiB t=3 p=4): ${ms(t)}`);
await a.lock();
t = performance.now();
await a.unlock(PW, secretKey);
console.log(`unlock (Argon2id m=64 MiB t=3 p=4): ${ms(t)}`);
const bankId = await a.saveItem(
login("Bank", "first password", {
totp: "otpauth://totp/Bank:ana?secret=JBSWY3DPEHPK3PXP&issuer=Bank",
tags: ["finance", "ünïcode ✓"],
favorite: true,
}),
);
const bank = await a.getItem(bankId);
assert.equal(bank.password, "first password");
assert.equal(bank.notes, null);
await a.saveItem({ ...bank, password: "second password" });
await a.saveItem({ id: "", kind: "secureNote", title: "Recovery codes", notes: "multi\nline 🔐" });
const list = await a.listItems();
assert.deepEqual(
list.map((i) => [i.title, i.kind, i.hasTotp]),
[
["Bank", "login", true],
["Recovery codes", "secureNote", false],
],
);
assert.equal((await a.itemHistory(bankId))[0].password, "first password");
await rejects(a.getItem("not-a-uuid"), "Invalid");
const code = kv.totpCode("JBSWY3DPEHPK3PXP", Math.floor(Date.now() / 1000));
assert.match(code.code, /^\d{6}$/);
assert.equal(kv.matchUrl("example.com", "https://example.com/x"), "exact");
assert.equal(kv.generatePassword(20, false).length, 20);
const exported = await a.exportEncrypted("export passphrase");
assert.ok(exported instanceof Uint8Array && exported.length > 100);
if (process.argv.includes("--write-fixture")) {
const items = [];
for (const s of await a.listItems()) items.push(await a.getItem(s.id));
items.sort((x, y) => (x.title < y.title ? -1 : 1));
const fixture = { masterPassword: PW, secretKey, entries: await a.__replicaEntries(), items };
const path = new URL("../tests/fixtures/wasm-vault.json", import.meta.url);
await writeFile(path, JSON.stringify(fixture, null, 2) + "\n");
console.log(`wrote ${path.pathname}`);
}
// ------------------------------------------------------------ live server round trip
const url = process.env.KESTROVE_SERVER_URL;
const token = process.env.KESTROVE_SETUP_TOKEN;
if (url && token) {
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
await a.setDevice("wasm device A", "linux");
await a.configureServer(url, []);
assert.equal((await a.serverInfo()).setupOpen, true, "server must be fresh");
await a.registerAccount(token);
let r = await a.syncNow();
assert.equal(r.pushed, 2);
console.log("A registered and pushed", r);
console.log("waiting 61 s for the server's auth rate limit…");
await sleep(61);
const b = await kv.VaultCore.newInMemory();
await b.setDevice("wasm device B", "macos");
await b.configureServer(url, []);
let res = await b.login(PW, secretKey, null);
if (res === "pendingApproval") {
const pending = (await a.listDevices()).find((d) => !d.approved);
assert.equal(pending.keyFingerprint, await b.deviceKeyFingerprint());
await a.approveDevice(pending.deviceId);
console.log("A approved B; waiting 61 s again…");
await sleep(61);
res = await b.login(PW, secretKey, null);
}
assert.equal(res, "ok");
r = await b.syncNow();
console.log("B synced", r);
const bItems = await b.listItems();
assert.deepEqual(bItems.map((i) => i.title).sort(), ["Bank", "Recovery codes"]);
const onB = await b.getItem(bankId);
assert.equal(onB.password, "second password");
await b.saveItem({ ...onB, notes: "edited on B" });
assert.equal((await b.syncNow()).pushed, 1);
await a.syncNow();
assert.equal((await a.getItem(bankId)).notes, "edited on B");
await b.deleteItem((await b.listItems()).find((i) => i.title === "Recovery codes").id);
await b.syncNow();
await a.syncNow();
assert.deepEqual((await a.listItems()).map((i) => i.title), ["Bank"]);
console.log("live round trip ok");
}
console.log("smoke ok");