feat(webext): avatar personas — selectable login bundles + one-click Become (ref #409)

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: <all_urls> so the picker can enumerate cookies
- gitignore the built .xpi
This commit is contained in:
CyberMind-FR 2026-05-28 14:13:20 +02:00
parent 68f04fa950
commit 5ab09b68f9
6 changed files with 186 additions and 26 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

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

View File

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

View File

@ -44,12 +44,25 @@
</section>
<section id="vault">
<h3>Session vault <span class="muted" style="font-weight:400">· encrypted, on-box</span></h3>
<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="profile" value="default">
<button id="vault-backup" class="btn">Back up logins</button>
<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>

View File

@ -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 = '<li class="muted">No backups yet</li>'; return; }
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");
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 = '<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();
@ -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);

View File

@ -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 };
})();