/* End-to-end encryption in real browsers, against a real server, with several devices at once. REPO=/path/to/Wally PYTHON=/path/to/python PORT=8897 node vault.mjs What the server stores is checked from outside with tests/vault_check.py, which opens it with a second implementation of the crypto. Screenshots go to SHOTS (a folder) when it is set. */ import { chromium } from "playwright"; import { spawn, execFileSync, spawnSync } from "node:child_process"; import { mkdtempSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash, createHmac } from "node:crypto"; const REPO = process.env.REPO || process.cwd(); const PYTHON = process.env.PYTHON || "python3"; const PORT = +(process.env.PORT || 8897); const SHOTS = process.env.SHOTS || ""; if (SHOTS && !existsSync(SHOTS)) mkdirSync(SHOTS, { recursive: true }); const PAUSE = 6; let failures = 0; const ok = (cond, msg) => { if (cond) console.log(" ok " + msg); else { failures++; console.log(" FAIL " + msg); } }; const section = (s) => console.log("\n" + s); /* ---------- the server ---------- */ let server = null; const data = mkdtempSync(join(tmpdir(), "wally-vault-")); const DB = join(data, "wally.db"); const base = "http://localhost:" + PORT; async function startServer() { const env = { ...process.env, WALLY_DATA: data, WALLY_PAUSE_SECONDS: String(PAUSE) }; delete env.DEMO; server = spawn(PYTHON, ["-m", "uvicorn", "utils.dev_server:app", "--port", String(PORT)], { cwd: REPO, env, stdio: ["ignore", "ignore", "pipe"] }); server.stderr.on("data", (d) => { if (/Traceback|ERROR/.test(String(d))) process.stderr.write("[server] " + d); }); for (let i = 0; i < 80; i++) { try { const r = await fetch(base + "/api/health"); if (r.ok) return; } catch (e) { /* not yet */ } await new Promise((r) => setTimeout(r, 200)); } throw new Error("server did not start"); } async function stopServer(signal = "SIGTERM") { if (!server) return; const done = new Promise((r) => server.once("exit", r)); server.kill(signal); await done; server = null; } const digest = () => createHash("sha256").update(readFileSync(DB)).digest("hex"); /* What whoever runs the server types when the kit is lost too: python3 -m api.signin_off */ const signinOff = () => { const r = spawnSync(PYTHON, ["-m", "api.signin_off"], { cwd: REPO, env: { ...process.env, WALLY_DATA: data }, encoding: "utf8" }); return { code: r.status, text: (r.stdout || "") + (r.stderr || "") }; }; /* The plain kit, checked by the server (no browser needed) */ const plainRecover = async (person_id, key) => (await fetch(base + "/api/kit/recover", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ person_id, key }) })).status; const check = (...args) => JSON.parse(execFileSync(PYTHON, [join(REPO, "tests/vault_check.py"), ...args], { cwd: REPO, encoding: "utf8", maxBuffer: 1 << 28 })); /* A client of the API with its own cookies, for setting things up */ function apiClient() { let cookie = ""; const fn = async (method, path, body, headers) => { const r = await fetch(base + "/api" + path, { method, headers: { "content-type": "application/json", cookie, ...(headers || {}) }, body: body === undefined ? undefined : JSON.stringify(body) }); for (const c of r.headers.getSetCookie ? r.headers.getSetCookie() : []) { const kv = c.split(";")[0], k = kv.split("=")[0]; cookie = cookie.split("; ").filter((x) => x && !x.startsWith(k + "=")).concat(/=$/.test(kv) || /Max-Age=0/i.test(c) ? [] : [kv]).join("; "); } const text = await r.text(); let out; try { out = JSON.parse(text); } catch (e) { out = text; } if (!r.ok) { const e = new Error(method + " " + path + " " + r.status + " " + text); e.status = r.status; throw e; } return out; }; return fn; } /* ---------- browsers ---------- */ const browser = await chromium.launch(); const EXPECTED = /status of (400|401|403|404|409|410|429|503)/; async function device({ width = 1280, height = 860, clock } = {}) { const ctx = await browser.newContext({ viewport: { width, height }, serviceWorkers: "block" }); const page = await ctx.newPage(); if (clock) await page.clock.install({ time: clock }); page.errors = []; page.on("console", (m) => { if (m.type() === "error" && !EXPECTED.test(m.text())) page.errors.push(m.text()); }); page.on("pageerror", (e) => page.errors.push("PAGEERROR " + e.message)); page.on("dialog", (d) => d.dismiss().catch(() => {})); return page; } const shot = async (page, name) => { if (SHOTS) { await page.waitForTimeout(300); await page.screenshot({ path: join(SHOTS, name + ".png") }); } }; const appShown = (page, timeout = 15000) => page.waitForSelector("#app:not([hidden])", { timeout }).then(() => true, () => false); // Unlock: their face and name, the password field and Forgot password?, as at sign-in const unlockShown = (page, timeout = 15000) => page.waitForFunction(() => !document.getElementById("gate").hidden && document.getElementById("vg-forgot") && document.querySelector("#gate input#vg-in[type=password]"), null, { timeout }).then(() => true, () => false); const gateShown = (page, text, timeout = 15000) => page.waitForFunction((t) => !document.getElementById("gate").hidden && document.getElementById("gate").innerText.includes(t), text, { timeout }).then(() => true, () => false); // Back at sign-in: their face and name, and the password field const atPassword = (page, name, timeout = 15000) => page.waitForFunction((n) => !document.getElementById("gate").hidden && document.getElementById("si-in") && document.getElementById("gate-h").textContent === n, name, { timeout }).then(() => true, () => false); const noErrors = (page, what) => ok(page.errors.length === 0, what + ": no console errors" + (page.errors.length ? " — " + page.errors.join(" | ") : "")); /* Run in the page, with core.js and the vault session, the same module instances the app uses */ const inPage = (page, fn, arg) => page.evaluate(async ([src, a]) => { const core = await import("/js/core.js"); const session = await import("/js/vault/session.js"); const store = await import("/js/vault/store.js"); return (0, eval)("(" + src + ")")({ core, session, store }, a); }, [fn.toString(), arg]); /* Wait until fn (run in the page) returns true */ async function until(page, fn, arg, timeout = 10000) { const end = Date.now() + timeout; while (Date.now() < end) { if (await inPage(page, fn, arg).catch(() => false)) return true; await page.waitForTimeout(150); } return false; } const ledger = (page) => inPage(page, async ({ core }) => (await core.GET("/ledger")).rows.map((r) => JSON.stringify(r)).sort()); const syncPage = (page) => inPage(page, async ({ session }) => session.syncNow()); const hasKeys = (page) => page.evaluate(() => new Promise((resolve) => { const r = indexedDB.open("wally-vault"); r.onupgradeneeded = () => { r.transaction.abort(); resolve(false); }; r.onerror = () => resolve(false); r.onsuccess = () => { const d = r.result; if (!d.objectStoreNames.contains("keys")) { d.close(); return resolve(false); } const g = d.transaction("keys").objectStore("keys").get("household"); g.onsuccess = () => { d.close(); resolve(!!g.result); }; g.onerror = () => { d.close(); resolve(false); }; }; })); async function signIn(page, personId, password) { await page.goto(base + "/", { waitUntil: "networkidle" }); await page.waitForSelector("#si-in, [data-prof]", { timeout: 10000 }); if (await page.locator('[data-prof="' + personId + '"]').count()) await page.click('[data-prof="' + personId + '"]'); else { const name = personId === 1 ? "Alex" : "Ana"; if (!(await page.locator("#gate").innerText()).includes(name)) { await page.click("#si-not"); await page.click('[data-prof="' + personId + '"]'); } } await page.fill("#si-in", password); await page.click("#si-go"); } /* Two-step codes, as an authenticator app works them out (RFC 6238) */ function base32(str) { const abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; let bits = ""; for (const ch of str.replace(/[\s=]/g, "").toUpperCase()) bits += abc.indexOf(ch).toString(2).padStart(5, "0"); const out = []; for (let i = 0; i + 8 <= bits.length; i += 8) out.push(parseInt(bits.slice(i, i + 8), 2)); return Buffer.from(out); } function totpAt(secret, counter) { const buf = Buffer.alloc(8); buf.writeBigUInt64BE(BigInt(counter)); const h = createHmac("sha1", base32(secret)).update(buf).digest(), o = h[h.length - 1] & 15; return String(((h.readUInt32BE(o) & 0x7fffffff) % 1e6)).padStart(6, "0"); } /* The server takes each 30-second code once for the whole household: the next one it will take */ let lastCounter = -1; async function freshCode(secret) { for (;;) { const now = Math.floor(Date.now() / 30000); for (const c of [now, now + 1]) if (c > lastCounter) { lastCounter = c; return totpAt(secret, c); } await new Promise((r) => setTimeout(r, 1000)); } } const HP1 = "house-pass-1", HP2 = "house-pass-2"; const TODAY = new Date().toISOString().slice(0, 10); const daysAgo = (n) => new Date(Date.now() - n * 864e5).toISOString().slice(0, 10); const WORDS = ["Mercadona", "Farmacia", "Therapy", "Café", "AMAZON", "Netflix", "holiday", "weekly", "Kids", "Fruiteria", "23.45"]; try { await startServer(); /* ================= a plain household, with some of everything ================= */ section("A plain household"); const setup = apiClient(); const ALEX = (await setup("POST", "/people", { name: "Alex" })).id; const ANA = (await setup("POST", "/people", { name: "Ana" })).id; const as = { "x-wally-person": String(ALEX) }; await setup("POST", "/transactions", { name: "Mercadona", category: "Groceries", amount: "23.45", date: daysAgo(2), paid_by: ALEX, note: "Café y pan", tags: ["weekly"] }, as); await setup("POST", "/transactions", { name: "Farmacia Sant Jordi", category: "Healthcare", amount: "7.10", date: daysAgo(1), person_id: ANA }, as); await setup("POST", "/transactions", { name: "Dentist", category: "Therapy", amount: "80.00", date: daysAgo(3), shares: { [ALEX]: 2000, [ANA]: 6000 } }, as); await setup("POST", "/transactions", { name: "Salary", category: "Income", amount: "2450", type: "income", date: daysAgo(5), person_id: ALEX }, as); await setup("POST", "/recurring", { name: "Netflix", category: "Entertainment", amount: "13.99", startDate: daysAgo(70), frequency: "monthly", paid_by: ANA }, as); await setup("POST", "/import/commit", { rows: [{ date: daysAgo(4), name: "AMAZON MKTPLACE 99", amount: "-15.20", import_ref: "ref-1", action: "add", category: "Shopping" }] }, as); await setup("POST", "/categories", { name: "Kids", color: 3 }, as); await setup("POST", "/tags", { name: "holiday" }, as); ok(!("kit" in (await setup("PUT", "/signin", { enabled: true, password: HP1 }, as))), "sign-in on: no kit comes with it, optional without encryption"); // Made in Settings, with the password: so encrypting has one to replace const plainKit = (await setup("POST", "/kit", { current: HP1 })).key; ok(await plainRecover(ANA, plainKit) === 200, "a kit made on its own is checked by the server"); ok(check("plain", DB).items.length >= 15, "the household has some of everything, in the clear"); /* ================= Alex turns encryption on, in the browser ================= */ section("Turning encryption on"); const alex1 = await device(); await signIn(alex1, ALEX, HP1); ok(await appShown(alex1), "Alex signs in (encryption off)"); const plainLedger = await ledger(alex1); // Alex's second device, open on the plain household while encryption is turned on const alex2 = await device({ width: 390, height: 844 }); await signIn(alex2, ALEX, HP1); ok(await appShown(alex2), "Alex signs in on a phone too"); // Ana, signed in on her own browser while encryption is turned on const ana0 = await device(); await signIn(ana0, ANA, HP1); ok(await appShown(ana0), "Ana signs in with the same household password (encryption off)"); await alex1.goto(base + "/settings/privacy", { waitUntil: "networkidle" }); await shot(alex1, "privacy-off"); // What the plain database holds just before encryption const plainBefore = check("plain", DB).items; await alex1.click("#pv-toggle"); const onText = await alex1.locator("#dlg").innerText(); ok(onText.includes("Turn On Encryption") && onText.includes("Only your household’s devices will read your data.") && onText.includes("Sign-in stays on") && !onText.includes("code"), "the sheet says what changes in one sentence and a footnote: no codes"); await alex1.fill("#pv-pw", "wrong-password"); await alex1.click("[data-dlg-ok]"); await alex1.waitForFunction(() => !document.getElementById("dlg-err").hidden, null, { timeout: 15000 }); ok((await alex1.locator("#dlg-err").innerText()).length > 0 && !check("grep", DB, "VAULT_ID").VAULT_ID, "a wrong password changes nothing"); await alex1.fill("#pv-pw", HP1); await alex1.click("[data-dlg-ok]"); await alex1.waitForFunction(() => document.querySelector("#dlg #dlg-t") && document.querySelector("#dlg #dlg-t").textContent.includes("Emergency Kit"), null, { timeout: 60000 }); await shot(alex1, "kit-dialog"); const alexKey = (await alex1.locator("#dlg .w-kitkey").innerText()).replace(/\s+/g, "-"); ok(/^([0-9A-Z]{4}-){11}[0-9A-Z]{4}$/.test(alexKey), "the Emergency Kit shows a 48-character recovery key, in 12 groups"); ok(await alex1.locator("#dlg [data-dlg-ok]").isDisabled(), "Done waits until the kit is saved"); await alex1.keyboard.press("Escape"); ok(await alex1.locator("#dlg .w-kitkey").count() === 1, "Escape doesn't close the kit"); await alex1.click("#kit-saved"); ok((await alex1.locator("#dlg").innerText()).includes("this recovery key gets you back in"), "one Emergency Kit, for the household"); await alex1.click("#dlg [data-dlg-ok]"); await alex1.waitForLoadState("networkidle"); ok(await appShown(alex1, 20000), "Wally opens again, encrypted"); ok(await alex1.locator("#dlg [data-code]").count() === 0 && !(await alex1.locator("#dlg").isVisible()), "no codes for anyone after the kit"); ok(await hasKeys(alex1), "the household key is kept on this browser"); ok(await plainRecover(ALEX, plainKit) === 401, "the kit from before encryption stops working"); const after = check("open", DB, HP1); ok(JSON.stringify(after.items) === JSON.stringify(plainBefore), "every item, opened with Alex’s password from outside the browser, is exactly what was there before (" + after.items.length + ")"); ok(after.counts.transaction === 0 && after.counts.category === 0 && after.counts.tag === 0 && after.counts.recurringtransaction === 0, "no table holds data in the clear"); const hits = Object.entries(check("grep", DB, ...WORDS)).filter(([, v]) => v).map(([k]) => k); ok(hits.length === 0, "no name, note, category, tag or amount is anywhere in wally.db" + (hits.length ? " — found " + hits.join(", ") : "")); ok(JSON.stringify(await ledger(alex1)) === JSON.stringify(plainLedger), "the app shows the same ledger as before, row for row"); ok(!existsSync(join(data, "wally.prev.db")) && !existsSync(join(data, "wally.next.db")) && !existsSync(join(data, "conversion.json")), "no old file, new file or journal is left"); noErrors(alex1, "turning on"); /* ================= encryption turned on elsewhere: Unlock once ================= */ section("A device that was open meanwhile"); await alex2.reload({ waitUntil: "networkidle" }); ok(await unlockShown(alex2), "Alex’s phone asks to unlock, once"); await shot(alex2, "unlock-phone"); await alex2.fill("#vg-in", "nope-nope"); await alex2.click("#vg-go"); await alex2.waitForFunction(() => document.getElementById("vg-msg").textContent.trim().length > 0, null, { timeout: 15000 }); ok((await alex2.locator("#vg-msg").innerText()).includes("isn’t right"), "a wrong password says so"); await alex2.fill("#vg-in", HP1); await alex2.click("#vg-go"); ok(await appShown(alex2), "the right one opens it"); ok(JSON.stringify(await ledger(alex2)) === JSON.stringify(plainLedger), "the phone has the same ledger"); await alex2.reload({ waitUntil: "networkidle" }); ok(await appShown(alex2), "and opens without asking again"); noErrors(alex2, "unlocking"); // Ana's browser, signed in before encryption: the same unlock, with the same password await ana0.reload({ waitUntil: "networkidle" }); ok(await unlockShown(ana0), "Ana’s browser, already signed in, asks to unlock once"); ok((await ana0.locator("#gate-h").innerText()) === "Ana" && (await ana0.getAttribute("#vg-in", "placeholder")) === "Household password", "her face and name, with the household password"); await shot(ana0, "unlock-ana"); await ana0.fill("#vg-in", HP1); await ana0.click("#vg-go"); ok(await appShown(ana0), "the same password opens it for Ana"); ok(JSON.stringify(await ledger(ana0)) === JSON.stringify(plainLedger), "with the same ledger"); ok(await hasKeys(ana0), "and the household key is kept on her browser"); noErrors(ana0, "Ana unlocking"); /* ================= a second person, on a new browser ================= */ section("A second person"); const ana1 = await device(); await signIn(ana1, ANA, HP1); ok(await appShown(ana1), "Ana signs in on a new browser with the household password, unlocked in the same step"); ok(!(await ana1.locator("#gate").isVisible()), "no code, no new password, no Emergency Kit of her own"); ok(JSON.stringify(await ledger(ana1)) === JSON.stringify(plainLedger), "with the same ledger"); const st1 = await inPage(ana1, async ({ core }) => core.GET("/auth/status")); ok(st1.me && st1.me.id === ANA, "as Ana"); noErrors(ana1, "Ana’s first encrypted sign-in"); /* ================= several devices ================= */ section("Two devices, the same household"); // A transaction added through the app on one device shows on the other await alex1.goto(base + "/transactions", { waitUntil: "networkidle" }); await alex1.keyboard.press("n"); await alex1.waitForSelector("#add-modal:not([hidden])"); const created = await inPage(alex1, async ({ core }) => core.POST("/transactions", { name: "Fruiteria Rosa", category: "Groceries", amount: "12.30", date: new Date().toISOString().slice(0, 10), tags: ["market"] })); await alex1.keyboard.press("Escape"); ok(created && created.id && created.amount === "12.30", "a transaction is saved, encrypted"); ok(!check("grep", DB, "Fruiteria").Fruiteria, "and its name is nowhere on the server"); await ana1.evaluate(() => window.dispatchEvent(new Event("focus"))); ok(await until(ana1, async ({ core }, id) => core.state.tx.some((t) => t.id === id && t.n === "Fruiteria Rosa"), created.id), "Ana’s browser shows it as soon as it gets focus"); ok(await ana1.locator("#main").innerText().then((x) => x.length > 0), "and the page is drawn again"); // The same transaction edited on two devices: different fields both kept const t1 = created.id; await syncPage(alex2); await inPage(alex1, async ({ core }, id) => core.PUT("/transactions/" + id, { name: "Fruiteria Rosa Mercat" }), t1); const merged = await inPage(alex2, async ({ core }, id) => core.PUT("/transactions/" + id, { amount: "14.00" }), t1); ok(merged.name === "Fruiteria Rosa Mercat" && merged.amount === "14.00", "edited on two devices: one changed the name, the other the amount, and both are kept"); // The same field: the later save wins await syncPage(ana1); await inPage(alex1, async ({ core }, id) => core.PUT("/transactions/" + id, { note: "first" }), t1); const later = await inPage(ana1, async ({ core }, id) => core.PUT("/transactions/" + id, { note: "second" }), t1); ok(later.note === "second" && later.name === "Fruiteria Rosa Mercat" && later.amount === "14.00", "the same field on two devices: the later save wins, the rest stays"); // No name: blank from the add sheet, sealed blank, and blank on the other device await alex1.keyboard.press("n"); await alex1.waitForSelector("#add-modal:not([hidden])"); await alex1.fill("#add-amt", "4.20"); await alex1.click("#add-cat"); await alex1.fill("#add-cat", "Groceries"); await alex1.keyboard.press("Enter"); await alex1.click("#add-go"); const noName = async ({ core }) => core.state.tx.some((t) => t.ac === 420 && t.n === "" && t.c === "Groceries"); ok(await until(alex1, noName), "with no name it is saved blank, not with the category as its name"); await syncPage(alex1); const sealedBlank = () => check("open", DB, HP1).items.map((x) => JSON.parse(x)).some((x) => x.k === "tx" && x.cents === 420 && x.name === "" && x.category === "Groceries"); let blankSealed = false; for (let i = 0; i < 40 && !(blankSealed = sealedBlank()); i++) await alex1.waitForTimeout(250); ok(blankSealed && check("open", DB, HP1).counts.transaction === 0, "opened from outside the browser, its name is empty, and nothing is in the clear"); await ana1.evaluate(() => window.dispatchEvent(new Event("focus"))); ok(await until(ana1, noName), "Ana’s browser has it, blank"); const cleared = await inPage(alex1, async ({ core }, id) => core.PUT("/transactions/" + id, { name: "" }), t1); ok(cleared.name === "" && cleared.amount === "14.00", "a name can be cleared, and the rest stays"); await inPage(alex1, async ({ core }) => core.DEL("/transactions/" + core.state.tx.find((t) => t.ac === 420).id)); // Edit against delete await syncPage(ana1); await inPage(alex1, async ({ core }, id) => core.DEL("/transactions/" + id), t1); const gone = await inPage(ana1, async ({ core }, id) => { try { await core.PUT("/transactions/" + id, { note: "too late" }); return "saved"; } catch (e) { return e.message; } }, t1); ok(gone === "Deleted on another device.", "editing what another device deleted says “Deleted on another device.”"); ok(!(await inPage(ana1, async ({ core }, id) => (await core.GET("/transactions/id/" + id)).length, t1)), "and it stays deleted"); // A category renamed on one device while another adds to it await syncPage(ana1); const cats = await Promise.all([ inPage(alex1, async ({ core }) => core.PUT("/categories/Therapy", { name: "Health care" })), inPage(ana1, async ({ core }) => core.POST("/transactions", { name: "Psychologist", category: "Therapy", amount: "60", date: new Date().toISOString().slice(0, 10) })), ]); await syncPage(alex1); await syncPage(ana1); const [la, lb] = [await ledger(alex1), await ledger(ana1)]; ok(JSON.stringify(la) === JSON.stringify(lb), "after a rename and an add at the same moment, both devices agree (" + la.length + " rows)"); ok(cats[0].name === "Health care", "the rename went through"); // Recurring charges due on two devices at once: each date added once const rule = await inPage(alex1, async ({ core }) => core.POST("/recurring", { name: "Gym", category: "Healthcare", amount: "39", startDate: new Date(Date.now() - 2 * 864e5).toISOString().slice(0, 10), frequency: "daily", person_id: 1 })); ok(rule && rule.last_run, "a daily rule is made, with its charges up to today"); const ahead = Date.now() + 3 * 864e5; const [p1, p2] = [await device({ clock: ahead }), await device({ clock: ahead })]; await Promise.all([signIn(p1, ALEX, HP1), signIn(p2, ALEX, HP1)]); await Promise.all([appShown(p1, 30000), appShown(p2, 30000)]); await p1.waitForTimeout(2500); await Promise.all([syncPage(p1), syncPage(p2)]); const gym = await inPage(p1, async ({ core }, rid) => (await core.GET("/ledger")).rows.filter((r) => r[12] === rid).map((r) => r[1]).sort(), rule.id); const gym2 = await inPage(p2, async ({ core }, rid) => (await core.GET("/ledger")).rows.filter((r) => r[12] === rid).map((r) => r[1]).sort(), rule.id); ok(gym.length === 6 && new Set(gym).size === 6 && JSON.stringify(gym) === JSON.stringify(gym2), "two devices opening three days later add each due charge once (" + gym.join(", ") + ")"); noErrors(p1, "device three days later"); noErrors(p2, "the other device three days later"); await p1.context().close(); await p2.context().close(); /* ================= when it asks again ================= */ section("When Wally asks again"); // Storage cleared, session still valid (Safari's seven days): the password only await alex2.evaluate(() => new Promise((r) => { const q = indexedDB.deleteDatabase("wally-vault"); q.onsuccess = q.onerror = q.onblocked = () => r(); })); await alex2.reload({ waitUntil: "networkidle" }); ok(await unlockShown(alex2), "storage cleared: the phone asks for the password only"); await alex2.fill("#vg-in", HP1); await alex2.click("#vg-go"); ok(await appShown(alex2), "and opens"); await syncPage(alex1); ok(JSON.stringify(await ledger(alex2)) === JSON.stringify(await ledger(alex1)), "downloading everything again gives the same ledger"); // Lock when Wally closes await alex2.goto(base + "/settings/privacy", { waitUntil: "networkidle" }); await shot(alex2, "privacy-phone"); await alex2.waitForSelector("#pv-lock", { timeout: 8000 }); await alex2.click("#pv-lock"); await alex2.waitForTimeout(300); ok(!(await hasKeys(alex2)), "“Lock when Wally closes” leaves no key on this browser"); await alex2.reload({ waitUntil: "networkidle" }); ok(await unlockShown(alex2), "so it asks each time Wally opens"); await alex2.fill("#vg-in", HP1); await alex2.click("#vg-go"); ok(await appShown(alex2), "and opens, with the key in memory only"); ok(!(await hasKeys(alex2)), "still no key stored"); // Moving around inside Wally keeps it open; only a new page load asks again await alex2.evaluate(() => { history.pushState(null, "", "/settings/privacy"); dispatchEvent(new PopStateEvent("popstate")); }); await alex2.waitForSelector("#pv-lock", { timeout: 8000 }); ok(await appShown(alex2, 1000), "moving between pages keeps it unlocked"); await alex2.click("#pv-lock"); await alex2.waitForTimeout(300); ok(await hasKeys(alex2), "turning it off keeps the key on this browser again"); // Sign out others: every other browser in the household, Ana's too; their next contact wipes them await alex1.goto(base + "/settings/security/browsers", { waitUntil: "networkidle" }); await alex1.waitForSelector('[data-act="others"]:not([disabled])', { timeout: 10000 }); await alex1.waitForFunction(() => /Ana · Last seen/.test(document.getElementById("set-pane").innerText), null, { timeout: 10000 }).catch(() => {}); ok(/Ana · Last seen/.test(await alex1.locator("#set-pane").innerText()), "Signed-in browsers shows Ana’s browsers too"); await alex1.click('[data-act="others"]'); await alex1.waitForSelector("#dlg .w-alert"); await alex1.click("#dlg [data-dlg-ok]"); await alex1.waitForFunction(() => document.getElementById("dlg").hidden, null, { timeout: 10000 }); await alex2.evaluate(() => window.dispatchEvent(new Event("focus"))); ok(await atPassword(alex2, "Alex", 15000) || await gateShown(alex2, "Who is signing in", 2000), "signed out elsewhere: the phone is back at sign-in"); ok(!(await hasKeys(alex2)), "and its key and copy are gone"); for (const [pg, name] of [[ana0, "Ana’s first browser"], [ana1, "Ana’s second browser"]]) { await pg.evaluate(() => window.dispatchEvent(new Event("focus"))); ok(await atPassword(pg, "Ana", 15000), "signed out elsewhere: " + name + " is back at sign-in"); ok(!(await hasKeys(pg)), "and its key is gone"); } await ana0.context().close(); await signIn(alex2, ALEX, HP1); ok(await appShown(alex2, 20000), "a new device signs in with the password, and is unlocked in the same step"); await signIn(ana1, ANA, HP1); ok(await appShown(ana1, 20000), "and so does Ana"); // Sign out const ana2 = await device(); await signIn(ana2, ANA, HP1); ok(await appShown(ana2, 20000), "Ana signs in on another browser"); // The app's own Sign out (the person menu, or Settings on your own page) await ana2.evaluate(() => import("/js/shell.js").then((m) => m.signOut())); await ana2.waitForURL(base + "/", { timeout: 10000 }); await ana2.waitForTimeout(500); ok(!(await hasKeys(ana2)), "signing out leaves no key on that browser"); await ana2.context().close(); /* ================= getting back in ================= */ section("Getting back in"); // Two-step on, for the household, from Settings › Sign-in & Privacy await alex1.goto(base + "/settings/security", { waitUntil: "networkidle" }); await alex1.click('a[data-pane="security/two-step"]'); await alex1.waitForSelector("#ts-secret", { state: "attached" }); const secret = (await alex1.textContent("#ts-secret")).replace(/\s/g, ""); await alex1.fill("#ts-code", totpAt(secret, Math.floor(Date.now() / 30000))); await alex1.fill("#ts-pw", HP1); await alex1.click("#dlg [data-dlg-ok]"); await alex1.waitForSelector("#rc-codes", { timeout: 15000 }); await alex1.click("#dlg [data-dlg-ok]"); ok((await inPage(alex1, async ({ core }) => core.GET("/auth/status"))).two_step === true, "two-step is on for the household (turned on with the password, encrypted)"); const off = signinOff(); ok(off.code === 1 && /encrypted/.test(off.text), "api.signin_off is refused while encrypted (" + off.text.trim() + ")"); const afterOff = await (await fetch(base + "/api/auth/status")).json(); ok(afterOff.signin && afterOff.vault.on && !!afterOff.kit && afterOff.two_step, "and nothing changed"); // Alex forgot the password and has no phone: the Emergency Kit, without a code const alex3 = await device(); await signIn(alex3, ALEX, HP1); await alex3.waitForSelector(".w-sicode", { timeout: 15000 }); ok(await alex3.locator("#si-nophone").count() === 1, "the code step has “No phone or codes?”"); await alex3.click("#si-nophone"); ok(await gateShown(alex3, "Use your recovery key"), "No phone or codes? asks for the recovery key"); ok((await alex3.locator("#gate").innerText()).includes("It works without two-step sign-in too."), "and says it works without two-step"); await shot(alex3, "recover"); await alex3.click("#vg-help"); ok(await gateShown(alex3, "Ask Ana"), "“Lost it too?” says to ask Ana, who uses the same password"); const lost = await alex3.locator("#gate").innerText(); ok(lost.includes("Not even whoever runs the server can.") && !lost.includes("signin_off"), "encrypted: nobody else can open it, and no command for the server"); await alex3.click("#vg-rec"); ok(await gateShown(alex3, "Use your recovery key"), "and back to the recovery key"); await alex3.fill("#vg-in", "AAAA-AAAA"); await alex3.click("#vg-go"); await alex3.waitForFunction(() => document.getElementById("vg-msg").textContent.trim().length > 0, null, { timeout: 15000 }); ok((await alex3.locator("#vg-msg").innerText()).length > 0, "a mistyped key is refused"); await alex3.fill("#vg-in", alexKey.toLowerCase()); await alex3.click("#vg-go"); ok(await gateShown(alex3, "Choose a new household password"), "the key from the kit works, with two-step on and no code"); await shot(alex3, "recover-newpw"); await alex3.fill("#vg-in", HP2); await alex3.fill("#vg-in2", HP2); await alex3.click("#vg-go"); ok(await appShown(alex3, 30000), "a new household password, and Wally opens, without a code"); const st3 = await inPage(alex3, async ({ core }) => core.GET("/auth/status")); ok(st3.me && st3.me.id === ALEX && st3.two_step === true, "signed in as Alex; two-step stays on for everyone’s phones"); const sess = await inPage(alex3, async ({ core }) => core.GET("/sessions")); ok(sess.length === 1 && sess[0].current, "every other browser is signed out on the server"); await alex1.evaluate(() => window.dispatchEvent(new Event("focus"))); ok(await atPassword(alex1, "Alex", 15000), "Alex’s other browser is back at sign-in"); await ana1.evaluate(() => window.dispatchEvent(new Event("focus"))); ok(await atPassword(ana1, "Ana", 15000), "and so is Ana’s"); // Forgot password from the password step: the same recovery key await alex1.click("#si-forgot"); ok(await gateShown(alex1, "Use your recovery key"), "Forgot password asks for the recovery key too"); await alex1.click("#vg-back"); ok(await atPassword(alex1, "Alex"), "Back to sign in"); // Ana: the new password, and a code from the same secret await signIn(ana1, ANA, HP2); await ana1.waitForSelector(".w-sicode", { timeout: 15000 }); await ana1.locator("#si-in").pressSequentially(await freshCode(secret)); ok(await appShown(ana1, 20000), "Ana signs in with the new password and the household’s two-step code, unlocked"); ok(JSON.stringify(await ledger(ana1)) === JSON.stringify(await ledger(alex3)), "with the same ledger"); // Settings › Sign-in, encrypted: what to do if the password is forgotten await alex3.goto(base + "/settings/security", { waitUntil: "networkidle" }); await alex3.waitForSelector('a[data-pane="security/kit"]'); const mainText = await alex3.locator("#set-pane").innerText(); ok(mainText.includes("If you forget the password") && mainText.includes("Emergency Kit"), "Settings › Sign-in & Privacy: the Emergency Kit gets you back in"); await alex3.click('a[data-pane="security/kit"]'); await alex3.waitForSelector('[data-act="kit"]'); const forgotText = await alex3.locator("#set-pane").innerText(); ok(!forgotText.includes("password.txt") && !forgotText.includes("signin_off") && forgotText.includes("Without the kit and the password, nobody can open the data, not even whoever runs the server."), "Emergency Kit, encrypted: lost the kit too means nobody can open it; no password.txt, no command"); // A new kit, made in this browser: the old one stops working const proveIn = async (key) => { const d = await device(); await d.goto(base + "/", { waitUntil: "networkidle" }); const r = await d.evaluate(async ([k, id]) => { const s = await import("/js/vault/session.js"); s.bind(await import("/js/core.js")); try { await s.prove(id, k); return 200; } catch (e) { return e.status; } }, [key, ALEX]); await d.context().close(); return r; }; await alex3.click('[data-act="kit"]'); await alex3.fill("#kit-pw", HP2); await alex3.click("#dlg [data-dlg-ok]"); await alex3.waitForSelector("#kit-saved", { timeout: 20000 }); const newKey = (await alex3.locator("#dlg .w-kitkey").innerText()).replace(/\s+/g, "-"); await alex3.click("#kit-saved"); await alex3.click("#dlg [data-dlg-ok]"); await alex3.waitForFunction(() => document.getElementById("dlg").hidden, null, { timeout: 10000 }); ok(newKey !== alexKey && await proveIn(alexKey) === 401, "encrypted: a new kit, and the old one stops working"); ok(await proveIn(newKey) === 200, "the new kit opens the household key"); await alex3.goto(base + "/settings/security/two-step", { waitUntil: "networkidle" }); await alex3.waitForSelector("#ts-toggle"); await alex3.click("#ts-toggle"); await alex3.fill("#ts-pw", HP2); await alex3.click("#dlg [data-dlg-ok]"); await alex3.waitForFunction(() => document.getElementById("dlg").hidden, null, { timeout: 15000 }); ok((await inPage(alex3, async ({ core }) => core.GET("/auth/status"))).two_step === false, "two-step off again, with the new password"); await alex3.reload({ waitUntil: "networkidle" }); await appShown(alex3); ok(!(await alex3.locator("#banner").textContent()).includes("helped"), "no “helped back in” banner"); noErrors(alex3, "getting back in"); noErrors(ana1, "Ana back in"); /* ================= the server stays in the dark ================= */ section("What the server holds"); await syncPage(alex3); const now = check("open", DB, HP2); const fromApp = await inPage(alex3, async ({ store }) => [...store.vault.recs.values()].filter((r) => r.obj).length); ok(now.items.length === fromApp, "the server has exactly the records the app has (" + fromApp + ")"); const hits2 = Object.entries(check("grep", DB, ...WORDS, "Psychologist", "Gym", "market")).filter(([, v]) => v).map(([k]) => k); ok(hits2.length === 0, "still nothing readable in wally.db" + (hits2.length ? " — found " + hits2.join(", ") : "")); await alex3.goto(base + "/settings/security", { waitUntil: "networkidle" }); const priv = await alex3.locator("#set-pane").innerText(); ok(priv.includes("Not one amount, name or category") && !priv.includes("What the server can see"), "Privacy says what the server sees, without a page of its own"); await shot(alex3, "privacy-on"); /* ================= stopping half-way ================= */ section("Turning off, stopped half-way"); // The browser goes away mid-way: after the pause, nothing changed const quitter = await device(); await signIn(quitter, ALEX, HP2); await appShown(quitter, 20000); await quitter.waitForTimeout(1500); let encrypted = digest(); await quitter.route("**/api/vault/convert/plain", () => {}); // never answered: as if the tab closed await quitter.goto(base + "/settings/privacy", { waitUntil: "networkidle" }); await quitter.click("#pv-toggle"); await quitter.fill("#pv-pw", HP2); await quitter.click("[data-dlg-ok]"); await ana1.waitForTimeout(1500); const paused = await (await fetch(base + "/api/auth/status")).json(); ok(paused.vault.paused && paused.vault.paused.by === "Alex", "while it runs, everyone sees “Alex is turning off encryption”"); const blocked = await inPage(ana1, async ({ core }) => { try { await core.POST("/transactions", { name: "x", category: "Food", amount: "1", date: new Date().toISOString().slice(0, 10) }); return "saved"; } catch (e) { return e.status + " " + e.message; } }); ok(/^503 Alex is turning off/.test(blocked) || /^503 Alex is decrypting/.test(blocked), "and saving waits: " + blocked); await quitter.context().close(); await new Promise((r) => setTimeout(r, (PAUSE + 2) * 1000)); await fetch(base + "/api/auth/status"); ok(digest() === encrypted && !existsSync(join(data, "wally.next.db")) && !existsSync(join(data, "conversion.json")), "the browser closed: after the pause the half-built file is gone and wally.db is untouched"); ok(!(await (await fetch(base + "/api/auth/status")).json()).vault.paused, "and the household is writable again"); // The server stops mid-way: on restart, nothing changed const victim = await device(); await signIn(victim, ALEX, HP2); await appShown(victim, 20000); await victim.waitForTimeout(1500); encrypted = digest(); let killed = null; await victim.route("**/api/vault/convert/readback*", async (route) => { killed = stopServer("SIGKILL"); await killed; route.abort(); }); await victim.goto(base + "/settings/privacy", { waitUntil: "networkidle" }); await victim.click("#pv-toggle"); await victim.fill("#pv-pw", HP2); await victim.click("[data-dlg-ok]"); await victim.waitForFunction(() => !document.getElementById("dlg-err").hidden, null, { timeout: 30000 }).catch(() => {}); ok(killed !== null && existsSync(join(data, "wally.next.db")), "the server was killed with the new file half-built"); await startServer(); ok(digest() === encrypted && !existsSync(join(data, "wally.next.db")) && !existsSync(join(data, "conversion.json")), "after a restart, wally.db is the old file, untouched"); await victim.context().close(); /* ================= turning off ================= */ section("Turning encryption off"); const beforeOff = check("open", DB, HP2).items; await alex3.reload({ waitUntil: "networkidle" }); await appShown(alex3); const encLedger = await ledger(alex3); // A category encrypted before colors followed spending still has its color: turning off doesn't stop on it const aged = await inPage(alex3, async ({ store }) => { const [id, r] = [...store.vault.recs.entries()].find(([, x]) => x.obj && x.obj.k === "cat"); await store.commit(new Map([[id, { obj: Object.assign({}, r.obj, { color: 3 }) }]])); return r.obj.name; }); ok(!!aged, "a category encrypted with an old color: " + aged); await alex3.goto(base + "/settings/privacy", { waitUntil: "networkidle" }); await alex3.click("#pv-toggle"); await shot(alex3, "turn-off"); ok(await alex3.locator("#dlg #pv-word").count() === 0 && !(await alex3.isDisabled("#dlg [data-dlg-ok]")), "turning off asks for the password only; Turn Off stays lit"); await alex3.click("#dlg [data-dlg-ok]"); await alex3.waitForFunction(() => { const e = document.getElementById("dlg-err"); return e && !e.hidden && /password/i.test(e.textContent); }, null, { timeout: 5000 }); ok(await alex3.locator("#dlg #pv-pw").count() === 1, "with the field empty it asks for the password, and stays open"); await alex3.fill("#pv-pw", HP2); await alex3.click("[data-dlg-ok]"); // The encrypted kit went with the encryption, and without it none is needed: Wally just opens again ok(await appShown(alex3, 60000) && await until(alex3, async ({ core }) => core.state.status && core.state.status.vault && !core.state.status.vault.on, null, 30000), "Wally opens again, in the clear"); ok(await alex3.locator("#onb-saved").count() === 0, "turned off: no kit to save, optional without encryption"); ok((await alex3.evaluate(() => fetch("/api/auth/status").then((r) => r.json()))).kit === null && await plainRecover(ALEX, newKey) === 401, "no kit left, and the encrypted one doesn’t work"); const plainAfter = check("plain", DB).items; ok(JSON.stringify(plainAfter) === JSON.stringify(beforeOff), "every item in the clear is exactly what was encrypted (" + plainAfter.length + ")"); ok(JSON.stringify(await ledger(alex3)) === JSON.stringify(encLedger), "the app shows the same ledger, now from the server"); ok(!(await hasKeys(alex3)), "no key is left on the browser"); await ana1.evaluate(() => window.dispatchEvent(new Event("focus"))); await ana1.waitForTimeout(3000); ok(await appShown(ana1) && !(await hasKeys(ana1)), "Ana’s open browser notices, forgets its copy and reloads in the clear"); ok(JSON.stringify(await ledger(ana1)) === JSON.stringify(encLedger), "and shows the same ledger"); noErrors(alex3, "turning off"); noErrors(ana1, "Ana after turning off"); /* ================= and on again, with more data ================= */ section("A bigger household"); const big = apiClient(); const kdf = await big("GET", "/vault/kdf"); const rows = []; for (let i = 0; i < 5000; i++) rows.push({ name: "Shop " + (i % 97), category: ["Groceries", "Food", "Shopping", "Travel"][i % 4], amount: String(5 + (i % 50) + 0.25), date: daysAgo(i % 700), person_id: i % 3 ? ALEX : null }); const login = await alex3.evaluate(async ([pw, salt]) => (await (await import("/js/vault/crypto.js")).fromPassword(pw, salt)).login, [HP2, kdf.salt]); await big("POST", "/login", { person_id: ALEX, password: login }); await big("POST", "/transactions/import", rows); await alex3.reload({ waitUntil: "networkidle" }); await appShown(alex3, 30000); const plainBig = check("plain", DB).items; await alex3.goto(base + "/settings/privacy", { waitUntil: "networkidle" }); await alex3.click("#pv-toggle"); await alex3.fill("#pv-pw", HP2); const t0 = Date.now(); await alex3.click("[data-dlg-ok]"); await alex3.waitForSelector("#dlg .w-kitkey", { timeout: 120000 }); const took = (Date.now() - t0) / 1000; ok(took < 60, "turning on with " + plainBig.length + " items took " + took.toFixed(1) + " s"); await alex3.click("#kit-saved"); await alex3.click("#dlg [data-dlg-ok]"); await appShown(alex3, 30000); const t1b = Date.now(); await alex3.reload({ waitUntil: "networkidle" }); await appShown(alex3, 30000); ok((Date.now() - t1b) / 1000 < 8, "opening Wally with " + plainBig.length + " encrypted records takes " + ((Date.now() - t1b) / 1000).toFixed(1) + " s"); ok(JSON.stringify(check("open", DB, HP2).items) === JSON.stringify(plainBig), "every one of them opens to exactly what it was"); const t2 = Date.now(); await inPage(alex3, async ({ core }) => core.PUT("/categories/Travel", { name: "Trips" })); ok((Date.now() - t2) / 1000 < 10, "renaming a category on 1,250 transactions takes " + ((Date.now() - t2) / 1000).toFixed(1) + " s"); noErrors(alex3, "a bigger household"); } catch (e) { failures++; console.log(" FAIL " + (e.stack || e)); } finally { await browser.close(); await stopServer(); } console.log(failures ? "\n" + failures + " failed" : "\nAll encryption checks passed."); process.exit(failures ? 1 : 0);