diff --git a/apps/pwa/public/vault-bulk.js b/apps/pwa/public/vault-bulk.js new file mode 100644 index 00000000..26df8b11 --- /dev/null +++ b/apps/pwa/public/vault-bulk.js @@ -0,0 +1,255 @@ +/* Unlock every team vault granted to the signed-in person from the dashboard. + The identity key and plaintext stay in this tab; the server returns only + wrapped keys and ciphertext. */ +(function () { + var card = document.getElementById("vault-bulk"); + if (!card) return; + + var form = card.querySelector("[data-role=bulk-unlock]"); + var keyInput = form.elements.key; + var running = card.querySelector("[data-role=bulk-running]"); + var progress = card.querySelector("[data-role=bulk-progress]"); + var results = card.querySelector("[data-role=bulk-results]"); + var summary = card.querySelector("[data-role=bulk-summary]"); + var search = card.querySelector("[data-role=bulk-search]"); + var rows = card.querySelector("[data-role=bulk-rows]"); + var more = card.querySelector('[data-action="bulk-more"]'); + var status = card.querySelector("[data-role=bulk-status]"); + var records = []; + var controller = null; + var pageSize = 100; + + function say(message) { status.textContent = message || ""; } + + function parseKey(raw) { + var text = String(raw || "").trim(); + if (text.charAt(0) === "{") { + var json; + try { json = JSON.parse(text); } catch (_) { throw new Error("That looks like JSON but does not parse."); } + text = (json.keys && json.keys.secretKey) || json.secretKey || ""; + if (!text) throw new Error("No keys.secretKey in that JSON."); + } + return text.replace(/^["']|["']$/g, "").replace(/\s+/g, ""); + } + + function getJSON(url, signal) { + return fetch(url, { + credentials: "same-origin", + headers: { accept: "application/json" }, + cache: "no-store", + signal: signal + }).then(function (res) { + return res.json().catch(function () { return {}; }).then(function (body) { + if (!res.ok) throw new Error(body.error || ("HTTP " + res.status)); + return body; + }); + }); + } + + function zero(na, bytes) { + if (bytes && na.memzero) na.memzero(bytes); + } + + function renderRows() { + var query = String(search.value || "").trim().toLowerCase(); + var matching = records.filter(function (record) { + return !query || (record.team + " " + record.vault + " " + record.name).toLowerCase().includes(query); + }); + var shown = Math.min(pageSize, matching.length); + rows.replaceChildren(); + matching.slice(0, shown).forEach(function (record) { + var tr = document.createElement("tr"); + [record.team, record.vault, record.name].forEach(function (text) { + var td = document.createElement("td"); + var code = document.createElement("code"); + code.textContent = text; + td.appendChild(code); + tr.appendChild(td); + }); + + var valueCell = document.createElement("td"); + var value = document.createElement("code"); + value.className = "mono"; + value.textContent = "••••••••"; + valueCell.appendChild(value); + tr.appendChild(valueCell); + + var actions = document.createElement("td"); + actions.style.whiteSpace = "nowrap"; + var show = document.createElement("button"); + show.type = "button"; + show.className = "btn compact"; + show.textContent = "Show"; + var revealed = false; + show.addEventListener("click", function () { + revealed = !revealed; + value.textContent = revealed ? record.value : "••••••••"; + show.textContent = revealed ? "Hide" : "Show"; + }); + actions.appendChild(show); + + var copy = document.createElement("button"); + copy.type = "button"; + copy.className = "btn compact"; + copy.textContent = "Copy"; + copy.style.marginLeft = "5px"; + copy.addEventListener("click", function () { + navigator.clipboard.writeText(record.value).then(function () { + copy.textContent = "Copied"; + setTimeout(function () { copy.textContent = "Copy"; }, 1200); + }, function () { say("Clipboard is blocked here; use Show and copy manually."); }); + }); + actions.appendChild(copy); + tr.appendChild(actions); + rows.appendChild(tr); + }); + summary.textContent = "Showing " + shown + " of " + matching.length + " matching secrets · " + records.length + " unlocked total."; + more.hidden = shown >= matching.length; + if (!more.hidden) more.textContent = "Show next " + Math.min(100, matching.length - shown); + } + + function clearAll(message) { + records = []; + search.value = ""; + rows.replaceChildren(); + summary.textContent = ""; + results.hidden = true; + running.hidden = true; + form.hidden = false; + keyInput.value = ""; + controller = null; + say(message); + } + + async function unlockEveryGrant(secretKeyB64, signal) { + if (!window.sodium) throw new Error("The crypto library did not load. Reload the page."); + var na = await window.sodium.ready.then(function () { return window.sodium; }); + var V = na.base64_variants.ORIGINAL; + var sk; + var pk; + try { + try { sk = na.from_base64(secretKeyB64, V); } + catch (_) { throw new Error("That is not a base64 identity key."); } + if (sk.length !== na.crypto_box_SECRETKEYBYTES) { + throw new Error("That key is " + sk.length + " bytes; an identity key is " + na.crypto_box_SECRETKEYBYTES + "."); + } + pk = na.crypto_scalarmult_base(sk); + + var me = await getJSON("/api/credshare/me", signal); + var registered = me.user && me.user.publicKey; + if (!registered) throw new Error("No identity key is registered for your account yet. Run `logicsrc login` first."); + if (na.to_base64(pk, V) !== registered) { + throw new Error("That identity key does not match this account. Use the key from the last machine that ran `logicsrc login`."); + } + + var grantBody = await getJSON("/api/credshare/me/grants", signal); + var grants = grantBody.grants || []; + if (!grants.length) throw new Error("No team vaults have been granted to your account yet."); + + var next = 0; + var finished = 0; + var opened = 0; + var failedVaults = 0; + var failedSecrets = 0; + var out = []; + progress.textContent = "Opening " + grants.length + " granted vaults…"; + + async function worker() { + while (!signal.aborted) { + var index = next++; + if (index >= grants.length) return; + var grant = grants[index]; + var dek = null; + try { + try { + dek = na.crypto_box_seal_open(na.from_base64(grant.wrappedDek, V), pk, sk); + } catch (_) { + failedVaults++; + finished++; + progress.textContent = "Opening vault " + finished + " of " + grants.length + "…"; + continue; + } + var body = await getJSON("/api/credshare/vaults/" + encodeURIComponent(grant.vaultId) + "/secrets", signal); + opened++; + (body.secrets || []).forEach(function (secret) { + try { + out.push({ + team: String(grant.team || ""), + vault: String(grant.vault || ""), + name: String(secret.name || ""), + value: na.to_string(na.crypto_secretbox_open_easy( + na.from_base64(secret.ciphertext, V), na.from_base64(secret.nonce, V), dek + )) + }); + } catch (_) { failedSecrets++; } + }); + } catch (_) { + if (signal.aborted) return; + failedVaults++; + } finally { + zero(na, dek); + } + finished++; + progress.textContent = "Opened " + opened + " of " + grants.length + " vaults…"; + } + } + + await Promise.all(Array.from({ length: Math.min(6, grants.length) }, worker)); + if (signal.aborted) return { cancelled: true, records: [] }; + out.sort(function (a, b) { + return a.team.localeCompare(b.team) || a.vault.localeCompare(b.vault) || a.name.localeCompare(b.name); + }); + return { records: out, opened: opened, total: grants.length, failedVaults: failedVaults, failedSecrets: failedSecrets }; + } finally { + zero(na, pk); + zero(na, sk); + } + } + + form.addEventListener("submit", function (event) { + event.preventDefault(); + var secretKey; + try { secretKey = parseKey(keyInput.value); } + catch (error) { say(error.message); return; } + keyInput.value = ""; + say(""); + form.hidden = true; + results.hidden = true; + running.hidden = false; + controller = new AbortController(); + var current = controller; + unlockEveryGrant(secretKey, current.signal).then(function (result) { + if (current.signal.aborted || result.cancelled) { + clearAll("Stopped and cleared. No values were kept."); + return; + } + records = result.records; + running.hidden = true; + results.hidden = false; + search.value = ""; + pageSize = 100; + renderRows(); + say(result.opened + " of " + result.total + " vaults opened" + + (result.failedVaults ? "; " + result.failedVaults + " vault(s) unavailable" : "") + + (result.failedSecrets ? "; " + result.failedSecrets + " value(s) could not decrypt" : "") + "."); + controller = null; + }).catch(function (error) { + if (current.signal.aborted) { + clearAll("Stopped and cleared. No values were kept."); + return; + } + running.hidden = true; + form.hidden = false; + controller = null; + say(error.message || "Could not unlock the vaults."); + }); + }); + + search.addEventListener("input", function () { pageSize = 100; renderRows(); }); + more.addEventListener("click", function () { pageSize += 100; renderRows(); }); + card.addEventListener("click", function (event) { + var action = event.target && event.target.getAttribute && event.target.getAttribute("data-action"); + if (action === "bulk-stop" && controller) controller.abort(); + else if (action === "bulk-lock") clearAll("Locked and cleared."); + }); +})(); diff --git a/apps/pwa/src/lib/vault-page.mjs b/apps/pwa/src/lib/vault-page.mjs index 9bb2c7b0..730bda3c 100644 --- a/apps/pwa/src/lib/vault-page.mjs +++ b/apps/pwa/src/lib/vault-page.mjs @@ -31,6 +31,36 @@ node -p 'require(require("os").homedir()+"/.config/logicsrc/identity.json").keys `; +/** Dashboard control for opening every vault granted to the signed-in person. */ +export function bulkUnlockCard() { + return `
+
Unlock all vaultsone key · all teams
+
+

Unlock every team vault granted to your account in one go. Your identity key and decrypted values stay in this browser tab; nothing is saved on this device or sent to the server.

+
This loads all accessible secrets into this tab's memory. Values stay masked until you reveal them.
+
+ + +
+ + +

+ ${KEY_HELP} +
+
`; +} + function keyCard({ publicKey, hasGrant, grantCommand }) { if (!publicKey) { // No identity registered yet: offer to make one here. Only in this state -- diff --git a/apps/pwa/src/routes/pages.mjs b/apps/pwa/src/routes/pages.mjs index c0428f62..4a888554 100644 --- a/apps/pwa/src/routes/pages.mjs +++ b/apps/pwa/src/routes/pages.mjs @@ -10,7 +10,7 @@ import { requireAuth, csrfInput, getCeremony, setCeremony, clearCeremony } from import { ApiKeyError, createApiKey, keyCoversVault, listApiKeys, machineKeyOptions, revokeApiKey } from "../lib/apikey.mjs"; import { requestOrigin } from "../lib/origin.mjs"; import { CLI_HINT } from "../lib/cli-hint.mjs"; -import { vaultPageBody } from "../lib/vault-page.mjs"; +import { bulkUnlockCard, vaultPageBody } from "../lib/vault-page.mjs"; import { keyFingerprint } from "../lib/keyfp.mjs"; import { teamAudit } from "./credshare.mjs"; import { config } from "../config.mjs"; @@ -119,12 +119,18 @@ export async function dashboardHandler(req, res) { ${ok ? `
${esc(ok)}${rotation ? " Vault grants were revoked; rotate affected vault keys before re-adding this person." : ""}
` : ""} ${err ? `
${esc(err)}
` : ""} ${CLI_HINT(requestOrigin(req, config.origin))} + ${bulkUnlockCard()} ${cards || `
You're not on any teams yet. Create one below or accept an invite.
`}
New team
${csrfInput(req)}
${footer}`; - res.type("html").send(page({ title: "LogicSRC ▸ teams", body, head: `` })); + res.set("Cache-Control", "no-store"); + res.type("html").send(page({ + title: "LogicSRC ▸ teams", + body: `${body}`, + head: `` + })); } pagesRouter.get("/dashboard", requireAuth, dashboardHandler); diff --git a/apps/pwa/test/vault-page.test.mjs b/apps/pwa/test/vault-page.test.mjs index a40b24c6..2f2ef646 100644 --- a/apps/pwa/test/vault-page.test.mjs +++ b/apps/pwa/test/vault-page.test.mjs @@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import express from "express"; -import { splitVaultName, vaultPageBody, KEY_HELP } from "../src/lib/vault-page.mjs"; +import { bulkUnlockCard, splitVaultName, vaultPageBody, KEY_HELP } from "../src/lib/vault-page.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const { db, run, isPostgres } = await import("../src/db.mjs"); @@ -127,13 +127,33 @@ test("a non-member and an unknown vault both fall through to 404", async () => { test("the dashboard links each vault to its page", async () => { const app = await serve({ id: "u-ann", email: "ann@example.com" }); try { - const { text } = await app.get("/dashboard"); + const { text, headers } = await app.get("/dashboard"); assert.match(text, /href="\/teams\/acme\/vaults\/v1"/); + assert.match(text, /id="vault-bulk"/); + assert.match(text, /src="\/vault-bulk\.js"/); + assert.equal(headers.get("cache-control"), "no-store"); } finally { await app.close(); } }); +test("bulk unlock is one identity-key form and warns that all accessible values load in-tab", () => { + const html = bulkUnlockCard(); + assert.match(html, /name="key"/); + assert.match(html, /Unlock all vaults/); + assert.match(html, /all accessible secrets/); + assert.match(html, /nothing is saved on this device or sent to the server/); +}); + +test("bulk unlock fetches existing encrypted grants and does not persist the key", () => { + const script = readFileSync(join(here, "..", "public", "vault-bulk.js"), "utf8"); + assert.match(script, /\/api\/credshare\/me\/grants/); + assert.match(script, /crypto_box_seal_open/); + assert.match(script, /crypto_secretbox_open_easy/); + assert.match(script, /cache: "no-store"/); + assert.doesNotMatch(script, /localStorage|sessionStorage/); +}); + test("vault names split the way the CLI splits them", () => { assert.deepEqual(splitVaultName("api--prod"), { project: "api", env: "prod" }); assert.deepEqual(splitVaultName("my-app--staging"), { project: "my-app", env: "staging" });