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,339 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user