diff --git a/apps/pwa/package.json b/apps/pwa/package.json index 48587b4..a175546 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -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" diff --git a/apps/pwa/public/sw.js b/apps/pwa/public/sw.js index 1dfbdc0..abd581f 100644 --- a/apps/pwa/public/sw.js +++ b/apps/pwa/public/sw.js @@ -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)); } diff --git a/apps/pwa/public/vault.js b/apps/pwa/public/vault.js new file mode 100644 index 0000000..4ea51cd --- /dev/null +++ b/apps/pwa/public/vault.js @@ -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); }); + } +})(); diff --git a/apps/pwa/src/lib/vault-page.mjs b/apps/pwa/src/lib/vault-page.mjs new file mode 100644 index 0000000..ab5916d --- /dev/null +++ b/apps/pwa/src/lib/vault-page.mjs @@ -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 = "--"; + +/** `--` 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 = `
+ Where do I find my key? +
+

It is the identity secret key the logicsrc CLI created the first time you ran logicsrc login. It lives only on that machine, in ~/.config/logicsrc/identity.json. On that machine, run:

+
logicsrc teams key
+

That needs CLI 0.5.0 or later (curl -fsSL https://logicsrc.com/install.sh | sh -s -- update). On an older CLI, either of these prints the same thing:

+
jq -r .keys.secretKey ~/.config/logicsrc/identity.json
+node -p 'require(require("os").homedir()+"/.config/logicsrc/identity.json").keys.secretKey'
+

Pasting the whole identity.json works too; only secretKey is read. Installs from before the config move keep it at ~/.logicsrc/identity.json. Anyone holding this key can read every vault you can, so treat it like a password.

+
+
`; + +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 `
Read the values
+
+

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:

+
    +
  • In a terminal: curl -fsSL https://logicsrc.com/install.sh | sh, then logicsrc login. Then logicsrc teams key prints the key to paste here.
  • +
  • Or here in this browser:
  • +
+ + +

+
`; + } + return `
Read the valueslocked
+
+ ${hasGrant ? "" : `
You have not been granted this vault yet, so your key cannot open it. Ask a member who can to run ${esc(grantCommand)}.
`} +
+ + + +
+ +

+ ${KEY_HELP} +
`; +} + +/** + * 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}` : " "} ${email || ""}`; + const rows = secrets.map((s) => ` + ${esc(s.name)} + •••••••• + ${Number(s.version) || 1} + ${esc(when(s.updated_at))} + `).join(""); + return `
+

teams / ${esc(team.slug)} / vaults

+

${esc(vault.name)}

