TUI: Verschlüsselungs-Key per Benutzername+Passwort ableiten statt Konsolen-Export

diora_tui/crypto.py:derive_key_b64 reproduziert app.js' deriveAndStoreKey()
(PBKDF2-HMAC-SHA256, 200000 Iterationen, Salt "diora:"+username) — der
`sync`-Prompt bietet das jetzt als Standardweg zum Schlüssel an, als
Alternative zum bisherigen Weg über die Browser-Konsole (await
exportEncKey()). Funktioniert nur für Accounts, die den Key je über
diora's "Unlock with password"-Formular abgeleitet haben; ein falsches
Passwort/Account führt zu einem abgefangenen DecryptError ("falscher
Key?"), nie zu stillem Fehlverhalten.

Getestet: zwei unabhängige PBKDF2-Implementierungen (hashlib,
cryptography) stimmen für die verwendeten Parameter byte-genau überein;
vollständiger sync-Lauf gegen einen echten Dev-Server mit einem Buch, das
unter einem exakt so abgeleiteten Key verschlüsselt wurde, entschlüsselt
korrekt; falsches Passwort wird sauber als Fehler gemeldet statt
abzustürzen.
This commit is contained in:
marwin 2026-08-15 16:55:03 +02:00
parent a4b10ae265
commit 50f711831c
3 changed files with 62 additions and 14 deletions

View file

@ -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`. - **Server-URL** — z.B. `https://diora.creamfresh.xyz`.
- **API-Token** — diora → Einstellungen (`/accounts/settings/`) → "Personal Access Token". - **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üsselungs-Key** — der AES-256-Schlüssel, mit dem deine Bücher clientseitig
verschlüsselt wurden. Der Export-Button dafür ist im UI aktuell nicht verdrahtet verschlüsselt wurden. Der `sync`-Prompt bietet zwei Wege:
(`exportEncKey()` in `app.js` existiert, hat aber keinen sichtbaren Button); bis das 1. **Aus Benutzername + Passwort ableiten** (Standard) — reproduziert exakt, was diora's
nachgezogen ist, in der Browser-Devtools-Konsole auf der diora-Seite ausführen: "Unlock with password"-Formular im Browser tut (PBKDF2-HMAC-SHA256, 200.000
```js Iterationen, Salt `"diora:" + username`; siehe `static/js/app.js:deriveAndStoreKey`).
await exportEncKey() 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
Das kopiert den Key ins Clipboard — von dort ins `sync`-Prompt einfügen. 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 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. Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei.

View file

@ -14,7 +14,7 @@ from textual.screen import Screen
from textual.widgets import Footer, Header, Label, ListItem, ListView, Static from textual.widgets import Footer, Header, Label, ListItem, ListView, Static
from . import config as config_mod from . import config as config_mod
from . import epub, progress, remote from . import crypto, epub, progress, remote
DEFAULT_LIBRARY = Path.home() / "Books" 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") 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: def _resolve_remote_config(server_override: str | None, *, save: bool) -> config_mod.RemoteConfig:
cfg = None if server_override else config_mod.load() cfg = None if server_override else config_mod.load()
if cfg is not None: 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.") print("Keine gespeicherten Zugangsdaten gefunden — bitte einmalig eingeben.")
server_url = (server_override or _prompt("Server-URL (z.B. https://diora.creamfresh.xyz)")).rstrip("/") 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) token = _prompt("API-Token (diora → Einstellungen → /accounts/settings/)", secret=True)
enc_key = _prompt( enc_key = _prompt_enc_key()
"Verschlüsselungs-Key (Base64; in der Browser-Konsole auf der diora-Seite: "
"await exportEncKey() kopiert ihn ins Clipboard)",
secret=True,
)
cfg = config_mod.RemoteConfig(server_url=server_url, api_token=token, enc_key_b64=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?"): if save or _ask_yes_no("Zugangsdaten lokal speichern, damit du sie nicht erneut eingeben musst?"):
config_mod.save(cfg) config_mod.save(cfg)

View file

@ -10,7 +10,12 @@ from __future__ import annotations
import base64 import base64
from cryptography.exceptions import InvalidTag from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM 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): 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) return AESGCM(key).decrypt(iv, ct, None)
except (InvalidTag, ValueError) as e: except (InvalidTag, ValueError) as e:
raise DecryptError(str(e)) from 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()