// Proves the pure-JS fallback (crypto-fallback.js) and Web Crypto read and // write the same ledger blobs, in both directions, with the app's parameters. // // node tests/crypto-interop.test.mjs (Node 22.12 or newer) // docker run --rm --network none -v "$PWD":/src:ro -w /src \ // node:24-alpine@sha256:50c8e8ca1d27439048670df5883f32d57cf81cff6233222c893fd0d9884cbd81 \ // node tests/crypto-interop.test.mjs // // Node's globalThis.crypto.subtle is the same Web Crypto API browsers expose. import { readFileSync } from "node:fs"; import assert from "node:assert/strict"; import * as fb from "../crypto-fallback.js"; const subtle = globalThis.crypto.subtle; const enc = new TextEncoder(); const dec = new TextDecoder(); const hex = b => Buffer.from(b).toString("hex"); const b64e = b => Buffer.from(b).toString("base64"); const b64d = s => new Uint8Array(Buffer.from(s, "base64")); let passed = 0; async function test(name, fn) { const t = Date.now(); await fn(); passed++; console.log(`ok ${name} (${Date.now() - t} ms)`); } // The parameters must be the app's own, not a copy that could drift. const appSrc = readFileSync(new URL("../app.js", import.meta.url), "utf8"); const ITER = Number(/const PBKDF2_ITERATIONS = (\d+);/.exec(appSrc)[1]); assert.equal(ITER, 250000); assert.match(appSrc, /name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256"/); assert.match(appSrc, /\{ name: "AES-GCM", length: 256 \}/); assert.match(appSrc, /crypto\.getRandomValues\(new Uint8Array\(12\)\)/); assert.match(appSrc, /crypto\.getRandomValues\(new Uint8Array\(16\)\)/); // --- Web Crypto side, written exactly as app.js does it ----------------- async function wcKey(pass, salt) { const km = await subtle.importKey("raw", enc.encode(pass), "PBKDF2", false, ["deriveKey"]); return subtle.deriveKey({ name: "PBKDF2", salt, iterations: ITER, hash: "SHA-256" }, km, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); } async function wcEncrypt(obj, pass) { const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await subtle.encrypt({ name: "AES-GCM", iv }, await wcKey(pass, salt), enc.encode(JSON.stringify(obj))); return { v: 1, salt: b64e(salt), iv: b64e(iv), ct: b64e(ct) }; } async function wcDecrypt(blob, pass) { const pt = await subtle.decrypt({ name: "AES-GCM", iv: b64d(blob.iv) }, await wcKey(pass, b64d(blob.salt)), b64d(blob.ct)); return JSON.parse(dec.decode(pt)); } // --- fallback side ------------------------------------------------------- async function fbEncrypt(obj, pass) { const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await fb.deriveKeyBytes(enc.encode(pass), salt, ITER); return { v: 1, salt: b64e(salt), iv: b64e(iv), ct: b64e(fb.encrypt(key, iv, enc.encode(JSON.stringify(obj)))) }; } async function fbDecrypt(blob, pass) { const key = await fb.deriveKeyBytes(enc.encode(pass), b64d(blob.salt), ITER); return JSON.parse(dec.decode(fb.decrypt(key, b64d(blob.iv), b64d(blob.ct)))); } // Fake data only. const sample = { version: 2, activeId: "b1", budgets: [{ id: "b1", name: "Test budget £€", currency: "£", accounts: [{ id: "a1", name: "Example Bank", balance: "123.45", bank: "cash" }], recurring: [], months: [{ id: "m1", ym: "2026-09", rows: [ { id: "r1", date: "2026-09-01", desc: "Fake coffee ☕", inc: "", out: "3.20", tag: "" }] }] }], }; await test("PBKDF2-HMAC-SHA256 known answer (c=1, RFC 7914 §11 style)", async () => { const k = await fb.deriveKeyBytes(enc.encode("password"), enc.encode("salt"), 1); assert.equal(hex(k), "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"); }); await test("AES-256-GCM known answer (GCM spec test case 14)", async () => { const ct = fb.encrypt(new Uint8Array(32), new Uint8Array(12), new Uint8Array(16)); assert.equal(hex(ct), "cea7403d4d606b6e074ec5d3baf39d18" + "d0d1c8a799996bf0265b98b5d48ab919"); }); for (const pass of ["correct horse battery staple", "pässwörd ✓ 🔐", "x"]) { await test(`derived key bytes match Web Crypto (${JSON.stringify(pass)}, ${ITER} iterations)`, async () => { const salt = crypto.getRandomValues(new Uint8Array(16)); const km = await subtle.importKey("raw", enc.encode(pass), "PBKDF2", false, ["deriveBits"]); const bits = await subtle.deriveBits({ name: "PBKDF2", salt, iterations: ITER, hash: "SHA-256" }, km, 256); assert.equal(hex(await fb.deriveKeyBytes(enc.encode(pass), salt, ITER)), hex(bits)); }); } await test("Web Crypto blob decrypts with the fallback", async () => { const blob = await wcEncrypt(sample, "throwaway-test-pass"); assert.deepEqual(Object.keys(blob), ["v", "salt", "iv", "ct"]); assert.deepEqual(await fbDecrypt(blob, "throwaway-test-pass"), sample); }); await test("fallback blob decrypts with Web Crypto", async () => { const blob = await fbEncrypt(sample, "throwaway-test-pass"); assert.deepEqual(await wcDecrypt(blob, "throwaway-test-pass"), sample); }); await test("same key, iv and plaintext give byte-identical ciphertext", async () => { const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const pt = enc.encode(JSON.stringify(sample)); const a = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv }, await wcKey("p", salt), pt)); const b = fb.encrypt(await fb.deriveKeyBytes(enc.encode("p"), salt, ITER), iv, pt); assert.equal(hex(b), hex(a)); }); await test("fallback rejects a wrong passphrase and tampered data", async () => { const blob = await wcEncrypt(sample, "right"); await assert.rejects(fbDecrypt(blob, "wrong")); const ct = b64d(blob.ct); ct[0] ^= 1; await assert.rejects(fbDecrypt({ ...blob, ct: b64e(ct) }, "right")); }); console.log(`\n${passed} passed`);