"""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()