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>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// 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`);
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""server.py: file allowlist, headers, blob checks, atomic saves, backups.
|
||||
|
||||
python3 tests/test_server.py
|
||||
"""
|
||||
import base64
|
||||
import datetime
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import server # noqa: E402
|
||||
|
||||
|
||||
def blob(ct_len=40):
|
||||
b = lambda n: base64.b64encode(os.urandom(n)).decode()
|
||||
return json.dumps({"v": 1, "salt": b(16), "iv": b(12), "ct": b(ct_len)}).encode()
|
||||
|
||||
|
||||
class ServerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
server.Handler.store = server.Store(self.tmp.name, 0, 3, 2)
|
||||
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
|
||||
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
||||
self.port = self.httpd.server_address[1]
|
||||
|
||||
def tearDown(self):
|
||||
self.httpd.shutdown()
|
||||
self.httpd.server_close()
|
||||
self.tmp.cleanup()
|
||||
|
||||
def req(self, method, path, body=None, headers=None):
|
||||
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
|
||||
h = {"Content-Type": "application/json"} if body is not None else {}
|
||||
h.update(headers or {})
|
||||
c.request(method, path, body=body, headers=h)
|
||||
r = c.getresponse()
|
||||
data = r.read()
|
||||
c.close()
|
||||
return r, data
|
||||
|
||||
def test_health_and_headers(self):
|
||||
r, data = self.req("GET", "/api/health")
|
||||
self.assertEqual((r.status, data), (200, b'{"ok":true}'))
|
||||
csp = r.getheader("Content-Security-Policy")
|
||||
self.assertIn("default-src 'none'", csp)
|
||||
self.assertIn("frame-ancestors 'none'", csp)
|
||||
self.assertEqual(r.getheader("X-Content-Type-Options"), "nosniff")
|
||||
self.assertEqual(r.getheader("X-Frame-Options"), "DENY")
|
||||
self.assertEqual(r.getheader("Referrer-Policy"), "no-referrer")
|
||||
|
||||
def test_serves_only_app_files(self):
|
||||
for path in ("/", "/index.html", "/app.js", "/theme.js", "/styles.css",
|
||||
"/crypto-fallback.js", "/banks/monzo.svg",
|
||||
"/vendor/noble-hashes-2.2.0/pbkdf2.js", "/vendor/noble-ciphers-2.2.0/aes.js"):
|
||||
r, _ = self.req("GET", path)
|
||||
self.assertEqual(r.status, 200, path)
|
||||
r, _ = self.req("GET", "/app.js")
|
||||
self.assertTrue(r.getheader("Content-Type").startswith("text/javascript"))
|
||||
for path in ("/server.py", "/Dockerfile", "/README.md", "/LICENSE", "/data/",
|
||||
"/data/ledger.enc", "/banks/", "/banks/NOTICE.txt", "/vendor/",
|
||||
"/vendor/README.md", "/vendor/noble-hashes-2.2.0/LICENSE",
|
||||
"/vendor/noble-hashes-2.2.0/argon2.js", "/tests/test_server.py",
|
||||
"/../server.py", "/%2e%2e/server.py", "/banks/..%2fserver.py",
|
||||
"/.git/config", "//etc/passwd", "/app.js.map"):
|
||||
r, _ = self.req("GET", path)
|
||||
self.assertEqual(r.status, 404, path)
|
||||
|
||||
def test_round_trip_and_validation(self):
|
||||
r, _ = self.req("GET", "/api/data")
|
||||
self.assertEqual(r.status, 204)
|
||||
good = blob()
|
||||
r, _ = self.req("PUT", "/api/data", good)
|
||||
self.assertEqual(r.status, 200)
|
||||
r, data = self.req("GET", "/api/data")
|
||||
self.assertEqual((r.status, data), (200, good))
|
||||
self.assertEqual(os.stat(os.path.join(self.tmp.name, "ledger.enc")).st_mode & 0o777, 0o600)
|
||||
bad = [b"not json", b"[]", b'{"salt":"a","iv":"b"}',
|
||||
json.dumps({"salt": "!!", "iv": "AAAAAAAAAAAAAAAA", "ct": "AAAAAAAAAAAAAAAAAAAAAA=="}).encode(),
|
||||
json.dumps({**json.loads(blob()), "extra": 1}).encode(),
|
||||
json.dumps({**json.loads(blob()), "v": "1"}).encode(),
|
||||
json.dumps({**json.loads(blob()), "salt": base64.b64encode(b"short").decode()}).encode(),
|
||||
blob(ct_len=4)]
|
||||
for body in bad:
|
||||
r, _ = self.req("PUT", "/api/data", body)
|
||||
self.assertEqual(r.status, 400, body)
|
||||
r, _ = self.req("PUT", "/api/data", good, {"Content-Type": "text/plain"})
|
||||
self.assertEqual(r.status, 415)
|
||||
r, _ = self.req("PUT", "/api/data", good, {"Origin": "http://evil.example"})
|
||||
self.assertEqual(r.status, 403)
|
||||
r, _ = self.req("PUT", "/api/data", good, {"Sec-Fetch-Site": "cross-site"})
|
||||
self.assertEqual(r.status, 403)
|
||||
r, _ = self.req("PUT", "/api/data", good, {"Origin": f"http://127.0.0.1:{self.port}",
|
||||
"Sec-Fetch-Site": "same-origin"})
|
||||
self.assertEqual(r.status, 200)
|
||||
r, _ = self.req("POST", "/api/data", good)
|
||||
self.assertEqual(r.status, 405)
|
||||
r, _ = self.req("PUT", "/index.html", good)
|
||||
self.assertEqual(r.status, 405)
|
||||
r, data = self.req("GET", "/api/data")
|
||||
self.assertEqual(data, good) # none of the rejected bodies landed
|
||||
|
||||
def test_backups_rotate(self):
|
||||
store = server.Handler.store
|
||||
bodies = [blob() for _ in range(7)]
|
||||
for b in bodies:
|
||||
r, _ = self.req("PUT", "/api/data", b)
|
||||
self.assertEqual(r.status, 200)
|
||||
snaps = store._snapshots()
|
||||
self.assertEqual(len(snaps), 3) # keep_recent=3, all taken today
|
||||
with open(os.path.join(store.backups, snaps[-1]), "rb") as f:
|
||||
self.assertEqual(f.read(), bodies[-2]) # the version before the last save
|
||||
# Older days: the last snapshot of each of the 2 newest days survives.
|
||||
for day in ("20260101", "20260102", "20260103"):
|
||||
for t in ("T080000000000Z", "T200000000000Z"):
|
||||
with open(os.path.join(store.backups, f"ledger-{day}{t}.enc"), "wb") as f:
|
||||
f.write(b"old")
|
||||
self.req("PUT", "/api/data", blob())
|
||||
snaps = store._snapshots()
|
||||
today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d")
|
||||
self.assertNotIn("ledger-20260101T200000000000Z.enc", snaps)
|
||||
self.assertNotIn("ledger-20260102T200000000000Z.enc", snaps)
|
||||
self.assertNotIn("ledger-20260103T080000000000Z.enc", snaps)
|
||||
self.assertIn("ledger-20260103T200000000000Z.enc", snaps)
|
||||
self.assertEqual(len([s for s in snaps if s[7:15] == today]), 3)
|
||||
self.assertEqual(len(snaps), 4)
|
||||
self.assertEqual([n for n in os.listdir(store.backups) if n.endswith(".tmp")], [])
|
||||
|
||||
def test_backup_interval(self):
|
||||
server.Handler.store = store = server.Store(self.tmp.name, 3600, 10, 30)
|
||||
for _ in range(4):
|
||||
self.req("PUT", "/api/data", blob())
|
||||
self.assertEqual(len(store._snapshots()), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user