TUI: Bücher per `diora-tui sync` vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.
Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.
Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
|
|
|
"""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
|
2026-08-15 16:55:03 +02:00
|
|
|
from cryptography.hazmat.primitives import hashes
|
TUI: Bücher per `diora-tui sync` vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.
Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.
Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
|
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
2026-08-15 16:55:03 +02:00
|
|
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
|
|
|
|
|
|
|
|
_PBKDF2_ITERATIONS = 200_000
|
|
|
|
|
_KEY_LENGTH_BYTES = 32
|
TUI: Bücher per `diora-tui sync` vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.
Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.
Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-08-15 16:55:03 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|