From 68f04fa950ca0aefeb0ac5a6e00851917e40c496 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 28 May 2026 13:02:47 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(avatar,webext):=20client-encrypted=20s?= =?UTF-8?q?ession=20vault=20=E2=80=94=20backup=20+=20profile=20switch=20(r?= =?UTF-8?q?ef=20#409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smart on-box "media cookie hoster": back up browser logins + quick-switch profiles. Nothing published, nothing off-box; SecuBox protects the backups. - secubox-avatar: vault endpoints (POST /vault/backup, GET /vault/list, GET /vault/restore/{profile}/{domain}, DELETE) storing OPAQUE ciphertext + metadata under /var/lib/secubox/avatar/vault (0700). require_jwt. Never sees plaintext or the key. - secubox-webext: popup/vault.js — WebCrypto AES-GCM + PBKDF2 (200k) from the operator passphrase; encrypts cookies IN THE BROWSER before upload, decrypts + cookies.set() on restore. Vault section in the popup (passphrase, profile, back-up, per-backup restore). host_permissions for linkedin/facebook/youtube. - Allowlist vaultDomains (default youtube/linkedin/facebook). LAN/SSO-gated. --- packages/secubox-avatar/api/main.py | 91 ++++++++++++++++++++++++ packages/secubox-webext/manifest.json | 4 +- packages/secubox-webext/popup/popup.css | 5 ++ packages/secubox-webext/popup/popup.html | 12 ++++ packages/secubox-webext/popup/popup.js | 56 +++++++++++++++ packages/secubox-webext/popup/vault.js | 85 ++++++++++++++++++++++ 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 packages/secubox-webext/popup/vault.js diff --git a/packages/secubox-avatar/api/main.py b/packages/secubox-avatar/api/main.py index 8f710448..9fea77ea 100644 --- a/packages/secubox-avatar/api/main.py +++ b/packages/secubox-avatar/api/main.py @@ -12,6 +12,7 @@ import shutil import hashlib import asyncio import json +import re import subprocess from datetime import datetime from pathlib import Path @@ -558,4 +559,94 @@ async def cred_poke(service: str, poke: CredPoke, user=Depends(require_jwt)): return {"success": ok, "service": service, "results": results} +# ══════════════════════════════════════════════════════════════════ +# Session vault — client-encrypted browser-session backup + profiles (#409) +# The SecuBox keeps + protects these backups: the EXTENSION encrypts cookies +# with the operator's passphrase (WebCrypto AES-GCM) BEFORE upload, so this +# module stores OPAQUE ciphertext only — never plaintext, never the key. A +# breach yields unreadable blobs. LAN/SSO-gated; nothing leaves the box. +# ══════════════════════════════════════════════════════════════════ + +VAULT_DIR = DATA_DIR / "vault" + + +class VaultBackup(BaseModel): + """An encrypted session backup pushed by the extension.""" + profile: str = Field("default", min_length=1, max_length=64) + domain: str = Field(..., min_length=1, max_length=128) + blob: str = Field(..., min_length=1) # base64 AES-GCM ciphertext + iv: str = Field(..., min_length=1) # base64 IV/nonce + meta: Optional[Dict[str, Any]] = None # non-secret: cookie count, captured_at… + + +def _safe(s: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]", "_", s)[:128] + + +def _vault_path(profile: str, domain: str) -> Path: + return VAULT_DIR / _safe(profile) / f"{_safe(domain)}.json" + + +@router.post("/vault/backup") +async def vault_backup(b: VaultBackup, user=Depends(require_jwt)): + """Store an encrypted session backup (ciphertext only).""" + p = _vault_path(b.profile, b.domain) + p.parent.mkdir(parents=True, exist_ok=True) + try: + os.chmod(VAULT_DIR, 0o700) + os.chmod(p.parent, 0o700) + except OSError: + pass + p.write_text(json.dumps({ + "profile": b.profile, "domain": b.domain, + "blob": b.blob, "iv": b.iv, "meta": b.meta or {}, + "saved_at": datetime.now().isoformat(), "by": user.get("sub", "?"), + })) + try: + os.chmod(p, 0o600) + except OSError: + pass + log.info("vault backup %s/%s by %s", b.profile, b.domain, user.get("sub", "?")) + return {"success": True, "profile": b.profile, "domain": b.domain} + + +@router.get("/vault/list") +async def vault_list(user=Depends(require_jwt)): + """List backups (metadata only — never the ciphertext).""" + out, profiles = [], set() + if VAULT_DIR.exists(): + for prof in sorted(VAULT_DIR.iterdir()): + if not prof.is_dir(): + continue + for f in sorted(prof.glob("*.json")): + try: + d = json.loads(f.read_text()) + profiles.add(d.get("profile", prof.name)) + out.append({"profile": d.get("profile"), "domain": d.get("domain"), + "meta": d.get("meta", {}), "saved_at": d.get("saved_at")}) + except Exception: + pass + return {"backups": out, "profiles": sorted(profiles)} + + +@router.get("/vault/restore/{profile}/{domain}") +async def vault_restore(profile: str, domain: str, user=Depends(require_jwt)): + """Return the encrypted blob for the extension to decrypt + apply locally.""" + p = _vault_path(profile, domain) + if not p.exists(): + raise HTTPException(status_code=404, detail="no such backup") + d = json.loads(p.read_text()) + return {"profile": d["profile"], "domain": d["domain"], + "blob": d["blob"], "iv": d["iv"], "meta": d.get("meta", {})} + + +@router.delete("/vault/{profile}/{domain}") +async def vault_delete(profile: str, domain: str, user=Depends(require_jwt)): + p = _vault_path(profile, domain) + if p.exists(): + p.unlink() + log.info("vault delete %s/%s by %s", profile, domain, user.get("sub", "?")) + return {"success": True} + + app.include_router(router) diff --git a/packages/secubox-webext/manifest.json b/packages/secubox-webext/manifest.json index 54a5f628..37f8b422 100644 --- a/packages/secubox-webext/manifest.json +++ b/packages/secubox-webext/manifest.json @@ -13,7 +13,9 @@ "host_permissions": [ "*://*.secubox.in/*", "https://*.youtube.com/*", - "https://youtube.com/*" + "https://youtube.com/*", + "https://*.linkedin.com/*", + "https://*.facebook.com/*" ], "action": { "default_title": "SecuBox Companion", diff --git a/packages/secubox-webext/popup/popup.css b/packages/secubox-webext/popup/popup.css index 86644866..b589d62a 100644 --- a/packages/secubox-webext/popup/popup.css +++ b/packages/secubox-webext/popup/popup.css @@ -53,6 +53,11 @@ li a:hover { color: var(--cyber-cyan); } .m-val { display: block; color: var(--cyber-cyan); font-size: 14px; font-weight: 700; } .m-lbl { display: block; color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: 1px; } .m-hot { color: var(--cinnabar) !important; } +#vault-pass { width: 100%; padding: 6px 8px; margin: 3px 0; background: #16161f; color: var(--text-primary); border: 1px solid #2a2a3a; border-radius: 4px; font-family: inherit; font-size: 12px; } +.vault-row { display: flex; gap: 6px; } +.vault-row input { flex: 1; min-width: 0; padding: 6px 8px; background: #16161f; color: var(--text-primary); border: 1px solid #2a2a3a; border-radius: 4px; font-family: inherit; font-size: 12px; } +.vault-row .btn { width: auto; white-space: nowrap; } +#vault-list li { font-size: 11px; gap: 6px; } #login-box { margin-top: 6px; } #login-box input { width: 100%; padding: 6px 8px; margin: 3px 0; diff --git a/packages/secubox-webext/popup/popup.html b/packages/secubox-webext/popup/popup.html index ee59a6ba..fbcba424 100644 --- a/packages/secubox-webext/popup/popup.html +++ b/packages/secubox-webext/popup/popup.html @@ -43,11 +43,23 @@ +
+

Session vault · encrypted, on-box

+ +
+ + +
+ +

+
+ + diff --git a/packages/secubox-webext/popup/popup.js b/packages/secubox-webext/popup/popup.js index 4a3a45ea..e6e2e45b 100644 --- a/packages/secubox-webext/popup/popup.js +++ b/packages/secubox-webext/popup/popup.js @@ -10,6 +10,9 @@ const DEFAULTS = { cookieEndpoint: "/api/v1/avatar/cred/poke/youtube", // SecuBox login endpoint (secubox-core JWT; any module's /auth/login works). loginEndpoint: "/api/v1/peertube/auth/login", + // Session-vault allowlist (#409): which domains to back up. Cookie access for + // these needs matching host_permissions in the manifest. + vaultDomains: ["youtube.com", "linkedin.com", "facebook.com"], }; async function getConfig() { @@ -182,6 +185,57 @@ async function syncAllLogins(cfg) { status.textContent = `Synced ${ok}/${services.length} login(s) from this browser.`; } +// ── Session vault (#409): encrypted backup + profile restore ──────────────── +async function loadVault(cfg) { + const list = document.getElementById("vault-list"); + if (!list || !cfg.hubBase) return; + list.innerHTML = ""; + try { + const data = await Vault.list(cfg.hubBase, await authHeaders()); + const backups = data.backups || []; + if (!backups.length) { list.innerHTML = '
  • No backups yet
  • '; return; } + for (const b of backups) { + const li = document.createElement("li"); + const span = document.createElement("span"); + span.style.flex = "1"; + const when = b.saved_at ? new Date(b.saved_at).toLocaleDateString() : ""; + span.textContent = `${b.profile}/${b.domain} (${(b.meta || {}).cookie_count || "?"}c, ${when})`; + const rb = document.createElement("button"); + rb.className = "btn"; rb.textContent = "Restore"; rb.style.width = "auto"; rb.style.padding = "2px 8px"; + rb.addEventListener("click", () => doVaultRestore(cfg, b.profile, b.domain)); + li.append(span, rb); + list.append(li); + } + } catch (e) { + list.innerHTML = `
  • vault: ${e?.message || e}
  • `; + } +} + +async function doVaultBackup(cfg) { + const status = document.getElementById("vault-status"); + const pass = document.getElementById("vault-pass").value; + const profile = (document.getElementById("vault-profile").value || "default").trim(); + if (!cfg.hubBase) { status.textContent = "Configure the hub first."; return; } + if (!pass) { status.textContent = "Enter a vault passphrase."; return; } + status.textContent = "Encrypting + backing up…"; + try { + const n = await Vault.backup(cfg.hubBase, await authHeaders(), pass, profile, cfg.vaultDomains); + status.textContent = n ? `Backed up ${n} domain(s) (encrypted).` : "Nothing to back up (not logged into those sites?)."; + loadVault(cfg); + } catch (e) { status.textContent = "Backup failed: " + (e?.message || e); } +} + +async function doVaultRestore(cfg, profile, domain) { + const status = document.getElementById("vault-status"); + const pass = document.getElementById("vault-pass").value; + if (!pass) { status.textContent = "Enter the vault passphrase to restore."; return; } + status.textContent = `Restoring ${domain}…`; + try { + const n = await Vault.restore(cfg.hubBase, await authHeaders(), pass, profile, domain); + status.textContent = `Restored ${n} cookies for ${domain} — reload that site's tab.`; + } catch (e) { status.textContent = "Restore failed (wrong passphrase?): " + (e?.message || e); } +} + document.addEventListener("DOMContentLoaded", async () => { const cfg = await getConfig(); const openOpts = () => api.runtime.openOptionsPage(); @@ -212,6 +266,8 @@ document.addEventListener("DOMContentLoaded", async () => { document.getElementById("module-list").innerHTML = '
  • Set the hub URL in settings.
  • '; return; } + document.getElementById("vault-backup").addEventListener("click", () => doVaultBackup(cfg)); renderModules(cfg); loadMetrics(cfg); + loadVault(cfg); }); diff --git a/packages/secubox-webext/popup/vault.js b/packages/secubox-webext/popup/vault.js new file mode 100644 index 00000000..334c4e70 --- /dev/null +++ b/packages/secubox-webext/popup/vault.js @@ -0,0 +1,85 @@ +// SecuBox Companion — client-side session vault (#409). +// Cookies are encrypted IN THE BROWSER (AES-GCM, PBKDF2 key from the operator's +// passphrase) before upload; the avatar module stores only opaque ciphertext. +// The passphrase never leaves the browser. Nothing is published off-box. +const Vault = (() => { + const api = globalThis.browser ?? globalThis.chrome; + const td = new TextDecoder(), te = new TextEncoder(); + const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); + const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); + + async function deriveKey(passphrase, salt) { + const mat = await crypto.subtle.importKey("raw", te.encode(passphrase), "PBKDF2", false, ["deriveKey"]); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations: 200000, hash: "SHA-256" }, + mat, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + } + async function encryptObj(obj, passphrase) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(passphrase, salt); + const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, te.encode(JSON.stringify(obj))); + return { blob: b64(ct), iv: b64(iv), salt: b64(salt) }; + } + async function decryptObj(blobB64, ivB64, saltB64, passphrase) { + const key = await deriveKey(passphrase, unb64(saltB64)); + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(ivB64) }, key, unb64(blobB64)); + return JSON.parse(td.decode(pt)); + } + + async function backup(hubBase, headers, passphrase, profile, domains) { + let n = 0; + for (const domain of domains) { + const cookies = await api.cookies.getAll({ domain }); + if (!cookies.length) continue; + const data = cookies.map((c) => ({ + name: c.name, value: c.value, domain: c.domain, path: c.path, + secure: c.secure, httpOnly: c.httpOnly, sameSite: c.sameSite, + expirationDate: c.expirationDate, + })); + const { blob, iv, salt } = await encryptObj(data, passphrase); + const r = await fetch(`${hubBase}/api/v1/avatar/vault/backup`, { + method: "POST", credentials: "include", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ profile, domain, blob, iv, + meta: { salt, cookie_count: cookies.length, captured_at: new Date().toISOString() } }), + }); + if (r.ok) n++; + } + return n; + } + + async function list(hubBase, headers) { + const r = await fetch(`${hubBase}/api/v1/avatar/vault/list`, { + credentials: "include", headers, + }); + if (!r.ok) throw new Error("list " + r.status); + return r.json(); + } + + async function restore(hubBase, headers, passphrase, profile, domain) { + const r = await fetch(`${hubBase}/api/v1/avatar/vault/restore/${encodeURIComponent(profile)}/${encodeURIComponent(domain)}`, { + credentials: "include", headers, + }); + if (!r.ok) throw new Error("restore " + r.status); + const d = await r.json(); + const cookies = await decryptObj(d.blob, d.iv, (d.meta || {}).salt, passphrase); + let n = 0; + for (const c of cookies) { + const host = c.domain.startsWith(".") ? c.domain.slice(1) : c.domain; + const url = (c.secure ? "https://" : "http://") + host + (c.path || "/"); + try { + await api.cookies.set({ + url, name: c.name, value: c.value, path: c.path || "/", + secure: c.secure, httpOnly: c.httpOnly, sameSite: c.sameSite, + expirationDate: c.expirationDate, + domain: c.domain.startsWith(".") ? c.domain : undefined, + }); + n++; + } catch (e) { /* skip cookies the browser refuses */ } + } + return n; + } + + return { backup, list, restore }; +})(); From 5ab09b68f9d67a7864a39018bbbf536529b186a2 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 28 May 2026 14:13:20 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(webext):=20avatar=20personas=20?= =?UTF-8?q?=E2=80=94=20selectable=20login=20bundles=20+=20one-click=20Beco?= =?UTF-8?q?me=20(ref=20#409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Companion's vault becomes a "persona": a named group of site logins the operator picks via a tick-list. The popup auto-lists which sites you're signed into (cookie counts), pre-ticks your saved selection, and remembers it. Saving encrypts the chosen group in the browser; the box keeps ciphertext only. One "Become" click restores the whole group on any LAN machine — passwordless sign-in across same-network computers. Nothing is published or leaves the box. - vault.js: discover() login list, restoreProfile() group restore, registrable() - popup: persona builder, selectable picker, persona-grouped list with Become - manifest: so the picker can enumerate cookies - gitignore the built .xpi --- .gitignore | 3 + packages/secubox-webext/manifest.json | 5 +- packages/secubox-webext/popup/popup.css | 12 +++ packages/secubox-webext/popup/popup.html | 19 +++- packages/secubox-webext/popup/popup.js | 124 +++++++++++++++++++---- packages/secubox-webext/popup/vault.js | 49 ++++++++- 6 files changed, 186 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index c8db728b..ea6e4e69 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ dist/ *.log .npm/ +# WebExtension build artifact (regenerated by packages/secubox-webext/build.sh) +packages/secubox-webext-*.xpi + # IDE .idea/ *.swp diff --git a/packages/secubox-webext/manifest.json b/packages/secubox-webext/manifest.json index 37f8b422..f0e70775 100644 --- a/packages/secubox-webext/manifest.json +++ b/packages/secubox-webext/manifest.json @@ -12,10 +12,7 @@ "permissions": ["storage", "cookies", "alarms"], "host_permissions": [ "*://*.secubox.in/*", - "https://*.youtube.com/*", - "https://youtube.com/*", - "https://*.linkedin.com/*", - "https://*.facebook.com/*" + "" ], "action": { "default_title": "SecuBox Companion", diff --git a/packages/secubox-webext/popup/popup.css b/packages/secubox-webext/popup/popup.css index b589d62a..89a80751 100644 --- a/packages/secubox-webext/popup/popup.css +++ b/packages/secubox-webext/popup/popup.css @@ -58,6 +58,18 @@ li a:hover { color: var(--cyber-cyan); } .vault-row input { flex: 1; min-width: 0; padding: 6px 8px; background: #16161f; color: var(--text-primary); border: 1px solid #2a2a3a; border-radius: 4px; font-family: inherit; font-size: 12px; } .vault-row .btn { width: auto; white-space: nowrap; } #vault-list li { font-size: 11px; gap: 6px; } +.picker-head { display: flex; justify-content: space-between; align-items: center; margin: 6px 0 2px; font-size: 11px; } +.linklike { background: none; border: none; color: var(--cyber-cyan); cursor: pointer; font-family: inherit; font-size: 11px; padding: 0; } +.linklike:hover { color: var(--gold-hermetic); } +#picker-list li { font-size: 11px; gap: 6px; padding: 2px 0; } +#picker-list label { display: flex; align-items: center; gap: 6px; flex: 1; cursor: pointer; } +#picker-list .rm { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 13px; padding: 0 2px; } +#picker-list .rm:hover { color: var(--cinnabar); } +.persona-row { flex-wrap: wrap; } +.persona-name { color: var(--gold-hermetic); font-weight: 700; flex: 1; } +.persona-domains { width: 100%; color: var(--text-muted); font-size: 10px; padding-left: 2px; } +.btn-become { border-color: var(--matrix-green); color: var(--matrix-green); width: auto !important; padding: 2px 8px !important; } +.btn-become:hover { background: var(--matrix-green); color: var(--cosmos-black); } #login-box { margin-top: 6px; } #login-box input { width: 100%; padding: 6px 8px; margin: 3px 0; diff --git a/packages/secubox-webext/popup/popup.html b/packages/secubox-webext/popup/popup.html index fbcba424..09bc0ea8 100644 --- a/packages/secubox-webext/popup/popup.html +++ b/packages/secubox-webext/popup/popup.html @@ -44,12 +44,25 @@
    -

    Session vault · encrypted, on-box

    +

    Avatar persona · encrypted, on-box

    - - + +
    + +
    + Logins in this persona + +
    + +

      diff --git a/packages/secubox-webext/popup/popup.js b/packages/secubox-webext/popup/popup.js index e6e2e45b..48a2ded8 100644 --- a/packages/secubox-webext/popup/popup.js +++ b/packages/secubox-webext/popup/popup.js @@ -185,7 +185,9 @@ async function syncAllLogins(cfg) { status.textContent = `Synced ${ok}/${services.length} login(s) from this browser.`; } -// ── Session vault (#409): encrypted backup + profile restore ──────────────── +// ── Avatar personas (#409): encrypted persona backup + one-click "become" ─── +// A persona = a named profile holding a GROUP of site logins. Restoring it +// adopts every login at once — passwordless sign-in on any LAN machine. async function loadVault(cfg) { const list = document.getElementById("vault-list"); if (!list || !cfg.hubBase) return; @@ -193,17 +195,30 @@ async function loadVault(cfg) { try { const data = await Vault.list(cfg.hubBase, await authHeaders()); const backups = data.backups || []; - if (!backups.length) { list.innerHTML = '
    • No backups yet
    • '; return; } + if (!backups.length) { list.innerHTML = '
    • No personas saved yet
    • '; return; } + const byProfile = new Map(); for (const b of backups) { + const g = byProfile.get(b.profile) || { domains: [], cookies: 0, when: "" }; + g.domains.push(b.domain); + g.cookies += (b.meta || {}).cookie_count || 0; + if (b.saved_at && b.saved_at > g.when) g.when = b.saved_at; + byProfile.set(b.profile, g); + } + for (const [profile, g] of byProfile) { const li = document.createElement("li"); - const span = document.createElement("span"); - span.style.flex = "1"; - const when = b.saved_at ? new Date(b.saved_at).toLocaleDateString() : ""; - span.textContent = `${b.profile}/${b.domain} (${(b.meta || {}).cookie_count || "?"}c, ${when})`; - const rb = document.createElement("button"); - rb.className = "btn"; rb.textContent = "Restore"; rb.style.width = "auto"; rb.style.padding = "2px 8px"; - rb.addEventListener("click", () => doVaultRestore(cfg, b.profile, b.domain)); - li.append(span, rb); + li.className = "persona-row"; + const name = document.createElement("span"); + name.className = "persona-name"; + const when = g.when ? new Date(g.when).toLocaleDateString() : ""; + name.textContent = `${profile} · ${g.domains.length} site(s), ${g.cookies}c${when ? " · " + when : ""}`; + const become = document.createElement("button"); + become.className = "btn btn-become"; become.textContent = "Become"; + become.title = `Restore all ${g.domains.length} logins of "${profile}" on this computer`; + become.addEventListener("click", () => doVaultBecome(cfg, profile)); + const doms = document.createElement("span"); + doms.className = "persona-domains"; + doms.textContent = g.domains.join(", "); + li.append(name, become, doms); list.append(li); } } catch (e) { @@ -217,25 +232,90 @@ async function doVaultBackup(cfg) { const profile = (document.getElementById("vault-profile").value || "default").trim(); if (!cfg.hubBase) { status.textContent = "Configure the hub first."; return; } if (!pass) { status.textContent = "Enter a vault passphrase."; return; } - status.textContent = "Encrypting + backing up…"; + const picked = selectedDomains(); + if (!picked.length) { status.textContent = "Tick at least one site under “edit”."; return; } + status.textContent = `Encrypting ${picked.length} site(s)…`; try { - const n = await Vault.backup(cfg.hubBase, await authHeaders(), pass, profile, cfg.vaultDomains); - status.textContent = n ? `Backed up ${n} domain(s) (encrypted).` : "Nothing to back up (not logged into those sites?)."; + const n = await Vault.backup(cfg.hubBase, await authHeaders(), pass, profile, picked); + status.textContent = n ? `Persona “${profile}” saved: ${n} site(s) encrypted.` : "Nothing saved (not logged into those sites?)."; loadVault(cfg); } catch (e) { status.textContent = "Backup failed: " + (e?.message || e); } } -async function doVaultRestore(cfg, profile, domain) { +// "Become" a persona: restore its whole login group on this machine. +async function doVaultBecome(cfg, profile) { const status = document.getElementById("vault-status"); const pass = document.getElementById("vault-pass").value; - if (!pass) { status.textContent = "Enter the vault passphrase to restore."; return; } - status.textContent = `Restoring ${domain}…`; + if (!pass) { status.textContent = "Enter the vault passphrase to become a persona."; return; } + status.textContent = `Becoming “${profile}”…`; try { - const n = await Vault.restore(cfg.hubBase, await authHeaders(), pass, profile, domain); - status.textContent = `Restored ${n} cookies for ${domain} — reload that site's tab.`; + const r = await Vault.restoreProfile(cfg.hubBase, await authHeaders(), pass, profile); + status.textContent = `Now “${profile}”: ${r.cookies} cookies across ${r.domains} site(s) — reload your tabs.`; } catch (e) { status.textContent = "Restore failed (wrong passphrase?): " + (e?.message || e); } } +// ── Selectable cookie picker (the "includer") ─────────────────────────────── +// Auto-lists every site you're signed into and pre-ticks your saved selection, +// so "keep all logins, pick which go into the persona" is one glance. The +// ticked set is what gets encrypted on the next save and is remembered. +const _selected = new Set(); // registrable domains ticked for the persona +const _counts = new Map(); // domain → live cookie count (from discover) + +function selectedDomains() { + return [..._selected]; +} + +async function persistSelection(cfg) { + cfg.vaultDomains = [..._selected].sort(); + await api.storage.local.set({ vaultDomains: cfg.vaultDomains }); +} + +// Merge auto-discovered logins with the saved selection into one tick-list. +async function renderPicker(cfg) { + const ul = document.getElementById("picker-list"); + if (!ul) return; + ul.innerHTML = '
    • Scanning your logins…
    • '; + if (!_selected.size) for (const d of (cfg.vaultDomains || [])) _selected.add(d); + + let discovered = []; + try { discovered = await Vault.discover(); } + catch (e) { /* no cookie permission yet — fall back to saved list only */ } + _counts.clear(); + for (const { domain, count } of discovered) _counts.set(domain, count); + + const domains = [...new Set([...discovered.map((d) => d.domain), ..._selected])].sort(); + ul.innerHTML = ""; + if (!domains.length) { ul.innerHTML = '
    • No logins found. Add one above.
    • '; return; } + + for (const d of domains) { + const li = document.createElement("li"); + const label = document.createElement("label"); + const cb = document.createElement("input"); + cb.type = "checkbox"; cb.checked = _selected.has(d); + cb.addEventListener("change", async () => { + cb.checked ? _selected.add(d) : _selected.delete(d); + await persistSelection(cfg); + }); + const txt = document.createElement("span"); + const n = _counts.get(d); + txt.textContent = n ? `${d} (${n}c)` : d; + label.append(cb, txt); + li.append(label); + ul.append(li); + } +} + +async function addPickerDomain(cfg) { + const input = document.getElementById("picker-add-input"); + const raw = (input.value || "").trim(); + if (!raw) return; + const d = Vault.registrable(raw); + if (d) _selected.add(d); + await persistSelection(cfg); + input.value = ""; + renderPicker(cfg); +} + document.addEventListener("DOMContentLoaded", async () => { const cfg = await getConfig(); const openOpts = () => api.runtime.openOptionsPage(); @@ -267,6 +347,14 @@ document.addEventListener("DOMContentLoaded", async () => { return; } document.getElementById("vault-backup").addEventListener("click", () => doVaultBackup(cfg)); + document.getElementById("picker-toggle").addEventListener("click", () => { + document.getElementById("picker").classList.toggle("hidden"); + }); + document.getElementById("picker-add").addEventListener("click", () => addPickerDomain(cfg)); + document.getElementById("picker-add-input").addEventListener("keydown", (e) => { + if (e.key === "Enter") addPickerDomain(cfg); + }); + renderPicker(cfg); renderModules(cfg); loadMetrics(cfg); loadVault(cfg); diff --git a/packages/secubox-webext/popup/vault.js b/packages/secubox-webext/popup/vault.js index 334c4e70..e427d73b 100644 --- a/packages/secubox-webext/popup/vault.js +++ b/packages/secubox-webext/popup/vault.js @@ -27,6 +27,38 @@ const Vault = (() => { return JSON.parse(td.decode(pt)); } + // Normalise a typed site to its registrable domain so "accounts.youtube.com" + // and "amazon.co.uk" store under one persona entry (not the public suffix). + const MULTI_TLD = new Set([ + "co.uk", "org.uk", "ac.uk", "gov.uk", "com.au", "net.au", "org.au", + "co.jp", "co.nz", "co.in", "co.za", "com.br", "com.mx", "com.tr", + "com.sg", "com.hk", "com.cn", + ]); + function registrable(host) { + const h = (host || "").trim().replace(/^\./, "").replace(/^https?:\/\//, "").split("/")[0].toLowerCase(); + const parts = h.split("."); + if (parts.length <= 2) return h; + const last2 = parts.slice(-2).join("."); + if (MULTI_TLD.has(last2)) return parts.slice(-3).join("."); + return last2; + } + + // Show which sites the operator is signed into, grouped by registrable + // domain, so the popup can offer a tick-list. Read-only and local: returns + // counts only, sends nothing off the machine. + async function discover() { + const all = await api.cookies.getAll({}); + const groups = new Map(); + for (const c of all) { + const d = registrable(c.domain); + if (!d || d.indexOf(".") < 0) continue; + groups.set(d, (groups.get(d) || 0) + 1); + } + return [...groups.entries()] + .map(([domain, count]) => ({ domain, count })) + .sort((a, b) => a.domain.localeCompare(b.domain)); + } + async function backup(hubBase, headers, passphrase, profile, domains) { let n = 0; for (const domain of domains) { @@ -81,5 +113,20 @@ const Vault = (() => { return n; } - return { backup, list, restore }; + // Become a persona: restore EVERY domain saved under one profile in a single + // click, so a fresh LAN machine adopts the whole login group at once. + async function restoreProfile(hubBase, headers, passphrase, profile) { + const data = await list(hubBase, headers); + const mine = (data.backups || []).filter((b) => b.profile === profile); + let domains = 0, cookies = 0; + for (const b of mine) { + try { + cookies += await restore(hubBase, headers, passphrase, profile, b.domain); + domains++; + } catch (e) { /* keep going; one bad domain shouldn't abort the persona */ } + } + return { domains, cookies }; + } + + return { backup, list, restore, restoreProfile, registrable, discover }; })(); From 79d6e08ffc5b4e8fd29f79200735ef97933ba049 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 28 May 2026 14:15:02 +0200 Subject: [PATCH 3/4] =?UTF-8?q?docs(wiki):=20SecuBox=20Companion=20?= =?UTF-8?q?=E2=80=94=20avatar=20personas,=20selectable=20picker,=20Become?= =?UTF-8?q?=20(ref=20#409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the persona model: a named, browser-encrypted group of site sessions the operator picks from a tick-list of their live logins, reapplied with one Become click on any same-LAN box. Explains the read / ticked-write posture and that ciphertext never leaves the SecuBox. --- docs/wiki/SecuBox-Companion.md | 146 +++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/wiki/SecuBox-Companion.md diff --git a/docs/wiki/SecuBox-Companion.md b/docs/wiki/SecuBox-Companion.md new file mode 100644 index 00000000..41093aca --- /dev/null +++ b/docs/wiki/SecuBox-Companion.md @@ -0,0 +1,146 @@ +# 🟠 SecuBox Companion (Firefox WebExtension) + +> Browser companion for a SecuBox appliance — live status, quick access, +> credential relay, and an **on-box, client-encrypted session vault**. +> Package: `secubox-webext` · Issues: #401, #402, #409 + +`🟠 AUTH` (credentials/login) · `🟢 ROOT` (system metrics) — the Companion +touches several modules but lives in the AUTH family because its core job is +brokering browser authentication to the box. + +--- + +## What it does + +| Surface | What you get | +|---------|--------------| +| **Toolbar popup** | Module health dots, quick links, a badge with the down-count | +| **Quick metrics** | Live CPU / RAM / Disk / Temp (`/api/v1/system/metrics`), red ≥ 90% | +| **Login sync** | "Sync browser logins" — relays your live cookies to the avatar broker for backend services (e.g. YouTube → PeerTube import) | +| **Avatar personas** | Group several site sessions under a named persona, **encrypted in the browser**; reapply a persona with one **Become** click to quick-switch profiles on your own boxes | + +Everything talks to **your** SecuBox over your LAN and rides the SSO-lite +session (#400). **Nothing is published off-box.** + +--- + +## Install (development / self-hosted) + +Firefox requires signing for permanent installs, so for a self-hosted box: + +1. Build the `.xpi`: + ```bash + cd packages/secubox-webext && ./build.sh # → ../secubox-webext-.xpi + ``` +2. `about:debugging` → **This Firefox** → **Load Temporary Add-on** → pick the + `.xpi` (or `manifest.json`). *(Temporary add-ons reset on Firefox restart.)* +3. Toolbar icon → **Settings** → set **Hub base URL** + (e.g. `https://admin.gk2.secubox.in`) → Save. + +For a permanent install: sign with `web-ext sign` (AMO) **or** run Firefox +Developer/ESR with `xpinstall.signatures.required=false`. + +--- + +## Features + +### Status + metrics +The popup polls each configured module's `/api/v1//health` and +`/api/v1/system/metrics`. The background event-page refreshes a toolbar badge +every 5 min (the down-module count). + +### Login sync (avatar peek/poke broker) +"Sync browser logins" reads your live cookies for each service the avatar +broker registers (`GET /api/v1/avatar/cred/peek`) and **pokes** them to +`POST /api/v1/avatar/cred/poke/{service}`. For `youtube` the box installs them +into the PeerTube LXC (via a root path-unit, no sudo — see #407) so yt-dlp +import can use your session. + +### Avatar personas (client-encrypted) `🟠 AUTH` + +A **persona** is the avatar wearing a named set of site sessions — a group of +logins you can save once and reapply on your own boxes. + +- A **passphrase** you type in the popup derives an AES-GCM key (PBKDF2, + 200 000 iters, WebCrypto). Sessions are **encrypted in the browser** before + upload. The avatar module stores **opaque ciphertext only** under + `/var/lib/secubox/avatar/vault/` (0700) — it never sees plaintext or the key. +- **Pick what's in it** — open *edit* under the persona and the popup lists the + sites you're currently signed into (with cookie counts). Tick the ones the + persona should hold; your selection is remembered (`vaultDomains`). You can + also type a site to add it. +- **Save persona** encrypts + uploads the ticked group under the persona name. +- **Become** decrypts the whole group locally with your passphrase and + `cookies.set()`s every site back at once — a one-click profile quick-switch. + Run it on another machine on the same LAN and that box adopts the persona's + logins (passwordless), without the sessions ever leaving the SecuBox. + +> 🔒 The passphrase never leaves the browser. Lose it and the personas are +> unreadable (by design). + +--- + +## Settings (`about:addons` → options, or popup → Settings) + +| Field | Meaning | +|-------|---------| +| **Hub base URL** | Your dashboard, no trailing slash | +| **Modules** | Comma-separated slugs to monitor | +| **Cookie endpoint** | Avatar poke endpoint (default `/api/v1/avatar/cred/poke/youtube`) | + +`vaultDomains` (the persona selection) is stored in extension storage and is +managed from the popup picker (*Avatar persona → edit*) — no manual editing +needed. The manifest grants `` so the picker can list every site +you're signed into; ticking is what scopes a persona. + +--- + +## Security model + +- **LAN/SSO only** — never expose the avatar webui / vault on clearnet. A public + cookie vault is a total-account-compromise target. +- **Client-side encryption** — the box stores ciphertext; a server breach yields + unreadable blobs. +- **Broad read, narrow write** — the persona picker needs `` to *list* + the sites you're signed into, but only the **sites you tick** are ever + encrypted and uploaded. Nothing is captured silently; selection is the gate. + Manifest V3. +- **Auth** — API calls send the SecuBox Bearer token (popup login) and ride the + SSO-lite cookie (#400). Backend endpoints are `require_jwt`. + +--- + +## YouTube import note + +YouTube blocks **server** IPs ("Sign in to confirm you're not a bot") and now +requires a PO token (SABR). The Companion relays your *browser's* cookies (and, +experimentally, a captured PO token) so the box uses your good session instead +of fighting from its flagged IP. If imports still fail, the box IP needs a +cooldown / fresh cookies — your workstation's `yt-dlp` is the reliable fallback. + +--- + +## Build a distributable XPI + +```bash +cd packages/secubox-webext +./build.sh # zip → ../secubox-webext-.xpi +# permanent install: web-ext sign (needs AMO API creds) +``` + +--- + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| All dots grey / "Set the hub URL" | Settings → set Hub base URL | +| "SecuBox login required" | Enter your SecuBox user/pass in the popup | +| Module red but it's up | Its `/api/v1//` route must be in `secubox-routes.d/` | +| "Restore failed (wrong passphrase?)" | Re-enter the exact vault passphrase | +| Persona list empty | Save a persona with at least one ticked, logged-in site first | + +--- + +*See also: [Architecture-Security](Architecture-Security.md), +[Developer-Guide](Developer-Guide.md).* From 3f7dbf8162555f61c2b149f8dbe0e8d35e9e9b06 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 28 May 2026 14:35:15 +0200 Subject: [PATCH 4/4] feat(avatar): add Personas tab to dashboard (ref #409) --- packages/secubox-avatar/www/avatar/index.html | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/packages/secubox-avatar/www/avatar/index.html b/packages/secubox-avatar/www/avatar/index.html index 3a1e41c9..d987d13d 100644 --- a/packages/secubox-avatar/www/avatar/index.html +++ b/packages/secubox-avatar/www/avatar/index.html @@ -267,6 +267,7 @@
      Identities
      Services
      +
      Personas
      @@ -312,6 +313,39 @@
      + + +
      +
      +
      +
      0
      +
      Personas Cached
      +
      +
      +
      0
      +
      Sites Held
      +
      +
      +
      0
      +
      Cookies Encrypted
      +
      +
      + +
      +
      +

      Cached Personas

      + ciphertext only · on-box +
      +

      + Login bundles the SecuBox Companion encrypted in your browser and parked here. + The box stores opaque ciphertext — it never holds the passphrase or the cookies in clear. + Decrypt + "Become" a persona from the Companion popup; this page only manages the cache. +

      +
      +

      Loading...

      +
      +
      +
      @@ -542,6 +576,91 @@ return icons[icon] || '?'; } + // ── Personas: the Companion cookie cacher (metadata only) ────────── + // The box holds opaque ciphertext; it cannot decrypt. This view manages + // the CACHE — which persona holds which sites, how big, when saved. + let vaultPersonas = []; + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, c => + ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + } + + async function loadVault() { + const container = document.getElementById('vault-list'); + container.innerHTML = '

      Loading...

      '; + const data = await api('/vault/list'); + if (!data) { container.innerHTML = '

      Failed to load personas.

      '; return; } + const map = {}; + (data.backups || []).forEach(b => { (map[b.profile] = map[b.profile] || []).push(b); }); + vaultPersonas = Object.keys(map).sort().map(profile => ({ profile, items: map[profile] })); + + let sites = 0, cookies = 0; + vaultPersonas.forEach(p => { + sites += p.items.length; + p.items.forEach(b => cookies += (b.meta || {}).cookie_count || 0); + }); + document.getElementById('vstat-personas').textContent = vaultPersonas.length; + document.getElementById('vstat-sites').textContent = sites; + document.getElementById('vstat-cookies').textContent = cookies; + renderVault(); + } + + function renderVault() { + const container = document.getElementById('vault-list'); + if (!vaultPersonas.length) { + container.innerHTML = ` +
      +
      *
      +

      No personas cached yet

      +

      Save one from the SecuBox Companion: pick your logins, tap "Save persona".

      +
      `; + return; + } + container.innerHTML = vaultPersonas.map((p, pi) => { + const cookies = p.items.reduce((n, b) => n + ((b.meta || {}).cookie_count || 0), 0); + const latest = p.items.map(b => b.saved_at).filter(Boolean).sort().slice(-1)[0]; + const when = latest ? new Date(latest).toLocaleString() : ''; + const rows = p.items.map((b, di) => ` +
      + ${escapeHtml(b.domain)} (${(b.meta || {}).cookie_count || '?'}c) + +
      `).join(''); + return ` +
      +
      +

      ${escapeHtml(p.profile)}

      + +
      +
      + ${p.items.length} site(s) + ${cookies} cookies + ${when ? `${escapeHtml(when)}` : ''} +
      + ${rows} +
      `; + }).join(''); + } + + async function forgetDomain(pi, di) { + const p = vaultPersonas[pi]; if (!p) return; + const b = p.items[di]; if (!b) return; + if (!confirm(`Forget ${b.domain} from persona "${p.profile}"? The encrypted backup is deleted from the box.`)) return; + const r = await api(`/vault/${encodeURIComponent(p.profile)}/${encodeURIComponent(b.domain)}`, { method: 'DELETE' }); + if (r?.success) { showToast('Forgot ' + b.domain); loadVault(); } + else showToast('Failed to forget', 'error'); + } + + async function forgetPersona(pi) { + const p = vaultPersonas[pi]; if (!p) return; + if (!confirm(`Forget the entire persona "${p.profile}"? All its encrypted site backups are deleted from the box.`)) return; + for (const b of p.items) { + await api(`/vault/${encodeURIComponent(p.profile)}/${encodeURIComponent(b.domain)}`, { method: 'DELETE' }); + } + showToast('Forgot persona ' + p.profile); + loadVault(); + } + function openCreateModal() { selectedIdentityId = null; document.getElementById('modal-title').textContent = 'New Identity'; @@ -740,12 +859,14 @@ document.getElementById('tab-' + tab.dataset.tab).classList.add('active'); if (tab.dataset.tab === 'services') loadServices(); + if (tab.dataset.tab === 'vault') loadVault(); }); }); function refresh() { loadIdentities(); loadServices(); + loadVault(); } // Initial load