#!/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: /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 / -> 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 /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()