Resolve open design questions: write-scoped grants, rollback, purge

Per-question resolution, per best practice rather than deferral:

- Push authorization: closed the least-privilege gap where read implied
  write. vault_grants gained can_write (default true, so nothing
  existing changes); enforced on push and grant (granting others is
  itself a mutation of vault membership, so it needs write too, not
  just read). 'keep grant --read-only' creates a read-only grant.

- Version history: not full history (conflates "undo a typo'd push"
  with "this leaked, stop retaining it" into one mechanism). Retains
  exactly one previous version as a rollback safety net
  (vaults.prev_ciphertext/prev_nonce + a vault_grants_previous mirror
  table so recipients can unwrap it), plus 'keep push --purge' for
  compromise-driven rotations that explicitly skips retention and
  wipes any existing previous version too.

- Per-secret-key granularity: resolved by NOT building it — documented
  the escape hatch (split into more vaults) instead of adding partial-
  decrypt complexity for a problem the existing primitive solves.

One more real bug caught during verification: the CLI's --previous flag
initially signed a path including its query string, but the server
verifies against req.originalUrl with the query stripped — a mismatch
that would have made every --previous request fail signature
verification. Fixed by splitting the signed path from the request URL
in signedFetch, signing only the former.

Verified end-to-end with three independent identities: read-only grant
correctly blocked from push and from granting others, write access and
read-only status both preserved correctly across a rotation, previous-
version pull working for a routine push and correctly unavailable to
every recipient after a purge push. Also re-verified against a fresh
Docker build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-12 19:50:08 +02:00
parent 67000b66ee
commit 001623c8e2
7 changed files with 284 additions and 101 deletions

View File

@@ -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) path. Fixed by verifying against `req.originalUrl` (path portion only)
instead, which stays consistent regardless of router nesting. instead, which stays consistent regardless of router nesting.
## Open questions ## Resolved design decisions
- **Version history**: keep only the latest payload per vault (what got These were open questions at initial implementation; resolved as follows.
built), or retain N previous versions for `keep pull --version=N`
rollback? Skipped for v1, revisit if a real rollback need shows up. ### Version history: one previous version, not full history
- **Push authorization**: does having a *read* grant (`vault_grants` row)
imply push rights too, or is push a separate capability? Went with Full N-version history (what Vault/AWS Secrets Manager do) conflates two
"read implies write" for v1 — a `can_write` flag on `vault_grants` is different needs that deserve different treatment: "I pushed a typo'd
the natural extension if that turns out to be wrong. value, let me roll back" wants a safety net; "this credential leaked"
- **Per-secret-key granularity**: v1 treats a vault as one opaque wants the old value to stop existing anywhere retrievable, which a kept
`.env`-shaped blob (matches the actual pain point this was built for: version directly undermines. Resolution: retain exactly the immediately
whole files get copied around, not individual keys). If a use case previous payload as a rollback safety net (`keep pull --previous`), plus
emerges for "grant access to just one value, not the whole bundle," an explicit hard-rotate mode (`keep push --purge`) that skips retention
that's a genuinely different data model (per-key rows, each separately entirely for compromise-driven rotations. Not full history — the two
wrapped) — don't half-build it speculatively. 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.

View File

