diora-web/tui/diora_tui/crypto.py
marwin 50f711831c 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.
2026-08-15 16:55:03 +02:00

51 lines
1.8 KiB
Python

"""AES-256-GCM helpers matching static/js/app.js's encryptBytes/decryptBytes
(Web Crypto AES-GCM, 12-byte IV hex-encoded, ciphertext base64, key handled
as raw bytes) — diora's books are end-to-end encrypted client-side, so the
server (and this module) only ever sees ciphertext plus the key the user
supplies out-of-band.
"""
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):
"""Ciphertext could not be decrypted with the given key (wrong key, or corrupt data)."""
def decrypt(key_b64: str, iv_hex: str, ciphertext_b64: str) -> bytes:
try:
key = base64.b64decode(key_b64)
iv = bytes.fromhex(iv_hex)
ct = base64.b64decode(ciphertext_b64)
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()