Files
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

340 lines
13 KiB
Python

#!/usr/bin/env python3
"""LibreLedger — tiny persistence server (Python standard library only).
Serves the app's own files AND stores the *already client-side-encrypted*
ledger blob in a file on disk: <data-dir>/ledger.enc
The browser does all the encryption (AES-256-GCM, key from PBKDF2). This
server only ever stores and returns the opaque ciphertext; it never sees the
passphrase, the key or a single figure from the ledger.
Endpoints:
GET /api/data -> 200 + encrypted blob, or 204 if nothing stored yet
PUT /api/data -> store the encrypted blob (checked to look like one)
GET /api/health -> 200 {"ok":true}
GET /<app file> -> one of the files in APP_FILES / banks/*.svg / VENDOR_FILES
everything else -> 404 (no directory listings, never data/ or this source)
Every save keeps the previous version under <data-dir>/backups/:
the newest BACKUP_KEEP_RECENT snapshots, plus the last snapshot of each of the
most recent BACKUP_KEEP_DAILY days. At most one snapshot is taken per
BACKUP_INTERVAL seconds, so typing does not churn through the recent ones.
Run: python3 server.py [--port 8080] [--host 127.0.0.1] [--data-dir ./data]
Open: http://localhost:8080
Every option can also be set in the environment: LIBRELEDGER_PORT,
LIBRELEDGER_HOST, LIBRELEDGER_DATA_DIR, LIBRELEDGER_BACKUP_INTERVAL,
LIBRELEDGER_BACKUP_KEEP_RECENT, LIBRELEDGER_BACKUP_KEEP_DAILY.
"""
import argparse
import base64
import binascii
import datetime
import json
import os
import re
import sys
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit, unquote
VERSION = "1.0.0"
ROOT = os.path.dirname(os.path.abspath(__file__))
MAX_BODY = 16 * 1024 * 1024 # a real ledger is tens of KB
# The only files this server hands out. Nothing else under ROOT is reachable:
# not data/, not this source, not the Dockerfile or the tests.
APP_FILES = {
"index.html": "text/html; charset=utf-8",
"app.js": "text/javascript; charset=utf-8",
"theme.js": "text/javascript; charset=utf-8",
"crypto-fallback.js": "text/javascript; charset=utf-8",
"styles.css": "text/css; charset=utf-8",
"favicon.svg": "image/svg+xml",
}
# The pure-JS crypto used when the browser has no Web Crypto (plain http on a
# LAN or VPN address). Byte-for-byte copies of the npm releases; see vendor/README.md.
VENDOR_FILES = {
"vendor/noble-hashes-2.2.0/pbkdf2.js",
"vendor/noble-hashes-2.2.0/hmac.js",
"vendor/noble-hashes-2.2.0/sha2.js",
"vendor/noble-hashes-2.2.0/_md.js",
"vendor/noble-hashes-2.2.0/_u64.js",
"vendor/noble-hashes-2.2.0/utils.js",
"vendor/noble-ciphers-2.2.0/aes.js",
"vendor/noble-ciphers-2.2.0/_polyval.js",
"vendor/noble-ciphers-2.2.0/utils.js",
}
BANK_SVG = re.compile(r"^banks/[a-z0-9]+\.svg$")
# Everything is same-origin; the page loads nothing from anywhere else.
# style-src-attr: the app sets a few colours through style="--dot:…" attributes.
CSP = ("default-src 'none'; script-src 'self'; style-src 'self'; "
"style-src-attr 'unsafe-inline'; img-src 'self'; connect-src 'self'; "
"base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
SECURITY_HEADERS = (
("Content-Security-Policy", CSP),
("X-Content-Type-Options", "nosniff"),
("X-Frame-Options", "DENY"),
("Referrer-Policy", "no-referrer"),
("Cross-Origin-Opener-Policy", "same-origin"),
("Cross-Origin-Resource-Policy", "same-origin"),
("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()"),
)
SAVE_LOCK = threading.Lock()
def _env_int(name, default):
try:
return int(os.environ.get(name, default))
except ValueError:
return default
class Store:
"""The ledger file and its rotating backups."""
def __init__(self, data_dir, interval, keep_recent, keep_daily):
self.dir = os.path.abspath(data_dir)
self.file = os.path.join(self.dir, "ledger.enc")
self.backups = os.path.join(self.dir, "backups")
self.interval = max(0, interval)
self.keep_recent = max(1, keep_recent)
self.keep_daily = max(0, keep_daily)
os.makedirs(self.backups, exist_ok=True)
def read(self):
try:
with open(self.file, "rb") as f:
return f.read()
except FileNotFoundError:
return None
def _snapshots(self):
names = [n for n in os.listdir(self.backups)
if n.startswith("ledger-") and n.endswith(".enc")]
return sorted(names) # the timestamp format sorts chronologically
def _backup_current(self):
if not os.path.exists(self.file):
return
snaps = self._snapshots()
now = datetime.datetime.now(datetime.timezone.utc)
if snaps and self.interval:
try:
last = datetime.datetime.strptime(snaps[-1][7:-4], "%Y%m%dT%H%M%S%fZ")
last = last.replace(tzinfo=datetime.timezone.utc)
if (now - last).total_seconds() < self.interval:
return
except ValueError:
pass
name = "ledger-" + now.strftime("%Y%m%dT%H%M%S%fZ") + ".enc"
self._write_atomic(os.path.join(self.backups, name), self.read())
self._prune()
def _prune(self):
snaps = self._snapshots()
keep = set(snaps[-self.keep_recent:])
days = {}
for n in snaps: # later names overwrite earlier: the last one of each day wins
days[n[7:15]] = n
for day in sorted(days)[-self.keep_daily:] if self.keep_daily else []:
keep.add(days[day])
for n in snaps:
if n not in keep:
try:
os.remove(os.path.join(self.backups, n))
except OSError:
pass
def _write_atomic(self, path, body):
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".ledger-", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as f:
f.write(body)
f.flush()
os.fsync(f.fileno())
os.chmod(tmp, 0o600)
os.replace(tmp, path)
dfd = os.open(os.path.dirname(path), os.O_RDONLY)
try:
os.fsync(dfd)
finally:
os.close(dfd)
finally:
if os.path.exists(tmp):
os.remove(tmp)
def save(self, body):
with SAVE_LOCK:
try:
self._backup_current()
except OSError as e: # a failed backup must not block the save
print(f"backup failed: {e}", file=sys.stderr)
self._write_atomic(self.file, body)
def _b64len(value):
"""Decoded length of a strict base64 string, or -1."""
if not isinstance(value, str) or len(value) > MAX_BODY * 2:
return -1
try:
return len(base64.b64decode(value, validate=True))
except (binascii.Error, ValueError):
return -1
def valid_blob(body):
"""True for {v?, salt, iv, ct} as the app writes it, and nothing else."""
try:
obj = json.loads(body)
except (ValueError, UnicodeDecodeError):
return False
if not isinstance(obj, dict) or not {"salt", "iv", "ct"} <= obj.keys():
return False
if set(obj) - {"v", "salt", "iv", "ct"}:
return False
if "v" in obj and (not isinstance(obj["v"], int) or isinstance(obj["v"], bool)):
return False
return (_b64len(obj["salt"]) == 16 and _b64len(obj["iv"]) == 12
and _b64len(obj["ct"]) >= 16)
class Handler(BaseHTTPRequestHandler):
store = None # set in main()
def version_string(self):
return "LibreLedger"
def log_message(self, fmt, *args):
# No client addresses in the log; the request line and status are enough.
sys.stderr.write("%s %s\n" % (self.log_date_time_string(), fmt % args))
def _send(self, code, body=b"", ctype="application/json", cache="no-store", head=False):
self.send_response(code)
for k, v in SECURITY_HEADERS:
self.send_header(k, v)
if code != 204:
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", cache)
self.end_headers()
if body and not head and code != 204:
self.wfile.write(body)
def _path(self):
return unquote(urlsplit(self.path).path)
def _static(self, path, head):
rel = "index.html" if path == "/" else path.lstrip("/")
if rel in APP_FILES:
ctype, cache = APP_FILES[rel], "no-cache"
elif rel in VENDOR_FILES:
ctype, cache = "text/javascript; charset=utf-8", "public, max-age=31536000, immutable"
elif BANK_SVG.match(rel):
ctype, cache = "image/svg+xml", "public, max-age=86400"
else:
return self._send(404, b'{"error":"not found"}', head=head)
try:
with open(os.path.join(ROOT, rel), "rb") as f:
body = f.read()
except OSError:
return self._send(404, b'{"error":"not found"}', head=head)
return self._send(200, body, ctype, cache, head=head)
def _get(self, head):
path = self._path()
if path == "/api/health":
return self._send(200, b'{"ok":true}', head=head)
if path == "/api/data":
body = self.store.read()
if body is None:
return self._send(204, head=head)
return self._send(200, body, head=head)
return self._static(path, head)
def do_GET(self):
self._get(head=False)
def do_HEAD(self):
self._get(head=True)
def _cross_site(self):
# A write must come from this app's own page. Browsers send these on
# cross-origin requests; a JSON PUT from elsewhere is also stopped by
# CORS preflight, since this server never answers one.
if self.headers.get("Sec-Fetch-Site", "same-origin") not in ("same-origin", "none"):
return True
origin = self.headers.get("Origin")
if origin:
host = self.headers.get("X-Forwarded-Host") or self.headers.get("Host") or ""
if urlsplit(origin).netloc.lower() != host.split(",")[0].strip().lower():
return True
return False
def do_PUT(self):
if self._path() != "/api/data":
return self._send(405, b'{"error":"method not allowed"}')
if self._cross_site():
return self._send(403, b'{"error":"cross-site request refused"}')
ctype = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
if ctype != "application/json":
return self._send(415, b'{"error":"expected application/json"}')
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
length = -1
if length <= 0 or length > MAX_BODY:
self.close_connection = True
return self._send(413 if length > MAX_BODY else 400, b'{"error":"bad content-length"}')
body = self.rfile.read(length)
# Only accept something shaped like our encrypted blob; never store junk.
if not valid_blob(body):
return self._send(400, b'{"error":"not an encrypted ledger blob"}')
try:
self.store.save(body)
except OSError as e:
print(f"save failed: {e}", file=sys.stderr)
return self._send(500, b'{"error":"could not write the ledger file"}')
return self._send(200, b'{"ok":true}')
def _refuse(self):
self._send(405, b'{"error":"method not allowed"}')
do_POST = do_DELETE = do_PATCH = do_OPTIONS = _refuse
def main():
ap = argparse.ArgumentParser(description="LibreLedger server")
ap.add_argument("--port", type=int, default=_env_int("LIBRELEDGER_PORT", 8080))
ap.add_argument("--host", default=os.environ.get("LIBRELEDGER_HOST", "127.0.0.1"),
help="address to listen on (default 127.0.0.1, this machine only)")
ap.add_argument("--data-dir", default=os.environ.get("LIBRELEDGER_DATA_DIR",
os.path.join(ROOT, "data")),
help="where ledger.enc and backups/ live (default ./data)")
ap.add_argument("--backup-interval", type=int,
default=_env_int("LIBRELEDGER_BACKUP_INTERVAL", 600),
help="minimum seconds between backup snapshots (default 600, 0 = every save)")
ap.add_argument("--backup-keep-recent", type=int,
default=_env_int("LIBRELEDGER_BACKUP_KEEP_RECENT", 10))
ap.add_argument("--backup-keep-daily", type=int,
default=_env_int("LIBRELEDGER_BACKUP_KEEP_DAILY", 30))
args = ap.parse_args()
os.umask(0o077)
Handler.store = Store(args.data_dir, args.backup_interval,
args.backup_keep_recent, args.backup_keep_daily)
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
httpd.daemon_threads = True
print(f"LibreLedger {VERSION} -> http://{args.host}:{args.port}", flush=True)
print(f"Encrypted data file: {Handler.store.file}", flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.server_close()
if __name__ == "__main__":
main()