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:
parent
a4b10ae265
commit
50f711831c
3 changed files with 62 additions and 14 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue