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>
340 lines
13 KiB
Python
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()
|