mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-04 13:45:40 +00:00
pwa: read vault values in the browser with a pasted identity key (#225)
* pwa: read vault values in the browser with a pasted identity key The web app listed vaults but could never show a value: decryption needs the member's X25519 secret key, which only lives in identity.json on the machine that ran `logicsrc login`. Each vault now has a page (/teams/:slug/vaults/:id, linked from the dashboard) listing its secret names. Paste the identity key (or the whole identity.json) and public/vault.js decrypts in the browser with the same libsodium calls as the CLI: crypto_box_seal_open for the grant, then crypto_secretbox_open_easy per value. Nothing is sent to the server. - The pasted key is checked against the public key the server has on file before use, so a key from another machine is named, not a generic failure. - Show / Copy per value, Download .env, Forget key. The key is kept in sessionStorage unless "remember on this device" is ticked. - A member with no identity yet can create one in the browser. That registers only the public half, and is offered only when no key is registered, since replacing one would orphan every grant sealed to it. - libsodium is served from node_modules at /vendor (no CDN), like simplewebauthn. - Vault pages are no-store and the service worker no longer caches no-store pages. - CLI: `logicsrc teams key` prints the secret key to stdout alone (pipe to pbcopy) and warns when the server holds a different public key. The page also gives a jq one-liner for CLIs released before this command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * pwa: rename data-secret to data-key-name ThreatCrush read data-secret="…" as a hardcoded credential (2 high, both the attribute name, never a value). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CLI 0.5.0 for teams key; install via install.sh, not npm @logicsrc/cli is not on npm; the CLI ships through curl -fsSL https://logicsrc.com/install.sh | sh, which builds master and reports the version from packages/cli/package.json. 0.5.0 marks the first build with teams key, and the vault page now says so and points older installs at the jq fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b75af67a43
commit
5bebc50beb
11 changed files with 586 additions and 9 deletions
|
|
@ -15,7 +15,8 @@
|
|||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.1.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^4.21.2"
|
||||
"express": "^4.21.2",
|
||||
"libsodium-wrappers": "^0.7.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@libsql/client": "^0.14.0"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* LogicSRC PWA service worker — offline app shell (network-first for docs). */
|
||||
const CACHE = "logicsrc-v1";
|
||||
const CACHE = "logicsrc-v2";
|
||||
const SHELL = ["/", "/icon.svg", "/manifest.webmanifest", "/passkey.js"];
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
|
|
@ -44,7 +44,8 @@ self.addEventListener("fetch", (e) => {
|
|||
e.respondWith(
|
||||
fetch(request)
|
||||
.then((res) => {
|
||||
if (res.ok && url.origin === location.origin) {
|
||||
// no-store pages (a vault's secret names) stay out of the offline cache
|
||||
if (res.ok && url.origin === location.origin && !/no-store/.test(res.headers.get("cache-control") || "")) {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put(request, copy));
|
||||
}
|
||||
|
|
|
|||
226
apps/pwa/public/vault.js
Normal file
226
apps/pwa/public/vault.js
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/* Vault page: decrypt secret values in the browser with the member's identity key.
|
||||
Mirrors plugins/credential-sharing/src/crypto.ts exactly:
|
||||
DEK = crypto_box_seal_open(wrappedDek, publicKey, secretKey)
|
||||
value = crypto_secretbox_open_easy(ciphertext, nonce, DEK)
|
||||
All base64 is the ORIGINAL variant. The key never leaves this page: it is
|
||||
checked against the public key the server holds, then used locally. */
|
||||
(function () {
|
||||
var main = document.querySelector("main[data-vault-id]");
|
||||
var card = document.getElementById("vault-key");
|
||||
if (!main || !card) return;
|
||||
|
||||
var vaultId = main.getAttribute("data-vault-id");
|
||||
var vaultName = main.getAttribute("data-vault-name");
|
||||
var registered = main.getAttribute("data-public-key");
|
||||
var hasGrant = main.getAttribute("data-has-grant") === "1";
|
||||
var STORE = "logicsrc.identityKey";
|
||||
var status = card.querySelector("[data-role=status]");
|
||||
var values = null; // name -> plaintext, once unlocked
|
||||
|
||||
function say(msg) { if (status) status.textContent = msg || ""; }
|
||||
|
||||
function store(kind) {
|
||||
try { return window[kind]; } catch (_) { return null; }
|
||||
}
|
||||
function readSaved() {
|
||||
var s = store("sessionStorage"), l = store("localStorage");
|
||||
try { return (s && s.getItem(STORE)) || (l && l.getItem(STORE)) || ""; } catch (_) { return ""; }
|
||||
}
|
||||
function save(key, remember) {
|
||||
try {
|
||||
var s = store("sessionStorage"), l = store("localStorage");
|
||||
if (s) s.setItem(STORE, key);
|
||||
if (l) { if (remember) l.setItem(STORE, key); else l.removeItem(STORE); }
|
||||
} catch (_) { /* private mode: the key just lasts as long as this page */ }
|
||||
}
|
||||
function forget() {
|
||||
try {
|
||||
var s = store("sessionStorage"), l = store("localStorage");
|
||||
if (s) s.removeItem(STORE);
|
||||
if (l) l.removeItem(STORE);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function sodium() {
|
||||
if (!window.sodium) return Promise.reject(new Error("The crypto library did not load. Reload the page."));
|
||||
return window.sodium.ready.then(function () { return window.sodium; });
|
||||
}
|
||||
|
||||
// Accept the bare base64 key, the key in quotes, or the whole identity.json.
|
||||
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) {
|
||||
return fetch(url, { credentials: "same-origin", headers: { accept: "application/json" } }).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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Check the key is ours, then open the grant and every value. */
|
||||
function unlock(secretKeyB64) {
|
||||
return sodium().then(function (na) {
|
||||
var V = na.base64_variants.ORIGINAL;
|
||||
var sk;
|
||||
try { sk = na.from_base64(secretKeyB64, V); } catch (_) { throw new Error("That is not a base64 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 + ".");
|
||||
var pk = na.crypto_scalarmult_base(sk);
|
||||
if (na.to_base64(pk, V) !== registered) {
|
||||
throw new Error("That key does not match the identity registered for your account. It may be from another machine. Your account uses the key from the last machine that ran logicsrc login.");
|
||||
}
|
||||
if (!hasGrant) throw new Error("Key accepted, but you have no grant for this vault yet.");
|
||||
return getJSON("/api/credshare/vaults/" + encodeURIComponent(vaultId) + "/grant").then(function (g) {
|
||||
var dek;
|
||||
try { dek = na.crypto_box_seal_open(na.from_base64(g.wrappedDek, V), pk, sk); }
|
||||
catch (_) { throw new Error("Your key cannot open this vault's grant. It was sealed to an older key, so ask a member to grant you again."); }
|
||||
return getJSON("/api/credshare/vaults/" + encodeURIComponent(vaultId) + "/secrets").then(function (body) {
|
||||
var out = {}, failed = [];
|
||||
(body.secrets || []).forEach(function (s) {
|
||||
try {
|
||||
out[s.name] = na.to_string(na.crypto_secretbox_open_easy(na.from_base64(s.ciphertext, V), na.from_base64(s.nonce, V), dek));
|
||||
} catch (_) { failed.push(s.name); }
|
||||
});
|
||||
return { values: out, failed: failed };
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function button(label, onClick) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "btn compact";
|
||||
b.textContent = label;
|
||||
b.addEventListener("click", onClick);
|
||||
return b;
|
||||
}
|
||||
|
||||
function render(result) {
|
||||
values = result.values;
|
||||
var rows = main.querySelectorAll("tr[data-key-name]");
|
||||
Array.prototype.forEach.call(rows, function (tr) {
|
||||
var name = tr.getAttribute("data-key-name");
|
||||
var cell = tr.querySelector(".secret-value");
|
||||
if (!cell) return;
|
||||
cell.textContent = "";
|
||||
if (!(name in values)) {
|
||||
var miss = document.createElement("span");
|
||||
miss.className = "faint mono";
|
||||
miss.textContent = "could not decrypt";
|
||||
cell.appendChild(miss);
|
||||
return;
|
||||
}
|
||||
var shown = false;
|
||||
var code = document.createElement("code");
|
||||
code.className = "mono";
|
||||
code.style.wordBreak = "break-all";
|
||||
code.textContent = "••••••••";
|
||||
var toggle = button("Show", function () {
|
||||
shown = !shown;
|
||||
code.textContent = shown ? values[name] : "••••••••";
|
||||
toggle.textContent = shown ? "Hide" : "Show";
|
||||
});
|
||||
var copy = button("Copy", function () {
|
||||
navigator.clipboard.writeText(values[name]).then(function () {
|
||||
copy.textContent = "Copied";
|
||||
setTimeout(function () { copy.textContent = "Copy"; }, 1200);
|
||||
}, function () { say("The clipboard is blocked here; use Show and copy by hand."); });
|
||||
});
|
||||
var wrap = document.createElement("div");
|
||||
wrap.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap";
|
||||
wrap.appendChild(code);
|
||||
wrap.appendChild(toggle);
|
||||
wrap.appendChild(copy);
|
||||
cell.appendChild(wrap);
|
||||
});
|
||||
var form = card.querySelector("[data-role=unlock]");
|
||||
var done = card.querySelector("[data-role=unlocked]");
|
||||
var state = card.querySelector("[data-role=state]");
|
||||
if (form) form.hidden = true;
|
||||
if (done) done.hidden = false;
|
||||
if (state) { state.textContent = "unlocked"; state.className = "pill on"; }
|
||||
say(result.failed.length ? result.failed.length + " value(s) did not decrypt: " + result.failed.join(", ") : "");
|
||||
}
|
||||
|
||||
// dotenv line: bare when safe, else double-quoted with JSON escapes (\n, \", \\).
|
||||
function envLine(name, value) {
|
||||
return name + "=" + (/^[A-Za-z0-9_\-.:\/@+,]*$/.test(value) ? value : JSON.stringify(value));
|
||||
}
|
||||
|
||||
card.addEventListener("click", function (e) {
|
||||
var action = e.target && e.target.getAttribute && e.target.getAttribute("data-action");
|
||||
if (action === "forget") {
|
||||
forget();
|
||||
location.reload();
|
||||
} else if (action === "download" && values) {
|
||||
var text = Object.keys(values).sort().map(function (k) { return envLine(k, values[k]); }).join("\n") + "\n";
|
||||
var a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
|
||||
a.download = vaultName + ".env";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 0);
|
||||
} else if (action === "generate") {
|
||||
generate(e.target);
|
||||
}
|
||||
});
|
||||
|
||||
var form = card.querySelector("[data-role=unlock]");
|
||||
if (form) {
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
var key;
|
||||
try { key = parseKey(form.elements.key.value); } catch (err) { say(err.message); return; }
|
||||
say("Decrypting…");
|
||||
unlock(key).then(function (result) {
|
||||
save(key, form.elements.remember.checked);
|
||||
form.elements.key.value = "";
|
||||
render(result);
|
||||
}, function (err) { say(err.message); });
|
||||
});
|
||||
// A key from earlier in this tab (or remembered) unlocks without asking.
|
||||
var saved = readSaved();
|
||||
if (saved && registered && hasGrant) {
|
||||
unlock(saved).then(render, function (err) { forget(); say(err.message); });
|
||||
}
|
||||
}
|
||||
|
||||
// No identity registered yet: make one here and register only its public half.
|
||||
function generate(btn) {
|
||||
btn.disabled = true;
|
||||
say("Creating a key…");
|
||||
getJSON("/api/credshare/me").then(function (me) {
|
||||
// Re-check: a login elsewhere may have registered a key since this page loaded.
|
||||
if (me.user && me.user.publicKey) throw new Error("A key was registered since this page loaded. Reload and paste that one.");
|
||||
return sodium();
|
||||
}).then(function (na) {
|
||||
var V = na.base64_variants.ORIGINAL;
|
||||
var kp = na.crypto_box_keypair();
|
||||
var publicKey = na.to_base64(kp.publicKey, V), secretKey = na.to_base64(kp.privateKey, V);
|
||||
var m = document.cookie.match(/(?:^|; )mc_csrf=([^;]+)/);
|
||||
return fetch("/api/credshare/keys", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json", "x-csrf-token": m ? decodeURIComponent(m[1]) : "" },
|
||||
body: JSON.stringify({ publicKey: publicKey })
|
||||
}).then(function (res) {
|
||||
if (!res.ok) throw new Error("Could not register the key (HTTP " + res.status + ").");
|
||||
save(secretKey, false);
|
||||
card.querySelector("[data-role=generated-key]").textContent = secretKey;
|
||||
card.querySelector("[data-role=generated]").hidden = false;
|
||||
btn.hidden = true;
|
||||
say("");
|
||||
});
|
||||
}).catch(function (err) { btn.disabled = false; say(err.message); });
|
||||
}
|
||||
})();
|
||||
101
apps/pwa/src/lib/vault-page.mjs
Normal file
101
apps/pwa/src/lib/vault-page.mjs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// One vault: its secret names (which the server can see) and, once the member
|
||||
// pastes their identity key, the values (which it cannot). Decryption happens
|
||||
// in public/vault.js with the same libsodium calls the CLI makes; the key is
|
||||
// checked against the public key the server holds before anything is opened,
|
||||
// and it is never sent anywhere.
|
||||
import { esc } from "./html.mjs";
|
||||
|
||||
const VAULT_SEP = "--";
|
||||
|
||||
/** `<project>--<env>` back into its parts, as the CLI's splitVaultName does. */
|
||||
export function splitVaultName(name) {
|
||||
const at = name.indexOf(VAULT_SEP);
|
||||
if (at <= 0) return null;
|
||||
const env = name.slice(at + VAULT_SEP.length);
|
||||
if (!env || env.includes(VAULT_SEP)) return null;
|
||||
return { project: name.slice(0, at), env };
|
||||
}
|
||||
|
||||
const when = (ms) => (ms ? new Date(Number(ms)).toISOString().slice(0, 16).replace("T", " ") : "—");
|
||||
|
||||
/** How to get the key onto the clipboard, for the machine that ran `logicsrc login`. */
|
||||
export const KEY_HELP = `<details class="key-help" style="margin-top:12px">
|
||||
<summary class="dim" style="cursor:pointer;font-size:.85rem">Where do I find my key?</summary>
|
||||
<div style="font-size:.85rem;margin-top:8px">
|
||||
<p class="dim" style="margin:0 0 8px">It is the identity secret key the <code>logicsrc</code> CLI created the first time you ran <code>logicsrc login</code>. It lives only on that machine, in <code>~/.config/logicsrc/identity.json</code>. On that machine, run:</p>
|
||||
<pre class="mono" style="margin:0 0 8px">logicsrc teams key</pre>
|
||||
<p class="dim" style="margin:0 0 8px">That needs CLI 0.5.0 or later (<code>curl -fsSL https://logicsrc.com/install.sh | sh -s -- update</code>). On an older CLI, either of these prints the same thing:</p>
|
||||
<pre class="mono" style="margin:0 0 8px">jq -r .keys.secretKey ~/.config/logicsrc/identity.json
|
||||
node -p 'require(require("os").homedir()+"/.config/logicsrc/identity.json").keys.secretKey'</pre>
|
||||
<p class="dim" style="margin:0">Pasting the whole <code>identity.json</code> works too; only <code>secretKey</code> is read. Installs from before the config move keep it at <code>~/.logicsrc/identity.json</code>. Anyone holding this key can read every vault you can, so treat it like a password.</p>
|
||||
</div>
|
||||
</details>`;
|
||||
|
||||
function keyCard({ publicKey, hasGrant, grantCommand }) {
|
||||
if (!publicKey) {
|
||||
// No identity registered yet: offer to make one here. Only in this state --
|
||||
// replacing a registered key would orphan every grant sealed to the old one.
|
||||
return `<div class="card" id="vault-key" style="margin-bottom:18px"><div class="card-head"><span class="h">Read the values</span></div>
|
||||
<div class="card-body">
|
||||
<p class="dim" style="margin-top:0;font-size:.88rem">You have no identity key yet, so no vault can be shared with you. Values are encrypted to a key only you hold. Make one in either place:</p>
|
||||
<ul class="dim" style="font-size:.88rem;padding-left:18px">
|
||||
<li>In a terminal: <code>curl -fsSL https://logicsrc.com/install.sh | sh</code>, then <code>logicsrc login</code>. Then <code>logicsrc teams key</code> prints the key to paste here.</li>
|
||||
<li>Or here in this browser:</li>
|
||||
</ul>
|
||||
<button class="btn acid" type="button" data-action="generate">Create a key in this browser</button>
|
||||
<div data-role="generated" hidden style="margin-top:12px">
|
||||
<div class="notice ok">Your identity key. Save it in your password manager <b>now</b>. It is shown once, and nobody, including us, can recover it:</div>
|
||||
<pre class="mono" data-role="generated-key" style="word-break:break-all;white-space:pre-wrap"></pre>
|
||||
<p class="dim" style="font-size:.85rem">Next, a teammate who can already read this vault runs <code>${esc(grantCommand)}</code>. Then reload this page.</p>
|
||||
</div>
|
||||
<p data-role="status" class="dim mono" style="font-size:.8rem;margin-bottom:0"></p>
|
||||
</div></div>`;
|
||||
}
|
||||
return `<div class="card" id="vault-key" style="margin-bottom:18px"><div class="card-head"><span class="h">Read the values</span><span class="pill" data-role="state">locked</span></div>
|
||||
<div class="card-body">
|
||||
${hasGrant ? "" : `<div class="notice err">You have not been granted this vault yet, so your key cannot open it. Ask a member who can to run <code>${esc(grantCommand)}</code>.</div>`}
|
||||
<form data-role="unlock" autocomplete="off">
|
||||
<label class="field"><span>Your identity key</span>
|
||||
<input type="password" name="key" placeholder="paste your secret key or identity.json" spellcheck="false" autocomplete="off" required></label>
|
||||
<label class="dim" style="display:flex;gap:8px;align-items:center;font-size:.85rem;margin:8px 0 12px"><input type="checkbox" name="remember" style="width:auto"> Remember on this device. Otherwise it is forgotten when this tab closes.</label>
|
||||
<button class="btn acid">Unlock</button>
|
||||
</form>
|
||||
<div data-role="unlocked" hidden><div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<span class="dim" style="font-size:.88rem;flex:1">Key loaded. Values were decrypted in this browser. Nothing was sent.</span>
|
||||
<button class="btn" type="button" data-action="download">Download .env</button>
|
||||
<button class="btn danger" type="button" data-action="forget">Forget key</button>
|
||||
</div></div>
|
||||
<p data-role="status" class="mono" style="font-size:.8rem;margin-bottom:0;color:var(--danger,#c23a3a)"></p>
|
||||
${KEY_HELP}
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page body below the app bar. `secrets` are rows of credshare_secrets
|
||||
* (name, version, updated_at only — ciphertext is fetched by vault.js).
|
||||
*/
|
||||
export function vaultPageBody({ team, vault, secrets, hasGrant, publicKey, email }) {
|
||||
const parts = splitVaultName(vault.name);
|
||||
const grantCommand = `logicsrc teams grant ${team.slug} ${parts ? `${parts.project} ${parts.env}` : "<project> <env>"} ${email || "<your email>"}`;
|
||||
const rows = secrets.map((s) => `<tr data-key-name="${esc(s.name)}">
|
||||
<td><code>${esc(s.name)}</code></td>
|
||||
<td class="secret-value"><span class="faint mono">••••••••</span></td>
|
||||
<td class="faint">${Number(s.version) || 1}</td>
|
||||
<td class="faint mono" style="font-size:.78rem;white-space:nowrap">${esc(when(s.updated_at))}</td>
|
||||
</tr>`).join("");
|
||||
return `<main class="wrap" style="max-width:920px;padding-top:26px;padding-bottom:40px"
|
||||
data-vault-id="${esc(vault.id)}" data-vault-name="${esc(vault.name)}"
|
||||
data-public-key="${esc(publicKey || "")}" data-has-grant="${hasGrant ? "1" : "0"}">
|
||||
<p class="faint mono" style="font-size:.8rem;margin:0 0 6px"><a href="/dashboard">teams</a> / ${esc(team.slug)} / vaults</p>
|
||||
<div class="section-title"><h1 style="font-size:1.5rem"><code>${esc(vault.name)}</code></h1><span class="count">${secrets.length}</span></div>
|
||||
${keyCard({ publicKey, hasGrant, grantCommand })}
|
||||
<div class="card"><div class="card-body">
|
||||
${secrets.length
|
||||
? `<div class="table-scroll"><table><thead><tr><th>Name</th><th>Value</th><th>v</th><th>Updated (UTC)</th></tr></thead><tbody>${rows}</tbody></table></div>`
|
||||
: `<p class="faint mono" style="font-size:.82rem;margin:0">This vault is empty. Push to it from the CLI: <code>logicsrc teams push ${esc(team.slug)} ${parts ? `${esc(parts.project)} ${esc(parts.env)}` : "<project> <env>"}</code></p>`}
|
||||
</div></div>
|
||||
</main>
|
||||
<script src="/vendor/libsodium.js" defer></script>
|
||||
<script src="/vendor/libsodium-wrappers.js" defer></script>
|
||||
<script src="/vault.js" defer></script>`;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Teams dashboard (/) + accept-invite (/teams/accept) + settings (/settings).
|
||||
// The browser holds no private key, so it never decrypts — it manages teams,
|
||||
// members, vaults (ciphertext metadata), invites, and CLI API keys.
|
||||
// Teams dashboard (/) + one vault (/teams/:slug/vaults/:id) + accept-invite
|
||||
// (/teams/accept) + settings (/settings). The server never decrypts. The vault
|
||||
// page can, in the browser, once the member pastes their identity key
|
||||
// (public/vault.js); everything else here is ciphertext metadata.
|
||||
import { Router } from "express";
|
||||
import { get, all, run } from "../db.mjs";
|
||||
import { id, sha256 } from "../lib/crypto.mjs";
|
||||
|
|
@ -9,6 +10,7 @@ import { requireAuth, csrfInput } from "../lib/session.mjs";
|
|||
import { createApiKey, listApiKeys, 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 { config } from "../config.mjs";
|
||||
import {
|
||||
TeamMemberError,
|
||||
|
|
@ -49,7 +51,7 @@ async function teamCard(team, uid) {
|
|||
for (const v of vaults) {
|
||||
const count = await get(`SELECT COUNT(*) AS n FROM credshare_secrets WHERE vault_id = ?`, [v.id]);
|
||||
const mine = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [v.id, uid]);
|
||||
vaultRows.push(`<tr><td><code>${esc(v.name)}</code></td><td>${Number(count?.n || 0)}</td><td>${mine ? "✓ you have access" : "— ask a member to grant you"}</td></tr>`);
|
||||
vaultRows.push(`<tr><td><a href="/teams/${esc(team.slug)}/vaults/${esc(v.id)}"><code>${esc(v.name)}</code></a></td><td>${Number(count?.n || 0)}</td><td>${mine ? "✓ you have access" : "— ask a member to grant you"}</td></tr>`);
|
||||
}
|
||||
|
||||
return `<div class="card" style="margin-bottom:22px">
|
||||
|
|
@ -107,6 +109,26 @@ export async function dashboardHandler(req, res) {
|
|||
|
||||
pagesRouter.get("/dashboard", requireAuth, dashboardHandler);
|
||||
|
||||
// ---- one vault: secret names, values decrypted in the browser ----
|
||||
pagesRouter.get("/teams/:slug/vaults/:vaultId", requireAuth, async (req, res, next) => {
|
||||
const ctx = await teamMemberContext(req);
|
||||
const vault = ctx && await get(`SELECT * FROM credshare_vaults WHERE id = ? AND team_id = ?`, [req.params.vaultId, ctx.team.id]);
|
||||
if (!vault) return next(); // 404, whether the vault is missing or not yours
|
||||
const secrets = await all(`SELECT name, version, updated_at FROM credshare_secrets WHERE vault_id = ? ORDER BY name`, [vault.id]);
|
||||
const grant = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [vault.id, req.user.id]);
|
||||
const key = await get(`SELECT public_key FROM credshare_keys WHERE user_id = ?`, [req.user.id]);
|
||||
const body = `${appBar(req)}${vaultPageBody({
|
||||
team: ctx.team,
|
||||
vault,
|
||||
secrets,
|
||||
hasGrant: Boolean(grant),
|
||||
publicKey: key?.public_key || "",
|
||||
email: req.user.email
|
||||
})}${footer}`;
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.type("html").send(page({ title: `LogicSRC ▸ ${vault.name}`, body }));
|
||||
});
|
||||
|
||||
// ---- team + invite form actions (session + CSRF) ----
|
||||
pagesRouter.post("/teams", requireAuth, async (req, res) => {
|
||||
const slug = String(req.body.slug || "").trim().toLowerCase();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { config } from "./config.mjs";
|
||||
import { migrate } from "./migrate.mjs";
|
||||
import { sessionMiddleware, csrfGuard } from "./lib/session.mjs";
|
||||
|
|
@ -26,6 +27,14 @@ app.use(express.static(path.join(config.root, "public"), { maxAge: "1h" }));
|
|||
// the @simplewebauthn/browser UMD bundle, served from node_modules (no CDN)
|
||||
app.get("/vendor/simplewebauthn-browser.umd.js", (_req, res) =>
|
||||
res.sendFile(path.join(config.root, "node_modules/@simplewebauthn/browser/dist/bundle/index.umd.min.js")));
|
||||
// libsodium for in-browser vault decryption (public/vault.js). Same two files
|
||||
// the CLI loads; resolved, not path-joined, so a hoisted workspace install works.
|
||||
// Load order on the page: libsodium.js (window.libsodium) then the wrappers (window.sodium).
|
||||
const requireHere = createRequire(import.meta.url);
|
||||
const sodiumWrappers = requireHere.resolve("libsodium-wrappers");
|
||||
const sodiumCore = createRequire(sodiumWrappers).resolve("libsodium");
|
||||
app.get("/vendor/libsodium.js", (_req, res) => res.sendFile(sodiumCore));
|
||||
app.get("/vendor/libsodium-wrappers.js", (_req, res) => res.sendFile(sodiumWrappers));
|
||||
|
||||
app.get("/healthz", (_req, res) => res.json({ ok: true, env: config.env }));
|
||||
|
||||
|
|
|
|||
159
apps/pwa/test/vault-page.test.mjs
Normal file
159
apps/pwa/test/vault-page.test.mjs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// The vault page shows secret names to any active team member and carries
|
||||
// what public/vault.js needs to decrypt values in the browser: the vault id,
|
||||
// the member's registered public key (to reject a wrong pasted key before
|
||||
// trying it) and whether a grant exists. It must never carry ciphertext or a
|
||||
// wrapped key — those are fetched by the script from the session-authed API.
|
||||
process.env.DATABASE_URL = process.env.PWA_TEST_DATABASE_URL || ":memory:";
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
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";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const { db, run, isPostgres } = await import("../src/db.mjs");
|
||||
const { pagesRouter } = await import("../src/routes/pages.mjs");
|
||||
|
||||
async function migrate() {
|
||||
if (isPostgres) {
|
||||
await db.execute("DROP SCHEMA public CASCADE");
|
||||
await db.execute("CREATE SCHEMA public");
|
||||
}
|
||||
for (const file of ["001_auth.sql", "002_credshare.sql"]) {
|
||||
const sql = readFileSync(join(here, "..", "src", isPostgres ? "migrations-pg" : "migrations", file), "utf8");
|
||||
for (const statement of sql.split(/;\s*$/m).map((s) => s.trim()).filter(Boolean)) {
|
||||
await db.execute(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function serve(user) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => { req.user = user; req.csrfToken = "t"; next(); });
|
||||
app.use(pagesRouter);
|
||||
const server = app.listen(0);
|
||||
await new Promise((resolve) => server.once("listening", resolve));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
return {
|
||||
async get(path) {
|
||||
const res = await fetch(`${base}${path}`, { redirect: "manual" });
|
||||
return { status: res.status, headers: res.headers, text: await res.text() };
|
||||
},
|
||||
close: () => new Promise((resolve) => server.close(resolve))
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await migrate();
|
||||
for (const [uid, email] of [["u-ann", "ann@example.com"], ["u-bob", "bob@example.com"], ["u-eve", "eve@example.com"]]) {
|
||||
await run(`INSERT INTO users (id, email, created_at) VALUES (?,?,?)`, [uid, email, now]);
|
||||
}
|
||||
await run(`INSERT INTO credshare_teams (id, slug, name, created_by, created_at) VALUES ('t1','acme','acme','u-ann',?)`, [now]);
|
||||
for (const [mid, uid, email] of [["m1", "u-ann", "ann@example.com"], ["m2", "u-bob", "bob@example.com"]]) {
|
||||
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, joined_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
[mid, "t1", uid, email, "member", "active", now, now]);
|
||||
}
|
||||
await run(`INSERT INTO credshare_keys (user_id, public_key, updated_at) VALUES ('u-ann','ANN_PUBLIC_KEY',?)`, [now]);
|
||||
await run(`INSERT INTO credshare_vaults (id, team_id, name, created_by, created_at) VALUES ('v1','t1','api--prod','u-ann',?)`, [now]);
|
||||
await run(`INSERT INTO credshare_vault_grants (vault_id, user_id, wrapped_dek, granted_by, created_at) VALUES ('v1','u-ann','WRAPPED_DEK_SECRET','u-ann',?)`, [now]);
|
||||
for (const name of ["DATABASE_URL", "STRIPE_KEY"]) {
|
||||
await run(`INSERT INTO credshare_secrets (vault_id, name, nonce, ciphertext, fingerprint, version, updated_by, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
["v1", name, "NONCE_" + name, "CIPHERTEXT_" + name, "fp", 2, "u-ann", now]);
|
||||
}
|
||||
|
||||
test("a member with a grant sees names, their public key, and the unlock form", async () => {
|
||||
const app = await serve({ id: "u-ann", email: "ann@example.com" });
|
||||
try {
|
||||
const res = await app.get("/teams/acme/vaults/v1");
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get("cache-control") || "", /no-store/);
|
||||
assert.match(res.text, /data-key-name="DATABASE_URL"/);
|
||||
assert.match(res.text, /data-key-name="STRIPE_KEY"/);
|
||||
assert.match(res.text, /data-public-key="ANN_PUBLIC_KEY"/);
|
||||
assert.match(res.text, /data-has-grant="1"/);
|
||||
assert.match(res.text, /data-role="unlock"/);
|
||||
assert.match(res.text, /logicsrc teams key/);
|
||||
assert.match(res.text, /src="\/vendor\/libsodium\.js"/);
|
||||
assert.match(res.text, /src="\/vault\.js"/);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the page never embeds ciphertext, nonces or the wrapped key", async () => {
|
||||
const app = await serve({ id: "u-ann", email: "ann@example.com" });
|
||||
try {
|
||||
const { text } = await app.get("/teams/acme/vaults/v1");
|
||||
assert.doesNotMatch(text, /CIPHERTEXT_|NONCE_|WRAPPED_DEK_SECRET/);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a member with no key is offered a browser-made key, not a paste box", async () => {
|
||||
const app = await serve({ id: "u-bob", email: "bob@example.com" });
|
||||
try {
|
||||
const { status, text } = await app.get("/teams/acme/vaults/v1");
|
||||
assert.equal(status, 200);
|
||||
assert.match(text, /data-public-key=""/);
|
||||
assert.match(text, /data-has-grant="0"/);
|
||||
assert.match(text, /data-action="generate"/);
|
||||
assert.doesNotMatch(text, /data-role="unlock"/);
|
||||
assert.match(text, /logicsrc teams grant acme api prod bob@example\.com/);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a non-member and an unknown vault both fall through to 404", async () => {
|
||||
const eve = await serve({ id: "u-eve", email: "eve@example.com" });
|
||||
try {
|
||||
assert.equal((await eve.get("/teams/acme/vaults/v1")).status, 404);
|
||||
} finally {
|
||||
await eve.close();
|
||||
}
|
||||
const ann = await serve({ id: "u-ann", email: "ann@example.com" });
|
||||
try {
|
||||
assert.equal((await ann.get("/teams/acme/vaults/nope")).status, 404);
|
||||
} finally {
|
||||
await ann.close();
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
assert.match(text, /href="\/teams\/acme\/vaults\/v1"/);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
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" });
|
||||
assert.equal(splitVaultName("legacy"), null);
|
||||
assert.equal(splitVaultName("a--b--c"), null);
|
||||
});
|
||||
|
||||
test("names are escaped", () => {
|
||||
const html = vaultPageBody({
|
||||
team: { slug: "acme" },
|
||||
vault: { id: "v", name: "x--y" },
|
||||
secrets: [{ name: `<img src=x onerror=alert(1)>`, version: 1, updated_at: now }],
|
||||
hasGrant: true,
|
||||
publicKey: "pk",
|
||||
email: "a@b.c"
|
||||
});
|
||||
assert.doesNotMatch(html, /<img src=x/);
|
||||
});
|
||||
|
||||
test("the key help names the CLI command and a fallback for older CLIs", () => {
|
||||
assert.match(KEY_HELP, /logicsrc teams key/);
|
||||
assert.match(KEY_HELP, /jq -r \.keys\.secretKey ~\/\.config\/logicsrc\/identity\.json/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue