SHA256
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>
156 lines
4.8 KiB
Python
156 lines
4.8 KiB
Python
"""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
|