|
| 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 | +})(); |
0 commit comments