Skip to content

Commit 9cbe2fd

Browse files
Merge pull request #274 from profullstack/codex/unlock-all-vaults
Unlock all granted vaults from the dashboard
2 parents 0cf528f + 1863996 commit 9cbe2fd

4 files changed

Lines changed: 315 additions & 4 deletions

File tree

‎apps/pwa/public/vault-bulk.js‎

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/* Unlock every team vault granted to the signed-in person from the dashboard.
2+
The identity key and plaintext stay in this tab; the server returns only
3+
wrapped keys and ciphertext. */
4+
(function () {
5+
var card = document.getElementById("vault-bulk");
6+
if (!card) return;
7+
8+
var form = card.querySelector("[data-role=bulk-unlock]");
9+
var keyInput = form.elements.key;
10+
var running = card.querySelector("[data-role=bulk-running]");
11+
var progress = card.querySelector("[data-role=bulk-progress]");
12+
var results = card.querySelector("[data-role=bulk-results]");
13+
var summary = card.querySelector("[data-role=bulk-summary]");
14+
var search = card.querySelector("[data-role=bulk-search]");
15+
var rows = card.querySelector("[data-role=bulk-rows]");
16+
var more = card.querySelector('[data-action="bulk-more"]');
17+
var status = card.querySelector("[data-role=bulk-status]");
18+
var records = [];
19+
var controller = null;
20+
var pageSize = 100;
21+
22+
function say(message) { status.textContent = message || ""; }
23+
24+
function parseKey(raw) {
25+
var text = String(raw || "").trim();
26+
if (text.charAt(0) === "{") {
27+
var json;
28+
try { json = JSON.parse(text); } catch (_) { throw new Error("That looks like JSON but does not parse."); }
29+
text = (json.keys && json.keys.secretKey) || json.secretKey || "";
30+
if (!text) throw new Error("No keys.secretKey in that JSON.");
31+
}
32+
return text.replace(/^["']|["']$/g, "").replace(/\s+/g, "");
33+
}
34+
35+
function getJSON(url, signal) {
36+
return fetch(url, {
37+
credentials: "same-origin",
38+
headers: { accept: "application/json" },
39+
cache: "no-store",
40+
signal: signal
41+
}).then(function (res) {
42+
return res.json().catch(function () { return {}; }).then(function (body) {
43+
if (!res.ok) throw new Error(body.error || ("HTTP " + res.status));
44+
return body;
45+
});
46+
});
47+
}
48+
49+
function zero(na, bytes) {
50+
if (bytes && na.memzero) na.memzero(bytes);
51+
}
52+
53+
function renderRows() {
54+
var query = String(search.value || "").trim().toLowerCase();
55+
var matching = records.filter(function (record) {
56+
return !query || (record.team + " " + record.vault + " " + record.name).toLowerCase().includes(query);
57+
});
58+
var shown = Math.min(pageSize, matching.length);
59+
rows.replaceChildren();
60+
matching.slice(0, shown).forEach(function (record) {
61+
var tr = document.createElement("tr");
62+
[record.team, record.vault, record.name].forEach(function (text) {
63+
var td = document.createElement("td");
64+
var code = document.createElement("code");
65+
code.textContent = text;
66+
td.appendChild(code);
67+
tr.appendChild(td);
68+
});
69+
70+
var valueCell = document.createElement("td");
71+
var value = document.createElement("code");
72+
value.className = "mono";
73+
value.textContent = "••••••••";
74+
valueCell.appendChild(value);
75+
tr.appendChild(valueCell);
76+
77+
var actions = document.createElement("td");
78+
actions.style.whiteSpace = "nowrap";
79+
var show = document.createElement("button");
80+
show.type = "button";
81+
show.className = "btn compact";
82+
show.textContent = "Show";
83+
var revealed = false;
84+
show.addEventListener("click", function () {
85+
revealed = !revealed;
86+
value.textContent = revealed ? record.value : "••••••••";
87+
show.textContent = revealed ? "Hide" : "Show";
88+
});
89+
actions.appendChild(show);
90+
91+
var copy = document.createElement("button");
92+
copy.type = "button";
93+
copy.className = "btn compact";
94+
copy.textContent = "Copy";
95+
copy.style.marginLeft = "5px";
96+
copy.addEventListener("click", function () {
97+
navigator.clipboard.writeText(record.value).then(function () {
98+
copy.textContent = "Copied";
99+
setTimeout(function () { copy.textContent = "Copy"; }, 1200);
100+
}, function () { say("Clipboard is blocked here; use Show and copy manually."); });
101+
});
102+
actions.appendChild(copy);
103+
tr.appendChild(actions);
104+
rows.appendChild(tr);
105+
});
106+
summary.textContent = "Showing " + shown + " of " + matching.length + " matching secrets · " + records.length + " unlocked total.";
107+
more.hidden = shown >= matching.length;
108+
if (!more.hidden) more.textContent = "Show next " + Math.min(100, matching.length - shown);
109+
}
110+
111+
function clearAll(message) {
112+
records = [];
113+
search.value = "";
114+
rows.replaceChildren();
115+
summary.textContent = "";
116+
results.hidden = true;
117+
running.hidden = true;
118+
form.hidden = false;
119+
keyInput.value = "";
120+
controller = null;
121+
say(message);
122+
}
123+
124+
async function unlockEveryGrant(secretKeyB64, signal) {
125+
if (!window.sodium) throw new Error("The crypto library did not load. Reload the page.");
126+
var na = await window.sodium.ready.then(function () { return window.sodium; });
127+
var V = na.base64_variants.ORIGINAL;
128+
var sk;
129+
var pk;
130+
try {
131+
try { sk = na.from_base64(secretKeyB64, V); }
132+
catch (_) { throw new Error("That is not a base64 identity key."); }
133+
if (sk.length !== na.crypto_box_SECRETKEYBYTES) {
134+
throw new Error("That key is " + sk.length + " bytes; an identity key is " + na.crypto_box_SECRETKEYBYTES + ".");
135+
}
136+
pk = na.crypto_scalarmult_base(sk);
137+
138+
var me = await getJSON("/api/credshare/me", signal);
139+
var registered = me.user && me.user.publicKey;
140+
if (!registered) throw new Error("No identity key is registered for your account yet. Run `logicsrc login` first.");
141+
if (na.to_base64(pk, V) !== registered) {
142+
throw new Error("That identity key does not match this account. Use the key from the last machine that ran `logicsrc login`.");
143+
}
144+
145+
var grantBody = await getJSON("/api/credshare/me/grants", signal);
146+
var grants = grantBody.grants || [];
147+
if (!grants.length) throw new Error("No team vaults have been granted to your account yet.");
148+
149+
var next = 0;
150+
var finished = 0;
151+
var opened = 0;
152+
var failedVaults = 0;
153+
var failedSecrets = 0;
154+
var out = [];
155+
progress.textContent = "Opening " + grants.length + " granted vaults…";
156+
157+
async function worker() {
158+
while (!signal.aborted) {
159+
var index = next++;
160+
if (index >= grants.length) return;
161+
var grant = grants[index];
162+
var dek = null;
163+
try {
164+
try {
165+
dek = na.crypto_box_seal_open(na.from_base64(grant.wrappedDek, V), pk, sk);
166+
} catch (_) {
167+
failedVaults++;
168+
finished++;
169+
progress.textContent = "Opening vault " + finished + " of " + grants.length + "…";
170+
continue;
171+
}
172+
var body = await getJSON("/api/credshare/vaults/" + encodeURIComponent(grant.vaultId) + "/secrets", signal);
173+
opened++;
174+
(body.secrets || []).forEach(function (secret) {
175+
try {
176+
out.push({
177+
team: String(grant.team || ""),
178+
vault: String(grant.vault || ""),
179+
name: String(secret.name || ""),
180+
value: na.to_string(na.crypto_secretbox_open_easy(
181+
na.from_base64(secret.ciphertext, V), na.from_base64(secret.nonce, V), dek
182+
))
183+
});
184+
} catch (_) { failedSecrets++; }
185+
});
186+
} catch (_) {
187+
if (signal.aborted) return;
188+
failedVaults++;
189+
} finally {
190+
zero(na, dek);
191+
}
192+
finished++;
193+
progress.textContent = "Opened " + opened + " of " + grants.length + " vaults…";
194+
}
195+
}
196+
197+
await Promise.all(Array.from({ length: Math.min(6, grants.length) }, worker));
198+
if (signal.aborted) return { cancelled: true, records: [] };
199+
out.sort(function (a, b) {
200+
return a.team.localeCompare(b.team) || a.vault.localeCompare(b.vault) || a.name.localeCompare(b.name);
201+
});
202+
return { records: out, opened: opened, total: grants.length, failedVaults: failedVaults, failedSecrets: failedSecrets };
203+
} finally {
204+
zero(na, pk);
205+
zero(na, sk);
206+
}
207+
}
208+
209+
form.addEventListener("submit", function (event) {
210+
event.preventDefault();
211+
var secretKey;
212+
try { secretKey = parseKey(keyInput.value); }
213+
catch (error) { say(error.message); return; }
214+
keyInput.value = "";
215+
say("");
216+
form.hidden = true;
217+
results.hidden = true;
218+
running.hidden = false;
219+
controller = new AbortController();
220+
var current = controller;
221+
unlockEveryGrant(secretKey, current.signal).then(function (result) {
222+
if (current.signal.aborted || result.cancelled) {
223+
clearAll("Stopped and cleared. No values were kept.");
224+
return;
225+
}
226+
records = result.records;
227+
running.hidden = true;
228+
results.hidden = false;
229+
search.value = "";
230+
pageSize = 100;
231+
renderRows();
232+
say(result.opened + " of " + result.total + " vaults opened" +
233+
(result.failedVaults ? "; " + result.failedVaults + " vault(s) unavailable" : "") +
234+
(result.failedSecrets ? "; " + result.failedSecrets + " value(s) could not decrypt" : "") + ".");
235+
controller = null;
236+
}).catch(function (error) {
237+
if (current.signal.aborted) {
238+
clearAll("Stopped and cleared. No values were kept.");
239+
return;
240+
}
241+
running.hidden = true;
242+
form.hidden = false;
243+
controller = null;
244+
say(error.message || "Could not unlock the vaults.");
245+
});
246+
});
247+
248+
search.addEventListener("input", function () { pageSize = 100; renderRows(); });
249+
more.addEventListener("click", function () { pageSize += 100; renderRows(); });
250+
card.addEventListener("click", function (event) {
251+
var action = event.target && event.target.getAttribute && event.target.getAttribute("data-action");
252+
if (action === "bulk-stop" && controller) controller.abort();
253+
else if (action === "bulk-lock") clearAll("Locked and cleared.");
254+
});
255+
})();

‎apps/pwa/src/lib/vault-page.mjs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,36 @@ node -p 'require(require("os").homedir()+"/.config/logicsrc/identity.json").keys
3131
</div>
3232
</details>`;
3333

34+
/** Dashboard control for opening every vault granted to the signed-in person. */
35+
export function bulkUnlockCard() {
36+
return `<section class="card" id="vault-bulk" style="margin-bottom:22px">
37+
<div class="card-head"><span class="h">Unlock all vaults</span><span class="pill">one key · all teams</span></div>
38+
<div class="card-body">
39+
<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>
40+
<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>
41+
<form data-role="bulk-unlock" autocomplete="off">
42+
<label class="field"><span>Your identity secret key or identity.json</span>
43+
<input type="password" name="key" placeholder="paste once to open all granted vaults" spellcheck="false" autocomplete="off" required></label>
44+
<button class="btn acid" style="font-size:1rem;padding:12px 22px">Unlock all vaults</button>
45+
</form>
46+
<div data-role="bulk-running" hidden>
47+
<p data-role="bulk-progress" class="mono dim" aria-live="polite"></p>
48+
<button class="btn danger" type="button" data-action="bulk-stop">Stop and lock</button>
49+
</div>
50+
<div data-role="bulk-results" hidden>
51+
<p data-role="bulk-summary" class="dim" aria-live="polite"></p>
52+
<label class="field"><span>Search team, vault, or secret name</span>
53+
<input type="search" data-role="bulk-search" placeholder="e.g. NPM_TOKEN" autocomplete="off"></label>
54+
<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>
55+
<button class="btn compact" type="button" data-action="bulk-more" hidden>Show more</button>
56+
<button class="btn danger" type="button" data-action="bulk-lock" style="margin-top:12px">Lock all and clear</button>
57+
</div>
58+
<p data-role="bulk-status" class="mono" style="font-size:.82rem;margin-bottom:0;color:var(--danger,#c23a3a)" aria-live="polite"></p>
59+
${KEY_HELP}
60+
</div>
61+
</section>`;
62+
}
63+
3464
function keyCard({ publicKey, hasGrant, grantCommand }) {
3565
if (!publicKey) {
3666
// No identity registered yet: offer to make one here. Only in this state --

‎apps/pwa/src/routes/pages.mjs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { requireAuth, csrfInput, getCeremony, setCeremony, clearCeremony } from
1010
import { ApiKeyError, createApiKey, keyCoversVault, listApiKeys, machineKeyOptions, revokeApiKey } from "../lib/apikey.mjs";
1111
import { requestOrigin } from "../lib/origin.mjs";
1212
import { CLI_HINT } from "../lib/cli-hint.mjs";
13-
import { vaultPageBody } from "../lib/vault-page.mjs";
13+
import { bulkUnlockCard, vaultPageBody } from "../lib/vault-page.mjs";
1414
import { keyFingerprint } from "../lib/keyfp.mjs";
1515
import { teamAudit } from "./credshare.mjs";
1616
import { config } from "../config.mjs";
@@ -119,12 +119,18 @@ export async function dashboardHandler(req, res) {
119119
${ok ? `<div class="notice ok">${esc(ok)}${rotation ? " Vault grants were revoked; rotate affected vault keys before re-adding this person." : ""}</div>` : ""}
120120
${err ? `<div class="notice err">${esc(err)}</div>` : ""}
121121
${CLI_HINT(requestOrigin(req, config.origin))}
122+
${bulkUnlockCard()}
122123
${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>`}
123124
<div class="card" style="margin-top:22px"><div class="card-head"><span class="h">New team</span></div>
124125
<div class="card-body"><form method="post" action="/teams" style="display:flex;gap:8px">${csrfInput(req)}
125126
<input name="slug" placeholder="team-slug" required style="flex:1"><button class="btn acid">Create team</button></form></div></div>
126127
</main>${footer}`;
127-
res.type("html").send(page({ title: "LogicSRC ▸ teams", body, head: `<script src="/pins.js" defer></script>` }));
128+
res.set("Cache-Control", "no-store");
129+
res.type("html").send(page({
130+
title: "LogicSRC ▸ teams",
131+
body: `${body}<script src="/vendor/libsodium.js" defer></script><script src="/vendor/libsodium-wrappers.js" defer></script><script src="/vault-bulk.js" defer></script>`,
132+
head: `<script src="/pins.js" defer></script>`
133+
}));
128134
}
129135

130136
pagesRouter.get("/dashboard", requireAuth, dashboardHandler);

‎apps/pwa/test/vault-page.test.mjs‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url";
1212
import { dirname, join } from "node:path";
1313
import express from "express";
1414

15-
import { splitVaultName, vaultPageBody, KEY_HELP } from "../src/lib/vault-page.mjs";
15+
import { bulkUnlockCard, splitVaultName, vaultPageBody, KEY_HELP } from "../src/lib/vault-page.mjs";
1616

1717
const here = dirname(fileURLToPath(import.meta.url));
1818
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 () => {
127127
test("the dashboard links each vault to its page", async () => {
128128
const app = await serve({ id: "u-ann", email: "ann@example.com" });
129129
try {
130-
const { text } = await app.get("/dashboard");
130+
const { text, headers } = await app.get("/dashboard");
131131
assert.match(text, /href="\/teams\/acme\/vaults\/v1"/);
132+
assert.match(text, /id="vault-bulk"/);
133+
assert.match(text, /src="\/vault-bulk\.js"/);
134+
assert.equal(headers.get("cache-control"), "no-store");
132135
} finally {
133136
await app.close();
134137
}
135138
});
136139

140+
test("bulk unlock is one identity-key form and warns that all accessible values load in-tab", () => {
141+
const html = bulkUnlockCard();
142+
assert.match(html, /name="key"/);
143+
assert.match(html, /Unlock all vaults/);
144+
assert.match(html, /all accessible secrets/);
145+
assert.match(html, /nothing is saved on this device or sent to the server/);
146+
});
147+
148+
test("bulk unlock fetches existing encrypted grants and does not persist the key", () => {
149+
const script = readFileSync(join(here, "..", "public", "vault-bulk.js"), "utf8");
150+
assert.match(script, /\/api\/credshare\/me\/grants/);
151+
assert.match(script, /crypto_box_seal_open/);
152+
assert.match(script, /crypto_secretbox_open_easy/);
153+
assert.match(script, /cache: "no-store"/);
154+
assert.doesNotMatch(script, /localStorage|sessionStorage/);
155+
});
156+
137157
test("vault names split the way the CLI splits them", () => {
138158
assert.deepEqual(splitVaultName("api--prod"), { project: "api", env: "prod" });
139159
assert.deepEqual(splitVaultName("my-app--staging"), { project: "my-app", env: "staging" });

0 commit comments

Comments
 (0)