diff --git a/aria-brain/agent.py b/aria-brain/agent.py
index 3147fb1..022ca0c 100644
--- a/aria-brain/agent.py
+++ b/aria-brain/agent.py
@@ -3268,6 +3268,11 @@ class Agent:
if not sat or not action:
return "FEHLER: satellite und action sind Pflicht."
params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {}
+ # Kennwoerter im Klartext gehen nie ans Sprachmodell — die zeigt nur
+ # das Diagnostic (👁) in Stefans Browser.
+ if action in ("fritzbox.backup_password",):
+ return ("FEHLER: Kennwoerter zeige ich nicht an — Stefan sieht es im Diagnostic "
+ "(Satelliten → Geraet → 💾 → 👁 bei der Sicherung).")
# Langlaeufer brauchen mehr als den Bridge-Default (20s): eine APK ueber
# WLAN zu installieren dauert 30-60s. Ohne das meldet ARIA "Timeout",
# obwohl die Installation noch laeuft.
diff --git a/diagnostic/index.html b/diagnostic/index.html
index 1488934..7f09b8f 100644
--- a/diagnostic/index.html
+++ b/diagnostic/index.html
@@ -6349,7 +6349,7 @@
m.innerHTML = `
${html}
`;
return m;
}
- function closeFbBackups() { const m = document.getElementById('fb-backups-modal'); if (m) m.remove(); fbB = null; }
+ function closeFbBackups() { const m = document.getElementById('fb-backups-modal'); if (m) m.remove(); fbB = null; } // vergisst auch angezeigte Kennwoerter
const _fbFmtDate = iso => { try { return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); } catch (_) { return iso; } };
const _fbPwSrc = { backupPass: 'Sicherungs-Kennwort', login: 'FritzBox-Kennwort', angegeben: 'eigenes Kennwort' };
async function openFbBackups(sat, ip, name) {
@@ -6368,12 +6368,19 @@
${escapeHtml(_fbFmtDate(b.erstellt) || b.datei)}
${escapeHtml(b.modell || '')}${b.firmware ? ' · FW ' + escapeHtml(b.firmware) : ''}
${_fmtBytes(b.bytes)}
- 🔒 ${escapeHtml(_fbPwSrc[b.kennwort] || b.kennwort || '')}
+ 🔒 ${escapeHtml(_fbPwSrc[b.kennwort] || b.kennwort || '')}
+ 👁
ℹ
⬇
Wiederherstellen
- 🗑 `).join('');
+ 🗑 `
+ + (fbB.pw && fbB.pw[b.datei] ? `
+
+ Kennwort dieser Sicherung:
+ ${escapeHtml(fbB.pw[b.datei].kennwort)}
+ Kopieren
+ ${fbB.pw[b.datei].hinweis ? `${escapeHtml(fbB.pw[b.datei].hinweis)}
` : ''} ` : '')).join('');
_fbModal(`
💾 Sicherungen: ${escapeHtml(fbB.name)} (${escapeHtml(fbB.ip)})
×
@@ -6472,6 +6479,41 @@
const r = await _fbRun('Wiederherstellen läuft', 'fritzbox.restore', { file: b.datei, confirmed: true });
if (fbB) _fbShowResult(r, 'Sicherung eingespielt');
}
+ // Kopieren auch ohne https: navigator.clipboard gibt es nur in "sicheren
+ // Kontexten" — sonst ueber ein unsichtbares Textfeld + execCommand('copy').
+ function copyText(t) {
+ const fallback = () => {
+ const ta = document.createElement('textarea');
+ ta.value = t; ta.setAttribute('readonly', '');
+ ta.style.cssText = 'position:fixed;top:-1000px;left:0;opacity:0;';
+ document.body.appendChild(ta); ta.select();
+ let ok = false;
+ try { ok = document.execCommand('copy'); } catch (_) {}
+ ta.remove();
+ return ok;
+ };
+ if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(t).then(() => true, fallback);
+ return Promise.resolve(fallback());
+ }
+ async function fbShowPw(i) {
+ const b = fbB.list[i];
+ if (!b) return;
+ fbB.pw = fbB.pw || {};
+ if (fbB.pw[b.datei]) { delete fbB.pw[b.datei]; _fbRender(''); return; } // zweiter Klick: verbergen
+ _fbRender('Hole Kennwort …');
+ const r = await fbCmd('fritzbox.backup_password', { file: b.datei });
+ if (!fbB) return;
+ if (!r.ok) { _fbRender('⚠ ' + (r.error || 'Kennwort nicht abrufbar')); return; }
+ fbB.pw[b.datei] = { kennwort: r.result.kennwort, hinweis: r.result.hinweis || '' };
+ _fbRender('');
+ }
+ async function fbCopyPw(datei, btn) {
+ const e = fbB && fbB.pw && fbB.pw[datei];
+ if (!e) return;
+ const ok = await copyText(e.kennwort);
+ btn.textContent = ok ? '✓ kopiert' : 'Kopieren ging nicht — markieren + Strg+C';
+ setTimeout(() => { if (btn.isConnected) btn.textContent = 'Kopieren'; }, 2500);
+ }
async function fbDownloadBackup(i) {
const b = fbB.list[i];
if (!b) return;
diff --git a/diagnostic/server.js b/diagnostic/server.js
index 04777aa..92f80c4 100644
--- a/diagnostic/server.js
+++ b/diagnostic/server.js
@@ -1415,7 +1415,15 @@ function connectRVS(forcePlain) {
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
} else if (msg.type === "sat_result") {
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
- broadcast({ type: "sat_result", payload: msg.payload || {} });
+ // Klartext-Kennwoerter (fritzbox.backup_password) nur an den Fragenden.
+ const p = msg.payload || {};
+ const asker = credsRevealWaiters.get(p.requestId);
+ if (asker) {
+ credsRevealWaiters.delete(p.requestId);
+ if (asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: "sat_result", payload: p }));
+ } else if (!(p.result && typeof p.result === "object" && "kennwort" in p.result)) { // Kennwort ohne Fragenden: verwerfen
+ broadcast({ type: "sat_result", payload: p });
+ }
} else if (msg.type === "agent_history" || msg.type === "agent_history_list") {
// Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list)
// der Bridge → an den Browser (Agent-Historie).
@@ -3370,6 +3378,10 @@ wss.on("connection", (ws) => {
params.rvs_tls = RVS_TLS === "true";
params.rvs_token = RVS_TOKEN;
}
+ if (String(msg.cmd || "") === "fritzbox.backup_password" && msg.requestId) {
+ credsRevealWaiters.set(String(msg.requestId), ws);
+ setTimeout(() => credsRevealWaiters.delete(String(msg.requestId)), 60000);
+ }
sendToRVS_raw({
type: "sat_command",
payload: {
diff --git a/satellite/satellite.py b/satellite/satellite.py
index a155343..f693b3d 100644
--- a/satellite/satellite.py
+++ b/satellite/satellite.py
@@ -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 "")