diora-web/tui/diora_tui/secretstore.py
mrwnslz 51b7316398 TUI: Lesestand-Vertrag (zuletzt gelesen gewinnt), Login wie diora_client, Bücher beim Öffnen laden
progress.py setzt docs/progress-sync.md um (read_at in Serverzeit mit Uhrversatz, sent_at beim
Senden, force, Warteschlange je Buch, Neuanwendung nach dem Abgleich; dieselben Regeln wie
Ops.kt/ops.js). Der Reader speichert nur, was der Leser bewegt hat; wiederherstellen,
Umbruch und Sprünge schreiben nichts. Neuere Stellen anderer Geräte werden angeboten
("Auf X weitergelesen – springen?"), nie von allein angewendet; "w" springt zur weitesten
Stelle. Fortschritt als Textanteil in Zeichen statt 0.0.

Abgleich über GET /books/ (ohne Markierungs-Blobs), Bücher erst beim Öffnen geladen
(☁ in der Liste). `diora-tui login` meldet wie die Apps an, liest den vorhandenen Token,
leitet den Schlüssel aus dem Passwort ab und speichert beides geschützt (DPAPI).
DIORA_TUI_HOME legt alle Dateien in einen Ordner (platformdirs folgt unter Windows den
Umgebungsvariablen nicht). 35 Tests, tools/smoke_server.py gegen einen lokalen Server.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:08:59 +01:00

55 lines
1.9 KiB
Python

"""Protect the account's token and book key at rest.
Windows: DPAPI (CryptProtectData), bound to this Windows user on this machine, as
tools/diora_client.py in diora-android does. Elsewhere there is no such store in the standard
library: the blob is plain base64 in a 0600 file (the same exposure the web client has with
its key in localStorage), marked as such so a later version can move it into a keyring.
"""
from __future__ import annotations
import base64
import sys
_ENTROPY = b"diora-tui"
def _dpapi(fn_name: str, data: bytes) -> bytes:
import ctypes
import ctypes.wintypes as wt
class Blob(ctypes.Structure):
_fields_ = [("cbData", wt.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
def blob(b: bytes):
buf = ctypes.create_string_buffer(b, len(b))
return Blob(len(b), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))), buf
inp, _k1 = blob(data)
ent, _k2 = blob(_ENTROPY)
out = Blob()
fn = getattr(ctypes.windll.crypt32, fn_name)
if not fn(ctypes.byref(inp), None, ctypes.byref(ent), None, None, 1, ctypes.byref(out)): # 1: no UI
raise ctypes.WinError()
try:
return ctypes.string_at(out.pbData, out.cbData)
finally:
ctypes.windll.kernel32.LocalFree(out.pbData)
def protect(data: bytes) -> str:
if sys.platform == "win32":
return "dpapi:" + base64.b64encode(_dpapi("CryptProtectData", data)).decode()
return "plain:" + base64.b64encode(data).decode()
def unprotect(blob: str) -> bytes:
kind, _, b64 = blob.partition(":")
raw = base64.b64decode(b64)
if kind == "dpapi":
if sys.platform != "win32":
raise ValueError("Zugangsdaten sind mit Windows-DPAPI geschützt und nur dort lesbar")
return _dpapi("CryptUnprotectData", raw)
if kind == "plain":
return raw
raise ValueError("unbekanntes Format der gespeicherten Zugangsdaten")