Files
LibreLedger/tests/crypto-interop.test.mjs
T
LibrePortal 2cffc3ab07 Start LibreLedger: an encrypted money ledger that stores only ciphertext
LibreLedger is a single-person money ledger. The browser encrypts
everything (PBKDF2-HMAC-SHA256 with 250k iterations, then AES-256-GCM),
and a small standard-library Python server stores only the resulting
{v, salt, iv, ct} blob. Ledger files from the earlier Money Ledger version
open unchanged.

- On plain http to a LAN or VPN address, where browsers hide Web Crypto,
  the app switches to vendored @noble/hashes and @noble/ciphers 2.2.0 and
  says so on the lock screen. tests/crypto-interop.test.mjs shows both
  paths read each other's files.
- The server hands out only the app's own files, sends a self-only CSP,
  nosniff, frame denial and no-referrer, checks the shape of each blob,
  refuses cross-site writes and writes atomically. It keeps the last 10
  backups plus one per day for 30 days.
- The container runs that server from a digest-pinned python alpine
  image, as an unprivileged user, with its data in /data and a health
  check on /api/health.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:17:16 +01:00

121 lines
5.8 KiB
JavaScript

// 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`);