@@ -102,20 +102,34 @@ npm run cli -- pull myapp/production > .env
```bash ```bash
# An existing recipient of a vault can grant a NEW recipient access, # 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 <new-recipient-id> keep grant myapp/production <new-recipient-id>
keep grant myapp/production <deploy-recipient-id> --read-only
# Revoking is an admin operation — pure metadata deletion, immediate: # Revoking is an admin operation — pure metadata deletion, immediate:
KEEP_ADMIN_PASSWORD=... keep revoke myapp/production <recipient-id> KEEP_ADMIN_PASSWORD=... keep revoke myapp/production <recipient-id>
# Revoke only stops FUTURE pulls. If this was a compromise response, # 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 push myapp/production --file .env.production
keep pull myapp/production --previous # "oops, undo that last push"
# See who's touched a vault and when: # See who's touched a vault and when:
keep log myapp/production 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 ## Docker
```bash ```bash
@@ -150,13 +164,17 @@ CLI-side: `KEEP_SERVER_URL` (default `http://localhost:3050`),
## Status ## Status
Implemented: identity, push/pull/grant/revoke/log, admin recipient Implemented: identity, push/pull/grant/revoke/log, read/write grant
management, Docker deploy. Verified end-to-end (two independent scoping, one-step rollback with an explicit purge mode for
identities, grant, pull, revoke persisting through a subsequent compromise-driven rotations, admin recipient management, Docker deploy.
rotation, malformed-auth rejection) against both a local server and the Verified end-to-end — two and three independent identities, read-only
built Docker image. See [IMPLEMENTATION.md](./IMPLEMENTATION.md) for the grants correctly blocked from push/grant, grants preserved across
full design and its still-open questions (version history, per-secret-key rotation, previous-version pull working and correctly wiped by
granularity). `--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 ## License

View File

@@ -11,7 +11,7 @@ import {
enc, enc,
} from '../../shared/crypto.js'; } from '../../shared/crypto.js';
export async function vaultPush(vaultKey: string, file: string): Promise<void> { export async function vaultPush(vaultKey: string, file: string, purge: boolean): Promise<void> {
await ready(); await ready();
const { identity, recipientId } = await requireIdentityAndRecipientId(); const { identity, recipientId } = await requireIdentityAndRecipientId();
@@ -21,8 +21,11 @@ export async function vaultPush(vaultKey: string, file: string): Promise<void> {
const existsRes = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/exists`)); const existsRes = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/exists`));
const wrapFor = new Map<string, string>(); // recipientId -> publicKey // recipientId -> { publicKey, canWrite } — canWrite is preserved across
wrapFor.set(recipientId, identity.id); // a rotation, not reset, so pushing an update never silently changes
// who else can write vs. only read.
const wrapFor = new Map<string, { publicKey: string; canWrite: boolean }>();
wrapFor.set(recipientId, { publicKey: identity.id, canWrite: true });
if (existsRes.exists) { if (existsRes.exists) {
const recipientsRes = await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/recipients`); 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<void> {
const body = await recipientsRes.json().catch(() => ({})); 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}'`); 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 }[]; const current = await recipientsRes.json() as { recipientId: string; publicKey: string; canWrite: boolean }[];
for (const r of current) if (r.publicKey) wrapFor.set(r.recipientId, r.publicKey); for (const r of current) if (r.publicKey) wrapFor.set(r.recipientId, { publicKey: r.publicKey, canWrite: r.canWrite });
} }
const symmetricKey = generateSymmetricKey(); const symmetricKey = generateSymmetricKey();
const { nonce, ciphertext } = secretboxEncrypt(symmetricKey, payload); const { nonce, ciphertext } = secretboxEncrypt(symmetricKey, payload);
const wrappedKeys: Record<string, string> = {}; const wrappedKeys: Record<string, { wrappedKey: string; canWrite: boolean }> = {};
for (const [rid, pubHex] of wrapFor) { for (const [rid, { publicKey, canWrite }] of wrapFor) {
wrappedKeys[rid] = enc.toBase64(Identity.sealFor(pubHex, symmetricKey)); wrappedKeys[rid] = { wrappedKey: enc.toBase64(Identity.sealFor(publicKey, symmetricKey)), canWrite };
} }
await expectOk(await signedFetch(identity, recipientId, 'POST', `/api/vaults/${encodeURIComponent(vaultKey)}/push`, { await expectOk(await signedFetch(identity, recipientId, 'POST', `/api/vaults/${encodeURIComponent(vaultKey)}/push`, {
ciphertext: enc.toBase64(ciphertext), ciphertext: enc.toBase64(ciphertext),
nonce: enc.toBase64(nonce), nonce: enc.toBase64(nonce),
wrappedKeys, 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<void> { export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean): Promise<void> {
await ready(); await ready();
const { identity, recipientId } = await requireIdentityAndRecipientId(); 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)); 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'); 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); const out = format === 'json' ? JSON.stringify(values, null, 2) : serializeEnv(values);
if (outFile) { if (outFile) {
fs.writeFileSync(outFile, out); 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 { } else {
process.stdout.write(out); process.stdout.write(out);
} }
} }
export async function vaultGrant(vaultKey: string, targetRecipientId: string): Promise<void> { export async function vaultGrant(vaultKey: string, targetRecipientId: string, readOnly: boolean): Promise<void> {
await ready(); await ready();
const { identity, recipientId } = await requireIdentityAndRecipientId(); 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`, { await expectOk(await signedFetch(identity, recipientId, 'POST', `/api/vaults/${encodeURIComponent(vaultKey)}/grant`, {
recipientId: targetRecipientId, recipientId: targetRecipientId,
wrappedKey, 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<void> { export async function vaultRevoke(vaultKey: string, targetRecipientId: string): Promise<void> {
await expectOk(await adminFetch('DELETE', `/api/admin/vaults/${encodeURIComponent(vaultKey)}/grants/${encodeURIComponent(targetRecipientId)}`)); await expectOk(await adminFetch('DELETE', `/api/admin/vaults/${encodeURIComponent(vaultKey)}/grants/${encodeURIComponent(targetRecipientId)}`));
console.log(`revoked ${targetRecipientId}'s access to '${vaultKey}'.`); 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<void> { export async function vaultLog(vaultKey: string): Promise<void> {

View File

@@ -8,6 +8,10 @@ function flag(args: string[], name: string, fallback?: string): string | undefin
return i !== -1 ? args[i + 1] : fallback; return i !== -1 ? args[i + 1] : fallback;
} }
function boolFlag(args: string[], name: string): boolean {
return args.includes(`--${name}`);
}
async function main(): Promise<void> { async function main(): Promise<void> {
const [, , cmd, sub, ...rest] = process.argv; const [, , cmd, sub, ...rest] = process.argv;
@@ -24,9 +28,9 @@ async function main(): Promise<void> {
if (sub === 'remove') return await recipientRemove(rest[0]); if (sub === 'remove') return await recipientRemove(rest[0]);
} }
if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!); 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')); 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]); if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]); if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
if (cmd === 'log') return await vaultLog(sub); if (cmd === 'log') return await vaultLog(sub);
@@ -45,9 +49,9 @@ function printUsage(): void {
keep identity show keep identity show
keep identity set-id <recipient-id> keep identity set-id <recipient-id>
keep push <vault> [--file .env] keep push <vault> [--file .env] [--purge] (--purge: don't retain the outgoing version as rollback)
keep pull <vault> [--format env|json] [--out <path>] keep pull <vault> [--format env|json] [--out <path>] [--previous]
keep grant <vault> <recipient-id> keep grant <vault> <recipient-id> [--read-only]
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD) keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
keep log <vault> keep log <vault>

View File

@@ -4,6 +4,16 @@ export function serverUrl(): string {
return process.env.KEEP_SERVER_URL ?? 'http://localhost:3050'; 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( export async function signedFetch(
identity: Identity, identity: Identity,
recipientId: string, recipientId: string,
@@ -11,12 +21,13 @@ export async function signedFetch(
path: string, path: string,
body?: unknown, body?: unknown,
): Promise<Response> { ): Promise<Response> {
const [signedPath, query] = splitQuery(path);
const bodyBytes = body !== undefined ? enc.fromUtf8(JSON.stringify(body)) : new Uint8Array(); const bodyBytes = body !== undefined ? enc.fromUtf8(JSON.stringify(body)) : new Uint8Array();
const timestamp = Math.floor(Date.now() / 1000); 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))); const signature = enc.toHex(identity.sign(enc.fromUtf8(toSign)));
return fetch(`${serverUrl()}${path}`, { return fetch(`${serverUrl()}${signedPath}${query}`, {
method, method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -13,6 +13,11 @@ db.exec(`
vault_key TEXT PRIMARY KEY, vault_key TEXT PRIMARY KEY,
ciphertext BLOB NOT NULL, ciphertext BLOB NOT NULL,
nonce 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_at INTEGER NOT NULL,
updated_by TEXT NOT NULL updated_by TEXT NOT NULL
); );
@@ -29,10 +34,26 @@ db.exec(`
vault_key TEXT NOT NULL, vault_key TEXT NOT NULL,
recipient_id TEXT NOT NULL, recipient_id TEXT NOT NULL,
wrapped_key TEXT NOT NULL, -- base64 crypto_box_seal(vault_symmetric_key, recipient_pubkey) 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, granted_at INTEGER NOT NULL,
PRIMARY KEY (vault_key, recipient_id) 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 ( CREATE TABLE IF NOT EXISTS access_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
vault_key TEXT NOT NULL, vault_key TEXT NOT NULL,
@@ -46,6 +67,8 @@ export interface VaultRow {
vault_key: string; vault_key: string;
ciphertext: Buffer; ciphertext: Buffer;
nonce: Buffer; nonce: Buffer;
prev_ciphertext: Buffer | null;
prev_nonce: Buffer | null;
updated_at: number; updated_at: number;
updated_by: string; updated_by: string;
} }
@@ -61,6 +84,7 @@ export interface GrantRow {
vault_key: string; vault_key: string;
recipient_id: string; recipient_id: string;
wrapped_key: string; wrapped_key: string;
can_write: number;
granted_at: 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; 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 { // `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<string, { wrappedKey: string; canWrite: boolean }>,
purge: boolean,
): void {
const tx = db.transaction(() => {
const existing = getVault(vaultKey);
const now = Math.floor(Date.now() / 1000);
if (purge) {
db.prepare( db.prepare(
`INSERT INTO vaults (vault_key, ciphertext, nonce, updated_at, updated_by) `INSERT INTO vaults (vault_key, ciphertext, nonce, prev_ciphertext, prev_nonce, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, NULL, NULL, ?, ?)
ON CONFLICT(vault_key) DO UPDATE SET ON CONFLICT(vault_key) DO UPDATE SET
ciphertext = excluded.ciphertext, nonce = excluded.nonce, ciphertext = excluded.ciphertext, nonce = excluded.nonce,
prev_ciphertext = NULL, prev_nonce = NULL,
updated_at = excluded.updated_at, updated_by = excluded.updated_by`, updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
).run(vaultKey, ciphertext, nonce, Math.floor(Date.now() / 1000), updatedBy); ).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 { export function getRecipient(recipientId: string): RecipientRow | undefined {
@@ -96,6 +177,7 @@ export function deleteRecipient(recipientId: string): void {
const tx = db.transaction(() => { const tx = db.transaction(() => {
db.prepare(`DELETE FROM recipients WHERE recipient_id = ?`).run(recipientId); 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 WHERE recipient_id = ?`).run(recipientId);
db.prepare(`DELETE FROM vault_grants_previous WHERE recipient_id = ?`).run(recipientId);
}); });
tx(); tx();
} }
@@ -107,6 +189,13 @@ export function hasGrant(vaultKey: string, recipientId: string): boolean {
return row != null; 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 { export function getGrant(vaultKey: string, recipientId: string): GrantRow | undefined {
return db.prepare( return db.prepare(
`SELECT * FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`, `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[]; 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( db.prepare(
`INSERT INTO vault_grants (vault_key, recipient_id, wrapped_key, granted_at) `INSERT INTO vault_grants (vault_key, recipient_id, wrapped_key, can_write, granted_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(vault_key, recipient_id) DO UPDATE SET wrapped_key = excluded.wrapped_key, granted_at = excluded.granted_at`, ON CONFLICT(vault_key, recipient_id) DO UPDATE SET
).run(vaultKey, recipientId, wrappedKeyB64, Math.floor(Date.now() / 1000)); 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));
// 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<string, string>): 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();
} }
export function deleteGrant(vaultKey: string, recipientId: string): void { export function deleteGrant(vaultKey: string, recipientId: string): void {

View File

@@ -1,12 +1,13 @@
import { Router } from 'express'; import { Router } from 'express';
import { import {
getVault, getVault,
upsertVault, pushVault,
hasGrant, hasGrant,
hasWriteGrant,
getGrant, getGrant,
getVaultGrantsPrevious,
listGrantsForVault, listGrantsForVault,
setGrant, setGrant,
replaceAllGrantsForVault,
logAccess, logAccess,
getAccessLog, getAccessLog,
listRecipients, 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 — // 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) => { router.get('/vaults/:key/recipients', (req: AuthedRequest, res) => {
const vaultKey = req.params.key; const vaultKey = req.params.key;
if (!hasGrant(vaultKey, req.recipientId!)) { 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])); const byId = new Map(listRecipients().map(r => [r.recipient_id, r]));
res.json(grants.map(g => { res.json(grants.map(g => {
const r = byId.get(g.recipient_id); 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) => { router.get('/vaults/:key/pull', (req: AuthedRequest, res) => {
const vaultKey = req.params.key; const vaultKey = req.params.key;
const recipientId = req.recipientId!; 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 grant = getGrant(vaultKey, recipientId);
const vault = getVault(vaultKey); 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 in one call — the client has already generated a fresh symmetric
// key and re-wrapped it for every currently-granted recipient (fetched // key and re-wrapped it for every currently-granted recipient (fetched
// via GET /vaults/:key/recipients first). Existing vaults require the // via GET /vaults/:key/recipients first). Existing vaults require the
// pusher to already be a grantee (read implies write, v1 decision from // pusher to hold a *write* grantread no longer implies write (see
// IMPLEMENTATION.md); a brand-new vault key may be created by any // IMPLEMENTATION.md's resolved design decisions). A brand-new vault key
// registered recipient, who becomes its first grantee. // 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) => { router.post('/vaults/:key/push', (req: AuthedRequest, res) => {
const vaultKey = req.params.key; const vaultKey = req.params.key;
const recipientId = req.recipientId!; const recipientId = req.recipientId!;
const existing = getVault(vaultKey); const existing = getVault(vaultKey);
if (existing && !hasGrant(vaultKey, recipientId)) { if (existing && !hasWriteGrant(vaultKey, recipientId)) {
res.status(403).json({ error: 'no access to this vault' }); res.status(403).json({ error: 'no write access to this vault' });
return; return;
} }
const body = req.body as { ciphertext?: string; nonce?: string; wrappedKeys?: Record<string, string> }; const body = req.body as {
ciphertext?: string;
nonce?: string;
wrappedKeys?: Record<string, { wrappedKey: string; canWrite?: boolean }>;
purge?: boolean;
};
if (!body.ciphertext || !body.nonce || !body.wrappedKeys || Object.keys(body.wrappedKeys).length === 0) { 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' }); res.status(400).json({ error: 'ciphertext, nonce, and at least one wrapped key are required' });
return; return;
} }
upsertVault(vaultKey, Buffer.from(body.ciphertext, 'base64'), Buffer.from(body.nonce, 'base64'), recipientId); const wrappedByRecipientId = new Map(
replaceAllGrantsForVault(vaultKey, new Map(Object.entries(body.wrappedKeys))); 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'); logAccess(vaultKey, recipientId, 'push');
res.json({ ok: true }); res.json({ ok: true });
}); });
// Adds ONE new recipient to an existing vault using the CURRENT // Adds ONE new recipient to an existing vault using the CURRENT
// (unrotated) symmetric key — the granter must already hold a grant // (unrotated) symmetric key — the granter must already hold a *write*
// (meaning they can unwrap the current key locally) and supplies a fresh // grant (granting is a mutation of the vault's membership, not merely a
// seal of that same key for the new recipient's public key. Does not // read) and supplies a fresh seal of that same key for the new
// touch the ciphertext or any other recipient's wrapped key. // recipient's public key. Does not touch the ciphertext or any other
// recipient's wrapped key.
router.post('/vaults/:key/grant', (req: AuthedRequest, res) => { router.post('/vaults/:key/grant', (req: AuthedRequest, res) => {
const vaultKey = req.params.key; const vaultKey = req.params.key;
const granterId = req.recipientId!; const granterId = req.recipientId!;
if (!hasGrant(vaultKey, granterId)) { if (!hasWriteGrant(vaultKey, granterId)) {
res.status(403).json({ error: 'no access to this vault' }); res.status(403).json({ error: 'no write access to this vault' });
return; 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) { if (!body.recipientId || !body.wrappedKey) {
res.status(400).json({ error: 'recipientId and wrappedKey are required' }); res.status(400).json({ error: 'recipientId and wrappedKey are required' });
return; return;
} }
setGrant(vaultKey, body.recipientId, body.wrappedKey); setGrant(vaultKey, body.recipientId, body.wrappedKey, body.canWrite !== false);
logAccess(vaultKey, granterId, 'grant'); logAccess(vaultKey, granterId, 'grant');
res.json({ ok: true }); res.json({ ok: true });
}); });