feat: Flask API for encrypted records (CRUD + replay protection)

Exposes a ciphertext-only REST API: list/get/create/update/delete of
records as opaque base64 blobs. Enforces base64/size validation,
rejects duplicates, and uses patch-version checks server-side so
replayed or stale updates are refused. Includes run.py entry point
that enables TLS when certs/ are present. Verified via test client.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
2026-10-10 23:59:19 +02:00
parent 440cd78203
commit a6a37d675d
2 changed files with 181 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
"""Flask application exposing the records API.
The server is a dumb ciphertext store: it never sees plaintext or the
key. Every record body is opaque base64. Requests are validated only
for shape and size, never for content.
Security properties:
- No plaintext is logged, stored, or echoed.
- Version counters are enforced server-side to detect replays.
- HTTPS is handled by ssl context in run.py (TLS for dev).
"""
import base64
import binascii
from flask import Flask, jsonify, request, send_from_directory
from . import storage
MAX_CIPHERTEXT_B64 = 32 * 1024 # plenty for one record, stops abuse
MAX_SALT_NONCE_B64 = 512
app = Flask(__name__, static_folder=None)
# ---------- basic hardening ----------
@app.after_request
def _security_headers(resp):
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "no-referrer"
resp.headers["Cache-Control"] = "no-store"
return resp
def _b64_field(data, field, max_len, required=True):
"""Validate and decode a base64 field from the JSON body. Returns bytes or None."""
if field not in data:
if required:
raise ValueError(f"missing field: {field}")
return None
raw = data[field]
if not isinstance(raw, str) or not raw or len(raw) > max_len:
raise ValueError(f"invalid field: {field}")
try:
return base64.b64decode(raw, validate=True)
except (binascii.Error, ValueError):
raise ValueError(f"invalid base64 in field: {field}")
def _record_json(rec):
return {
"uuid": rec["uuid"],
"ciphertext": base64.b64encode(rec["ciphertext"]).decode(),
"nonce": base64.b64encode(rec["nonce"]).decode(),
"salt": base64.b64encode(rec["salt"]).decode(),
"version": rec["version"],
"created_at": rec["created_at"],
"updated_at": rec["updated_at"],
}
# ---------- static UI ----------
@app.route("/")
def index():
return send_from_directory("static", "index.html")
@app.route("/static/<path:filename>")
def static_files(filename):
return send_from_directory("static", filename)
# ---------- API ----------
@app.get("/api/records")
def api_list():
return jsonify([_record_json(r) for r in storage.list_records()])
@app.get("/api/records/<uuid>")
def api_get(uuid):
rec = storage.get_record(uuid)
if rec is None:
return jsonify({"error": "not found"}), 404
return jsonify(_record_json(rec))
@app.post("/api/records")
def api_create():
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "invalid JSON body"}), 400
try:
uuid = data.get("uuid")
if not isinstance(uuid, str) or not uuid or len(uuid) > 64:
raise ValueError("invalid field: uuid")
ct = _b64_field(data, "ciphertext", MAX_CIPHERTEXT_B64)
nonce = _b64_field(data, "nonce", MAX_SALT_NONCE_B64)
salt = _b64_field(data, "salt", MAX_SALT_NONCE_B64)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if storage.get_record(uuid) is not None:
return jsonify({"error": "record already exists"}), 409
rec = storage.create_record(uuid, ct, nonce, salt)
return jsonify(_record_json(rec)), 201
@app.put("/api/records/<uuid>")
def api_update(uuid):
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "invalid JSON body"}), 400
try:
version = data.get("version")
if not isinstance(version, int) or version < 1:
raise ValueError("invalid field: version")
ct = _b64_field(data, "ciphertext", MAX_CIPHERTEXT_B64)
nonce = _b64_field(data, "nonce", MAX_SALT_NONCE_B64)
salt = _b64_field(data, "salt", MAX_SALT_NONCE_B64)
except ValueError as e:
return jsonify({"error": str(e)}), 400
rec = storage.update_record(uuid, ct, nonce, salt, expected_version=version)
if rec is None:
# 409: version mismatch (concurrent edit or replay) — do not
# distinguish from "not found" to avoid leaking existence.
return jsonify({"error": "version conflict or not found"}), 409
return jsonify(_record_json(rec))
@app.delete("/api/records/<uuid>")
def api_delete(uuid):
data = request.get_json(silent=True) or {}
version = data.get("version")
if not isinstance(version, int) or version < 1:
return jsonify({"error": "invalid field: version"}), 400
if not storage.delete_record(uuid, expected_version=version):
return jsonify({"error": "version conflict or not found"}), 409
return "", 204
@app.errorhandler(404)
def not_found(_e):
return jsonify({"error": "not found"}), 404
@app.errorhandler(500)
def server_error(_e):
# Never leak stack traces or data.
return jsonify({"error": "internal server error"}), 500
+26
View File
@@ -0,0 +1,26 @@
"""Dev/Prod entry point. Runs the Flask app, over TLS when certs exist."""
import os
import ssl
from backend.app import app
from backend import storage
def main():
storage.init_db()
cert = os.path.join("certs", "cert.pem")
key = os.path.join("certs", "key.pem")
if os.path.exists(cert) and os.path.exists(key):
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(cert, key)
app.run(host="127.0.0.1", port=8443, ssl_context=context)
else:
print("WARNING: no certs/ found, running WITHOUT TLS (dev only!).")
app.run(host="127.0.0.1", port=5000)
if __name__ == "__main__":
main()