diff --git a/apps/logicsrc-web/src/lib/page-markup.ts b/apps/logicsrc-web/src/lib/page-markup.ts index 9e2ca8a..c9bdace 100644 --- a/apps/logicsrc-web/src/lib/page-markup.ts +++ b/apps/logicsrc-web/src/lib/page-markup.ts @@ -48,7 +48,8 @@ const credentialProviders = [ { name: ".env", detail: "Parse, diff, redact, and write local env files without leaking values into logs." }, { name: "Doppler", detail: "Sync project/config scoped secrets through provider adapters and auditable key fingerprints." }, { name: "Railway", detail: "Read and write service variables as a deployment target with explicit approval gates." }, - { name: "GitHub Secrets", detail: "Manage repo, organization, and environment secrets through provider-neutral operations." } + { name: "GitHub Secrets", detail: "Manage repo, organization, and environment secrets through provider-neutral operations." }, + { name: "sh1pt", detail: "Sync the distribution credential vault — App Store Connect, Play, npm, Docker, and Cloudflare tokens — through the sh1pt CLI." } ]; const credentialSurfaces = [ diff --git a/apps/pwa/src/db.mjs b/apps/pwa/src/db.mjs index 9515986..037b35e 100644 --- a/apps/pwa/src/db.mjs +++ b/apps/pwa/src/db.mjs @@ -27,3 +27,11 @@ export async function all(sql, args = []) { const r = await db.execute({ sql, args }); return r.rows; } + +/** + * Run many statements in ONE write transaction — all of them commit, or none + * do. Needed anywhere a partial write would leave unrecoverable state, e.g. + * re-keying a vault, where new grants over old ciphertext locks out every + * member permanently. + */ +export const batch = (statements) => db.batch(statements, "write"); diff --git a/apps/pwa/src/routes/credshare.mjs b/apps/pwa/src/routes/credshare.mjs index 75f862c..b4c5015 100644 --- a/apps/pwa/src/routes/credshare.mjs +++ b/apps/pwa/src/routes/credshare.mjs @@ -6,7 +6,7 @@ // Auth: the acting user comes from a browser session (req.user) OR a // `Bearer lsk_…` API key (the logicsrc CLI). Mounted at /api/credshare. import { Router } from "express"; -import { get, all, run } from "../db.mjs"; +import { get, all, run, batch } from "../db.mjs"; import { id, token, sha256 } from "../lib/crypto.mjs"; import { bearer, userForApiKey } from "../lib/apikey.mjs"; import { config } from "../config.mjs"; @@ -200,10 +200,117 @@ credshareRouter.get("/api/credshare/vaults/:id/grants", api(async (req, res, use const granted = new Set((await all(`SELECT user_id FROM credshare_vault_grants WHERE vault_id = ?`, [vault.id])).map((r) => r.user_id)); const members = await all(`SELECT * FROM credshare_members WHERE team_id = ?`, [vault.team_id]); const grants = []; - for (const m of members) grants.push({ email: m.email, hasPublicKey: m.user_id ? Boolean(await publicKeyFor(m.user_id)) : false, hasAccess: Boolean(m.user_id && granted.has(m.user_id)) }); + for (const m of members) { + // The public key is included so a client can re-seal the vault key to every + // member in one pass (see rekey). Public keys are public by construction -- + // they exist to be sealed against -- and this route is already member-only. + const publicKey = m.user_id ? await publicKeyFor(m.user_id) : null; + grants.push({ + email: m.email, + publicKey, + status: m.status, + hasPublicKey: Boolean(publicKey), + hasAccess: Boolean(m.user_id && granted.has(m.user_id)) + }); + } res.json({ grants }); })); +// Re-key a vault: swap in a fresh DEK, re-seal it to the members who keep +// access, and re-encrypt every secret under it. Values do not change, which the +// server enforces by requiring each submitted fingerprint to equal the stored +// one -- it cannot see values, but it can prove they were not swapped. +// +// This is ONE transaction on purpose. The DEK is recoverable only through the +// grants, so a half-applied rotation (new grants over old ciphertext, or the +// reverse) would make the vault permanently unreadable by everyone. +credshareRouter.post("/api/credshare/vaults/:id/rekey", api(async (req, res, user) => { + const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return; + const iHold = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [vault.id, user.id]); + if (!iHold) return res.status(403).json({ error: "Only a member with vault access can re-key it." }); + + const grants = Array.isArray(req.body?.grants) ? req.body.grants : null; + const secrets = Array.isArray(req.body?.secrets) ? req.body.secrets : null; + const revoke = Array.isArray(req.body?.revoke) ? req.body.revoke.map(norm) : []; + if (!grants || !secrets) return res.status(422).json({ error: "Expected { grants, secrets, revoke? }." }); + if (grants.length === 0) return res.status(422).json({ error: "A re-key must keep at least one member, or the vault becomes unreadable." }); + + // The caller must keep their own access; otherwise they lock themselves out + // the moment the transaction commits. + const me = await get(`SELECT email FROM users WHERE id = ?`, [user.id]); + if (!grants.some((g) => norm(g?.email) === norm(me?.email))) { + return res.status(422).json({ error: "A re-key must include your own grant." }); + } + + // Every secret must be accounted for, with an unchanged fingerprint. This is + // what makes "re-key" distinct from "write": no value may change here. + const stored = await all(`SELECT name, fingerprint FROM credshare_secrets WHERE vault_id = ?`, [vault.id]); + const storedByName = new Map(stored.map((s) => [s.name, s.fingerprint])); + if (secrets.length !== stored.length) { + return res.status(409).json({ error: `Re-key covers ${secrets.length} secret(s) but the vault holds ${stored.length}. Re-read the vault and retry.` }); + } + for (const s of secrets) { + if (!s || typeof s.name !== "string" || typeof s.nonce !== "string" || typeof s.ciphertext !== "string" || typeof s.fingerprint !== "string") { + return res.status(422).json({ error: "Each secret needs { name, nonce, ciphertext, fingerprint }." }); + } + if (!storedByName.has(s.name)) { + return res.status(409).json({ error: `"${s.name}" is not in this vault. Re-read the vault and retry.` }); + } + if (storedByName.get(s.name) !== s.fingerprint) { + return res.status(409).json({ error: `Re-key would change the value of "${s.name}". A re-key re-encrypts; it never changes values.` }); + } + } + + // Resolve grant targets before writing anything. + const resolved = []; + for (const g of grants) { + const email = norm(g?.email); + if (!email || typeof g?.wrappedDek !== "string" || !g.wrappedDek) { + return res.status(422).json({ error: "Each grant needs { email, wrappedDek }." }); + } + const target = await get(`SELECT id FROM users WHERE email = ?`, [email]); + if (!target) return res.status(409).json({ error: `${email} has not logged in yet, so the vault key cannot be sealed to them.` }); + resolved.push({ email, userId: target.id, wrappedDek: g.wrappedDek }); + } + + const revokedUsers = []; + for (const email of revoke) { + const target = await get(`SELECT id FROM users WHERE email = ?`, [email]); + if (target) revokedUsers.push({ email, userId: target.id }); + } + + const now = Date.now(); + const statements = []; + for (const s of secrets) { + statements.push({ + sql: `UPDATE credshare_secrets SET nonce = ?, ciphertext = ?, version = version + 1, updated_by = ?, updated_at = ? WHERE vault_id = ? AND name = ?`, + args: [s.nonce, s.ciphertext, user.id, now, vault.id, s.name] + }); + } + for (const g of resolved) { + statements.push({ + sql: `INSERT INTO credshare_vault_grants (vault_id, user_id, wrapped_dek, granted_by, created_at) VALUES (?,?,?,?,?) ON CONFLICT(vault_id, user_id) DO UPDATE SET wrapped_dek = excluded.wrapped_dek, granted_by = excluded.granted_by, created_at = excluded.created_at`, + args: [vault.id, g.userId, g.wrappedDek, user.id, now] + }); + } + for (const r of revokedUsers) { + statements.push({ sql: `DELETE FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, args: [vault.id, r.userId] }); + } + statements.push({ + sql: `INSERT INTO credshare_audit (id, team_id, vault_id, actor_user_id, action, key_name, fingerprint, created_at) VALUES (?,?,?,?,?,?,?,?)`, + args: [id(), vault.team_id, vault.id, user.id, "vault:rekey", null, null, now] + }); + for (const r of revokedUsers) { + statements.push({ + sql: `INSERT INTO credshare_audit (id, team_id, vault_id, actor_user_id, action, key_name, fingerprint, created_at) VALUES (?,?,?,?,?,?,?,?)`, + args: [id(), vault.team_id, vault.id, user.id, "vault:revoke", r.email, null, now] + }); + } + + await batch(statements); + res.json({ ok: true, rekeyed: secrets.length, granted: resolved.map((g) => g.email), revoked: revokedUsers.map((r) => r.email) }); +})); + credshareRouter.post("/api/credshare/vaults/:id/grants", api(async (req, res, user) => { const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return; const iHold = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [vault.id, user.id]); diff --git a/apps/pwa/test/credshare-rekey.test.mjs b/apps/pwa/test/credshare-rekey.test.mjs new file mode 100644 index 0000000..911c121 --- /dev/null +++ b/apps/pwa/test/credshare-rekey.test.mjs @@ -0,0 +1,228 @@ +// Integration tests for POST /api/credshare/vaults/:id/rekey. +// +// The endpoint is the last line of defence on a genuinely unrecoverable +// operation: the vault DEK exists only inside the grants, so a rotation that +// commits half-way, drops a secret, or leaves the caller ungranted destroys the +// vault permanently. These tests pin the guards that stop that. +// +// Runs against an in-memory libSQL database, so DATABASE_URL must be set before +// anything imports db.mjs. +process.env.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"; + +const here = dirname(fileURLToPath(import.meta.url)); +const { db, run, get, all } = await import("../src/db.mjs"); +const { credshareRouter } = await import("../src/routes/credshare.mjs"); + +/** Apply the schema this router depends on. */ +async function migrate() { + for (const file of ["001_auth.sql", "002_credshare.sql"]) { + const sql = readFileSync(join(here, "..", "src", "migrations", file), "utf8"); + for (const statement of sql.split(/;\s*$/m).map((s) => s.trim()).filter(Boolean)) { + await db.execute(statement); + } + } +} + +/** + * Mount the router with a fixed acting user, mimicking a browser session. + * Returns a fetch-like helper bound to an ephemeral port. + */ +async function serve(actingUserId) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { id: actingUserId }; + next(); + }); + app.use(credshareRouter); + 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 post(path, body) { + const res = await fetch(`${base}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body) + }); + return { status: res.status, body: await res.json() }; + }, + async get(path) { + const res = await fetch(`${base}${path}`); + return { status: res.status, body: await res.json() }; + }, + close: () => new Promise((resolve) => server.close(resolve)), + }; +} + +const now = Date.now(); + +/** A team with one vault, two granted members, and two secrets. */ +async function seed() { + await run(`INSERT INTO users (id, email, created_at) VALUES (?,?,?)`, ["u_me", "me@example.com", now]); + await run(`INSERT INTO users (id, email, created_at) VALUES (?,?,?)`, ["u_them", "them@example.com", now]); + await run(`INSERT INTO credshare_keys (user_id, public_key, updated_at) VALUES (?,?,?)`, ["u_me", "pk-me", now]); + await run(`INSERT INTO credshare_keys (user_id, public_key, updated_at) VALUES (?,?,?)`, ["u_them", "pk-them", now]); + await run(`INSERT INTO credshare_teams (id, slug, name, created_by, created_at) VALUES (?,?,?,?,?)`, ["t1", "acme", "Acme", "u_me", now]); + await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, created_at) VALUES (?,?,?,?,?,?,?)`, + ["m1", "t1", "u_me", "me@example.com", "owner", "active", now]); + await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, created_at) VALUES (?,?,?,?,?,?,?)`, + ["m2", "t1", "u_them", "them@example.com", "member", "active", now]); + await run(`INSERT INTO credshare_vaults (id, team_id, name, created_by, created_at) VALUES (?,?,?,?,?)`, ["v1", "t1", "app--prod", "u_me", now]); + for (const uid of ["u_me", "u_them"]) { + await run(`INSERT INTO credshare_vault_grants (vault_id, user_id, wrapped_dek, granted_by, created_at) VALUES (?,?,?,?,?)`, + ["v1", uid, `old-wrapped-${uid}`, "u_me", now]); + } + await run(`INSERT INTO credshare_secrets (vault_id, name, nonce, ciphertext, fingerprint, version, updated_by, updated_at) VALUES (?,?,?,?,?,?,?,?)`, + ["v1", "API_KEY", "n1", "c1", "fp-api", 1, "u_me", now]); + await run(`INSERT INTO credshare_secrets (vault_id, name, nonce, ciphertext, fingerprint, version, updated_by, updated_at) VALUES (?,?,?,?,?,?,?,?)`, + ["v1", "DB_URL", "n2", "c2", "fp-db", 1, "u_me", now]); +} + +/** A well-formed rotation: same names, same fingerprints, fresh ciphertext. */ +function validBody(overrides = {}) { + return { + grants: [ + { email: "me@example.com", wrappedDek: "new-wrapped-me" }, + { email: "them@example.com", wrappedDek: "new-wrapped-them" } + ], + secrets: [ + { name: "API_KEY", nonce: "n1b", ciphertext: "c1b", fingerprint: "fp-api" }, + { name: "DB_URL", nonce: "n2b", ciphertext: "c2b", fingerprint: "fp-db" } + ], + revoke: [], + ...overrides + }; +} + +await migrate(); +await seed(); + +test("re-keys every secret and grant in one commit", async (t) => { + const app = await serve("u_me"); + t.after(() => app.close()); + + const res = await app.post("/api/credshare/vaults/v1/rekey", validBody()); + assert.equal(res.status, 200); + assert.equal(res.body.rekeyed, 2); + + const secrets = await all(`SELECT name, nonce, ciphertext, fingerprint, version FROM credshare_secrets WHERE vault_id = 'v1' ORDER BY name`); + // New ciphertext, bumped version -- and the fingerprint is untouched, which + // is the machine-checkable statement that no VALUE changed. + assert.deepEqual(secrets.map((s) => s.ciphertext), ["c1b", "c2b"]); + assert.deepEqual(secrets.map((s) => s.version), [2, 2]); + assert.deepEqual(secrets.map((s) => s.fingerprint), ["fp-api", "fp-db"]); + + const grants = await all(`SELECT user_id, wrapped_dek FROM credshare_vault_grants WHERE vault_id = 'v1' ORDER BY user_id`); + assert.deepEqual(grants.map((g) => g.wrapped_dek), ["new-wrapped-me", "new-wrapped-them"]); + + const audit = await get(`SELECT action FROM credshare_audit WHERE vault_id = 'v1' AND action = 'vault:rekey'`); + assert.equal(audit.action, "vault:rekey"); +}); + +test("refuses a rotation that would change a value", async (t) => { + const app = await serve("u_me"); + t.after(() => app.close()); + + const res = await app.post("/api/credshare/vaults/v1/rekey", validBody({ + secrets: [ + { name: "API_KEY", nonce: "x", ciphertext: "x", fingerprint: "fp-DIFFERENT" }, + { name: "DB_URL", nonce: "n2c", ciphertext: "c2c", fingerprint: "fp-db" } + ] + })); + + assert.equal(res.status, 409); + assert.match(res.body.error, /never changes values/); + // Nothing was written. + const row = await get(`SELECT ciphertext FROM credshare_secrets WHERE vault_id = 'v1' AND name = 'DB_URL'`); + assert.equal(row.ciphertext, "c2b"); +}); + +test("refuses a rotation that drops a secret", async (t) => { + const app = await serve("u_me"); + t.after(() => app.close()); + + const res = await app.post("/api/credshare/vaults/v1/rekey", validBody({ + secrets: [{ name: "API_KEY", nonce: "z", ciphertext: "z", fingerprint: "fp-api" }] + })); + + assert.equal(res.status, 409); + assert.match(res.body.error, /covers 1 secret\(s\) but the vault holds 2/); +}); + +test("refuses to let the caller lock themselves out", async (t) => { + const app = await serve("u_me"); + t.after(() => app.close()); + + const res = await app.post("/api/credshare/vaults/v1/rekey", validBody({ + grants: [{ email: "them@example.com", wrappedDek: "new-wrapped-them" }] + })); + + assert.equal(res.status, 422); + assert.match(res.body.error, /must include your own grant/); +}); + +test("refuses a rotation that grants nobody", async (t) => { + const app = await serve("u_me"); + t.after(() => app.close()); + + const res = await app.post("/api/credshare/vaults/v1/rekey", validBody({ grants: [] })); + + assert.equal(res.status, 422); + assert.match(res.body.error, /at least one member/); +}); + +test("revoking drops the grant row so access is not merely stale", async (t) => { + const app = await serve("u_me"); + t.after(() => app.close()); + + const res = await app.post("/api/credshare/vaults/v1/rekey", validBody({ + grants: [{ email: "me@example.com", wrappedDek: "newer-me" }], + secrets: [ + { name: "API_KEY", nonce: "n1d", ciphertext: "c1d", fingerprint: "fp-api" }, + { name: "DB_URL", nonce: "n2d", ciphertext: "c2d", fingerprint: "fp-db" } + ], + revoke: ["them@example.com"] + })); + + assert.equal(res.status, 200); + assert.deepEqual(res.body.revoked, ["them@example.com"]); + + const theirs = await get(`SELECT 1 AS hit FROM credshare_vault_grants WHERE vault_id = 'v1' AND user_id = 'u_them'`); + assert.equal(theirs, null, "revoked member should have no grant row left"); + + // And the revocation is on the audit trail by name. + const ev = await get(`SELECT key_name FROM credshare_audit WHERE vault_id = 'v1' AND action = 'vault:revoke'`); + assert.equal(ev.key_name, "them@example.com"); +}); + +test("a member with no vault access cannot re-key it", async (t) => { + // u_them was just revoked above, so they are an active member without a grant. + const app = await serve("u_them"); + t.after(() => app.close()); + + const res = await app.post("/api/credshare/vaults/v1/rekey", validBody()); + + assert.equal(res.status, 403); + assert.match(res.body.error, /Only a member with vault access/); +}); + +test("grants expose public keys and status so a client can re-seal in one pass", async (t) => { + const app = await serve("u_me"); + t.after(() => app.close()); + + const res = await app.get("/api/credshare/vaults/v1/grants"); + + assert.equal(res.status, 200); + const me = res.body.grants.find((g) => g.email === "me@example.com"); + assert.equal(me.publicKey, "pk-me"); + assert.equal(me.status, "active"); + assert.equal(me.hasAccess, true); +}); diff --git a/docs/credential-sharing.md b/docs/credential-sharing.md index 6d830d6..4217e33 100644 --- a/docs/credential-sharing.md +++ b/docs/credential-sharing.md @@ -55,12 +55,30 @@ env doppler railway github-secrets +sh1pt ``` - `.env`: read, diff, redact, and write local environment files. - Doppler: sync project/config scoped secrets. - Railway: sync service variables. - GitHub Secrets: sync repository, organization, and environment secrets. +- sh1pt: sync the distribution credential vault — App Store Connect keys, Play + service accounts, npm and Docker tokens, Cloudflare tokens. + +`sh1pt` is the one adapter driven through a **CLI** rather than an HTTP API, +because sh1pt publishes `sh1pt secret set|get|list|rm` as the interface to its +vault and documents no REST endpoint for it. That is a transport choice inside +an adapter, which is the layer where product-specific I/O belongs; it does not +move product-specific commands into the core contract. Two consequences worth +stating: + +- Values are written on the child process's **stdin**, never as argv. A secret + passed as a command-line argument is readable by any user on the host via + `ps` for the lifetime of the call. +- `sh1pt secret get` requires interactive confirmation and so cannot be + scripted. The adapter is therefore write-only for values (`readValues: + false`), exactly like `github-secrets`: it can be a sync target but never a + source, and it supports no value-restoring rollback. ## Core Objects @@ -96,11 +114,14 @@ plan diff approve sync +rotate rollback audit export ``` +`logicsrc secrets …` is an accepted alias for `logicsrc credentials …`. + Examples: ```bash @@ -202,6 +223,45 @@ logicsrc teams members acme logicsrc teams vaults acme ``` +### Rotating a vault key + +```bash +# Dry run (the default): show what a rotation would re-key and revoke. +logicsrc credentials rotate acme web prod + +# Apply it. +logicsrc credentials rotate acme web prod --approve + +# Every vault in the team that you can open. +logicsrc secrets rotate acme --approve +``` + +Rotation replaces the vault DEK, re-seals it to the members who keep access, and +re-encrypts every secret under it. **Secret values do not change** — nothing that +consumes them breaks. What changes is that every wrapped key issued before the +rotation is dead, so a copy of an old grant buys nothing. + +Who keeps access: + +- **`--active`** (default): only members whose team status is `active` *and* who + hold a grant today. This is the "someone left the team" rotation — everyone + else is revoked. +- **`--all`**: everyone holding a grant today, whatever their status. Pure + crypto hygiene, no revocation. + +A member who holds access but has never uploaded a public key cannot be re-sealed +to; they are reported under `skipped` and revoked rather than dropped silently. + +Safety properties, all enforced rather than documented: + +- The whole next state is applied in **one transaction**. The DEK is recoverable + only through the grants, so a half-applied rotation — new grants over old + ciphertext, or the reverse — would make the vault permanently unreadable. +- The server requires every submitted fingerprint to equal the stored one. It + cannot see values, but it can prove a rotation did not swap any. +- A rotation that would leave the caller ungranted, grant nobody, or cover the + wrong number of secrets is rejected before anything is written. + `logicsrc login` picks its flow from the machine it runs on: - **Has its own browser** → loopback OAuth-PKCE: a `127.0.0.1` listener catches diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3ae710e..dd5adc6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,7 @@ import { teamsPushAction, teamsPullAction } from "./teams.js"; +import { credentialsRotateAction } from "./rotate.js"; import { boards, tasks } from "./fixtures.js"; import { print, type OutputFormat } from "./format.js"; import { parsePositiveInteger } from "./numeric-options.js"; @@ -404,7 +405,13 @@ program.command("plugins").option("--format ", "table, json, or markdown print(snapshot.plugins, options.format as OutputFormat); }); -const credentials = program.command("credentials").alias("creds").description("Credential Sharing OpenSpec: portable, auditable secret sync."); +const credentials = program + .command("credentials") + .alias("creds") + // `secrets` is what people reach for; keep it pointing at the same group + // rather than growing a second, divergent surface. + .alias("secrets") + .description("Credential Sharing OpenSpec: portable, auditable secret sync."); function endpointFromOptions(options: Record, prefix: "" | "from" | "to"): CredentialEndpoint { const pick = (name: string) => { @@ -543,6 +550,25 @@ credentials print(credentialEngine().exportCredentialAudit(options.run), options.format as OutputFormat); }); +credentials + .command("rotate") + .argument("", "Team slug") + .argument("[project]", "Project name (omit with env to rotate every vault in the team)") + .argument("[env]", "Environment name (prod, staging, …)") + .option("--all", "Re-seal to everyone who holds access today, whatever their member status") + .option("--active", "Re-seal only to active members, dropping the rest (default)") + .option("--approve", "Apply the rotation (dry run by default)") + .option("--format ", "table, json, or markdown", "table") + .description("Re-key a vault: new vault key, secrets re-encrypted, values unchanged.") + .action((team, project, env, options) => + credentialsRotateAction(team, project, env, { + // --all widens the keep-list; --active is the default and needs no flag. + scope: options.all ? "all" : "active", + approve: Boolean(options.approve), + format: options.format as OutputFormat + }) + ); + credentials .command("export") .requiredOption("--run ", "Sync run id") diff --git a/packages/cli/src/rotate.ts b/packages/cli/src/rotate.ts new file mode 100644 index 0000000..0d7e9a7 --- /dev/null +++ b/packages/cli/src/rotate.ts @@ -0,0 +1,156 @@ +import { + TeamClient, + TeamApiError, + requireAuth, + resolveApiUrl, + planVaultRekey, + type RekeyMember +} from "@logicsrc/plugin-credential-sharing"; +import { print, type OutputFormat } from "./format.js"; +import { vaultName } from "./teams.js"; + +/** + * `logicsrc credentials rotate` — re-key a team vault. + * + * Rotation replaces the vault's data-encryption key and re-encrypts every + * secret under it. Secret VALUES do not change, so nothing that consumes them + * breaks; what changes is that every wrapped key issued before now is dead. + * That is what makes it the right move after someone leaves a team, or on a + * schedule as plain hygiene. + * + * All crypto happens here, on the member's machine. The server receives the + * finished state (ciphertext plus sealed keys) and commits it in one + * transaction. + */ + +export interface RotateOptions { + /** "active" (default) drops non-active members; "all" keeps everyone. */ + scope: "active" | "all"; + /** Rotation only writes with --approve, matching `credentials sync`. */ + approve: boolean; + format: OutputFormat; +} + +interface VaultTarget { + id: string; + name: string; +} + +function authedClient(): { client: TeamClient; identity: ReturnType } { + const identity = requireAuth(); + return { client: new TeamClient({ apiUrl: resolveApiUrl(identity), token: identity.apiToken }), identity }; +} + +/** + * Which vaults this invocation covers: one when a project/env pair is given, + * otherwise every vault in the team the caller can actually open. + */ +async function resolveTargets( + client: TeamClient, + slug: string, + project?: string, + env?: string +): Promise { + const { vaults } = await client.listVaults(slug); + if (project || env) { + if (!project || !env) { + throw new Error("Give both a project and an env, or neither to rotate the whole team: logicsrc creds rotate [project] [env]"); + } + const name = vaultName(project, env); + const found = vaults.find((v) => v.name === name); + if (!found) { + const known = vaults.map((v) => v.name).join(", "); + throw new Error(`Vault "${name}" not found in team "${slug}".${known ? ` Existing vaults: ${known}.` : ""}`); + } + return [{ id: found.id, name: found.name }]; + } + // Vaults the caller holds no grant on cannot be re-keyed by them; skip rather + // than fail the whole sweep. + const accessible = vaults.filter((v) => v.hasAccess); + if (accessible.length === 0) { + throw new Error(`No vaults in "${slug}" that you have access to. Ask a member to grant you, or name a vault explicitly.`); + } + return accessible.map((v) => ({ id: v.id, name: v.name })); +} + +async function rotateOne( + client: TeamClient, + identity: ReturnType, + vault: VaultTarget, + options: RotateOptions +): Promise> { + let myWrappedDek: string; + try { + myWrappedDek = (await client.getMyGrant(vault.id)).wrappedDek; + } catch (error) { + if (error instanceof TeamApiError && error.status === 403) { + throw new Error(`You don't have access to "${vault.name}", so you can't re-key it. Ask an existing member.`); + } + throw error; + } + + const [{ secrets }, { grants }] = await Promise.all([client.listSecrets(vault.id), client.listGrants(vault.id)]); + + const members: RekeyMember[] = grants.map((g) => ({ + email: g.email, + publicKey: g.publicKey, + status: g.status, + hasAccess: g.hasAccess + })); + + const plan = await planVaultRekey({ + myWrappedDek, + identity: identity.keys, + secrets: secrets.map((s) => ({ name: s.name, nonce: s.nonce, ciphertext: s.ciphertext, fingerprint: s.fingerprint })), + members, + scope: options.scope + }); + + const summary: Record = { + vault: vault.name, + secrets: plan.secrets.length, + keeps: plan.grants.map((g) => g.email), + revokes: plan.revoked, + skipped: plan.skipped, + applied: false + }; + + if (!options.approve) { + return summary; + } + + const result = await client.rekeyVault(vault.id, { + grants: plan.grants, + secrets: plan.secrets, + revoke: plan.revoked + }); + summary.applied = true; + summary.rekeyed = result.rekeyed; + return summary; +} + +export async function credentialsRotateAction( + slug: string, + project: string | undefined, + env: string | undefined, + options: RotateOptions +): Promise { + const { client, identity } = authedClient(); + const targets = await resolveTargets(client, slug, project, env); + + const results: Array> = []; + for (const vault of targets) { + results.push(await rotateOne(client, identity, vault, options)); + } + + if (!options.approve) { + const totalRevokes = results.reduce((n, r) => n + (r.revokes as string[]).length, 0); + console.error( + `Dry run: ${results.length} vault(s) would be re-keyed${totalRevokes ? `, revoking ${totalRevokes} grant(s)` : ""}. Values are unchanged by a re-key. Re-run with --approve to apply.` + ); + } else { + console.error(`Re-keyed ${results.length} vault(s). Every previously issued vault key is now dead.`); + } + + print(results, options.format); +} diff --git a/plugins/credential-sharing/src/client.ts b/plugins/credential-sharing/src/client.ts index f77e200..ecce645 100644 --- a/plugins/credential-sharing/src/client.ts +++ b/plugins/credential-sharing/src/client.ts @@ -50,10 +50,20 @@ export interface RemoteSecret { export interface RemoteGrantRow { email: string; + /** X25519 public key, so a client can re-seal to every member in one pass. */ + publicKey: string | null; + status: "active" | "invited"; hasPublicKey: boolean; hasAccess: boolean; } +export interface RekeyResult { + ok: boolean; + rekeyed: number; + granted: string[]; + revoked: string[]; +} + export class TeamApiError extends Error { constructor( public readonly status: number, @@ -158,6 +168,21 @@ export class TeamClient { putSecrets(vaultId: string, upserts: Array<{ name: string; nonce: string; ciphertext: string; fingerprint: string }>, deletes: string[]) { return this.request<{ ok: boolean; applied: string[] }>("PUT", `/vaults/${encodeURIComponent(vaultId)}/secrets`, { upserts, deletes }); } + /** + * Swap the vault to a fresh DEK. The whole next state goes over in one call + * because the server applies it in a single transaction — see rekey.ts for + * why a partial rotation is unrecoverable. + */ + rekeyVault( + vaultId: string, + body: { + grants: Array<{ email: string; wrappedDek: string }>; + secrets: Array<{ name: string; nonce: string; ciphertext: string; fingerprint: string }>; + revoke: string[]; + } + ) { + return this.request("POST", `/vaults/${encodeURIComponent(vaultId)}/rekey`, body); + } listAudit(vaultId: string) { return this.request<{ audit: Array> }>("GET", `/vaults/${encodeURIComponent(vaultId)}/audit`); } diff --git a/plugins/credential-sharing/src/index.ts b/plugins/credential-sharing/src/index.ts index e43bfe9..77b28b1 100644 --- a/plugins/credential-sharing/src/index.ts +++ b/plugins/credential-sharing/src/index.ts @@ -101,4 +101,11 @@ export { type CredentialStore } from "./store.js"; export { fingerprintValue, fingerprintsEqual } from "./fingerprint.js"; +export { + planVaultRekey, + type RekeyPlan, + type RekeyPlanInput, + type RekeyMember, + type SealedSecret +} from "./rekey.js"; export * from "./types.js"; diff --git a/plugins/credential-sharing/src/providers/index.ts b/plugins/credential-sharing/src/providers/index.ts index 3c4f3f7..1b5d63b 100644 --- a/plugins/credential-sharing/src/providers/index.ts +++ b/plugins/credential-sharing/src/providers/index.ts @@ -3,9 +3,10 @@ import { envProvider } from "./env.js"; import { dopplerProvider } from "./doppler.js"; import { railwayProvider } from "./railway.js"; import { githubSecretsProvider } from "./github-secrets.js"; +import { sh1ptProvider } from "./sh1pt.js"; import { teamProvider } from "./team.js"; -export const credentialProviders: CredentialProvider[] = [envProvider, dopplerProvider, railwayProvider, githubSecretsProvider, teamProvider]; +export const credentialProviders: CredentialProvider[] = [envProvider, dopplerProvider, railwayProvider, githubSecretsProvider, sh1ptProvider, teamProvider]; export const credentialProviderRegistry: Map = new Map( credentialProviders.map((provider) => [provider.id, provider]) @@ -22,5 +23,5 @@ export function listCredentialProviderManifests(): CredentialProviderManifest[] })); } -export { envProvider, dopplerProvider, railwayProvider, githubSecretsProvider, teamProvider }; +export { envProvider, dopplerProvider, railwayProvider, githubSecretsProvider, sh1ptProvider, teamProvider }; export { parseEnv, applyEnv } from "./env.js"; diff --git a/plugins/credential-sharing/src/providers/sh1pt.test.ts b/plugins/credential-sharing/src/providers/sh1pt.test.ts new file mode 100644 index 0000000..43ba9ba --- /dev/null +++ b/plugins/credential-sharing/src/providers/sh1pt.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseSecretList, sh1ptProvider } from "./sh1pt.js"; + +/** + * These run against a real fake `sh1pt` binary rather than a mocked execFile, + * so the child-process path — argv, stdin, exit codes — is genuinely exercised. + * That matters here: the whole point of the adapter is that secret values go + * over stdin and never appear in argv. + */ +let dir: string; +let binPath: string; +let logPath: string; + +/** Write a stub `sh1pt` that records how it was invoked, then prints `stdout`. */ +function installFakeSh1pt(body: string): void { + writeFileSync( + binPath, + `#!/usr/bin/env node +const fs = require("fs"); +let stdin = ""; +process.stdin.on("data", (c) => (stdin += c)); +process.stdin.on("end", run); +if (process.stdin.isTTY) run(); +function run() { + const calls = fs.existsSync(${JSON.stringify(logPath)}) + ? JSON.parse(fs.readFileSync(${JSON.stringify(logPath)}, "utf8")) + : []; + calls.push({ argv: process.argv.slice(2), stdin }); + fs.writeFileSync(${JSON.stringify(logPath)}, JSON.stringify(calls)); + ${body} +} +`, + { mode: 0o755 } + ); + chmodSync(binPath, 0o755); +} + +function calls(): Array<{ argv: string[]; stdin: string }> { + return existsSync(logPath) ? JSON.parse(readFileSync(logPath, "utf8")) : []; +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sh1pt-test-")); + binPath = join(dir, "sh1pt"); + logPath = join(dir, "calls.json"); + process.env.SH1PT_BIN = binPath; +}); + +afterEach(() => { + delete process.env.SH1PT_BIN; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("parseSecretList", () => { + it("reads plain and decorated key lists, and never invents a key", () => { + expect(parseSecretList("NPM_TOKEN\nDOCKER_PAT\n")).toEqual(["NPM_TOKEN", "DOCKER_PAT"]); + expect(parseSecretList(" - NPM_TOKEN\n * DOCKER_PAT\n\n")).toEqual(["NPM_TOKEN", "DOCKER_PAT"]); + // A value accidentally printed alongside a key has whitespace in it, so it + // is dropped rather than treated as a key name. + expect(parseSecretList("NPM_TOKEN = npm_abc123\nDOCKER_PAT\n")).toEqual(["DOCKER_PAT"]); + expect(parseSecretList("")).toEqual([]); + }); +}); + +describe("sh1ptProvider", () => { + it("is declared write-only: sh1pt secret get needs a human, so values cannot be read back", () => { + expect(sh1ptProvider.capabilities.readValues).toBe(false); + expect(sh1ptProvider.capabilities.rollback).toBe(false); + expect(sh1ptProvider.readValues).toBeUndefined(); + expect(sh1ptProvider.capabilities.write).toBe(true); + }); + + it("lists key names and reports values as unreadable", async () => { + installFakeSh1pt(`process.stdout.write("NPM_TOKEN\\nCLOUDFLARE_TOKEN\\n");`); + + const snapshot = await sh1ptProvider.inspect({ provider: "sh1pt", project: "acme", config: "prod" }); + + expect(snapshot.valuesReadable).toBe(false); + expect(snapshot.keys.map((k) => k.name)).toEqual(["CLOUDFLARE_TOKEN", "NPM_TOKEN"]); + // Names only — a fingerprint would imply we had seen the value. + expect(snapshot.keys.every((k) => k.fingerprint === undefined)).toBe(true); + expect(calls()[0].argv).toEqual(["secret", "list", "--project", "acme", "--env", "prod"]); + }); + + it("passes secret values on stdin and NEVER in argv", async () => { + installFakeSh1pt(""); + const value = "npm_supersecret_value"; + + const results = await sh1ptProvider.write({ + endpoint: { provider: "sh1pt" }, + upserts: { NPM_TOKEN: value }, + deletes: [], + dryRun: false + }); + + expect(results).toEqual([{ key: "NPM_TOKEN", applied: true }]); + const call = calls()[0]; + expect(call.argv).toEqual(["secret", "set", "NPM_TOKEN"]); + // The security property this adapter exists to hold: a value in argv is + // readable by any process on the box via `ps`. + expect(call.argv.join(" ")).not.toContain(value); + expect(call.stdin.trim()).toBe(value); + }); + + it("deletes through `secret rm`", async () => { + installFakeSh1pt(""); + + const results = await sh1ptProvider.write({ + endpoint: { provider: "sh1pt" }, + upserts: {}, + deletes: ["OLD_TOKEN"], + dryRun: false + }); + + expect(results).toEqual([{ key: "OLD_TOKEN", applied: true }]); + expect(calls()[0].argv).toEqual(["secret", "rm", "OLD_TOKEN"]); + }); + + it("a dry run touches nothing", async () => { + installFakeSh1pt(""); + + const results = await sh1ptProvider.write({ + endpoint: { provider: "sh1pt" }, + upserts: { A: "1" }, + deletes: ["B"], + dryRun: true + }); + + expect(results).toEqual([ + { key: "A", applied: false }, + { key: "B", applied: false } + ]); + expect(calls()).toEqual([]); + }); + + it("reports a per-key failure instead of aborting the whole run", async () => { + installFakeSh1pt(` + if (process.argv[4] === "BAD") { process.stderr.write("vault rejected BAD"); process.exit(1); } + `); + + const results = await sh1ptProvider.write({ + endpoint: { provider: "sh1pt" }, + upserts: { GOOD: "1", BAD: "2" }, + deletes: [], + dryRun: false + }); + + expect(results.find((r) => r.key === "GOOD")?.applied).toBe(true); + const bad = results.find((r) => r.key === "BAD"); + expect(bad?.applied).toBe(false); + expect(bad?.error).toMatch(/vault rejected BAD/); + }); + + it("rejects a malformed secret name before shelling out", async () => { + installFakeSh1pt(""); + + await expect( + sh1ptProvider.write({ provider: "sh1pt", endpoint: { provider: "sh1pt" }, upserts: { "not a key": "x" }, deletes: [], dryRun: false } as never) + ).rejects.toThrow(/not a valid sh1pt secret name/); + expect(calls()).toEqual([]); + }); + + it("explains how to fix a missing sh1pt CLI", async () => { + process.env.SH1PT_BIN = join(dir, "does-not-exist"); + + await expect(sh1ptProvider.inspect({ provider: "sh1pt" })).rejects.toThrow(/sh1pt CLI was not found on PATH/); + }); +}); diff --git a/plugins/credential-sharing/src/providers/sh1pt.ts b/plugins/credential-sharing/src/providers/sh1pt.ts new file mode 100644 index 0000000..3a7f051 --- /dev/null +++ b/plugins/credential-sharing/src/providers/sh1pt.ts @@ -0,0 +1,156 @@ +/** + * sh1pt credential provider — the distribution-credential vault. + * + * Unlike every other adapter here, sh1pt is driven through its CLI rather than + * a REST call. That is not a shortcut: sh1pt publishes `sh1pt secret set|get| + * list|rm` as the interface to its cloud vault and documents no HTTP API for + * it, so the CLI *is* the contract. Auth comes from `sh1pt login`, which writes + * ~/.sh1pt/credentials; this adapter never handles a sh1pt token itself. + * + * The vault holds delivery credentials that Doppler/Railway/GitHub generally do + * not — App Store Connect keys, Play service accounts, npm and Docker tokens, + * Cloudflare tokens — which is exactly why it is worth syncing into. + * + * Values are written on STDIN, never as argv. `sh1pt secret set ` prompts + * for the value when it is omitted, and a secret passed as a command-line + * argument is world-readable in `ps` for the life of the process. + */ +import { execFile } from "node:child_process"; +import { keysFromNames } from "../fingerprint.js"; +import type { CredentialEndpoint, CredentialProvider, CredentialWriteResult } from "../types.js"; + +/** The binary to invoke. Overridable so CI can point at a pinned build. */ +function sh1ptBin(): string { + return process.env.SH1PT_BIN || "sh1pt"; +} + +/** + * Secret names sh1pt will accept. We validate before shelling out: execFile + * does not use a shell, so this is not injection defence, it is a clear error + * instead of a confusing CLI usage failure on a malformed key. + */ +const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/; + +function assertSecretName(name: string): void { + if (!SECRET_NAME.test(name)) { + throw new Error( + `"${name}" is not a valid sh1pt secret name. Use letters, digits, underscore, dot or dash, starting with a letter or underscore.` + ); + } +} + +interface RunResult { + stdout: string; + stderr: string; +} + +/** + * Run the sh1pt CLI. `stdin` is written to the child when provided, which is + * how secret values are handed over without ever appearing in the process + * table. Scoping flags come from the endpoint: `project` maps to sh1pt's + * project, `config` to its environment. + */ +function runSh1pt(args: string[], endpoint: CredentialEndpoint, stdin?: string): Promise { + const scoped = [...args]; + if (endpoint.project) scoped.push("--project", endpoint.project); + if (endpoint.config) scoped.push("--env", endpoint.config); + + return new Promise((resolve, reject) => { + const child = execFile( + sh1ptBin(), + scoped, + { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + (error, stdout, stderr) => { + if (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + reject( + new Error( + `The sh1pt CLI was not found on PATH. Install it and run "sh1pt login", or set SH1PT_BIN to its path.` + ) + ); + return; + } + // stderr carries sh1pt's own message (not logged in, unknown project, …). + reject(new Error(`sh1pt ${scoped[0]} ${scoped[1] ?? ""} failed: ${(stderr || error.message).trim().slice(0, 300)}`)); + return; + } + resolve({ stdout, stderr }); + } + ); + // Always close stdin, even with nothing to send. Leaving it open hangs any + // sh1pt subcommand that waits on input (a confirmation prompt, say) until + // the process is killed. + child.stdin?.end(stdin ?? ""); + }); +} + +/** + * `sh1pt secret list` prints key names, one per line, and never values. We + * tolerate a decorated list (bullets, blank lines) but refuse anything with + * whitespace inside it, which would mean the output format changed under us. + */ +export function parseSecretList(stdout: string): string[] { + return stdout + .split("\n") + .map((line) => line.replace(/^[\s*\-•]+/, "").trim()) + .filter((line) => line.length > 0 && SECRET_NAME.test(line)); +} + +export const sh1ptProvider: CredentialProvider = { + id: "sh1pt", + name: "sh1pt", + description: "Distribution credential vault (App Store, Play, npm, Docker, Cloudflare) via the sh1pt CLI.", + // readValues is false by design, not by omission: `sh1pt secret get` requires + // interactive confirmation, so it cannot be scripted. That also makes this a + // write-only target -- no rollback pre-image can be captured, same as + // github-secrets. + capabilities: { readValues: false, readNames: true, write: true, delete: true, rollback: false, audit: false }, + authRequirements: ["sh1pt login"], + status: "available", + + async inspect(endpoint) { + const { stdout } = await runSh1pt(["secret", "list"], endpoint); + return { + provider: "sh1pt", + endpoint, + valuesReadable: false, + keys: keysFromNames(parseSecretList(stdout)), + inspectedAt: new Date().toISOString() + }; + }, + + async write({ endpoint, upserts, deletes, dryRun }) { + for (const key of [...Object.keys(upserts), ...deletes]) { + assertSecretName(key); + } + + const results: CredentialWriteResult[] = []; + if (dryRun) { + return [ + ...Object.keys(upserts).map((key) => ({ key, applied: false })), + ...deletes.map((key) => ({ key, applied: false })) + ]; + } + + // One invocation per key: the CLI has no bulk form, and a partial failure + // should report per-key rather than abort the whole run. + for (const [key, value] of Object.entries(upserts)) { + try { + await runSh1pt(["secret", "set", key], endpoint, `${value}\n`); + results.push({ key, applied: true }); + } catch (error) { + results.push({ key, applied: false, error: error instanceof Error ? error.message : String(error) }); + } + } + for (const key of deletes) { + try { + await runSh1pt(["secret", "rm", key], endpoint); + results.push({ key, applied: true }); + } catch (error) { + results.push({ key, applied: false, error: error instanceof Error ? error.message : String(error) }); + } + } + return results; + } +}; diff --git a/plugins/credential-sharing/src/rekey.test.ts b/plugins/credential-sharing/src/rekey.test.ts new file mode 100644 index 0000000..b960243 --- /dev/null +++ b/plugins/credential-sharing/src/rekey.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { + generateIdentityKeyPair, + generateVaultKey, + encryptValue, + decryptValue, + wrapVaultKey, + unwrapVaultKey, + type IdentityKeyPair +} from "./crypto.js"; +import { fingerprintValue } from "./fingerprint.js"; +import { planVaultRekey, type RekeyMember, type SealedSecret } from "./rekey.js"; + +/** Build a vault sealed under a fresh DEK, plus a grant for each holder. */ +async function makeVault(values: Record, holders: IdentityKeyPair[]) { + const dek = await generateVaultKey(); + const secrets: SealedSecret[] = []; + for (const [name, value] of Object.entries(values)) { + const sealed = await encryptValue(value, dek); + secrets.push({ name, nonce: sealed.nonce, ciphertext: sealed.ciphertext, fingerprint: fingerprintValue(value) }); + } + const wrapped = await Promise.all(holders.map((h) => wrapVaultKey(dek, h.publicKey))); + return { dek, secrets, wrapped }; +} + +function member(email: string, keys: IdentityKeyPair | null, over: Partial = {}): RekeyMember { + return { email, publicKey: keys?.publicKey ?? null, status: "active", hasAccess: true, ...over }; +} + +describe("planVaultRekey", () => { + it("re-encrypts every secret under a new key without changing any value", async () => { + const me = await generateIdentityKeyPair(); + const values = { API_KEY: "sk-live-abc123", DB_URL: "postgres://u:p@h/db" }; + const { dek: oldDek, secrets, wrapped } = await makeVault(values, [me]); + + const plan = await planVaultRekey({ + myWrappedDek: wrapped[0], + identity: me, + secrets, + members: [member("me@example.com", me)] + }); + + // The new grant opens a DEK that is genuinely different... + const newDek = await unwrapVaultKey(plan.grants[0].wrappedDek, me); + expect(newDek).not.toBe(oldDek); + + // ...and every value survives the trip unchanged. + for (const secret of plan.secrets) { + const roundTripped = await decryptValue({ nonce: secret.nonce, ciphertext: secret.ciphertext }, newDek); + expect(roundTripped).toBe(values[secret.name as keyof typeof values]); + } + // Fingerprints are the contract the server checks: same value, same print. + expect(plan.secrets.map((s) => s.fingerprint).sort()).toEqual(secrets.map((s) => s.fingerprint).sort()); + }); + + it("makes the OLD key useless against the rotated ciphertext", async () => { + const me = await generateIdentityKeyPair(); + const { dek: oldDek, secrets, wrapped } = await makeVault({ TOKEN: "hunter2" }, [me]); + + const plan = await planVaultRekey({ myWrappedDek: wrapped[0], identity: me, secrets, members: [member("me@example.com", me)] }); + + // This is the entire point of rotating: a leaked old DEK buys nothing. + await expect( + decryptValue({ nonce: plan.secrets[0].nonce, ciphertext: plan.secrets[0].ciphertext }, oldDek) + ).rejects.toThrow(); + }); + + it("drops a departed member, and their old grant no longer opens the vault", async () => { + const me = await generateIdentityKeyPair(); + const leaver = await generateIdentityKeyPair(); + const { secrets, wrapped } = await makeVault({ TOKEN: "hunter2" }, [me, leaver]); + const leaverOldGrant = wrapped[1]; + + const plan = await planVaultRekey({ + myWrappedDek: wrapped[0], + identity: me, + secrets, + // The leaver is still on the vault but is no longer an active member. + members: [member("me@example.com", me), member("leaver@example.com", leaver, { status: "invited" })] + }); + + expect(plan.grants.map((g) => g.email)).toEqual(["me@example.com"]); + expect(plan.revoked).toEqual(["leaver@example.com"]); + + // The leaver's old grant still opens the OLD dek — but that dek is now + // worthless against the re-encrypted ciphertext. + const staleDek = await unwrapVaultKey(leaverOldGrant, leaver); + await expect( + decryptValue({ nonce: plan.secrets[0].nonce, ciphertext: plan.secrets[0].ciphertext }, staleDek) + ).rejects.toThrow(); + }); + + it("--all keeps a non-active member who holds access", async () => { + const me = await generateIdentityKeyPair(); + const other = await generateIdentityKeyPair(); + const { secrets, wrapped } = await makeVault({ TOKEN: "hunter2" }, [me, other]); + + const plan = await planVaultRekey({ + myWrappedDek: wrapped[0], + identity: me, + secrets, + members: [member("me@example.com", me), member("other@example.com", other, { status: "invited" })], + scope: "all" + }); + + expect(plan.grants.map((g) => g.email).sort()).toEqual(["me@example.com", "other@example.com"]); + expect(plan.revoked).toEqual([]); + // And the kept member can actually read the rotated vault. + const theirDek = await unwrapVaultKey(plan.grants.find((g) => g.email === "other@example.com")!.wrappedDek, other); + expect(await decryptValue({ nonce: plan.secrets[0].nonce, ciphertext: plan.secrets[0].ciphertext }, theirDek)).toBe("hunter2"); + }); + + it("never drops a member silently when they have no key to re-seal to", async () => { + const me = await generateIdentityKeyPair(); + const { secrets, wrapped } = await makeVault({ TOKEN: "hunter2" }, [me]); + + const plan = await planVaultRekey({ + myWrappedDek: wrapped[0], + identity: me, + secrets, + members: [member("me@example.com", me), member("keyless@example.com", null)] + }); + + expect(plan.skipped).toEqual([{ email: "keyless@example.com", reason: "no public key on file" }]); + expect(plan.revoked).toContain("keyless@example.com"); + }); + + it("refuses to rotate a vault into a state nobody can read", async () => { + const me = await generateIdentityKeyPair(); + const { secrets, wrapped } = await makeVault({ TOKEN: "hunter2" }, [me]); + + await expect( + planVaultRekey({ + myWrappedDek: wrapped[0], + identity: me, + secrets, + members: [member("me@example.com", me, { status: "invited" })] + }) + ).rejects.toThrow(/no member would keep access/i); + }); + + it("aborts on a secret that does not decrypt rather than dropping it", async () => { + const me = await generateIdentityKeyPair(); + const { secrets, wrapped } = await makeVault({ GOOD: "value" }, [me]); + const corrupted = [...secrets, { name: "BAD", nonce: secrets[0].nonce, ciphertext: secrets[0].ciphertext.replace(/^./, "A"), fingerprint: fingerprintValue("x") }]; + + await expect( + planVaultRekey({ myWrappedDek: wrapped[0], identity: me, secrets: corrupted, members: [member("me@example.com", me)] }) + ).rejects.toThrow(/did not decrypt|inconsistent/i); + }); + + it("aborts when a stored fingerprint disagrees with its ciphertext", async () => { + const me = await generateIdentityKeyPair(); + const { secrets, wrapped } = await makeVault({ TOKEN: "hunter2" }, [me]); + const tampered = [{ ...secrets[0], fingerprint: fingerprintValue("something-else") }]; + + await expect( + planVaultRekey({ myWrappedDek: wrapped[0], identity: me, secrets: tampered, members: [member("me@example.com", me)] }) + ).rejects.toThrow(/fingerprint/i); + }); + + it("rotates an empty vault without inventing secrets", async () => { + const me = await generateIdentityKeyPair(); + const { wrapped } = await makeVault({}, [me]); + + const plan = await planVaultRekey({ myWrappedDek: wrapped[0], identity: me, secrets: [], members: [member("me@example.com", me)] }); + expect(plan.secrets).toEqual([]); + expect(plan.grants).toHaveLength(1); + }); +}); diff --git a/plugins/credential-sharing/src/rekey.ts b/plugins/credential-sharing/src/rekey.ts new file mode 100644 index 0000000..ac8ff35 --- /dev/null +++ b/plugins/credential-sharing/src/rekey.ts @@ -0,0 +1,155 @@ +/** + * Vault re-keying (rotation) for LogicSRC team vaults. + * + * Re-keying replaces a vault's data-encryption key. Every secret is decrypted + * with the old DEK and re-encrypted under a new one, and the new DEK is sealed + * afresh to each member who should keep access. Secret VALUES never change -- + * that is the whole point. Nothing downstream breaks; what changes is that + * every previously-issued wrapped DEK becomes useless, so anyone dropped from + * the grant list can no longer read the vault even if they kept a copy of their + * old grant. + * + * This module is deliberately pure: it takes the current sealed state plus the + * caller's identity and returns the complete next state. All of it runs on the + * member's machine -- the server receives ciphertext and sealed keys only, and + * never sees either DEK. + * + * Ordering matters and is NOT this module's problem: a half-applied rotation + * (new grants, old ciphertext, or the reverse) locks everyone out permanently, + * because the DEK is recoverable only through the grants. The server applies + * the result of `planVaultRekey` in a single transaction; see the + * /vaults/:id/rekey endpoint. + */ +import { decryptValue, encryptValue, generateVaultKey, unwrapVaultKey, wrapVaultKey, type IdentityKeyPair } from "./crypto.js"; +import { fingerprintValue, fingerprintsEqual } from "./fingerprint.js"; + +/** A secret as the server stores it. */ +export interface SealedSecret { + name: string; + nonce: string; + ciphertext: string; + fingerprint: string; +} + +/** A member who is a candidate to receive the new DEK. */ +export interface RekeyMember { + email: string; + /** X25519 public key, or null if they have never uploaded one. */ + publicKey: string | null; + /** Team membership status. Only "active" members are kept by default. */ + status: "active" | "invited"; + /** Whether they hold a grant on this vault today. */ + hasAccess: boolean; +} + +export interface RekeyPlanInput { + /** The caller's own wrapped DEK, which bootstraps the whole operation. */ + myWrappedDek: string; + /** The caller's identity keypair. */ + identity: IdentityKeyPair; + /** Every secret currently in the vault. */ + secrets: SealedSecret[]; + /** Every team member, with their current access. */ + members: RekeyMember[]; + /** + * Who keeps access. + * - "active" (default): only members whose team status is "active" AND who + * hold a grant today. This is the "someone left the team" rotation. + * - "all": everyone holding a grant today, whatever their status. Pure + * crypto hygiene -- re-key without revoking anyone. + */ + scope?: "active" | "all"; +} + +export interface RekeyPlan { + /** Re-encrypted secrets, ready to write. Values are identical to the input. */ + secrets: SealedSecret[]; + /** New sealed DEKs, one per retained member. */ + grants: Array<{ email: string; wrappedDek: string }>; + /** Members whose access this rotation removes. */ + revoked: string[]; + /** Members skipped because they have no public key to seal to. */ + skipped: Array<{ email: string; reason: string }>; +} + +/** Members who have no key yet cannot be sealed to, whatever the scope. */ +function sealable(member: RekeyMember): boolean { + return typeof member.publicKey === "string" && member.publicKey.length > 0; +} + +/** + * Build the complete next state of a vault under a fresh DEK. + * + * Throws rather than returning a partial plan: a rotation that silently dropped + * a secret it could not decrypt would destroy it on write. + */ +export async function planVaultRekey(input: RekeyPlanInput): Promise { + const scope = input.scope ?? "active"; + + const oldDek = await unwrapVaultKey(input.myWrappedDek, input.identity); + const newDek = await generateVaultKey(); + + // Decrypt everything BEFORE encrypting anything. If one secret fails to open + // we abort with the vault untouched, rather than writing a half-rotated set. + const plaintext = new Map(); + for (const secret of input.secrets) { + let value: string; + try { + value = await decryptValue({ nonce: secret.nonce, ciphertext: secret.ciphertext }, oldDek); + } catch { + throw new Error( + `Cannot rotate: "${secret.name}" did not decrypt with your vault key. The vault may already be mid-rotation, or your grant is stale — re-run after a member with access re-grants you.` + ); + } + // The fingerprint is a deterministic hash of the value, so a mismatch here + // means the stored row was already inconsistent. Refuse to propagate it. + if (!fingerprintsEqual(secret.fingerprint, fingerprintValue(value))) { + throw new Error( + `Cannot rotate: "${secret.name}" has a fingerprint that does not match its ciphertext. Refusing to re-encrypt a record that is already inconsistent.` + ); + } + plaintext.set(secret.name, value); + } + + const secrets: SealedSecret[] = []; + for (const secret of input.secrets) { + const value = plaintext.get(secret.name) as string; + const sealed = await encryptValue(value, newDek); + secrets.push({ + name: secret.name, + nonce: sealed.nonce, + ciphertext: sealed.ciphertext, + // Unchanged by construction -- the value did not change. Recomputed + // rather than copied so a bug here surfaces as a server-side rejection. + fingerprint: fingerprintValue(value) + }); + } + + const grants: Array<{ email: string; wrappedDek: string }> = []; + const revoked: string[] = []; + const skipped: Array<{ email: string; reason: string }> = []; + + for (const member of input.members) { + const keep = member.hasAccess && (scope === "all" || member.status === "active"); + if (!keep) { + if (member.hasAccess) revoked.push(member.email); + continue; + } + if (!sealable(member)) { + // Holds access today but has no key to re-seal to. Rotating would cut + // them off silently, so surface it instead of burying it. + skipped.push({ email: member.email, reason: "no public key on file" }); + revoked.push(member.email); + continue; + } + grants.push({ email: member.email, wrappedDek: await wrapVaultKey(newDek, member.publicKey as string) }); + } + + if (grants.length === 0) { + throw new Error( + "Cannot rotate: no member would keep access, which would make the vault permanently unreadable. Grant at least one active member with a registered key first." + ); + } + + return { secrets, grants, revoked, skipped }; +} diff --git a/plugins/credential-sharing/src/types.ts b/plugins/credential-sharing/src/types.ts index 655a95e..957d9d0 100644 --- a/plugins/credential-sharing/src/types.ts +++ b/plugins/credential-sharing/src/types.ts @@ -11,7 +11,7 @@ import type { LogicSrcPrincipal, LogicSrcPolicyDecision } from "@logicsrc/accoun * - Adapters declare read/write capabilities before a plan is generated. */ -export type CredentialProviderId = "env" | "doppler" | "railway" | "github-secrets" | (string & {}); +export type CredentialProviderId = "env" | "doppler" | "railway" | "github-secrets" | "sh1pt" | "team" | (string & {}); export interface CredentialProviderCapabilities { /** Adapter can read raw secret values (enables value-level fingerprint diffs). */