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>
55 lines
1.9 KiB
Python
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")
|