mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 11:12:29 +00:00
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:
commit
6fad8bd99f
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -33,6 +33,9 @@ dist/
|
|||
*.log
|
||||
.npm/
|
||||
|
||||
# WebExtension build artifact (regenerated by packages/secubox-webext/build.sh)
|
||||
packages/secubox-webext-*.xpi
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.swp
|
||||
|
|
|
|||
146
docs/wiki/SecuBox-Companion.md
Normal file
146
docs/wiki/SecuBox-Companion.md
Normal 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).*
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
132
packages/secubox-webext/popup/vault.js
Normal file
132
packages/secubox-webext/popup/vault.js
Normal 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 };
|
||||
})();
|
||||
Loading…
Reference in New Issue
Block a user