From 1cdb3fa784e068484d34c81ea50be0aac0418bb59003686bccc9303b8ce2334a Mon Sep 17 00:00:00 2001 From: jhkaktus Date: Wed, 14 Oct 2026 20:29:22 +0200 Subject: [PATCH] rewirite to new name --- .gitignore | 170 +--- README.md | 80 -- backend/app.py | 830 ++++++++++++--- backend/static/crypto.js | 194 +--- backend/static/index.html | 146 --- backend/static/style.css | 228 ++--- backend/static/ui.js | 944 ++++++++--------- backend/static/vendor/noble-ciphers/_arx.js | 381 +------ .../static/vendor/noble-ciphers/_poly1305.js | 320 +----- backend/static/vendor/noble-ciphers/chacha.js | 462 +-------- backend/static/vendor/noble-ciphers/utils.js | 897 +--------------- backend/static/vendor/noble-hashes/_blake.js | 61 +- backend/static/vendor/noble-hashes/_md.js | 211 +--- backend/static/vendor/noble-hashes/_u64.js | 79 +- backend/static/vendor/noble-hashes/argon2.js | 579 +---------- backend/static/vendor/noble-hashes/blake2.js | 576 +---------- backend/static/vendor/noble-hashes/utils.js | 692 +------------ backend/storage.py | 962 ++++++++++++++++-- backend/test_integrity.py | 354 +++++-- deploy/crushdb.service | 44 - deploy/who2.service | 17 + requirements.txt | 4 +- run.py | 45 +- 23 files changed, 2521 insertions(+), 5755 deletions(-) delete mode 100644 README.md delete mode 100644 backend/static/index.html delete mode 100644 deploy/crushdb.service create mode 100644 deploy/who2.service diff --git a/.gitignore b/.gitignore index 9cbc615..c2f9b0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,133 +1,10 @@ +pastel + # ---> Python -# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - # Environments .env .venv @@ -137,46 +14,11 @@ ENV/ env.bak/ venv.bak/ -# Spyder project settings -.spyderproject -.spyproject +# SQLite +db.sqlite3 +db.sqlite3-journal -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - - -# project -venv/ +# Instance / state instance/ certs/ __pycache__/ diff --git a/README.md b/README.md deleted file mode 100644 index 744cf63..0000000 --- a/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# crushdb – verschlüsselte Datensätze zwischen Personen - -Sichere Datenbank für Transaktionen zwischen zwei Personen -(Person A, Person B, Timestamp-Start, Timestamp-Ende, Aktion). - -Alle Datensätze werden **im Browser** verschlüsselt -(XChaCha20-Poly1305, Schlüsselableitung per Argon2id aus der Passphrase). -Der Server sieht ausschließlich Chiffretext – auch bei vollständiger -Serverkompromittierung bleiben die Inhalte ohne Passphrase vertraulich. - -## Erster Start (UX) - -- **Beim ersten Besuch** (Datenbank leer): Die App zeigt "Willkommen" und - fragt zweimal nach einer neuen Passphrase (mindestens 8 Zeichen). - Diese schützt ab sofort alle Datensätze – es gibt keine Zurücksetzung. -- **Bei weiteren Besuchen**: Die App bittet einmal um die bekannte - Passphrase ("Entsperren"). -- Der Server speichert niemals die Passphrase selbst, nur verschlüsselte - Datensätze. - -## Starten (HTTP; TLS über Reverse Proxy) - -```bash -python3 -m venv venv -./venv/bin/pip install -r requirements.txt -./venv/bin/python run.py -# → http://127.0.0.1:8085 -``` - -Die App lauscht auf dem lokalen HTTP-Port (default `127.0.0.1:8085`, -überschreibbar via `HOST`/`PORT`). Ein vertrauenswürdiger Reverse Proxy -(nginx, caddy, …) beendet HTTPS und reicht intern an die App weiter. -Die App selbst muss von außen nicht erreichbar sein. - -### Per systemd laufen lassen - -Ein fertiges Unit-File liegt in `deploy/crushdb.service` (Reinheit: -nur `instance/` schreibbar, keine Capabilities, Restart on failure). -Nach dem Kopieren nach `/etc/systemd/system/` und `daemon-reload` -startet `systemctl enable --now crushdb` die App dauerhaft. - -## Tests - -```bash -./venv/bin/python -m pytest backend/test_integrity.py -v # Integrität/Replay -node .e2e.mjs # optional: End-to-End (siehe unten) -``` - -## Architektur - -| Komponente |Datei | Aufgabe | -|---|---|---| -| Speicher | `backend/storage.py` | SQLite, speichert nur Chiffretext (uuid, ciphertext, nonce, salt, version) | -| API | `backend/app.py` | Flask REST API, Validierung nur von Form/Größe, Versions-Konfliktprüfung | -| Krypto (Client) | `backend/static/crypto.js` | Argon2id-KDF + XChaCha20-Poly1305, uuid+salt als AEAD-AAD gebunden | -| UI | `backend/static/ui.js` + `index.html`/`style.css` | Tabelle, Erstellen, Bearbeiten, Löschen; Passphrase bleibt im RAM | -| Server | `run.py` | HTTP intern hinter Reverse Proxy, DB-Init | - -## Sicherheitsmodell - -- Passphrase → Argon2id (t=2, m=16 MiB) → 256-Bit-Sitzungsschlüssel, - ausschließlich im Browser/im Arbeitsspeicher; niemals am Server. -- Jeder Datensatz: eigene zufällige 16-Byte-Salt + 24-Byte-Nonce. - uuid + salt sind als AEAD-Associated-Data gebunden – Vertauschen oder - Um-Salten wird beim Entschlüsseln sicher erkannt. -- Monotoner Versionszähler pro Datensatz: veraltete Updates/Deletes - (Replay alter Stände) → `409`, Duplikate → `409`. -- Klartexte werden clientseitig nie persistiert; beim Sperren/Beenden - wird der Schlüssel verworfen (`wipeKey`). -- HTTPS wird außen vom vertrauenswürdigen **Reverse Proxy** beendet - (nginx/caddy/…); die App lauscht nur intern auf HTTP. - -## Bekannte Grenzen - -- Datenverlust (Löschen/Überschreiben) durch Angreifer bleibt möglich – - Vertraulichkeit ist geschützt, Verfügbarkeit nicht. -- Backups der SQLite-Datei enthalten ausschließlich Chiffretext. -- Backups des Passphrasen-Verlusts = Datenverlust (by design). - -Siehe `spezification.txt` für die Anforderungen. diff --git a/backend/app.py b/backend/app.py index d03f9e5..2eb3cff 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,155 +1,729 @@ -"""Flask application exposing the records API. +import os +import logging +from functools import wraps -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. +from flask import Flask, request, jsonify, session, render_template_string, redirect, url_for -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) +from backend import storage -# ---------- 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 +SENSITIVE_FIELDS = {"password", "passwd", "secret", "token", "access_token", "refresh_token"} +"""Redact sensitive values and long blobs from log-friendly repr.""" -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}") +def redact_for_log(value): + if isinstance(value, dict): + return {k: "[redacted]" if k.lower() in SENSITIVE_FIELDS else redact_for_log(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [redact_for_log(v) for v in value] + return value + + +def _request_snapshot(request): + body = {} try: - return base64.b64decode(raw, validate=True) - except (binascii.Error, ValueError): - raise ValueError(f"invalid base64 in field: {field}") - - -def _record_json(rec): + if request.is_json: + body = request.get_json(silent=True) or {} + elif request.method in ("POST", "PUT", "PATCH") and request.form: + body = {k: v for k, v in request.form.items()} + except Exception: + body = {} 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"], + "method": request.method, + "path": request.path, + "args": dict(request.args), + "body": redact_for_log(body), } -# ---------- static UI ---------- - -@app.route("/") -def index(): - return send_from_directory("static", "index.html") +def _maybe_log_request(app, request): + if app.debug: + app.logger.debug("request: %s", _request_snapshot(request)) -@app.route("/static/") -def static_files(filename): - return send_from_directory("static", filename) +ARTICLE_TYPE_LABELS = { + "contact_details": "Contact details", + "notes": "Notes", + "profile_text": "Profile text", + "documents": "Documents", + "links": "Links", + "phone_numbers": "Phone numbers", + "addresses": "Addresses", + "social_profiles": "Social profiles", +} -# ---------- API ---------- +def create_app(): + app = Flask(__name__, static_folder="static", static_url_path="/static") + app.secret_key = os.environ.get("FLASK_SECRET_KEY") or os.urandom(24).hex() + storage.init_db() -@app.get("/api/records") -def api_list(): - return jsonify([_record_json(r) for r in storage.list_records()]) + @app.route("/") + def index(): + if request.args: + # Never accept credentials (or anything else) via the query string. + app.logger.warning("ignoring query string on index: keys=%s", list(request.args.keys())) + account = session.get("account") + if account: + fresh = storage.get_account_by_id(account.get("id")) or account + session["account"] = fresh + return render_index(fresh) + return render_login_page() + + def _wants_json(): + if request.is_json: + return True + if request.form: + return False + best = request.accept_mimetypes.best_match(["application/json", "text/html"]) + return best == "application/json" + + def _form_or_json(field): + if field in request.form: + return request.form.get(field) + if request.is_json: + data = request.get_json(silent=True) or {} + return data.get(field) + return None + + @app.route("/login", methods=["GET", "POST"]) + def login(): + if request.method == "GET": + if request.args: + app.logger.warning("ignoring query string on login: keys=%s", list(request.args.keys())) + return render_login_page() + _maybe_log_request(app, request) + email = _form_or_json("email") + password = _form_or_json("password") + wants_json = _wants_json() + if not email or not password: + app.logger.warning("login attempt missing credentials: path=%s", request.path) + if wants_json: + return reply_error("email and password required") + return render_login_page("email and password required") + account = storage.authenticate(email, password) + if not account: + app.logger.info("login failed for email=%s path=%s", email, request.path) + if wants_json: + return reply_error("invalid credentials") + return render_login_page("invalid credentials") + session["account"] = account + app.logger.info("login ok for email=%s path=%s", email, request.path) + if wants_json: + return reply_ok(account) + return redirect("/") + + @app.route("/logout") + def logout(): + session.pop("account", None) + return redirect("/") + + @app.route("/account/create", methods=["GET", "POST"]) + def create_account_page(): + if request.method == "GET": + return render_account_create_page() + _maybe_log_request(app, request) + return _create_account() + + @app.route("/account", methods=["POST"]) + def create_account_route(): + return _create_account() + + def _create_account(): + email = _form_or_json("email") + password = _form_or_json("password") + wants_json = _wants_json() + if not email or not password: + if wants_json: + return reply_error("email and password required") + return render_account_create_page("email and password required") + try: + account = storage.create_account(email, password) + app.logger.info("account created email=%s path=%s", email, request.path) + if wants_json: + return reply_ok(account) + return redirect("/") + except ValueError as e: + if "already exists" in str(e).lower(): + app.logger.info("account exists email=%s path=%s", email, request.path) + else: + app.logger.warning("account create failed email=%s err=%s path=%s", email, str(e), request.path) + if wants_json: + return reply_error(str(e)) + return render_account_create_page(str(e)) + + @app.route("/account/me") + def me(): + account = session.get("account") + if not account: + return reply_error("not authenticated") + fresh = storage.get_account_by_id(account.get("id")) or account + session["account"] = fresh + return reply_ok(fresh) + + def get_account(): + return session.get("account") + + def login_required(f): + @wraps(f) + def wrapped(*args, **kwargs): + account = get_account() + if not account: + return reply_error("authentication required") + request.account = account + return f(*args, **kwargs) + + return wrapped + + @app.route("/persons", methods=["GET"]) + @login_required + def list_persons_route(): + persons = storage.list_persons(request.account) + return reply_ok(persons) + + @app.route("/persons", methods=["POST"]) + @login_required + def create_person_route(): + name = _form_or_json("name") or "" + try: + person = storage.create_person(request.account, name) + except (PermissionError, ValueError) as e: + return reply_error(str(e)) + return reply_ok(person) + + @app.route("/admin/persons", methods=["POST"]) + @login_required + def admin_create_person_route(): + name = _form_or_json("name") or "" + email = _form_or_json("email") + password = _form_or_json("password") + try: + person = storage.create_person_with_account(request.account, name, email, password) + return reply_ok(person) + except (PermissionError, ValueError) as e: + return reply_error(str(e)) + + @app.route("/persons/") + @login_required + def get_person_route(person_id): + person = storage.get_person(person_id) + if not person: + return reply_error("person not found") + if not storage.is_admin(request.account) and person_id != request.account.get("person_id"): + # Only admins see which emails belong to another person. + person = dict(person) + person["emails"] = [] + return reply_ok(person) + + @app.route("/persons/", methods=["PATCH"]) + @login_required + def update_person_route(person_id): + name = request.form.get("name") or request.json.get("name") + name = name if name is not None else None + person = storage.update_person(request.account, person_id, name) + if not person: + return reply_error("person not found") + return reply_ok(person) + + @app.route("/persons/", methods=["DELETE"]) + @login_required + def delete_person_route(person_id): + ok = storage.delete_person(request.account, person_id) + if not ok: + return reply_error("person not found") + return reply_ok({"deleted": True}) + + @app.route("/persons//emails/attach", methods=["POST"]) + @login_required + def attach_email_route(person_id): + email = request.form.get("email") or request.json.get("email") + if not email: + return reply_error("email required") + try: + person = storage.attach_email_to_person(request.account, person_id, email) + return reply_ok(person) + except (ValueError, PermissionError) as e: + return reply_error(str(e)) + + @app.route("/persons//emails/detach", methods=["POST"]) + @login_required + def detach_email_route(person_id): + email = request.form.get("email") or request.json.get("email") + if not email: + return reply_error("email required") + try: + person = storage.detach_email_from_person(request.account, person_id, email) + return reply_ok(person) + except (ValueError, PermissionError) as e: + return reply_error(str(e)) + + @app.route("/articles", methods=["GET"]) + @login_required + def list_articles_route(): + person_id = request.args.get("person_id") + articles = storage.list_articles(request.account, person_id) + return reply_ok(articles) + + @app.route("/articles", methods=["POST"]) + @login_required + def create_article_route(): + person_id = _form_or_json("person_id") + article_type = _form_or_json("article_type") + title = _form_or_json("title") or "" + content = _form_or_json("content") or "" + visibility = _form_or_json("visibility") or "private" + group_ids = request.json.get("group_ids") if request.is_json else None + if group_ids is None: + group_ids = [] + if article_type not in storage.ARTICLE_TYPES: + return reply_error("invalid article type") + if person_id is None: + return reply_error("person_id required") + try: + article = storage.create_article( + request.account, person_id, article_type, title, content, visibility, group_ids + ) + return reply_ok(article) + except (ValueError, PermissionError) as e: + return reply_error(str(e)) + + @app.route("/articles/") + @login_required + def get_article_route(article_id): + article = storage.get_article(article_id) + if not article: + return reply_error("article not found") + if not storage._can_read_article(request.account, article): + article = {k: v for k, v in article.items() if k != "content"} + return reply_ok(article) + + @app.route("/articles/", methods=["PATCH"]) + @login_required + def update_article_route(article_id): + title = request.form.get("title") or request.json.get("title") + content = request.form.get("content") or request.json.get("content") + visibility = request.form.get("visibility") or request.json.get("visibility") + group_ids = request.json.get("group_ids") if request.is_json else None + title = title if title is not None else None + content = content if content is not None else None + visibility = visibility if visibility is not None else None + try: + article = storage.update_article( + request.account, article_id, title=title, content=content, visibility=visibility, group_ids=group_ids + ) + if not article: + return reply_error("article not found") + return reply_ok(article) + except (ValueError, PermissionError) as e: + return reply_error(str(e)) + + @app.route("/articles/", methods=["DELETE"]) + @login_required + def delete_article_route(article_id): + try: + ok = storage.delete_article(request.account, article_id) + if not ok: + return reply_error("article not found") + return reply_ok({"deleted": True}) + except PermissionError as e: + return reply_error(str(e)) + + @app.route("/groups", methods=["GET"]) + @login_required + def list_groups_route(): + person_id = request.args.get("person_id") + groups = storage.list_groups(request.account, person_id) + return reply_ok(groups) + + @app.route("/groups", methods=["POST"]) + @login_required + def create_group_route(): + person_id = request.form.get("person_id") or request.json.get("person_id") + name = request.form.get("name") or request.json.get("name") or "" + if not person_id: + return reply_error("person_id required") + try: + group = storage.create_group(request.account, person_id, name) + return reply_ok(group) + except PermissionError as e: + return reply_error(str(e)) + + @app.route("/groups/") + @login_required + def get_group_route(group_id): + group = storage.get_group(group_id) + if not group: + return reply_error("group not found") + if not storage.group_is_visible_to(request.account, group_id): + return reply_error("group not found") + return reply_ok(group) + + @app.route("/groups/", methods=["PATCH"]) + @login_required + def update_group_route(group_id): + name = request.form.get("name") or request.json.get("name") + name = name if name is not None else None + try: + group = storage.update_group(request.account, group_id, name) + if not group: + return reply_error("group not found") + return reply_ok(group) + except PermissionError as e: + return reply_error(str(e)) + + @app.route("/groups/", methods=["DELETE"]) + @login_required + def delete_group_route(group_id): + try: + ok = storage.delete_group(request.account, group_id) + if not ok: + return reply_error("group not found") + return reply_ok({"deleted": True}) + except PermissionError as e: + return reply_error(str(e)) + + @app.route("/groups//members/", methods=["POST"]) + @login_required + def add_member_route(group_id, person_id): + try: + group = storage.add_group_member(request.account, group_id, person_id) + return reply_ok(group) + except (ValueError, PermissionError) as e: + return reply_error(str(e)) + + @app.route("/groups//members/", methods=["DELETE"]) + @login_required + def remove_member_route(group_id, person_id): + try: + group = storage.remove_group_member(request.account, group_id, person_id) + return reply_ok(group) + except PermissionError as e: + return reply_error(str(e)) + + @app.route("/rights", methods=["GET"]) + @login_required + def list_rights_route(): + account_id = request.args.get("account_id") + try: + rights = storage.list_rights(request.account, account_id) + except PermissionError as e: + return reply_error(str(e)) + return reply_ok(rights) + + @app.route("/rights/grant", methods=["POST"]) + @login_required + def grant_right_route(): + target_account_id = request.form.get("account_id") or request.json.get("account_id") + right_name = request.form.get("right") or request.json.get("right") + if not target_account_id or not right_name: + return reply_error("account_id and right required") + try: + storage.grant_right(request.account, target_account_id, right_name) + return reply_ok({"granted": right_name}) + except (ValueError, PermissionError) as e: + return reply_error(str(e)) + + @app.route("/rights/revoke", methods=["POST"]) + @login_required + def revoke_right_route(): + target_account_id = request.form.get("account_id") or request.json.get("account_id") + right_name = request.form.get("right") or request.json.get("right") + if not target_account_id or not right_name: + return reply_error("account_id and right required") + try: + storage.revoke_right(request.account, target_account_id, right_name) + return reply_ok({"revoked": right_name}) + except PermissionError as e: + return reply_error(str(e)) + + @app.route("/api/types") + def article_types(): + return reply_ok({ + "article_types": storage.ARTICLE_TYPES, + "rights": storage.RIGHTS, + "article_type_labels": ARTICLE_TYPE_LABELS, + }) + + return app -@app.get("/api/records/") -def api_get(uuid): - rec = storage.get_record(uuid) - if rec is None: - return jsonify({"error": "not found"}), 404 - return jsonify(_record_json(rec)) +def render_account_create_page(error=None): + return render_template_string( + """ + + + + + + Who² - Create account + + + +
+

