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 || '')} + - `).join(''); + ` + + (fbB.pw && fbB.pw[b.datei] ? ` +
+ Kennwort dieser Sicherung: + ${escapeHtml(fbB.pw[b.datei].kennwort)} +
+ ${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 "")