diff --git a/tui/README.md b/tui/README.md index 7bd92ef..bf2efa6 100644 --- a/tui/README.md +++ b/tui/README.md @@ -50,14 +50,21 @@ Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unt - **Server-URL** — z.B. `https://diora.creamfresh.xyz`. - **API-Token** — diora → Einstellungen (`/accounts/settings/`) → "Personal Access Token". -- **Verschlüsselungs-Key (Base64)** — der AES-Schlüssel, mit dem deine Bücher im Browser - verschlüsselt wurden. Der Export-Button dafür ist im UI aktuell nicht verdrahtet - (`exportEncKey()` in `app.js` existiert, hat aber keinen sichtbaren Button); bis das - nachgezogen ist, in der Browser-Devtools-Konsole auf der diora-Seite ausführen: - ```js - await exportEncKey() - ``` - Das kopiert den Key ins Clipboard — von dort ins `sync`-Prompt einfügen. +- **Verschlüsselungs-Key** — der AES-256-Schlüssel, mit dem deine Bücher clientseitig + verschlüsselt wurden. Der `sync`-Prompt bietet zwei Wege: + 1. **Aus Benutzername + Passwort ableiten** (Standard) — reproduziert exakt, was diora's + "Unlock with password"-Formular im Browser tut (PBKDF2-HMAC-SHA256, 200.000 + Iterationen, Salt `"diora:" + username`; siehe `static/js/app.js:deriveAndStoreKey`). + Funktioniert nur, wenn der Account diesen Weg im Browser mindestens einmal benutzt + hat — sonst wurde der Key ursprünglich zufällig im Browser erzeugt, und diese + Ableitung trifft ihn nicht. `sync` meldet einen falschen Key als + Entschlüsselungsfehler (harmlos, kein Datenverlust), nie als falsches Ergebnis. + 2. **Base64-Key direkt einfügen** — für den Fall, dass Weg 1 nicht passt. Der + Export-Button dafür ist im Browser-UI aktuell nicht verdrahtet (`exportEncKey()` in + `app.js` existiert, hat aber keinen sichtbaren Button); bis das nachgezogen ist, in + der Browser-Devtools-Konsole auf der diora-Seite (nicht `/accounts/settings/` — die + lädt `app.js` nicht) ausführen: `await exportEncKey()` — kopiert den Key ins + Clipboard, von dort ins `sync`-Prompt einfügen. Der Key/Token wird genauso vertrauensvoll behandelt wie im Web-Client (dort liegt der Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei. diff --git a/tui/diora_tui/app.py b/tui/diora_tui/app.py index 809413d..5277446 100644 --- a/tui/diora_tui/app.py +++ b/tui/diora_tui/app.py @@ -14,7 +14,7 @@ from textual.screen import Screen from textual.widgets import Footer, Header, Label, ListItem, ListView, Static from . import config as config_mod -from . import epub, progress, remote +from . import crypto, epub, progress, remote DEFAULT_LIBRARY = Path.home() / "Books" @@ -163,6 +163,27 @@ def _ask_yes_no(question: str) -> bool: return input(f"{question} [y/N]: ").strip().lower() in ("y", "yes", "j", "ja") +def _prompt_enc_key() -> str: + print() + print("Verschlüsselungs-Key — zwei Wege:") + print(" [1] Aus Benutzername + Passwort ableiten (wie diora's 'Unlock'-Formular im Browser)") + print(" [2] Base64-Key direkt einfügen (z.B. per Browser-Konsole exportiert)") + choice = input("Wahl [1/2, Standard 1]: ").strip() or "1" + if choice == "2": + return _prompt("Verschlüsselungs-Key (Base64)", secret=True) + + print( + "Hinweis: das liefert nur dann den richtigen Key, wenn dieser Account je über " + "diora's Passwort-Ableitung ('Unlock with password' im Browser) entsperrt wurde. " + "War der Key dort nur automatisch zufällig erzeugt, kommt hier ein anderer " + "(falscher) Key raus — 'sync' meldet das dann als Entschlüsselungsfehler, ohne " + "etwas kaputtzumachen; in dem Fall stattdessen [2] mit dem exportierten Key nutzen." + ) + username = _prompt("diora-Benutzername") + password = _prompt("diora-Passwort", secret=True) + return crypto.derive_key_b64(username, password) + + def _resolve_remote_config(server_override: str | None, *, save: bool) -> config_mod.RemoteConfig: cfg = None if server_override else config_mod.load() if cfg is not None: @@ -171,11 +192,7 @@ def _resolve_remote_config(server_override: str | None, *, save: bool) -> config print("Keine gespeicherten Zugangsdaten gefunden — bitte einmalig eingeben.") server_url = (server_override or _prompt("Server-URL (z.B. https://diora.creamfresh.xyz)")).rstrip("/") token = _prompt("API-Token (diora → Einstellungen → /accounts/settings/)", secret=True) - enc_key = _prompt( - "Verschlüsselungs-Key (Base64; in der Browser-Konsole auf der diora-Seite: " - "await exportEncKey() kopiert ihn ins Clipboard)", - secret=True, - ) + enc_key = _prompt_enc_key() cfg = config_mod.RemoteConfig(server_url=server_url, api_token=token, enc_key_b64=enc_key) if save or _ask_yes_no("Zugangsdaten lokal speichern, damit du sie nicht erneut eingeben musst?"): config_mod.save(cfg) diff --git a/tui/diora_tui/crypto.py b/tui/diora_tui/crypto.py index d0268ab..75a1a7e 100644 --- a/tui/diora_tui/crypto.py +++ b/tui/diora_tui/crypto.py @@ -10,7 +10,12 @@ from __future__ import annotations import base64 from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +_PBKDF2_ITERATIONS = 200_000 +_KEY_LENGTH_BYTES = 32 class DecryptError(Exception): @@ -25,3 +30,22 @@ def decrypt(key_b64: str, iv_hex: str, ciphertext_b64: str) -> bytes: return AESGCM(key).decrypt(iv, ct, None) except (InvalidTag, ValueError) as e: raise DecryptError(str(e)) from e + + +def derive_key_b64(username: str, password: str) -> str: + """Re-derive the AES-256 key the same way app.js's deriveAndStoreKey() does: + PBKDF2-HMAC-SHA256, 200000 iterations, salt = "diora:" + username. Only + yields the key that actually decrypts a user's books if that account's + key was ever set up via diora's "unlock with password" flow — a browser + that only ever auto-generated a random key (the default) has a key this + can't reproduce. + """ + salt = f"diora:{username}".encode("utf-8") + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=_KEY_LENGTH_BYTES, + salt=salt, + iterations=_PBKDF2_ITERATIONS, + ) + raw = kdf.derive(password.encode("utf-8")) + return base64.b64encode(raw).decode()