Create account

+
+ + + +
+

Already have an account? Log in

+
{{ error }}
+
+ + + + + + """, + error=error or "", + ) -@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 +def render_login_page(error=None): + return render_template_string( + """ + + + + + + Who² - Login + + + +
+

Who²

+
+ + + +
+

No account yet? Create one

+
{{ error }}
+
+ + + + + + + + + + + + + + + """, + error=error or "", + ) -@app.put("/api/records/") -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 +def render_index(account=None): + is_admin = storage.is_admin(account) + return render_template_string( + """ + + + + + + Who² + + + +
+

Who²

+ +
+
+
+

My person

+
+
- 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)) +
+

{% if is_admin %}Add article{% else %}Add note{% endif %}

+
+ {% if is_admin %} + + + {% endif %} + + + {% if is_admin %} + + {% endif %} + +
+

+
+ +
+

Articles

+
    +
    + +
    +

    My groups

    +
    + + +
    +
      +
      + + + + +
      +
      + + {% if is_admin %} +
      +

      Admin · create person

      +
      + + + + +
      +
        +
        + +
        +

        Admin · person emails

        +
        + + + + +
        +

        The email needs an account; create one above by giving a password.

        +
        + +
        +

        Admin · accounts & rights

        +
        + + + + +
        +
          +
          + {% endif %} +
          + + + + + + + + + + + + + + + + + """, + is_admin=is_admin, + ) -@app.delete("/api/records/") -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 +def reply_ok(body): + resp = jsonify(body) + resp.status_code = 200 + return resp -@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 +def reply_error(message): + resp = jsonify({"error": message}) + resp.status_code = 400 + return resp diff --git a/backend/static/crypto.js b/backend/static/crypto.js index 1d27a2e..ac1065f 100644 --- a/backend/static/crypto.js +++ b/backend/static/crypto.js @@ -1,155 +1,55 @@ -/** - * crypto.js – client-side encryption module (vanilla ES module). - * - * All cryptography happens in the browser. The passphrase and derived - * key never leave this module's memory and are never sent anywhere. - * - * Design: - * - Key derivation: Argon2id (via @noble/hashes), one key per session, - * derived once from the passphrase the user enters on opening the - * site. The salt used for derivation is *not* the per-record salt; - * it is a random domain salt stored alongside each record. - * - Cipher: XChaCha20-Poly1305 (AEAD) with a fresh random 24-byte nonce - * per record (via @noble/ciphers). - * - Each record carries its own random salt. That salt is bound into - * the AEAD header (as an extra attribute) so re-deriving the key for - * a record with a modified salt reliably fails. TLS-like: header is - * encrypted alongside the payload body. - * - AAD (associated data) binds the record's uuid into the AEAD header, - * so a ciphertext cannot be swapped between records undetected. - * - * Everything is in-memory only: this file has no persistence, and the - * session key must be wiped explicitly by the caller (wipeKey). - */ +"use strict"; -import { xchacha20poly1305 } from "./vendor/noble-ciphers/chacha.js"; -import { randomBytes } from "./vendor/noble-ciphers/utils.js"; -import { argon2id } from "./vendor/noble-hashes/argon2.js"; - -const ARGON2_PARAMS = { - // Moderate defaults so unlocking stays usable on typical hardware in-JS. - t: 2, // passes - m: 16384, // memory, KiB (16 MiB) - p: 1, // parallelism - dkLen: 32, // 256-bit key for XChaCha20 -}; - -const REC_SALT_LEN = 16; // per-record salt is independent from KDF salt? No: - // the KDF salt IS the per-record salt (see below). - -// ---------- base64 / utf8 helpers ---------- - -export function b64encode(bytes) { - let bin = ""; - for (const b of bytes) bin += String.fromCharCode(b); - return btoa(bin); -} - -export function b64decode(str) { - const bin = atob(str); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -export function utf8encode(str) { - return new TextEncoder().encode(str); -} - -export function utf8decode(bytes) { - return new TextDecoder().decode(bytes); -} - -export function randomUuid() { - return crypto.randomUUID(); -} - -// ---------- key derivation ---------- - -/** - * Derive a 32-byte key from a passphrase + salt with Argon2id. - * Runs entirely in the browser; the passphrase never leaves it. - */ -export function deriveKey(passphrase, saltBytes) { - if (typeof passphrase !== "string" || passphrase.length === 0) - throw new Error("invalid passphrase"); - if (!(saltBytes instanceof Uint8Array) || saltBytes.length < 8) - throw new Error("invalid salt"); - const pwBytes = utf8encode(passphrase); - try { - return argon2id(pwBytes, saltBytes, ARGON2_PARAMS); - } finally { - pwBytes.fill(0); // best-effort scratch wipe +function req(path, options) { + const headers = { "Content-Type": "application/json" }; + options = Object.assign({ method: "GET", headers }, options || {}); + if (options.body && typeof options.body === "object") { + options.body = JSON.stringify(options.body); } + return fetch(path, options).then(function (r) { + return r.json().then(function (data) { + if (!r.ok) throw new Error(data.error || "request failed"); + return data; + }); + }); } -// ---------- record encryption ---------- - -/** - * Encrypt a record payload. - * - * For a NEW record pass salt = randomSalt(). For an UPDATE pass the - * salt already stored on the record — the payload must be encrypted - * under a key derived from the SAME salt, otherwise decryption on the - * other side fails loudly. This binds the salt to the ciphertext: - * a modified salt (= malicious re-key) cannot go unnoticed. - * - * Payload is any JSON-serializable value. Returns the record body - * ready for the API ({uuid, ciphertext, nonce, salt}) with the AEAD - * computed over uuid+salt as associated data. - */ -export function encryptRecord(key, uuid, payload, saltBytes) { - if (!(saltBytes instanceof Uint8Array) || saltBytes.length !== REC_SALT_LEN) - throw new Error("invalid record salt"); - const nonce = randomBytes(24); - // AAD binds uuid AND salt: swapping ciphertext between records or - // silently changing the salt breaks the tag. - const aad = utf8encode( - "crushdb:record:" + uuid + ":" + b64encode(saltBytes) - ); - const plaintext = utf8encode(JSON.stringify(payload)); - const ciphertext = xchacha20poly1305(key, nonce, aad).encrypt(plaintext); - plaintext.fill(0); // no plaintext residue in heap - return { - uuid, - ciphertext: b64encode(ciphertext), - nonce: b64encode(nonce), - salt: b64encode(saltBytes), - }; -} - -export function randomSalt() { - return randomBytes(REC_SALT_LEN); -} - -// ---------- record decryption ---------- - -/** - * Decrypt a record fetched from the API. - * Throws on wrong key, wrong salt, or any tampering (AEAD tag). - */ -export function decryptRecord(key, rec) { - if (!rec || typeof rec.uuid !== "string") throw new Error("invalid record"); - const saltBytes = b64decode(rec.salt); - const aad = utf8encode( - "crushdb:record:" + rec.uuid + ":" + b64encode(saltBytes) - ); - const ct = b64decode(rec.ciphertext); - const nonce = b64decode(rec.nonce); - const plaintext = xchacha20poly1305(key, nonce, aad).decrypt(ct); - try { - return JSON.parse(utf8decode(plaintext)); - } finally { - plaintext.fill(0); +function postForm(path, form) { + const data = {}; + for (const [k, v] of new FormData(form)) { + if (v) data[k] = v; } + return req(path, { method: "POST", body: JSON.stringify(data) }); } -// ---------- hygiene ---------- - -/** - * Best-effort wipe of a derived key (32 bytes). JS cannot guarantee - * zeroing, but this removes the obvious copy. - */ -export function wipeKey(key) { - if (key instanceof Uint8Array) key.fill(0); +function patchJson(path, body) { + return req(path, { method: "PATCH", body: body }); +} + +function delReq(path) { + return req(path, { method: "DELETE" }); +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function uuid() { + if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID(); + const a = new Uint8Array(16); + (crypto.getRandomValues || function () { for (let i = 0; i < a.length; i++) a[i] = Math.floor(Math.random() * 256); })(a); + a[6] = (a[6] & 0x0f) | 0x40; + a[8] = (a[8] & 0x3f) | 0x80; + const hex = Array.from(a, b => b.toString(16).padStart(2, "0")).join(""); + return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20); +} + +function appendMsg(el, message, isError) { + el.textContent = message || ""; + el.classList.toggle("hidden", !message); + el.style.color = isError ? "#dc2626" : "#047857"; } diff --git a/backend/static/index.html b/backend/static/index.html deleted file mode 100644 index cb3d832..0000000 --- a/backend/static/index.html +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - crushdb - - - -

          crushdb

          -

          Verschlüsselte Datensätze zwischen zwei Personen

          - -
          - - - - - -
          -
          -

          Entsperren

          -

          - Du hast bereits verschlüsselte Datensätze hinterlegt. - Gib deine Passphrase ein, um sie zu lesen. -

          - - - -

          - Die Passphrase verlässt niemals deinen Browser. Sie wird per - Argon2id lokal abgeleitet und nur im Arbeitsspeicher gehalten. -

          -
          -
          - - - - - - - - - - - - - - diff --git a/backend/static/style.css b/backend/static/style.css index eab4479..4c5e545 100644 --- a/backend/static/style.css +++ b/backend/static/style.css @@ -1,165 +1,89 @@ -/* crushdb – simple, clean dark UI */ - -:root { - --bg: #101418; - --panel: #1a2028; - --panel-2: #202830; - --border: #2c3642; - --text: #e6edf3; - --muted: #8b98a5; - --accent: #4da3ff; - --danger: #ff5d5d; - --ok: #47d68a; -} - * { box-sizing: border-box; } - body { - margin: 0 auto; - max-width: 960px; - padding: 2rem 1rem; - font-family: system-ui, -apple-system, "Segoe UI", sans-serif; - background: var(--bg); - color: var(--text); - line-height: 1.5; + font-family: system-ui, -apple-system, sans-serif; + margin: 0; + background: #f6f7f9; + color: #1f2330; } - -h1 { margin: 0 0 0.25rem; font-size: 1.6rem; } -.tagline { margin: 0 0 1.5rem; color: var(--muted); } - -#flash { - min-height: 1.4rem; - margin-bottom: 1rem; - color: var(--ok); +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #ffffff; + border-bottom: 1px solid #e4e7ee; } -#flash.error { color: var(--danger); } - -#unlock, #setup { - max-width: 420px; - margin: 3rem auto; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - padding: 1.5rem; +.account-bar { display: flex; align-items: center; gap: 12px; } +main { + max-width: 1100px; + margin: 16px auto; + padding: 0 16px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; } - -#unlock h2, #setup h2 { margin: 0 0 0.5rem; font-size: 1.15rem; } -.intro { color: var(--muted); font-size: 0.9rem; margin: 0 0 1rem; } - -#unlock label, #setup label { display: block; margin-bottom: 0.75rem; } -#unlock input, #setup input { +.panel { + background: #ffffff; + border: 1px solid #e4e7ee; + border-radius: 8px; + padding: 14px; +} +.panel h2 { margin-top: 0; font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b7280; } +.panel label { + display: block; + font-size: 12px; + color: #6b7280; + margin: 8px 0 2px; +} +input, select, textarea { width: 100%; - margin-top: 0.25rem; + padding: 6px 8px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 14px; + font-family: inherit; } -.hint { color: var(--muted); font-size: 0.85rem; margin-top: 1rem; } - -input, button { - font: inherit; - color: var(--text); -} - -input { - background: var(--panel-2); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.5rem 0.7rem; -} -input:focus { outline: 1px solid var(--accent); } - -button { - background: var(--accent); - color: #0b1016; +textarea { min-height: 60px; resize: vertical; } +button, .btn-small { + background: #2563eb; + color: #fff; border: none; - border-radius: 6px; - padding: 0.5rem 1rem; + padding: 6px 12px; + border-radius: 4px; cursor: pointer; - font-weight: 600; + font-size: 14px; } -button:hover { filter: brightness(1.1); } - -#app .toolbar { - display: flex; - gap: 0.75rem; - margin-bottom: 1rem; +button[type="button"] { background: #e5e7eb; color: #1f2330; } +.btn-small { padding: 4px 8px; font-size: 12px; } +.btn-ghost { background: transparent; color: #dc2626; border: 1px solid #dc2626; } +.card { max-width: 420px; margin: 60px auto; padding: 24px; } +.card h1 { margin: 0 0 16px; } +.hint { font-size: 12px; color: #6b7280; margin-top: 12px; } +.msg { color: #dc2626; font-size: 12px; margin-top: 8px; word-break: break-word; } +.hidden { display: none; } +ul { list-style: none; padding: 0; margin: 8px 0 0; } +li { + padding: 8px 10px; + border: 1px solid #e4e7ee; + border-radius: 4px; + margin: 4px 0; + font-size: 13px; + background: #fafbfc; } -#lock-btn { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); } - -table { - width: 100%; - border-collapse: collapse; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - overflow: hidden; +li .meta { color: #6b7280; font-size: 11px; display: block; margin-top: 4px; } +.account-bar .dot { width: 8px; height: 8px; border-radius: 50%; background: #10b981; display: inline-block; } +.badge { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 8px; + border-radius: 999px; + background: #e5e7eb; + color: #374151; } - -th, td { - padding: 0.6rem 0.75rem; - text-align: left; - border-bottom: 1px solid var(--border); +.panel.admin { + border-color: #c7d2fe; + background: #f8faff; } -thead th { background: var(--panel-2); color: var(--muted); font-weight: 600; } -tbody tr:last-child td { border-bottom: none; } -tbody tr:hover { background: var(--panel-2); } - -.row-error td { color: var(--danger); font-size: 0.9rem; } - -td:last-child, td button { white-space: nowrap; } -td button + button { margin-left: 0.4rem; } - -/* filter bar */ -.filters { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem; - margin-bottom: 1rem; -} -.filters input[type="search"] { flex: 1 1 220px; min-width: 170px; } -.filters select { max-width: 220px; } -.filter-count { color: var(--muted); font-size: 0.85rem; margin-left: auto; white-space: nowrap; } - -select { - font: inherit; - color: var(--text); - background: var(--panel-2); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.5rem 0.7rem; -} -select:focus { outline: 1px solid var(--accent); } -option { background: var(--panel); color: var(--text); } - -.secondary { - background: var(--panel-2); - color: var(--text); - border: 1px solid var(--border); -} - -/* modal */ -/* NOTE: keep `hidden` winning over the flex display below, otherwise the - backdrop is permanently visible and blocks everything underneath. */ -#editor-backdrop[hidden] { display: none; } - -#editor-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; -} - -#editor { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - padding: 1.5rem; - width: min(440px, 92vw); -} - -#editor h2 { margin-top: 0; font-size: 1.15rem; } -#editor label { display: block; margin-bottom: 0.75rem; color: var(--muted); } -#editor input { width: 100%; margin-top: 0.25rem; } -#editor .actions { display: flex; gap: 0.75rem; margin-top: 1rem; } -#editor-cancel { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); } +.panel.admin h2 { color: #4338ca; } +#myperson .meta { margin-top: 4px; } diff --git a/backend/static/ui.js b/backend/static/ui.js index 7aa58aa..d67b0e5 100644 --- a/backend/static/ui.js +++ b/backend/static/ui.js @@ -1,488 +1,504 @@ -/** - * ui.js – browser UI logic for crushdb. - * - * Two distinct screens before the app opens: - * - * 1. "setup" (first visit): No encrypted records exist on the server - * yet. The user picks a NEW passphrase (typed twice to avoid - * typos). This passphrase then protects every future record. - * - * 2. "unlock" (returning): encrypted records exist, but the user's - * key isn't known to the browser (it never is – that's the whole - * point of client-side encryption). The user types their - * existing passphrase. - * - * Which screen to show is decided by a cheap server call: - * GET /api/records → empty array ⇒ setup, else unlock. - * - * The passphrase itself NEVER leaves the browser: Argon2id derives a - * 256-bit session key in memory, and that key exists only here. - */ +"use strict"; -import { - deriveKey, - encryptRecord, - decryptRecord, - randomSalt, - randomUuid, - wipeKey, -} from "./crypto.js"; +(function () { + const msgEl = document.getElementById("app-msg") || document.getElementById("login-msg"); + const isLoginPage = !!document.getElementById("login-form"); -// ---------- tiny DOM helpers ---------- + let currentAccount = null; -const $ = (sel) => document.querySelector(sel); - -function el(tag, attrs = {}, children = []) { - const node = document.createElement(tag); - for (const [k, v] of Object.entries(attrs)) { - if (k === "class") node.className = v; - else if (k === "text") node.textContent = v; - else if (k.startsWith("on") && typeof v === "function") { - // Event handlers MUST be assigned as properties: setAttribute(k, fn) - // stringifies the function ("... => ...") and never binds a listener, - // which silently killed the Bearbeiten/Löschen buttons. - node[k] = v; - } - else node.setAttribute(k, v); - } - for (const child of children) node.appendChild(child); - return node; -} - -function flash(msg, isError = false) { - const box = $("#flash"); - box.textContent = msg; - box.className = isError ? "flash error" : "flash"; - setTimeout(() => { box.textContent = ""; }, 5000); -} - -// ---------- session state (memory only) ---------- - -let sessionKey = null; // Uint8Array(32) or null -let busy = false; // Argon2id takes a second; block double submits - -// Decrypted records kept in memory so filters/datalist suggestions can -// re-render without re-fetching and re-decrypting everything. -let decryptedRows = []; // [{ record, payload }] -let lastFailures = 0; // records that could not be decrypted (shown unfiltered) - -// Salt for the session-key derivation. Fixed domain string, contains -// no secret; the per-record salt is separate (see crypto.js). -const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1"); - -function resetSession() { - wipeKey(sessionKey); - sessionKey = null; - decryptedRows = []; - lastFailures = 0; - $("#records").replaceChildren(); - $("#app").hidden = true; - location.reload(); // cheapest and safest way back to a clean state -} - -// ---------- API helpers ---------- - -async function api(path, opts = {}) { - const res = await fetch(path, { - headers: opts.body ? { "Content-Type": "application/json" } : {}, - ...opts, - }); - if (!res.ok) { - let detail = ""; - try { detail = (await res.json()).error ?? ""; } catch { /* noop */ } - throw new Error(`HTTP ${res.status}${detail ? ": " + detail : ""}`); - } - if (res.status === 204) return null; - return res.json(); -} - -// ---------- rendering ---------- - -// Timestamps are free text now ("unknown", "Sommer 2025", …). Display -// them verbatim; nothing to parse or format. -function showTs(value) { - return (value === null || value === undefined || value === "") ? "–" : String(value); -} - -function renderRow(record, payload) { - const tr = el("tr"); - const cells = [ - payload.personA, - payload.personB, - showTs(payload.tsStart), - showTs(payload.tsEnd), - showTs(payload.startCause), - showTs(payload.endCause), - payload.action, - ]; - for (const text of cells) { - const td = el("td"); - td.textContent = text ?? ""; // textContent = no HTML injection - tr.appendChild(td); + function showMessage(text, isError) { + if (!msgEl) return; + appendMsg(msgEl, text, isError); } - const ops = el("td"); - ops.appendChild( - el("button", { - text: "Bearbeiten", - type: "button", - onclick: () => openEditor(record, payload), - }) - ); - ops.appendChild( - el("button", { - text: "Löschen", - type: "button", - onclick: () => removeRecord(record), - }) - ); - tr.appendChild(ops); - return tr; -} - -async function reload() { - const raw = await api("/api/records"); - decryptedRows = []; - lastFailures = 0; - for (const record of raw) { - try { - const payload = decryptRecord(sessionKey, record); - payload.__version = record.version; // used for optimistic locking - decryptedRows.push({ record, payload }); - } catch { - lastFailures++; - } - } - refreshSuggestions(); - renderRows(); - $("#new-btn").disabled = false; - if (lastFailures > 0) flash(`${lastFailures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true); -} - -// ---------- filters & suggestions ---------- - -function sorted(values) { - return [...values].sort((a, b) => a.localeCompare(b, "de")); -} - -// Rebuild the dropdowns (editor) and the filter