feat: Kennwort je FritzBox-Sicherung — gespeichert, per 👁 anzeigen, kopieren

Bisher merkte sich der Satellit nur die QUELLE des Kennworts. Wurde das
Sicherungs-Kennwort spaeter geaendert, passte es nicht mehr zu aelteren
Sicherungen — auch "Wiederherstellen" haette das falsche genommen.

- Satellit: das benutzte Kennwort liegt verschluesselt (Fernet, wie der
  Zugangsdaten-Speicher) in der Begleitdatei; Liste/Ansicht geben es nie
  heraus. Wiederherstellen nimmt genau dieses. fritzbox.backup_password
  liefert es (aeltere Sicherungen: aktuelles der Quelle + Hinweis).
  Feld "kennwort" im Sicherungs-Ergebnis heisst jetzt "verschluesselt_mit".
- Diagnostic-Server: Ergebnisse mit Kennwort gehen nur an den Browser,
  der gefragt hat; ohne Fragenden werden sie verworfen, nie an alle.
- Brain: fritzbox.backup_password ist fuer ARIA gesperrt (Kennwoerter
  gehen nicht ans Sprachmodell).
- Diagnostic: 👁 in der Kennwort-Spalte → Zeile mit dem Kennwort und
  "Kopieren" — klappt auch ohne https (Fallback ueber execCommand).

Getestet: Satellit (Kennwort nicht im Klartext gespeichert, nach
Kennwort-Aenderung zeigt 👁 und nimmt Wiederherstellen das alte), Fenster
in headless Chromium ohne Clipboard-API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 18:26:37 +01:00
co-authored by Claude Opus 5.5
parent e44e2c2a70
commit 2cd8f1a0de
4 changed files with 108 additions and 8 deletions
+45 -4
View File
@@ -1524,7 +1524,11 @@ def _fb_backup(ip: str, user: str, pw: str, params: dict) -> dict:
"bytes": len(d.content), "model": fi.get("NewModelName", ""), "firmware": fi.get("NewSoftwareVersion", ""),
"serial": fi.get("NewSerialNumber", ""),
# nur die QUELLE des Kennworts — zum Wiederherstellen noetig, nie der Wert
"kennwort": "angegeben" if params.get("password") else ("backupPass" if stored else "login")}
"kennwort": "angegeben" if params.get("password") else ("backupPass" if stored else "login"),
# Das benutzte Kennwort selbst, verschluesselt wie der Zugangsdaten-
# Speicher: passt auch noch, wenn das Sicherungs-Kennwort spaeter
# geaendert wird (Wiederherstellen + 👁 im Diagnostic).
"kennwort_enc": _creds_cipher().encrypt(export_pw.encode()).decode()}
with open(path[:-len(".export")] + ".json", "w", encoding="utf-8") as fh:
json.dump(meta, fh, ensure_ascii=False, indent=1)
keep = _fb_backup_keep(ip)
@@ -1545,7 +1549,7 @@ def _fb_backup(ip: str, user: str, pw: str, params: dict) -> dict:
return {"ok": True, "result": {
"ip": ip, "datei": path, "bytes": len(d.content), "auf": "Satellit",
**({"file_b64": file_b64, "filename": fname} if file_b64 else {}),
"kennwort": ("das angegebene Kennwort" if params.get("password")
"verschluesselt_mit": ("das angegebene Kennwort" if params.get("password")
else "das im Satelliten hinterlegte Sicherungs-Kennwort" if stored
else "das FritzBox-Login-Kennwort"),
"hinweis": f"Liegt auf dem Satelliten, die letzten {keep} je Box bleiben. "
@@ -1591,12 +1595,44 @@ def _fb_backup_path(name: str) -> str:
return path if os.path.isfile(path) else ""
def _fb_backup_meta(path: str) -> dict:
def _fb_backup_meta(path: str, with_secret: bool = False) -> dict:
try:
with open(path[:-len(".export")] + ".json", encoding="utf-8") as fh:
return json.load(fh)
m = json.load(fh)
except Exception:
return {}
if not with_secret:
m.pop("kennwort_enc", None)
return m
def _fb_backup_password(ip: str, path: str) -> tuple:
"""(kennwort, hinweis) fuer genau diese Sicherung. Aeltere Sicherungen ohne
gespeichertes Kennwort: das AKTUELL hinterlegte der Quelle (mit Hinweis)."""
meta = _fb_backup_meta(path, with_secret=True)
if meta.get("kennwort_enc"):
try:
return _creds_cipher().decrypt(meta["kennwort_enc"].encode()).decode(), ""
except Exception:
pass
fb = _creds_for(ip).get("fritzbox", {})
src = meta.get("kennwort", "")
if src == "backupPass" and fb.get("backupPass"):
return fb["backupPass"], ("Aeltere Sicherung: das ist das AKTUELL hinterlegte Sicherungs-Kennwort — "
"wurde es seitdem geaendert, passt es nicht.")
if src == "login" and fb.get("pass"):
return fb["pass"], "Aeltere Sicherung: das ist das aktuelle FritzBox-Kennwort."
return "", "Fuer diese Sicherung ist kein Kennwort bekannt (eigens genanntes Kennwort)."
def _fb_backup_pw_action(ip: str, params: dict) -> dict:
path = _fb_backup_path(params.get("file") or params.get("datei") or "")
if not path:
return {"ok": False, "error": "Sicherung nicht gefunden."}
pw, hint = _fb_backup_password(ip, path)
if not pw:
return {"ok": False, "error": hint}
return {"ok": True, "result": {"datei": os.path.basename(path), "kennwort": pw, "hinweis": hint}}
def _fb_backups(ip: str, params: dict) -> dict:
@@ -1724,8 +1760,11 @@ def _fb_restore(ip: str, user: str, pw: str, params: dict) -> dict:
"Stefan fragen; nur nach seinem ausdruecklichen Ja erneut mit confirmed=true.")}
src = meta.get("kennwort", "")
stored = _creds_for(ip).get("fritzbox", {}).get("backupPass")
saved_pw, _hint = _fb_backup_password(ip, path)
if params.get("password"):
export_pw = str(params["password"])
elif saved_pw and _fb_backup_meta(path, with_secret=True).get("kennwort_enc"):
export_pw = saved_pw # genau das Kennwort dieser Sicherung
elif src == "angegeben":
return {"ok": False, "error": "Diese Sicherung wurde mit einem eigens genannten Kennwort erstellt — "
"Stefan muss es fuer das Wiederherstellen angeben (password)."}
@@ -1878,6 +1917,8 @@ def _do_fritzbox(action: str, params: dict) -> dict:
return _fb_backup_delete(params)
if action == "fritzbox.backup_get":
return _fb_backup_get(params)
if action == "fritzbox.backup_password": # nur fuers Diagnostic (Brain sperrt es)
return _fb_backup_pw_action(ip, params)
fb = _creds_for(ip).get("fritzbox", {})
user = str(params.get("user") or fb.get("user") or "")
pw = str(params.get("pass") or params.get("password") or fb.get("pass") or "")