${secrets.length}
+ ${keyCard({ publicKey, hasGrant, grantCommand })} +
+ ${secrets.length + ? `
${rows}
NameValuevUpdated (UTC)
` + : `

This vault is empty. Push to it from the CLI: logicsrc teams push ${esc(team.slug)} ${parts ? `${esc(parts.project)} ${esc(parts.env)}` : "<project> <env>"}

`} +
+
+ + + `; +} diff --git a/apps/pwa/src/routes/pages.mjs b/apps/pwa/src/routes/pages.mjs index 4341cf7..68d0ba4 100644 --- a/apps/pwa/src/routes/pages.mjs +++ b/apps/pwa/src/routes/pages.mjs @@ -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(`${esc(v.name)}${Number(count?.n || 0)}${mine ? "✓ you have access" : "— ask a member to grant you"}`); + vaultRows.push(`${esc(v.name)}${Number(count?.n || 0)}${mine ? "✓ you have access" : "— ask a member to grant you"}`); } return `
@@ -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(); diff --git a/apps/pwa/src/server.mjs b/apps/pwa/src/server.mjs index ad12051..c5add0a 100644 --- a/apps/pwa/src/server.mjs +++ b/apps/pwa/src/server.mjs @@ -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 })); diff --git a/apps/pwa/test/vault-page.test.mjs b/apps/pwa/test/vault-page.test.mjs new file mode 100644 index 0000000..336b0bb --- /dev/null +++ b/apps/pwa/test/vault-page.test.mjs @@ -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: ``, version: 1, updated_at: now }], + hasGrant: true, + publicKey: "pk", + email: "a@b.c" + }); + assert.doesNotMatch(html, / { + assert.match(KEY_HELP, /logicsrc teams key/); + assert.match(KEY_HELP, /jq -r \.keys\.secretKey ~\/\.config\/logicsrc\/identity\.json/); +}); diff --git a/bun.lock b/bun.lock index ece3a5c..a45bdbf 100644 --- a/bun.lock +++ b/bun.lock @@ -86,6 +86,7 @@ "@simplewebauthn/server": "^13.1.0", "cookie-parser": "^1.4.7", "express": "^4.21.2", + "libsodium-wrappers": "^0.7.15", }, "devDependencies": { "@libsql/client": "^0.14.0", @@ -143,7 +144,7 @@ }, "packages/cli": { "name": "@logicsrc/cli", - "version": "0.4.0", + "version": "0.5.0", "bin": { "logicsrc": "dist/index.js", }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 7ea4ce0..c87958e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@logicsrc/cli", - "version": "0.4.0", + "version": "0.5.0", "description": "LogicSRC CLI: every LogicSRC standard and tool as one command.", "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8df24de..7dd5bea 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -23,6 +23,7 @@ import { teamsCategoriesAction, teamsSecretsAction, teamsExportAction, + teamsKeyAction, teamsGrantAction, teamsTuiAction, teamsPushAction, @@ -734,6 +735,7 @@ Quick start ( is e.g. profullstack; see yours with "logicsrc teams list"): logicsrc teams pull vault -> ./.env logicsrc teams push ./.env -> vault logicsrc teams grant dev@example.com + logicsrc teams key your key, to read values in the web app Categories: logicsrc teams categories. Help for one command: logicsrc teams --help ` @@ -860,6 +862,23 @@ Vaults you have no access to are skipped and listed at the end. teamsExportAction(slug, { project, env, category: options.category, search: options.search }, { out: options.out, yes: options.yes }) ); +teams + .command("key") + .description("Print this machine's identity secret key, to read vault values in the web app.") + .addHelpText( + "after", + ` +The web app lists secret names but decrypts values only in your browser, with +this key. Open a vault from the dashboard, paste the key, and the values open +there; the key is checked against your registered public key and never sent. + +Examples: + logicsrc teams key print it + logicsrc teams key | pbcopy straight to the clipboard (macOS; wl-copy / xclip on Linux) +` + ) + .action(() => teamsKeyAction()); + teams .command("tui") .alias("ui") diff --git a/packages/cli/src/teams.ts b/packages/cli/src/teams.ts index 1561f70..6e7427f 100644 --- a/packages/cli/src/teams.ts +++ b/packages/cli/src/teams.ts @@ -16,6 +16,7 @@ import { resolveApiUrl, createCredentialEngine, identityPath, + verifyIdentityIntegrity, unwrapVaultKey, wrapVaultKey, decryptValue, @@ -301,6 +302,43 @@ export async function whoamiAction(format: OutputFormat): Promise { print({ loggedIn: true, email: me.user.email, apiUrl: resolveApiUrl(identity), publicKey: me.user.publicKey, teams: me.teams.map((t) => t.slug) }, format); } +/** + * `logicsrc teams key` — print this machine's identity secret key, for pasting + * into the web app's vault page, which decrypts in the browser with it. + * + * The key goes to stdout alone (so `| pbcopy` works); everything else goes to + * stderr. When logged in it also checks the server holds the matching public + * key, because the web app refuses any other key and the reason is otherwise + * invisible from the browser. + */ +export async function teamsKeyAction(): Promise { + const identity = readIdentity(); + if (!identity?.keys?.secretKey) { + throw new Error(`No identity key on this machine (${identityPath()}). Run "logicsrc login" first.`); + } + if (!(await verifyIdentityIntegrity(identity))) { + throw new Error(`${identityPath()} is damaged: its public key does not match its secret key.`); + } + let origin = resolveApiUrl(identity); + if (identity.apiToken) { + const { client } = authedClient(); + const me = await client.me(); + if (me.user.publicKey && me.user.publicKey !== identity.keys.publicKey) { + console.error( + `Warning: ${me.user.email} has a different key registered (from another machine's login). ` + + "The web app will reject this one; use the key from that machine." + ); + } + } else { + origin = defaultApiUrl(); + } + process.stdout.write(`${identity.keys.secretKey}\n`); + console.error( + `Your identity secret key. Paste it on a vault page at ${origin}/dashboard to read values in the browser. ` + + "Anyone holding it can read every vault you can: keep it in a password manager, never in chat or a ticket." + ); +} + export async function teamsCreateAction(slug: string, options: { name?: string; format: OutputFormat }): Promise { const { client } = authedClient(); const { team } = await client.createTeam(slug, options.name);