Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 255 additions & 0 deletions apps/pwa/public/vault-bulk.js
Original file line number Diff line number Diff line change
@@ -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.");
});
})();
30 changes: 30 additions & 0 deletions apps/pwa/src/lib/vault-page.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,36 @@ node -p 'require(require("os").homedir()+"/.config/logicsrc/identity.json").keys
</div>
</details>`;

/** Dashboard control for opening every vault granted to the signed-in person. */
export function bulkUnlockCard() {
return `<section class="card" id="vault-bulk" style="margin-bottom:22px">
<div class="card-head"><span class="h">Unlock all vaults</span><span class="pill">one key · all teams</span></div>
<div class="card-body">
<p class="dim" style="margin-top:0;font-size:.88rem">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.</p>
<div class="notice warn" style="margin-bottom:12px">This loads all accessible secrets into this tab's memory. Values stay masked until you reveal them.</div>
<form data-role="bulk-unlock" autocomplete="off">
<label class="field"><span>Your identity secret key or identity.json</span>
<input type="password" name="key" placeholder="paste once to open all granted vaults" spellcheck="false" autocomplete="off" required></label>
<button class="btn acid" style="font-size:1rem;padding:12px 22px">Unlock all vaults</button>
</form>
<div data-role="bulk-running" hidden>
<p data-role="bulk-progress" class="mono dim" aria-live="polite"></p>
<button class="btn danger" type="button" data-action="bulk-stop">Stop and lock</button>
</div>
<div data-role="bulk-results" hidden>
<p data-role="bulk-summary" class="dim" aria-live="polite"></p>
<label class="field"><span>Search team, vault, or secret name</span>
<input type="search" data-role="bulk-search" placeholder="e.g. NPM_TOKEN" autocomplete="off"></label>
<div class="table-scroll"><table><thead><tr><th>Team</th><th>Vault</th><th>Secret</th><th>Value</th><th></th></tr></thead><tbody data-role="bulk-rows"></tbody></table></div>
<button class="btn compact" type="button" data-action="bulk-more" hidden>Show more</button>
<button class="btn danger" type="button" data-action="bulk-lock" style="margin-top:12px">Lock all and clear</button>
</div>
<p data-role="bulk-status" class="mono" style="font-size:.82rem;margin-bottom:0;color:var(--danger,#c23a3a)" aria-live="polite"></p>
${KEY_HELP}
</div>
</section>`;
}

function keyCard({ publicKey, hasGrant, grantCommand }) {
if (!publicKey) {
// No identity registered yet: offer to make one here. Only in this state --
Expand Down
10 changes: 8 additions & 2 deletions apps/pwa/src/routes/pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -119,12 +119,18 @@ export async function dashboardHandler(req, res) {
${ok ? `<div class="notice ok">${esc(ok)}${rotation ? " Vault grants were revoked; rotate affected vault keys before re-adding this person." : ""}</div>` : ""}
${err ? `<div class="notice err">${esc(err)}</div>` : ""}
${CLI_HINT(requestOrigin(req, config.origin))}
${bulkUnlockCard()}
${cards || `<div class="card"><div class="card-body dim">You're not on any teams yet. Create one below or accept an invite.</div></div>`}
<div class="card" style="margin-top:22px"><div class="card-head"><span class="h">New team</span></div>
<div class="card-body"><form method="post" action="/teams" style="display:flex;gap:8px">${csrfInput(req)}
<input name="slug" placeholder="team-slug" required style="flex:1"><button class="btn acid">Create team</button></form></div></div>
</main>${footer}`;
res.type("html").send(page({ title: "LogicSRC ▸ teams", body, head: `<script src="/pins.js" defer></script>` }));
res.set("Cache-Control", "no-store");
res.type("html").send(page({
title: "LogicSRC ▸ teams",
body: `${body}<script src="/vendor/libsodium.js" defer></script><script src="/vendor/libsodium-wrappers.js" defer></script><script src="/vault-bulk.js" defer></script>`,
head: `<script src="/pins.js" defer></script>`
}));
}

pagesRouter.get("/dashboard", requireAuth, dashboardHandler);
Expand Down
24 changes: 22 additions & 2 deletions apps/pwa/test/vault-page.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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" });
Expand Down
Loading