diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index a46cd96..2a66243 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -234,18 +234,43 @@ which would have silently mismatched a client that signs the full request path. Fixed by verifying against `req.originalUrl` (path portion only) instead, which stays consistent regardless of router nesting. -## Open questions +## Resolved design decisions -- **Version history**: keep only the latest payload per vault (what got - built), or retain N previous versions for `keep pull --version=N` - rollback? Skipped for v1, revisit if a real rollback need shows up. -- **Push authorization**: does having a *read* grant (`vault_grants` row) - imply push rights too, or is push a separate capability? Went with - "read implies write" for v1 — a `can_write` flag on `vault_grants` is - the natural extension if that turns out to be wrong. -- **Per-secret-key granularity**: v1 treats a vault as one opaque - `.env`-shaped blob (matches the actual pain point this was built for: - whole files get copied around, not individual keys). If a use case - emerges for "grant access to just one value, not the whole bundle," - that's a genuinely different data model (per-key rows, each separately - wrapped) — don't half-build it speculatively. +These were open questions at initial implementation; resolved as follows. + +### Version history: one previous version, not full history + +Full N-version history (what Vault/AWS Secrets Manager do) conflates two +different needs that deserve different treatment: "I pushed a typo'd +value, let me roll back" wants a safety net; "this credential leaked" +wants the old value to stop existing anywhere retrievable, which a kept +version directly undermines. Resolution: retain exactly the immediately +previous payload as a rollback safety net (`keep pull --previous`), plus +an explicit hard-rotate mode (`keep push --purge`) that skips retention +entirely for compromise-driven rotations. Not full history — the two +real use cases are served without the complexity (or the confused +security story) of an unbounded log of past secret values. + +### Push authorization: read no longer implies write + +The original "read implies write" v1 simplification was a real +least-privilege gap: an automated deploy identity that only ever needs +to *pull* a vault also had the power to overwrite it — a compromised +read-only consumer could push garbage values or silently exclude other +recipients from the next rotation. Fixed rather than deferred, since the +cost of closing it is low and the cost of retrofitting it later (once +grants without write intent already exist) is higher: `vault_grants` +gained a `can_write` column (`1` by default, so nothing existing +changes behavior), enforced in the push handler, with `keep grant +--read-only` to create a read-only grant explicitly. + +### Per-secret-key granularity: resolved by not building it + +The right answer for "different recipients need different subsets of +secrets" is splitting into more vaults (`myapp/db`, `myapp/api`, each +with their own recipient list), not partial-access ACLs within one +blob. A vault staying the permission boundary keeps the mental model +simple and auditable; per-key wrapping would solve the same problem +with materially more complexity for no real gain over just making more +vaults. No code change — this documents the escape hatch instead of +leaving a dangling TODO. diff --git a/README.md b/README.md index e0e163b..19a5066 100644 --- a/README.md +++ b/README.md @@ -102,20 +102,34 @@ npm run cli -- pull myapp/production > .env ```bash # An existing recipient of a vault can grant a NEW recipient access, -# without needing admin rights or re-encrypting the payload: +# without needing admin rights or re-encrypting the payload. Read access +# by default — add --read-only for a recipient that should only ever +# pull, never push (e.g. an automated deploy identity): keep grant myapp/production +keep grant myapp/production --read-only # Revoking is an admin operation — pure metadata deletion, immediate: KEEP_ADMIN_PASSWORD=... keep revoke myapp/production # Revoke only stops FUTURE pulls. If this was a compromise response, -# also rotate the actual values: +# rotate AND purge — --purge skips retaining the outgoing version as a +# rollback, since the whole point of rotating for a leak is that the old +# value stops being retrievable by anyone: +keep push myapp/production --file .env.production --purge + +# A routine push (no compromise involved) keeps the outgoing version as +# a one-step rollback safety net: keep push myapp/production --file .env.production +keep pull myapp/production --previous # "oops, undo that last push" # See who's touched a vault and when: keep log myapp/production ``` +Pushing to an existing vault requires a *write* grant — a read-only +recipient can pull but can't push or grant others access. A brand-new +vault's first push is always read-write for the pusher. + ## Docker ```bash @@ -150,13 +164,17 @@ CLI-side: `KEEP_SERVER_URL` (default `http://localhost:3050`), ## Status -Implemented: identity, push/pull/grant/revoke/log, admin recipient -management, Docker deploy. Verified end-to-end (two independent -identities, grant, pull, revoke persisting through a subsequent -rotation, malformed-auth rejection) against both a local server and the -built Docker image. See [IMPLEMENTATION.md](./IMPLEMENTATION.md) for the -full design and its still-open questions (version history, per-secret-key -granularity). +Implemented: identity, push/pull/grant/revoke/log, read/write grant +scoping, one-step rollback with an explicit purge mode for +compromise-driven rotations, admin recipient management, Docker deploy. +Verified end-to-end — two and three independent identities, read-only +grants correctly blocked from push/grant, grants preserved across +rotation, previous-version pull working and correctly wiped by +`--purge` for every recipient, malformed-auth rejection — against both +a local server and the built Docker image. See +[IMPLEMENTATION.md](./IMPLEMENTATION.md) for the full design and its +resolved decisions (per-secret-key granularity deliberately not built — +use multiple vaults instead). ## License diff --git a/src/cli/commands/vault.ts b/src/cli/commands/vault.ts index eace0d9..3ed6514 100644 --- a/src/cli/commands/vault.ts +++ b/src/cli/commands/vault.ts @@ -11,7 +11,7 @@ import { enc, } from '../../shared/crypto.js'; -export async function vaultPush(vaultKey: string, file: string): Promise { +export async function vaultPush(vaultKey: string, file: string, purge: boolean): Promise { await ready(); const { identity, recipientId } = await requireIdentityAndRecipientId(); @@ -21,8 +21,11 @@ export async function vaultPush(vaultKey: string, file: string): Promise { const existsRes = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/exists`)); - const wrapFor = new Map(); // recipientId -> publicKey - wrapFor.set(recipientId, identity.id); + // recipientId -> { publicKey, canWrite } — canWrite is preserved across + // a rotation, not reset, so pushing an update never silently changes + // who else can write vs. only read. + const wrapFor = new Map(); + wrapFor.set(recipientId, { publicKey: identity.id, canWrite: true }); if (existsRes.exists) { const recipientsRes = await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/recipients`); @@ -30,32 +33,34 @@ export async function vaultPush(vaultKey: string, file: string): Promise { const body = await recipientsRes.json().catch(() => ({})); throw new Error(body.error ?? `no access to vault '${vaultKey}' — ask an existing grantee to run 'keep grant ${vaultKey} ${recipientId}'`); } - const current = await recipientsRes.json() as { recipientId: string; publicKey: string }[]; - for (const r of current) if (r.publicKey) wrapFor.set(r.recipientId, r.publicKey); + const current = await recipientsRes.json() as { recipientId: string; publicKey: string; canWrite: boolean }[]; + for (const r of current) if (r.publicKey) wrapFor.set(r.recipientId, { publicKey: r.publicKey, canWrite: r.canWrite }); } const symmetricKey = generateSymmetricKey(); const { nonce, ciphertext } = secretboxEncrypt(symmetricKey, payload); - const wrappedKeys: Record = {}; - for (const [rid, pubHex] of wrapFor) { - wrappedKeys[rid] = enc.toBase64(Identity.sealFor(pubHex, symmetricKey)); + const wrappedKeys: Record = {}; + for (const [rid, { publicKey, canWrite }] of wrapFor) { + wrappedKeys[rid] = { wrappedKey: enc.toBase64(Identity.sealFor(publicKey, symmetricKey)), canWrite }; } await expectOk(await signedFetch(identity, recipientId, 'POST', `/api/vaults/${encodeURIComponent(vaultKey)}/push`, { ciphertext: enc.toBase64(ciphertext), nonce: enc.toBase64(nonce), wrappedKeys, + purge, })); - console.log(`pushed '${vaultKey}' — ${Object.keys(values).length} key${Object.keys(values).length === 1 ? '' : 's'}, wrapped for ${wrapFor.size} recipient${wrapFor.size === 1 ? '' : 's'}.`); + console.log(`pushed '${vaultKey}' — ${Object.keys(values).length} key${Object.keys(values).length === 1 ? '' : 's'}, wrapped for ${wrapFor.size} recipient${wrapFor.size === 1 ? '' : 's'}${purge ? ' (purged previous version)' : ''}.`); } -export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile?: string): Promise { +export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean): Promise { await ready(); const { identity, recipientId } = await requireIdentityAndRecipientId(); - const data = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/pull`)); + const query = previous ? '?previous=1' : ''; + const data = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/pull${query}`)); const symmetricKey = identity.openSealed(enc.fromBase64(data.wrappedKey)); if (!symmetricKey) throw new Error('failed to unwrap this vault\'s key — wrong identity, or the grant is stale'); @@ -66,13 +71,13 @@ export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFil const out = format === 'json' ? JSON.stringify(values, null, 2) : serializeEnv(values); if (outFile) { fs.writeFileSync(outFile, out); - console.error(`wrote ${Object.keys(values).length} key(s) to ${outFile}`); + console.error(`wrote ${Object.keys(values).length} key(s) to ${outFile}${previous ? ' (previous version)' : ''}`); } else { process.stdout.write(out); } } -export async function vaultGrant(vaultKey: string, targetRecipientId: string): Promise { +export async function vaultGrant(vaultKey: string, targetRecipientId: string, readOnly: boolean): Promise { await ready(); const { identity, recipientId } = await requireIdentityAndRecipientId(); @@ -88,15 +93,16 @@ export async function vaultGrant(vaultKey: string, targetRecipientId: string): P await expectOk(await signedFetch(identity, recipientId, 'POST', `/api/vaults/${encodeURIComponent(vaultKey)}/grant`, { recipientId: targetRecipientId, wrappedKey, + canWrite: !readOnly, })); - console.log(`granted '${vaultKey}' to ${targetRecipientId}.`); + console.log(`granted ${readOnly ? 'read-only ' : ''}'${vaultKey}' access to ${targetRecipientId}.`); } export async function vaultRevoke(vaultKey: string, targetRecipientId: string): Promise { await expectOk(await adminFetch('DELETE', `/api/admin/vaults/${encodeURIComponent(vaultKey)}/grants/${encodeURIComponent(targetRecipientId)}`)); console.log(`revoked ${targetRecipientId}'s access to '${vaultKey}'.`); - console.log(`Note: this stops future pulls only. If this was a compromise response, also run 'keep push ${vaultKey}' with rotated values.`); + console.log(`Note: this stops future pulls only. If this was a compromise response, also run 'keep push ${vaultKey} --purge' with rotated values.`); } export async function vaultLog(vaultKey: string): Promise { diff --git a/src/cli/index.ts b/src/cli/index.ts index 169ad33..cb3a1d2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,6 +8,10 @@ function flag(args: string[], name: string, fallback?: string): string | undefin return i !== -1 ? args[i + 1] : fallback; } +function boolFlag(args: string[], name: string): boolean { + return args.includes(`--${name}`); +} + async function main(): Promise { const [, , cmd, sub, ...rest] = process.argv; @@ -24,9 +28,9 @@ async function main(): Promise { if (sub === 'remove') return await recipientRemove(rest[0]); } - if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!); - if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out')); - if (cmd === 'grant') return await vaultGrant(sub, rest[0]); + if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!, boolFlag(rest, 'purge')); + if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous')); + if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only')); if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]); if (cmd === 'log') return await vaultLog(sub); @@ -45,9 +49,9 @@ function printUsage(): void { keep identity show keep identity set-id - keep push [--file .env] - keep pull [--format env|json] [--out ] - keep grant + keep push [--file .env] [--purge] (--purge: don't retain the outgoing version as rollback) + keep pull [--format env|json] [--out ] [--previous] + keep grant [--read-only] keep revoke (admin — needs KEEP_ADMIN_PASSWORD) keep log diff --git a/src/cli/signedFetch.ts b/src/cli/signedFetch.ts index f719d10..2be9b4c 100644 --- a/src/cli/signedFetch.ts +++ b/src/cli/signedFetch.ts @@ -4,6 +4,16 @@ export function serverUrl(): string { return process.env.KEEP_SERVER_URL ?? 'http://localhost:3050'; } +function splitQuery(path: string): [string, string] { + const i = path.indexOf('?'); + return i === -1 ? [path, ''] : [path.slice(0, i), path.slice(i)]; +} + +// `path` must NOT include a query string — the server verifies the +// signature against req.originalUrl with the query string stripped +// (see server/auth.ts), so signing one here would never match. Pass +// query params via `path` only for the actual request URL, kept +// separate from what gets signed. export async function signedFetch( identity: Identity, recipientId: string, @@ -11,12 +21,13 @@ export async function signedFetch( path: string, body?: unknown, ): Promise { + const [signedPath, query] = splitQuery(path); const bodyBytes = body !== undefined ? enc.fromUtf8(JSON.stringify(body)) : new Uint8Array(); const timestamp = Math.floor(Date.now() / 1000); - const toSign = signingString(method, path, timestamp, bodyBytes); + const toSign = signingString(method, signedPath, timestamp, bodyBytes); const signature = enc.toHex(identity.sign(enc.fromUtf8(toSign))); - return fetch(`${serverUrl()}${path}`, { + return fetch(`${serverUrl()}${signedPath}${query}`, { method, headers: { 'Content-Type': 'application/json', diff --git a/src/server/db.ts b/src/server/db.ts index b5ad7a8..73d91e6 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -10,11 +10,16 @@ db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS vaults ( - vault_key TEXT PRIMARY KEY, - ciphertext BLOB NOT NULL, - nonce BLOB NOT NULL, - updated_at INTEGER NOT NULL, - updated_by TEXT NOT NULL + vault_key TEXT PRIMARY KEY, + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL, + -- Exactly one previous version, retained as a rollback safety net for + -- an accidental bad push — not a full history. NULL if there is no + -- previous version, or if the last push explicitly purged it. + prev_ciphertext BLOB, + prev_nonce BLOB, + updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS recipients ( @@ -29,10 +34,26 @@ db.exec(` vault_key TEXT NOT NULL, recipient_id TEXT NOT NULL, wrapped_key TEXT NOT NULL, -- base64 crypto_box_seal(vault_symmetric_key, recipient_pubkey) + -- Read implied write in the original design; resolved to a real + -- capability distinction — a recipient that only needs to pull a + -- vault (e.g. an automated deploy identity) shouldn't also be able + -- to overwrite it. Defaults to 1 so nothing existing changes. + can_write INTEGER NOT NULL DEFAULT 1, granted_at INTEGER NOT NULL, PRIMARY KEY (vault_key, recipient_id) ); + -- Mirrors vault_grants, but for the one retained previous version — + -- wrapped keys as they were at push time, so a recipient who had + -- access to the previous version can still unwrap it via --previous + -- even if their current grant has since changed. + CREATE TABLE IF NOT EXISTS vault_grants_previous ( + vault_key TEXT NOT NULL, + recipient_id TEXT NOT NULL, + wrapped_key TEXT NOT NULL, + PRIMARY KEY (vault_key, recipient_id) + ); + CREATE TABLE IF NOT EXISTS access_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, vault_key TEXT NOT NULL, @@ -46,6 +67,8 @@ export interface VaultRow { vault_key: string; ciphertext: Buffer; nonce: Buffer; + prev_ciphertext: Buffer | null; + prev_nonce: Buffer | null; updated_at: number; updated_by: string; } @@ -61,6 +84,7 @@ export interface GrantRow { vault_key: string; recipient_id: string; wrapped_key: string; + can_write: number; granted_at: number; } @@ -68,14 +92,71 @@ export function getVault(vaultKey: string): VaultRow | undefined { return db.prepare(`SELECT * FROM vaults WHERE vault_key = ?`).get(vaultKey) as VaultRow | undefined; } -export function upsertVault(vaultKey: string, ciphertext: Buffer, nonce: Buffer, updatedBy: string): void { - db.prepare( - `INSERT INTO vaults (vault_key, ciphertext, nonce, updated_at, updated_by) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(vault_key) DO UPDATE SET - ciphertext = excluded.ciphertext, nonce = excluded.nonce, - updated_at = excluded.updated_at, updated_by = excluded.updated_by`, - ).run(vaultKey, ciphertext, nonce, Math.floor(Date.now() / 1000), updatedBy); +// `purge`: skip retaining the outgoing version as "previous" — used for +// compromise-driven rotations, where the whole point is that the old +// value stops being retrievable by anyone. A purge also clears out +// whatever previous version already existed, since it would defeat the +// purpose to still leave the version-before-that recoverable. +export function pushVault( + vaultKey: string, + ciphertext: Buffer, + nonce: Buffer, + updatedBy: string, + wrappedByRecipientId: Map, + purge: boolean, +): void { + const tx = db.transaction(() => { + const existing = getVault(vaultKey); + const now = Math.floor(Date.now() / 1000); + + if (purge) { + db.prepare( + `INSERT INTO vaults (vault_key, ciphertext, nonce, prev_ciphertext, prev_nonce, updated_at, updated_by) + VALUES (?, ?, ?, NULL, NULL, ?, ?) + ON CONFLICT(vault_key) DO UPDATE SET + ciphertext = excluded.ciphertext, nonce = excluded.nonce, + prev_ciphertext = NULL, prev_nonce = NULL, + updated_at = excluded.updated_at, updated_by = excluded.updated_by`, + ).run(vaultKey, ciphertext, nonce, now, updatedBy); + db.prepare(`DELETE FROM vault_grants_previous WHERE vault_key = ?`).run(vaultKey); + } else { + db.prepare( + `INSERT INTO vaults (vault_key, ciphertext, nonce, prev_ciphertext, prev_nonce, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(vault_key) DO UPDATE SET + ciphertext = excluded.ciphertext, nonce = excluded.nonce, + prev_ciphertext = excluded.prev_ciphertext, prev_nonce = excluded.prev_nonce, + updated_at = excluded.updated_at, updated_by = excluded.updated_by`, + ).run(vaultKey, ciphertext, nonce, existing?.ciphertext ?? null, existing?.nonce ?? null, now, updatedBy); + + // Move the current grants (as they stood before this push) into + // vault_grants_previous, so someone who had access to the + // outgoing version can still pull it via --previous. + db.prepare(`DELETE FROM vault_grants_previous WHERE vault_key = ?`).run(vaultKey); + if (existing) { + db.prepare( + `INSERT INTO vault_grants_previous (vault_key, recipient_id, wrapped_key) + SELECT vault_key, recipient_id, wrapped_key FROM vault_grants WHERE vault_key = ?`, + ).run(vaultKey); + } + } + + db.prepare(`DELETE FROM vault_grants WHERE vault_key = ?`).run(vaultKey); + const insert = db.prepare( + `INSERT INTO vault_grants (vault_key, recipient_id, wrapped_key, can_write, granted_at) VALUES (?, ?, ?, ?, ?)`, + ); + for (const [recipientId, { wrappedKey, canWrite }] of wrappedByRecipientId) { + insert.run(vaultKey, recipientId, wrappedKey, canWrite ? 1 : 0, now); + } + }); + tx(); +} + +export function getVaultGrantsPrevious(vaultKey: string, recipientId: string): string | undefined { + const row = db.prepare( + `SELECT wrapped_key FROM vault_grants_previous WHERE vault_key = ? AND recipient_id = ?`, + ).get(vaultKey, recipientId) as { wrapped_key: string } | undefined; + return row?.wrapped_key; } export function getRecipient(recipientId: string): RecipientRow | undefined { @@ -96,6 +177,7 @@ export function deleteRecipient(recipientId: string): void { const tx = db.transaction(() => { db.prepare(`DELETE FROM recipients WHERE recipient_id = ?`).run(recipientId); db.prepare(`DELETE FROM vault_grants WHERE recipient_id = ?`).run(recipientId); + db.prepare(`DELETE FROM vault_grants_previous WHERE recipient_id = ?`).run(recipientId); }); tx(); } @@ -107,6 +189,13 @@ export function hasGrant(vaultKey: string, recipientId: string): boolean { return row != null; } +export function hasWriteGrant(vaultKey: string, recipientId: string): boolean { + const row = db.prepare( + `SELECT can_write FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`, + ).get(vaultKey, recipientId) as { can_write: number } | undefined; + return row != null && row.can_write === 1; +} + export function getGrant(vaultKey: string, recipientId: string): GrantRow | undefined { return db.prepare( `SELECT * FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`, @@ -117,29 +206,13 @@ export function listGrantsForVault(vaultKey: string): GrantRow[] { return db.prepare(`SELECT * FROM vault_grants WHERE vault_key = ?`).all(vaultKey) as GrantRow[]; } -export function setGrant(vaultKey: string, recipientId: string, wrappedKeyB64: string): void { +export function setGrant(vaultKey: string, recipientId: string, wrappedKeyB64: string, canWrite: boolean): void { db.prepare( - `INSERT INTO vault_grants (vault_key, recipient_id, wrapped_key, granted_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(vault_key, recipient_id) DO UPDATE SET wrapped_key = excluded.wrapped_key, granted_at = excluded.granted_at`, - ).run(vaultKey, recipientId, wrappedKeyB64, Math.floor(Date.now() / 1000)); -} - -// Used by `push` (rotation): replaces every wrapped-key row for a vault -// in one transaction, so a concurrent grant/revoke can't interleave with -// a partial rewrap and leave the vault in a mixed old/new-key state. -export function replaceAllGrantsForVault(vaultKey: string, wrappedByRecipientId: Map): void { - const tx = db.transaction(() => { - db.prepare(`DELETE FROM vault_grants WHERE vault_key = ?`).run(vaultKey); - const insert = db.prepare( - `INSERT INTO vault_grants (vault_key, recipient_id, wrapped_key, granted_at) VALUES (?, ?, ?, ?)`, - ); - const now = Math.floor(Date.now() / 1000); - for (const [recipientId, wrappedKeyB64] of wrappedByRecipientId) { - insert.run(vaultKey, recipientId, wrappedKeyB64, now); - } - }); - tx(); + `INSERT INTO vault_grants (vault_key, recipient_id, wrapped_key, can_write, granted_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(vault_key, recipient_id) DO UPDATE SET + wrapped_key = excluded.wrapped_key, can_write = excluded.can_write, granted_at = excluded.granted_at`, + ).run(vaultKey, recipientId, wrappedKeyB64, canWrite ? 1 : 0, Math.floor(Date.now() / 1000)); } export function deleteGrant(vaultKey: string, recipientId: string): void { diff --git a/src/server/routes.ts b/src/server/routes.ts index 9522b7b..f2c000e 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,12 +1,13 @@ import { Router } from 'express'; import { getVault, - upsertVault, + pushVault, hasGrant, + hasWriteGrant, getGrant, + getVaultGrantsPrevious, listGrantsForVault, setGrant, - replaceAllGrantsForVault, logAccess, getAccessLog, listRecipients, @@ -33,7 +34,10 @@ router.get('/vaults/:key/exists', (req, res) => { }); // Only current grantees of a vault can see who else has access to it — -// needed by `keep push` to know who to re-wrap the rotated key for. +// needed by `keep push` to know who to re-wrap the rotated key for, and +// to preserve each recipient's can_write flag across a rotation (a push +// shouldn't silently upgrade a read-only grantee to read-write, or vice +// versa). router.get('/vaults/:key/recipients', (req: AuthedRequest, res) => { const vaultKey = req.params.key; if (!hasGrant(vaultKey, req.recipientId!)) { @@ -44,13 +48,34 @@ router.get('/vaults/:key/recipients', (req: AuthedRequest, res) => { const byId = new Map(listRecipients().map(r => [r.recipient_id, r])); res.json(grants.map(g => { const r = byId.get(g.recipient_id); - return { recipientId: g.recipient_id, label: r?.label ?? null, publicKey: r?.public_key ?? null }; + return { recipientId: g.recipient_id, label: r?.label ?? null, publicKey: r?.public_key ?? null, canWrite: g.can_write === 1 }; })); }); router.get('/vaults/:key/pull', (req: AuthedRequest, res) => { const vaultKey = req.params.key; const recipientId = req.recipientId!; + const wantPrevious = req.query.previous === '1' || req.query.previous === 'true'; + + if (wantPrevious) { + const vault = getVault(vaultKey); + const wrappedKey = getVaultGrantsPrevious(vaultKey, recipientId); + if (!vault || !vault.prev_ciphertext || !vault.prev_nonce || !wrappedKey) { + logAccess(vaultKey, recipientId, 'pull'); + res.status(404).json({ error: 'no previous version, or no access to it' }); + return; + } + logAccess(vaultKey, recipientId, 'pull'); + res.json({ + ciphertext: vault.prev_ciphertext.toString('base64'), + nonce: vault.prev_nonce.toString('base64'), + wrappedKey, + updatedAt: null, + updatedBy: null, + }); + return; + } + const grant = getGrant(vaultKey, recipientId); const vault = getVault(vaultKey); @@ -74,53 +99,74 @@ router.get('/vaults/:key/pull', (req: AuthedRequest, res) => { // key in one call — the client has already generated a fresh symmetric // key and re-wrapped it for every currently-granted recipient (fetched // via GET /vaults/:key/recipients first). Existing vaults require the -// pusher to already be a grantee (read implies write, v1 decision from -// IMPLEMENTATION.md); a brand-new vault key may be created by any -// registered recipient, who becomes its first grantee. +// pusher to hold a *write* grant — read no longer implies write (see +// IMPLEMENTATION.md's resolved design decisions). A brand-new vault key +// may be created by any registered recipient, who becomes its first +// (read-write) grantee. +// +// `purge: true` skips retaining the outgoing version as "previous" — +// for compromise-driven rotations, where the old value should stop +// being retrievable by anyone, not survive one more pull as a rollback. router.post('/vaults/:key/push', (req: AuthedRequest, res) => { const vaultKey = req.params.key; const recipientId = req.recipientId!; const existing = getVault(vaultKey); - if (existing && !hasGrant(vaultKey, recipientId)) { - res.status(403).json({ error: 'no access to this vault' }); + if (existing && !hasWriteGrant(vaultKey, recipientId)) { + res.status(403).json({ error: 'no write access to this vault' }); return; } - const body = req.body as { ciphertext?: string; nonce?: string; wrappedKeys?: Record }; + const body = req.body as { + ciphertext?: string; + nonce?: string; + wrappedKeys?: Record; + purge?: boolean; + }; if (!body.ciphertext || !body.nonce || !body.wrappedKeys || Object.keys(body.wrappedKeys).length === 0) { res.status(400).json({ error: 'ciphertext, nonce, and at least one wrapped key are required' }); return; } - upsertVault(vaultKey, Buffer.from(body.ciphertext, 'base64'), Buffer.from(body.nonce, 'base64'), recipientId); - replaceAllGrantsForVault(vaultKey, new Map(Object.entries(body.wrappedKeys))); + const wrappedByRecipientId = new Map( + Object.entries(body.wrappedKeys).map(([rid, w]) => [rid, { wrappedKey: w.wrappedKey, canWrite: w.canWrite !== false }]), + ); + + pushVault( + vaultKey, + Buffer.from(body.ciphertext, 'base64'), + Buffer.from(body.nonce, 'base64'), + recipientId, + wrappedByRecipientId, + body.purge === true, + ); logAccess(vaultKey, recipientId, 'push'); res.json({ ok: true }); }); // Adds ONE new recipient to an existing vault using the CURRENT -// (unrotated) symmetric key — the granter must already hold a grant -// (meaning they can unwrap the current key locally) and supplies a fresh -// seal of that same key for the new recipient's public key. Does not -// touch the ciphertext or any other recipient's wrapped key. +// (unrotated) symmetric key — the granter must already hold a *write* +// grant (granting is a mutation of the vault's membership, not merely a +// read) and supplies a fresh seal of that same key for the new +// recipient's public key. Does not touch the ciphertext or any other +// recipient's wrapped key. router.post('/vaults/:key/grant', (req: AuthedRequest, res) => { const vaultKey = req.params.key; const granterId = req.recipientId!; - if (!hasGrant(vaultKey, granterId)) { - res.status(403).json({ error: 'no access to this vault' }); + if (!hasWriteGrant(vaultKey, granterId)) { + res.status(403).json({ error: 'no write access to this vault' }); return; } - const body = req.body as { recipientId?: string; wrappedKey?: string }; + const body = req.body as { recipientId?: string; wrappedKey?: string; canWrite?: boolean }; if (!body.recipientId || !body.wrappedKey) { res.status(400).json({ error: 'recipientId and wrappedKey are required' }); return; } - setGrant(vaultKey, body.recipientId, body.wrappedKey); + setGrant(vaultKey, body.recipientId, body.wrappedKey, body.canWrite !== false); logAccess(vaultKey, granterId, 'grant'); res.json({ ok: true }); });