mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
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:
parent
68f04fa950
commit
5ab09b68f9
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user