Merge pull request #412 from CyberMind-FR/feature/409-secubox-avatar-smart-media-cookie-hoster

secubox-avatar: Companion personas + client-encrypted session vault (#409)
This commit is contained in:
CyberMind 2026-05-29 09:20:48 +02:00 committed by GitHub
commit 6fad8bd99f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 680 additions and 2 deletions

3
.gitignore vendored
View File

@ -33,6 +33,9 @@ dist/
*.log
.npm/
# WebExtension build artifact (regenerated by packages/secubox-webext/build.sh)
packages/secubox-webext-*.xpi
# IDE
.idea/
*.swp

View File

@ -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-<ver>.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/<module>/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 `<all_urls>` 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 `<all_urls>` 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-<ver>.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/<m>/` 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).*

View File

@ -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)

View File

@ -267,6 +267,7 @@
<div class="tabs">
<div class="tab active" data-tab="identities">Identities</div>
<div class="tab" data-tab="services">Services</div>
<div class="tab" data-tab="vault">Personas</div>
</div>
<div class="content">
@ -312,6 +313,39 @@
</div>
</div>
</div>
<!-- Personas Tab (Companion cookie cacher) -->
<div id="tab-vault" class="tab-panel">
<div class="stats-row">
<div class="stat-card purple">
<div class="value" id="vstat-personas">0</div>
<div class="label">Personas Cached</div>
</div>
<div class="stat-card cyan">
<div class="value" id="vstat-sites">0</div>
<div class="label">Sites Held</div>
</div>
<div class="stat-card green">
<div class="value" id="vstat-cookies">0</div>
<div class="label">Cookies Encrypted</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>Cached Personas</h2>
<span class="badge badge-purple">ciphertext only · on-box</span>
</div>
<p style="color: var(--text-dim); font-size: 0.8rem; margin-bottom: 1rem;">
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.
</p>
<div id="vault-list" class="identity-grid">
<p style="color: var(--text-dim);">Loading...</p>
</div>
</div>
</div>
</div>
</main>
@ -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 =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function loadVault() {
const container = document.getElementById('vault-list');
container.innerHTML = '<p style="color: var(--text-dim);">Loading...</p>';
const data = await api('/vault/list');
if (!data) { container.innerHTML = '<p style="color: var(--red);">Failed to load personas.</p>'; 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 = `
<div class="empty-state">
<div class="icon">*</div>
<p>No personas cached yet</p>
<p style="font-size:0.8rem;">Save one from the SecuBox Companion: pick your logins, tap "Save persona".</p>
</div>`;
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) => `
<div style="display:flex; justify-content:space-between; align-items:center; gap:0.5rem; padding:0.3rem 0; border-top:1px solid var(--border);">
<span>${escapeHtml(b.domain)} <span style="color:var(--text-dim);">(${(b.meta || {}).cookie_count || '?'}c)</span></span>
<button class="btn small danger" onclick="forgetDomain(${pi},${di})">Forget</button>
</div>`).join('');
return `
<div class="card" style="margin-bottom:0;">
<div class="card-header">
<h2 style="color:var(--purple);">${escapeHtml(p.profile)}</h2>
<button class="btn small danger" onclick="forgetPersona(${pi})">Forget all</button>
</div>
<div class="identity-services" style="margin-bottom:0.5rem;">
<span class="badge badge-cyan">${p.items.length} site(s)</span>
<span class="badge badge-green">${cookies} cookies</span>
${when ? `<span class="badge badge-purple">${escapeHtml(when)}</span>` : ''}
</div>
${rows}
</div>`;
}).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

View File

@ -12,8 +12,7 @@
"permissions": ["storage", "cookies", "alarms"],
"host_permissions": [
"*://*.secubox.in/*",
"https://*.youtube.com/*",
"https://youtube.com/*"
"<all_urls>"
],
"action": {
"default_title": "SecuBox Companion",

View File

@ -53,6 +53,23 @@ 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; }
.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;

View File

@ -43,11 +43,36 @@
</div>
</section>
<section id="vault">
<h3>Avatar persona <span class="muted" style="font-weight:400">· encrypted, on-box</span></h3>
<input id="vault-pass" type="password" placeholder="vault passphrase (stays in browser)" autocomplete="off">
<div class="vault-row">
<input id="vault-profile" type="text" placeholder="persona name" value="default">
<button id="vault-backup" class="btn">Save persona</button>
</div>
<div class="picker-head">
<span class="muted">Logins in this persona</span>
<button id="picker-toggle" class="linklike" type="button">edit ▾</button>
</div>
<div id="picker" class="hidden">
<div class="vault-row">
<input id="picker-add-input" type="text" placeholder="add a site (e.g. github.com)">
<button id="picker-add" class="btn">Add</button>
</div>
<ul id="picker-list"></ul>
</div>
<ul id="vault-list"></ul>
<p id="vault-status" class="muted"></p>
</section>
<footer>
<a id="open-hub" href="#" target="_blank">Open dashboard ↗</a>
<a id="open-options-footer" href="#">Settings</a>
</footer>
<script src="vault.js"></script>
<script src="popup.js"></script>
</body>
</html>

View File

@ -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,137 @@ async function syncAllLogins(cfg) {
status.textContent = `Synced ${ok}/${services.length} login(s) from this browser.`;
}
// ── 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;
list.innerHTML = "";
try {
const data = await Vault.list(cfg.hubBase, await authHeaders());
const backups = data.backups || [];
if (!backups.length) { list.innerHTML = '<li class="muted">No personas saved yet</li>'; 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");
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) {
list.innerHTML = `<li class="muted">vault: ${e?.message || e}</li>`;
}
}
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; }
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, 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); }
}
// "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 become a persona."; return; }
status.textContent = `Becoming “${profile}”…`;
try {
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 = '<li class="muted">Scanning your logins…</li>';
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 = '<li class="muted">No logins found. Add one above.</li>'; 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();
@ -212,6 +346,16 @@ document.addEventListener("DOMContentLoaded", async () => {
document.getElementById("module-list").innerHTML = '<li class="muted">Set the hub URL in settings.</li>';
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);
});

View File

@ -0,0 +1,132 @@
// 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));
}
// 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) {
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;
}
// 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 };
})();