From 5ab09b68f9d67a7864a39018bbbf536529b186a2 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 28 May 2026 14:13:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(webext):=20avatar=20personas=20=E2=80=94?= =?UTF-8?q?=20selectable=20login=20bundles=20+=20one-click=20Become=20(ref?= =?UTF-8?q?=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 }; })();