Mirrors kv-ffi's docs/10 §5 API in WebCore and binds it under the names the web app codes against: setupPayloadForKit, addDevicePayload, revokeEnrollmentTicket, loginWithSetup, joinStart/joinPoll/joinCancel, joinApprove, joinSend, and the free function parseSetupPayload (also callable through the worker client). Unlike kv-ffi, the Secret Key is not persisted: every unlock on the web takes it, so it is kept zeroized in the unlocked state and dropped on lock. loginWithSetup refuses a code for a different server than the configured origin (the browser only talks to its own origin). Tests: ticket enrollment and scan-to-join in both roles against kv-ffi via the kv-sync test server, a swapped-approver rejection, a Chrome test (IndexedDB never holds the Secret Key, JS shapes, error codes) and a Node smoke check of the built package. Co-Authored-By: Claude Opus 5.5 <[email protected]>
167 lines
6.2 KiB
JavaScript
167 lines
6.2 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");
|
|
assert.deepEqual(list[0].tags, ["finance", "ünïcode ✓"]);
|
|
assert.ok(list[0].modifiedAt >= bank.modifiedAt);
|
|
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);
|
|
|
|
// Onboarding (docs/10): the Kit's Setup Code round-trips through the free parser.
|
|
const k = await kv.VaultCore.newInMemory();
|
|
const kSecret = await k.createAccount(PW);
|
|
await k.configureServer("https://vault.example.org", []);
|
|
const setup = kv.parseSetupPayload(await k.setupPayloadForKit());
|
|
assert.deepEqual(setup, {
|
|
serverUrl: "https://vault.example.org",
|
|
spkiPins: [],
|
|
accountId: await k.accountId(),
|
|
secretKey: kSecret,
|
|
ticket: null,
|
|
});
|
|
await rejects(k.joinPoll(), "Invalid");
|
|
k.free();
|
|
|
|
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");
|