diff --git a/apps/api/.env.example b/apps/api/.env.example index ca83ff24..6bae15f8 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -11,6 +11,11 @@ STATE_SIGNING_SECRET=replace_me_32_byte_random_string # Encrypts integration secrets at rest (AWS connect external IDs, Slack/Linear # tokens). Required for any integration that stores a secret — without it those # routes 500. Must base64-decode to exactly 32 bytes: openssl rand -base64 32 +# To rotate, use a keyring instead of a single key: "2:,1:". +# The first entry encrypts new values; later entries only decrypt. A single key +# is version 1. After every service runs with the keyring, re-encrypt stored +# values with `pnpm --filter @superlog/db credentials:rewrap` (try --dry-run +# first), then drop the old entry. AGENT_SECRETS_KEY= # OAuth provider credentials for Better Auth (set per-environment). GOOGLE_CLIENT_ID= diff --git a/apps/api/src/cloudflare.ts b/apps/api/src/cloudflare.ts index b80ed506..e6ad84b8 100644 --- a/apps/api/src/cloudflare.ts +++ b/apps/api/src/cloudflare.ts @@ -17,6 +17,7 @@ // exactly like the Slack connector's /slack/oauth/callback. import { + LEGACY_INTEGRATION_SECRET_KEY_VERSION, db, decryptIntegrationSecret, encryptIntegrationSecret, @@ -179,7 +180,7 @@ async function ensureIngestKey( const ingestKey = decryptIntegrationSecret({ ciphertext: existing.ingestKeyCiphertext, nonce: existing.ingestKeyNonce, - keyVersion: existing.ingestKeyKeyVersion ?? 1, + keyVersion: existing.ingestKeyKeyVersion ?? LEGACY_INTEGRATION_SECRET_KEY_VERSION, }); return { ingestKey, apiKeyId: existing.apiKeyId, minted: false }; } @@ -235,7 +236,7 @@ function decryptRefreshToken(row: CloudflareInstallationRow): string | null { ? decryptIntegrationSecret({ ciphertext: row.refreshTokenCiphertext, nonce: row.refreshTokenNonce, - keyVersion: row.refreshTokenKeyVersion ?? 1, + keyVersion: row.refreshTokenKeyVersion ?? LEGACY_INTEGRATION_SECRET_KEY_VERSION, }) : null; } diff --git a/apps/api/src/gcp/gcp.test.ts b/apps/api/src/gcp/gcp.test.ts index c8ba31e0..29d1357b 100644 --- a/apps/api/src/gcp/gcp.test.ts +++ b/apps/api/src/gcp/gcp.test.ts @@ -47,7 +47,14 @@ test("GCP config requires the secrets key before starting OAuth", () => { } as NodeJS.ProcessEnv; assert.equal(gcpConfigFromEnv(env), null); - assert.ok(gcpConfigFromEnv({ ...env, AGENT_SECRETS_KEY: "encryption-key" })); + assert.equal(gcpConfigFromEnv({ ...env, AGENT_SECRETS_KEY: "encryption-key" }), null); + assert.ok(gcpConfigFromEnv({ ...env, AGENT_SECRETS_KEY: randomBytes(32).toString("base64") })); + assert.ok( + gcpConfigFromEnv({ + ...env, + AGENT_SECRETS_KEY: `2:${randomBytes(32).toString("base64")},1:${randomBytes(32).toString("base64")}`, + }), + ); }); before(async () => { diff --git a/apps/api/src/gcp/interfaces.ts b/apps/api/src/gcp/interfaces.ts index d6608c51..b466d7c5 100644 --- a/apps/api/src/gcp/interfaces.ts +++ b/apps/api/src/gcp/interfaces.ts @@ -1,4 +1,4 @@ -import { db, schema } from "@superlog/db"; +import { db, isValidIntegrationSecretKeyring, schema } from "@superlog/db"; import { eq } from "drizzle-orm"; import type { Context, Hono } from "hono"; import { HTTPException } from "hono/http-exception"; @@ -74,7 +74,7 @@ export function gcpConfigFromEnv(env: NodeJS.ProcessEnv = process.env): GcpConne !pushServiceAccountEmail || !pushEndpoint || !pushAudience || - !env.AGENT_SECRETS_KEY + !isValidIntegrationSecretKeyring(env.AGENT_SECRETS_KEY) ) { return null; } diff --git a/apps/api/src/railway.ts b/apps/api/src/railway.ts index 09a3d333..c8dca867 100644 --- a/apps/api/src/railway.ts +++ b/apps/api/src/railway.ts @@ -20,6 +20,7 @@ // connectors' callbacks. import { + LEGACY_INTEGRATION_SECRET_KEY_VERSION, db, decryptIntegrationSecret, encryptIntegrationSecret, @@ -129,7 +130,7 @@ async function ensureIngestKey( const ingestKey = decryptIntegrationSecret({ ciphertext: existing.ingestKeyCiphertext, nonce: existing.ingestKeyNonce, - keyVersion: existing.ingestKeyKeyVersion ?? 1, + keyVersion: existing.ingestKeyKeyVersion ?? LEGACY_INTEGRATION_SECRET_KEY_VERSION, }); return { ingestKey, apiKeyId: existing.apiKeyId, minted: false }; } diff --git a/apps/api/src/render.ts b/apps/api/src/render.ts index 610425a6..f9d81090 100644 --- a/apps/api/src/render.ts +++ b/apps/api/src/render.ts @@ -21,9 +21,11 @@ // reads scoped to the workspace the user picked. import { + LEGACY_INTEGRATION_SECRET_KEY_VERSION, db, decryptIntegrationSecret, encryptIntegrationSecret, + integrationSecretEncryptionConfigured, mintApiKey, schema, } from "@superlog/db"; @@ -143,7 +145,7 @@ async function ensureIngestKey( const ingestKey = decryptIntegrationSecret({ ciphertext: existing.ingestKeyCiphertext, nonce: existing.ingestKeyNonce, - keyVersion: existing.ingestKeyKeyVersion ?? 1, + keyVersion: existing.ingestKeyKeyVersion ?? LEGACY_INTEGRATION_SECRET_KEY_VERSION, }); return { ingestKey, apiKeyId: existing.apiKeyId, minted: false }; } @@ -342,7 +344,7 @@ export function mountRenderAuthed( // Storing the pasted key requires the at-rest encryption key; without it the // connect flow could never complete, so the routes self-disable (and // system-capabilities hides the connector tile). - const secretsConfigured = () => !!process.env.AGENT_SECRETS_KEY; + const secretsConfigured = () => integrationSecretEncryptionConfigured(); app.get("/api/projects/:projectId/render/installation", async (c) => { const ctx = await requireProjectAccess(c, c.req.param("projectId")); diff --git a/apps/api/src/system-capabilities.test.ts b/apps/api/src/system-capabilities.test.ts index 856b0a12..1521c7b3 100644 --- a/apps/api/src/system-capabilities.test.ts +++ b/apps/api/src/system-capabilities.test.ts @@ -2,6 +2,8 @@ import { strict as assert } from "node:assert"; import { test } from "node:test"; import { buildSystemCapabilities } from "./system-capabilities.js"; +const SECRETS_KEY = Buffer.alloc(32, 7).toString("base64"); + test("system capabilities default to the open-core community edition", () => { assert.deepEqual(buildSystemCapabilities({}), { edition: "community", @@ -20,7 +22,7 @@ test("system capabilities default to the open-core community edition", () => { test("renderConnect only needs AGENT_SECRETS_KEY (API-key connect, no OAuth client)", () => { assert.equal(buildSystemCapabilities({}).renderConnect, false); - assert.equal(buildSystemCapabilities({ AGENT_SECRETS_KEY: "k" }).renderConnect, true); + assert.equal(buildSystemCapabilities({ AGENT_SECRETS_KEY: SECRETS_KEY }).renderConnect, true); }); test("gcpConnect requires OAuth and integration-owned Pub/Sub configuration", () => { @@ -39,7 +41,7 @@ test("gcpConnect requires OAuth and integration-owned Pub/Sub configuration", () GCP_PUBSUB_PUSH_ENDPOINT: "https://intake.example.com/gcp/pubsub", GCP_PUBSUB_PUSH_AUDIENCE: "https://intake.example.com/gcp/pubsub", STATE_SIGNING_SECRET: "state-secret", - AGENT_SECRETS_KEY: "encryption-key", + AGENT_SECRETS_KEY: SECRETS_KEY, }; assert.equal(buildSystemCapabilities(complete).gcpConnect, true); assert.equal( @@ -73,7 +75,7 @@ test("railwayConnect flips on only when client creds + both platform secrets are RAILWAY_CLIENT_ID: "id", RAILWAY_CLIENT_SECRET: "secret", STATE_SIGNING_SECRET: "s", - AGENT_SECRETS_KEY: "k", + AGENT_SECRETS_KEY: SECRETS_KEY, }).railwayConnect, true, ); @@ -101,7 +103,7 @@ test("cloudflareConnect flips on only when all connector vars (incl. STATE_SIGNI CLOUDFLARE_CLIENT_SECRET: "secret", CLOUDFLARE_OTLP_INTAKE_URL: "https://intake.example.com", STATE_SIGNING_SECRET: "state-secret", - AGENT_SECRETS_KEY: "k", + AGENT_SECRETS_KEY: SECRETS_KEY, }).cloudflareConnect, true, ); @@ -145,7 +147,7 @@ test("sentryConnect requires the public Sentry App and platform secrets", () => SENTRY_CLIENT_SECRET: "secret", SENTRY_APP_SLUG: "superlog", STATE_SIGNING_SECRET: "state-secret", - AGENT_SECRETS_KEY: "encryption-key", + AGENT_SECRETS_KEY: SECRETS_KEY, }).sentryConnect, true, ); @@ -174,8 +176,40 @@ test("vercelConnect flips on only when all connector vars (incl. STATE_SIGNING_S VERCEL_INTEGRATION_SLUG: "superlog", VERCEL_OTLP_INTAKE_URL: "https://intake.example.com", STATE_SIGNING_SECRET: "state-secret", - AGENT_SECRETS_KEY: "k", + AGENT_SECRETS_KEY: SECRETS_KEY, }).vercelConnect, true, ); }); + +test("capabilities read the key from the env object they are given, not process.env", () => { + const original = process.env.AGENT_SECRETS_KEY; + process.env.AGENT_SECRETS_KEY = SECRETS_KEY; + try { + assert.equal(buildSystemCapabilities({}).renderConnect, false); + } finally { + if (original === undefined) Reflect.deleteProperty(process.env, "AGENT_SECRETS_KEY"); + else process.env.AGENT_SECRETS_KEY = original; + } +}); + +test("connectors accept an AGENT_SECRETS_KEY keyring and stay hidden for a malformed key", () => { + const keyring = `2:${Buffer.alloc(32, 2).toString("base64")},1:${SECRETS_KEY}`; + assert.equal(buildSystemCapabilities({ AGENT_SECRETS_KEY: keyring }).renderConnect, true); + assert.equal(buildSystemCapabilities({ AGENT_SECRETS_KEY: "not-a-key" }).renderConnect, false); + assert.equal( + buildSystemCapabilities({ AGENT_SECRETS_KEY: `2:${SECRETS_KEY},2:${SECRETS_KEY}` }) + .renderConnect, + false, + ); + assert.equal( + buildSystemCapabilities({ + SENTRY_CLIENT_ID: "id", + SENTRY_CLIENT_SECRET: "secret", + SENTRY_APP_SLUG: "superlog", + STATE_SIGNING_SECRET: "state-secret", + AGENT_SECRETS_KEY: "encryption-key", + }).sentryConnect, + false, + ); +}); diff --git a/apps/api/src/system-capabilities.ts b/apps/api/src/system-capabilities.ts index 16d1323a..3d541389 100644 --- a/apps/api/src/system-capabilities.ts +++ b/apps/api/src/system-capabilities.ts @@ -1,3 +1,5 @@ +import { isValidIntegrationSecretKeyring } from "@superlog/db/integration-secrets"; + export type SuperlogEdition = "community" | "cloud" | "private"; export type BillingProvider = "none" | "stripe"; @@ -68,12 +70,14 @@ export function buildSystemCapabilities(env: CapabilityEnv = process.env): Syste // All three OAuth connectors also need AGENT_SECRETS_KEY — provisioning // encrypts the granted tokens/ingest keys at rest and throws without it, so // advertising the connector would offer a connect that can never complete. + // The key is validated (single key or keyring), not just checked for presence. + const secretsKeyConfigured = isValidIntegrationSecretKeyring(env.AGENT_SECRETS_KEY); const cloudflareConnect = !!( env.CLOUDFLARE_CLIENT_ID && env.CLOUDFLARE_CLIENT_SECRET && env.CLOUDFLARE_OTLP_INTAKE_URL && env.STATE_SIGNING_SECRET && - env.AGENT_SECRETS_KEY + secretsKeyConfigured ); // Mirrors vercelConfigFromEnv's required-vars check plus the install-url // route's STATE_SIGNING_SECRET requirement, same as the Cloudflare gate. @@ -83,19 +87,19 @@ export function buildSystemCapabilities(env: CapabilityEnv = process.env): Syste env.VERCEL_INTEGRATION_SLUG && env.VERCEL_OTLP_INTAKE_URL && env.STATE_SIGNING_SECRET && - env.AGENT_SECRETS_KEY + secretsKeyConfigured ); // Mirrors railwayConfigFromEnv's required-vars check plus STATE_SIGNING_SECRET. const railwayConnect = !!( env.RAILWAY_CLIENT_ID && env.RAILWAY_CLIENT_SECRET && env.STATE_SIGNING_SECRET && - env.AGENT_SECRETS_KEY + secretsKeyConfigured ); // Render connect is an API-key paste, not OAuth — no client id/secret and no // signed state. Storing the pasted key still needs the at-rest encryption // key, so that's the whole gate. - const renderConnect = !!env.AGENT_SECRETS_KEY; + const renderConnect = secretsKeyConfigured; const gcpPushAudience = env.GCP_PUBSUB_PUSH_AUDIENCE ?? env.GCP_PUBSUB_PUSH_ENDPOINT; const gcpConnect = !!( env.GCP_OAUTH_CLIENT_ID && @@ -107,14 +111,14 @@ export function buildSystemCapabilities(env: CapabilityEnv = process.env): Syste env.GCP_PUBSUB_PUSH_ENDPOINT && gcpPushAudience && env.STATE_SIGNING_SECRET && - env.AGENT_SECRETS_KEY + secretsKeyConfigured ); const sentryConnect = !!( env.SENTRY_CLIENT_ID && env.SENTRY_CLIENT_SECRET && env.SENTRY_APP_SLUG && env.STATE_SIGNING_SECRET && - env.AGENT_SECRETS_KEY + secretsKeyConfigured ); return { diff --git a/apps/api/src/vercel.ts b/apps/api/src/vercel.ts index 6b2c74c1..60452214 100644 --- a/apps/api/src/vercel.ts +++ b/apps/api/src/vercel.ts @@ -17,6 +17,7 @@ // and Slack connectors' callbacks. import { + LEGACY_INTEGRATION_SECRET_KEY_VERSION, db, decryptIntegrationSecret, encryptIntegrationSecret, @@ -122,7 +123,7 @@ async function ensureIngestKey( const ingestKey = decryptIntegrationSecret({ ciphertext: existing.ingestKeyCiphertext, nonce: existing.ingestKeyNonce, - keyVersion: existing.ingestKeyKeyVersion ?? 1, + keyVersion: existing.ingestKeyKeyVersion ?? LEGACY_INTEGRATION_SECRET_KEY_VERSION, }); return { ingestKey, apiKeyId: existing.apiKeyId, minted: false }; } @@ -317,10 +318,7 @@ async function provisionInstallation(input: { // revoked independently (otherwise both installs share one live key). const sameConfiguration = existing?.configurationId === input.token.installationId ? existing : null; - const { ingestKey, apiKeyId, minted } = await ensureIngestKey( - input.projectId, - sameConfiguration, - ); + const { ingestKey, apiKeyId, minted } = await ensureIngestKey(input.projectId, sameConfiguration); // Undo everything this run created that isn't safely persisted to an install // row: the new remote drains (minus any id the prior same-configuration row @@ -436,10 +434,7 @@ async function provisionInstallation(input: { installedByUserId: input.userId, }) .onConflictDoUpdate({ - target: [ - schema.vercelInstallations.projectId, - schema.vercelInstallations.configurationId, - ], + target: [schema.vercelInstallations.projectId, schema.vercelInstallations.configurationId], set: { teamId: input.token.teamId, teamName, diff --git a/apps/worker/src/jobs/cloudflare-reconcile.ts b/apps/worker/src/jobs/cloudflare-reconcile.ts index b4a7a10b..dfcfd7bd 100644 --- a/apps/worker/src/jobs/cloudflare-reconcile.ts +++ b/apps/worker/src/jobs/cloudflare-reconcile.ts @@ -17,7 +17,12 @@ import { reconcileWorkerWiring, refreshAccessToken, } from "@superlog/cloudflare"; -import { decryptIntegrationSecret, encryptIntegrationSecret, schema } from "@superlog/db"; +import { + LEGACY_INTEGRATION_SECRET_KEY_VERSION, + decryptIntegrationSecret, + encryptIntegrationSecret, + schema, +} from "@superlog/db"; import { and, eq, isNull, sql } from "drizzle-orm"; import { lockInstallation } from "../cloudflare/lock.js"; import { @@ -129,7 +134,7 @@ function createStore( refreshToken = decryptIntegrationSecret({ ciphertext: cur.refreshTokenCiphertext, nonce: cur.refreshTokenNonce, - keyVersion: cur.refreshTokenKeyVersion ?? 1, + keyVersion: cur.refreshTokenKeyVersion ?? LEGACY_INTEGRATION_SECRET_KEY_VERSION, }); } catch { refreshToken = null; diff --git a/apps/worker/src/jobs/cloudflare-refresh.ts b/apps/worker/src/jobs/cloudflare-refresh.ts index 9c11d79a..7740719c 100644 --- a/apps/worker/src/jobs/cloudflare-refresh.ts +++ b/apps/worker/src/jobs/cloudflare-refresh.ts @@ -9,7 +9,12 @@ // configured, since without it there's nothing to refresh with. import { cloudflareClientFromEnv } from "@superlog/cloudflare"; -import { decryptIntegrationSecret, encryptIntegrationSecret, schema } from "@superlog/db"; +import { + LEGACY_INTEGRATION_SECRET_KEY_VERSION, + decryptIntegrationSecret, + encryptIntegrationSecret, + schema, +} from "@superlog/db"; import { and, eq, isNull } from "drizzle-orm"; import { lockInstallation } from "../cloudflare/lock.js"; import { @@ -65,7 +70,7 @@ function createStore(db: JobDeps["db"]): CloudflareRefresherStore { refreshToken = decryptIntegrationSecret({ ciphertext: cur.refreshTokenCiphertext, nonce: cur.refreshTokenNonce, - keyVersion: cur.refreshTokenKeyVersion ?? 1, + keyVersion: cur.refreshTokenKeyVersion ?? LEGACY_INTEGRATION_SECRET_KEY_VERSION, }); } catch (err) { // A row whose refresh token can't be decrypted (corrupted diff --git a/apps/worker/src/jobs/gcp-metrics-pull.test.ts b/apps/worker/src/jobs/gcp-metrics-pull.test.ts index 84d61375..18edeb88 100644 --- a/apps/worker/src/jobs/gcp-metrics-pull.test.ts +++ b/apps/worker/src/jobs/gcp-metrics-pull.test.ts @@ -21,6 +21,13 @@ test("the GCP metrics job stays disabled without the integration-secrets key", ( assert.equal(job.create({} as JobDeps), null); }); +test("the GCP metrics job stays disabled when the integration-secrets key is malformed", () => { + process.env.GCP_INTEGRATION_PROJECT_ID = "superlog-observability"; + process.env.AGENT_SECRETS_KEY = "not-a-key"; + + assert.equal(job.create({} as JobDeps), null); +}); + test("metric intake acknowledges permanent rejections but retries transient failures", () => { for (const status of [200, 400, 402, 413]) { assert.equal(isMetricIntakeAcknowledged(status), true); diff --git a/apps/worker/src/jobs/gcp-metrics-pull.ts b/apps/worker/src/jobs/gcp-metrics-pull.ts index a3f4b2e2..636ac4a7 100644 --- a/apps/worker/src/jobs/gcp-metrics-pull.ts +++ b/apps/worker/src/jobs/gcp-metrics-pull.ts @@ -1,4 +1,9 @@ -import { decryptIntegrationSecret, schema } from "@superlog/db"; +import { + LEGACY_INTEGRATION_SECRET_KEY_VERSION, + decryptIntegrationSecret, + integrationSecretEncryptionConfigured, + schema, +} from "@superlog/db"; import { createAwsFederatedGcpClient } from "@superlog/gcp-auth"; import { and, eq, isNull } from "drizzle-orm"; import { GoogleAuth } from "google-auth-library"; @@ -55,7 +60,7 @@ function createStore(db: JobDeps["db"]): GcpMetricsPullerStore { ingestKey = decryptIntegrationSecret({ ciphertext: row.ingestKeyCiphertext, nonce: row.ingestKeyNonce, - keyVersion: row.ingestKeyKeyVersion ?? 1, + keyVersion: row.ingestKeyKeyVersion ?? LEGACY_INTEGRATION_SECRET_KEY_VERSION, }); } } catch (error) { @@ -168,8 +173,8 @@ export const job: JobDefinition = { log.info({}, "GCP_INTEGRATION_PROJECT_ID not set — GCP metrics pull disabled"); return null; } - if (!process.env.AGENT_SECRETS_KEY) { - log.info({}, "AGENT_SECRETS_KEY not set — GCP metrics pull disabled"); + if (!integrationSecretEncryptionConfigured()) { + log.info({}, "AGENT_SECRETS_KEY not set or invalid — GCP metrics pull disabled"); return null; } const scopes = ["https://www.googleapis.com/auth/cloud-platform"]; diff --git a/apps/worker/src/jobs/railway-pull.ts b/apps/worker/src/jobs/railway-pull.ts index 1085b658..76fcd4e2 100644 --- a/apps/worker/src/jobs/railway-pull.ts +++ b/apps/worker/src/jobs/railway-pull.ts @@ -9,7 +9,12 @@ // Railway OAuth client is configured — without it tokens can't be refreshed, // so pulling would strand within the hour anyway. -import { decryptIntegrationSecret, encryptIntegrationSecret, schema } from "@superlog/db"; +import { + LEGACY_INTEGRATION_SECRET_KEY_VERSION, + decryptIntegrationSecret, + encryptIntegrationSecret, + schema, +} from "@superlog/db"; import { railwayConfigFromEnv } from "@superlog/railway"; import { eq, isNull } from "drizzle-orm"; import type { JobDefinition, JobDeps } from "../jobs.js"; @@ -53,7 +58,7 @@ function createStore(db: JobDeps["db"]): RailwayPullerStore { ? decryptIntegrationSecret({ ciphertext: row.refreshTokenCiphertext, nonce: row.refreshTokenNonce, - keyVersion: row.refreshTokenKeyVersion ?? 1, + keyVersion: row.refreshTokenKeyVersion ?? LEGACY_INTEGRATION_SECRET_KEY_VERSION, }) : null, tokenExpiresAt: row.tokenExpiresAt, @@ -62,7 +67,7 @@ function createStore(db: JobDeps["db"]): RailwayPullerStore { ? decryptIntegrationSecret({ ciphertext: row.ingestKeyCiphertext, nonce: row.ingestKeyNonce, - keyVersion: row.ingestKeyKeyVersion ?? 1, + keyVersion: row.ingestKeyKeyVersion ?? LEGACY_INTEGRATION_SECRET_KEY_VERSION, }) : null, grantedProjects: row.grantedProjects ?? [], diff --git a/apps/worker/src/jobs/render-pull.ts b/apps/worker/src/jobs/render-pull.ts index 2864c52b..0912edf3 100644 --- a/apps/worker/src/jobs/render-pull.ts +++ b/apps/worker/src/jobs/render-pull.ts @@ -9,7 +9,12 @@ // AGENT_SECRETS_KEY is set — without it the stored Render API keys can't be // decrypted, so there is nothing the puller could do. -import { decryptIntegrationSecret, schema } from "@superlog/db"; +import { + LEGACY_INTEGRATION_SECRET_KEY_VERSION, + decryptIntegrationSecret, + integrationSecretEncryptionConfigured, + schema, +} from "@superlog/db"; import { eq, isNull } from "drizzle-orm"; import type { JobDefinition, JobDeps } from "../jobs.js"; import { logger } from "../logger.js"; @@ -54,7 +59,7 @@ function createStore(db: JobDeps["db"]): RenderPullerStore { ? decryptIntegrationSecret({ ciphertext: row.ingestKeyCiphertext, nonce: row.ingestKeyNonce, - keyVersion: row.ingestKeyKeyVersion ?? 1, + keyVersion: row.ingestKeyKeyVersion ?? LEGACY_INTEGRATION_SECRET_KEY_VERSION, }) : null, services: row.services ?? [], @@ -118,8 +123,8 @@ export const job: JobDefinition = { name: "render-pull", schedule: "* * * * *", create: (deps) => { - if (!process.env.AGENT_SECRETS_KEY) { - log.info({}, "AGENT_SECRETS_KEY not set — render pull job disabled"); + if (!integrationSecretEncryptionConfigured()) { + log.info({}, "AGENT_SECRETS_KEY not set or invalid — render pull job disabled"); return null; } const store = createStore(deps.db); diff --git a/packages/db/package.json b/packages/db/package.json index 9592d477..484f9cac 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -8,7 +8,8 @@ ".": "./src/index.ts", "./agent-pr-retry-domain": "./src/agent-pr-retry-domain.ts", "./schema": "./src/schema.ts", - "./keys": "./src/keys.ts" + "./keys": "./src/keys.ts", + "./integration-secrets": "./src/integration-secrets.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -19,6 +20,7 @@ "db:studio": "drizzle-kit studio", "db:seed": "tsx src/seed.ts", "credentials:migrate": "tsx src/credential-migration-cli.ts", + "credentials:rewrap": "tsx src/integration-secret-rewrap-cli.ts", "test:migrations": "tsx --test src/migrations.test.ts", "test": "tsx --test src/*.test.ts" }, diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index eea62e5a..216f85bf 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -383,8 +383,17 @@ export { encryptIntegrationSecret, decryptIntegrationSecret, integrationSecretEncryptionConfigured, + currentIntegrationSecretKeyVersion, + isValidIntegrationSecretKeyring, + parseIntegrationSecretKeyring, + LEGACY_INTEGRATION_SECRET_KEY_VERSION, type IntegrationSecretCipher, + type IntegrationSecretKeyring, } from "./integration-secrets.js"; +export { + ENCRYPTED_INTEGRATION_SECRET_COLUMNS, + type EncryptedSecretColumn, +} from "./integration-secret-columns.js"; export { exchangeLinearCode, findActiveLinearInstallationByWebhookId, diff --git a/packages/db/src/integration-secret-columns.ts b/packages/db/src/integration-secret-columns.ts new file mode 100644 index 00000000..0b9b25ff --- /dev/null +++ b/packages/db/src/integration-secret-columns.ts @@ -0,0 +1,195 @@ +import { type PgColumn, type PgTable, getTableConfig } from "drizzle-orm/pg-core"; +import * as schema from "./schema.js"; + +/** + * How a column stores the AES-GCM nonce. Most tables use `bytea`; the Sentry + * installation table stores it as base64 text. + */ +export type NonceEncoding = "bytea" | "base64-text"; + +/** + * One envelope encrypted with `AGENT_SECRETS_KEY`: a ciphertext column, its + * nonce, and the key version it was written with, plus the row's primary key. + */ +export type EncryptedSecretColumn = { + table: PgTable; + id: PgColumn; + ciphertext: PgColumn; + nonce: PgColumn; + keyVersion: PgColumn; + nonceEncoding: NonceEncoding; +}; + +function envelope( + table: T, + columns: (table: T) => { ciphertext: PgColumn; nonce: PgColumn; keyVersion: PgColumn }, + nonceEncoding: NonceEncoding = "bytea", +): EncryptedSecretColumn { + return { table, id: table.id, ...columns(table), nonceEncoding }; +} + +/** `.`, e.g. `linear_installations.access_token_key_version`. */ +export function encryptedSecretColumnLabel(column: EncryptedSecretColumn): string { + return `${getTableConfig(column.table).name}.${column.keyVersion.name}`; +} + +/** + * Every column encrypted with `AGENT_SECRETS_KEY`. Key rotation re-encrypts + * exactly these columns, so a new encrypted column must be added here; the + * rewrap test fails for any `*_key_version` column in the schema that is + * missing from this list. + */ +export const ENCRYPTED_INTEGRATION_SECRET_COLUMNS: readonly EncryptedSecretColumn[] = [ + envelope(schema.projectMcpServers, (t) => ({ + ciphertext: t.authCiphertext, + nonce: t.authNonce, + keyVersion: t.authKeyVersion, + })), + envelope(schema.projectMcpOauthAttempts, (t) => ({ + ciphertext: t.payloadCiphertext, + nonce: t.payloadNonce, + keyVersion: t.payloadKeyVersion, + })), + envelope(schema.webhookEndpoints, (t) => ({ + ciphertext: t.secretCiphertext, + nonce: t.secretNonce, + keyVersion: t.secretKeyVersion, + })), + envelope(schema.slackInstallations, (t) => ({ + ciphertext: t.botAccessTokenCiphertext, + nonce: t.botAccessTokenNonce, + keyVersion: t.botAccessTokenKeyVersion, + })), + envelope(schema.linearInstallations, (t) => ({ + ciphertext: t.accessTokenCiphertext, + nonce: t.accessTokenNonce, + keyVersion: t.accessTokenKeyVersion, + })), + envelope(schema.linearInstallations, (t) => ({ + ciphertext: t.refreshTokenCiphertext, + nonce: t.refreshTokenNonce, + keyVersion: t.refreshTokenKeyVersion, + })), + envelope(schema.linearInstallations, (t) => ({ + ciphertext: t.webhookSecretCiphertext, + nonce: t.webhookSecretNonce, + keyVersion: t.webhookSecretKeyVersion, + })), + envelope(schema.notionInstallations, (t) => ({ + ciphertext: t.accessTokenCiphertext, + nonce: t.accessTokenNonce, + keyVersion: t.accessTokenKeyVersion, + })), + envelope( + schema.sentryInstallations, + (t) => ({ + ciphertext: t.accessTokenCiphertext, + nonce: t.accessTokenNonce, + keyVersion: t.accessTokenKeyVersion, + }), + "base64-text", + ), + envelope( + schema.sentryInstallations, + (t) => ({ + ciphertext: t.refreshTokenCiphertext, + nonce: t.refreshTokenNonce, + keyVersion: t.refreshTokenKeyVersion, + }), + "base64-text", + ), + envelope( + schema.sentryInstallations, + (t) => ({ + ciphertext: t.relayTokenCiphertext, + nonce: t.relayTokenNonce, + keyVersion: t.relayTokenKeyVersion, + }), + "base64-text", + ), + envelope(schema.sentryAuthorizationSessions, (t) => ({ + ciphertext: t.accessTokenCiphertext, + nonce: t.accessTokenNonce, + keyVersion: t.accessTokenKeyVersion, + })), + envelope(schema.sentryAuthorizationSessions, (t) => ({ + ciphertext: t.refreshTokenCiphertext, + nonce: t.refreshTokenNonce, + keyVersion: t.refreshTokenKeyVersion, + })), + envelope(schema.orgIntegrationSecrets, (t) => ({ + ciphertext: t.ciphertext, + nonce: t.nonce, + keyVersion: t.keyVersion, + })), + envelope(schema.cloudConnections, (t) => ({ + ciphertext: t.externalIdCiphertext, + nonce: t.externalIdNonce, + keyVersion: t.externalIdKeyVersion, + })), + envelope(schema.cloudStreamKeys, (t) => ({ + ciphertext: t.keyCiphertext, + nonce: t.keyNonce, + keyVersion: t.keyKeyVersion, + })), + envelope(schema.gcpAuthorizationSessions, (t) => ({ + ciphertext: t.accessTokenCiphertext, + nonce: t.accessTokenNonce, + keyVersion: t.accessTokenKeyVersion, + })), + envelope(schema.gcpConnections, (t) => ({ + ciphertext: t.ingestKeyCiphertext, + nonce: t.ingestKeyNonce, + keyVersion: t.ingestKeyKeyVersion, + })), + envelope(schema.cloudflareInstallations, (t) => ({ + ciphertext: t.accessTokenCiphertext, + nonce: t.accessTokenNonce, + keyVersion: t.accessTokenKeyVersion, + })), + envelope(schema.cloudflareInstallations, (t) => ({ + ciphertext: t.refreshTokenCiphertext, + nonce: t.refreshTokenNonce, + keyVersion: t.refreshTokenKeyVersion, + })), + envelope(schema.cloudflareInstallations, (t) => ({ + ciphertext: t.ingestKeyCiphertext, + nonce: t.ingestKeyNonce, + keyVersion: t.ingestKeyKeyVersion, + })), + envelope(schema.vercelInstallations, (t) => ({ + ciphertext: t.accessTokenCiphertext, + nonce: t.accessTokenNonce, + keyVersion: t.accessTokenKeyVersion, + })), + envelope(schema.vercelInstallations, (t) => ({ + ciphertext: t.ingestKeyCiphertext, + nonce: t.ingestKeyNonce, + keyVersion: t.ingestKeyKeyVersion, + })), + envelope(schema.railwayInstallations, (t) => ({ + ciphertext: t.accessTokenCiphertext, + nonce: t.accessTokenNonce, + keyVersion: t.accessTokenKeyVersion, + })), + envelope(schema.railwayInstallations, (t) => ({ + ciphertext: t.refreshTokenCiphertext, + nonce: t.refreshTokenNonce, + keyVersion: t.refreshTokenKeyVersion, + })), + envelope(schema.railwayInstallations, (t) => ({ + ciphertext: t.ingestKeyCiphertext, + nonce: t.ingestKeyNonce, + keyVersion: t.ingestKeyKeyVersion, + })), + envelope(schema.renderInstallations, (t) => ({ + ciphertext: t.renderApiKeyCiphertext, + nonce: t.renderApiKeyNonce, + keyVersion: t.renderApiKeyKeyVersion, + })), + envelope(schema.renderInstallations, (t) => ({ + ciphertext: t.ingestKeyCiphertext, + nonce: t.ingestKeyNonce, + keyVersion: t.ingestKeyKeyVersion, + })), +]; diff --git a/packages/db/src/integration-secret-rewrap-cli.ts b/packages/db/src/integration-secret-rewrap-cli.ts new file mode 100644 index 00000000..0f980477 --- /dev/null +++ b/packages/db/src/integration-secret-rewrap-cli.ts @@ -0,0 +1,55 @@ +import { closeDb, db } from "./client.js"; +import { + parseRewrapArgs, + rewrapIntegrationSecrets, + rewrapSucceeded, +} from "./integration-secret-rewrap.js"; +import { integrationSecretKeyring } from "./integration-secrets.js"; + +// Usage: pnpm --filter @superlog/db credentials:rewrap [--dry-run] [--batch-size=N] +// +// Prints one JSON line per encrypted column and a final summary line so the +// output stays readable in line-oriented log viewers. Never prints plaintext or +// key material. +const MAX_FAILURE_LINES = 50; + +async function main(): Promise { + const args = parseRewrapArgs(process.argv.slice(2)); + const keyring = integrationSecretKeyring(); + const report = await rewrapIntegrationSecrets(db, { ...args, keyring }); + + for (const column of report.columns) console.log(JSON.stringify(column)); + for (const failure of report.failures.slice(0, MAX_FAILURE_LINES)) { + console.log(JSON.stringify({ failure })); + } + if (report.failures.length > MAX_FAILURE_LINES) { + console.log(JSON.stringify({ omittedFailures: report.failures.length - MAX_FAILURE_LINES })); + } + console.log( + JSON.stringify({ + summary: { + dryRun: report.dryRun, + currentVersion: report.currentVersion, + configuredVersions: report.configuredVersions, + ...report.totals, + }, + }), + ); + + if (!rewrapSucceeded(report)) { + console.error( + report.totals.failed > 0 + ? `${report.totals.failed} encrypted value(s) could not be re-encrypted` + : `${report.totals.remaining} encrypted value(s) are still on an old key version; re-run the rewrap`, + ); + process.exitCode = 1; + } +} + +main() + .then(() => closeDb()) + .catch(async (error) => { + console.error(error instanceof Error ? error.message : error); + await closeDb(); + process.exitCode = 1; + }); diff --git a/packages/db/src/integration-secret-rewrap.test.ts b/packages/db/src/integration-secret-rewrap.test.ts new file mode 100644 index 00000000..45ffe6ef --- /dev/null +++ b/packages/db/src/integration-secret-rewrap.test.ts @@ -0,0 +1,415 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import path from "node:path"; +import { afterEach, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { eq } from "drizzle-orm"; +import { PgTable, getTableConfig } from "drizzle-orm/pg-core"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import type { DB } from "./client.js"; +import { hydrateLinearInstallation, linearCredentialFields } from "./credential-storage.js"; +import { + ENCRYPTED_INTEGRATION_SECRET_COLUMNS, + encryptedSecretColumnLabel, +} from "./integration-secret-columns.js"; +import { decryptIntegrationSecret, encryptIntegrationSecret } from "./integration-secrets.js"; +import * as schema from "./schema.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS = path.resolve(HERE, "../migrations"); +process.env.DATABASE_URL ??= "postgres://localhost:5434/superlog"; + +const { parseRewrapArgs, rewrapIntegrationSecrets, rewrapEncryptedSecretRow } = await import( + "./integration-secret-rewrap.js" +); + +const originalKey = process.env.AGENT_SECRETS_KEY; +const originalMode = process.env.CREDENTIAL_STORAGE_MODE; + +afterEach(() => { + restoreEnv("AGENT_SECRETS_KEY", originalKey); + restoreEnv("CREDENTIAL_STORAGE_MODE", originalMode); +}); + +function key(): string { + return randomBytes(32).toString("base64"); +} + +async function freshDb(): Promise<{ client: PGlite; db: DB }> { + const client = new PGlite(); + const db = drizzle(client, { schema }) as unknown as DB; + await migrate(db as never, { migrationsFolder: MIGRATIONS }); + return { client, db }; +} + +async function seedProject(db: DB) { + const [org] = await db.insert(schema.orgs).values({ name: "Acme", slug: "acme" }).returning(); + assert.ok(org); + const [project] = await db + .insert(schema.projects) + .values({ orgId: org.id, name: "App", slug: "app" }) + .returning(); + assert.ok(project); + return { org, project }; +} + +/** Seeds one row in several encrypted tables, all written under the current env key. */ +async function seedEncryptedRows(db: DB) { + const { org, project } = await seedProject(db); + process.env.CREDENTIAL_STORAGE_MODE = "encrypted-only"; + + const [linear] = await db + .insert(schema.linearInstallations) + .values({ + projectId: project.id, + workspaceId: "linear-workspace", + ...linearCredentialFields({ + accessToken: "linear-access", + refreshToken: null, + webhookSecret: "linear-webhook", + }), + }) + .returning(); + assert.ok(linear); + + const access = encryptIntegrationSecret("sentry-access"); + const relay = encryptIntegrationSecret("sentry-relay"); + const [sentry] = await db + .insert(schema.sentryInstallations) + .values({ + projectId: project.id, + sentryInstallationId: "sentry-install", + organizationSlug: "acme", + sentryProjectSlug: "app", + accessTokenCiphertext: access.ciphertext, + accessTokenNonce: access.nonce.toString("base64"), + accessTokenKeyVersion: access.keyVersion, + relayTokenCiphertext: relay.ciphertext, + relayTokenNonce: relay.nonce.toString("base64"), + relayTokenKeyVersion: relay.keyVersion, + }) + .returning(); + assert.ok(sentry); + + const [integration] = await db + .insert(schema.orgIntegrations) + .values({ orgId: org.id, slug: "custom" }) + .returning(); + assert.ok(integration); + const orgSecret = encryptIntegrationSecret("org-secret"); + const [secret] = await db + .insert(schema.orgIntegrationSecrets) + .values({ + orgIntegrationId: integration.id, + secretName: "API_TOKEN", + ciphertext: orgSecret.ciphertext, + nonce: orgSecret.nonce, + keyVersion: orgSecret.keyVersion, + }) + .returning(); + assert.ok(secret); + + const cfAccess = encryptIntegrationSecret("cloudflare-access"); + const cfIngest = encryptIntegrationSecret("cloudflare-ingest"); + const [cloudflare] = await db + .insert(schema.cloudflareInstallations) + .values({ + projectId: project.id, + accountId: "cf-account", + accessTokenCiphertext: cfAccess.ciphertext, + accessTokenNonce: cfAccess.nonce, + accessTokenKeyVersion: cfAccess.keyVersion, + ingestKeyCiphertext: cfIngest.ciphertext, + ingestKeyNonce: cfIngest.nonce, + // Envelopes written before versions were recorded are version 1. + ingestKeyKeyVersion: null, + }) + .returning(); + assert.ok(cloudflare); + + return { linear, sentry, secret, cloudflare }; +} + +function totals(report: Awaited>) { + return report.totals; +} + +function columnReport(report: Awaited>, label: string) { + const entry = report.columns.find((column) => column.column === label); + assert.ok(entry, `missing report entry for ${label}`); + return entry; +} + +test("every *_key_version column in the schema is covered by the rewrap registry", () => { + const covered = new Set(ENCRYPTED_INTEGRATION_SECRET_COLUMNS.map(encryptedSecretColumnLabel)); + const inSchema: string[] = []; + for (const value of Object.values(schema)) { + if (!(value instanceof PgTable)) continue; + const config = getTableConfig(value); + for (const column of config.columns) { + if (column.name.endsWith("key_version")) inSchema.push(`${config.name}.${column.name}`); + } + } + + assert.ok(inSchema.length > 0); + const missing = inSchema.filter((label) => !covered.has(label)); + assert.deepEqual( + missing, + [], + "add every new encrypted column to ENCRYPTED_INTEGRATION_SECRET_COLUMNS so key rotation re-encrypts it", + ); + assert.equal( + covered.size, + ENCRYPTED_INTEGRATION_SECRET_COLUMNS.length, + "duplicate registry entry", + ); + assert.deepEqual([...covered].sort(), [...inSchema].sort()); +}); + +test("every registry entry points at one table's id, ciphertext, nonce and key version", () => { + for (const entry of ENCRYPTED_INTEGRATION_SECRET_COLUMNS) { + const config = getTableConfig(entry.table); + const label = encryptedSecretColumnLabel(entry); + for (const column of [entry.id, entry.ciphertext, entry.nonce, entry.keyVersion]) { + assert.ok(config.columns.includes(column), `${label}: ${column.name} is from another table`); + } + assert.equal(entry.id.primary, true, `${label}: id must be the primary key`); + assert.equal(entry.ciphertext.getSQLType(), "bytea", `${label}: ciphertext must be bytea`); + assert.equal( + entry.nonce.getSQLType(), + entry.nonceEncoding === "base64-text" ? "text" : "bytea", + `${label}: nonce encoding does not match the column type`, + ); + assert.equal(entry.keyVersion.getSQLType(), "integer", `${label}: key version must be integer`); + } +}); + +test("rewrap moves version 1 envelopes to the current version and is idempotent", async () => { + const k1 = key(); + const k2 = key(); + process.env.AGENT_SECRETS_KEY = k1; + const { client, db } = await freshDb(); + try { + const seeded = await seedEncryptedRows(db); + + process.env.AGENT_SECRETS_KEY = `2:${k2},1:${k1}`; + + const dryRun = await rewrapIntegrationSecrets(db, { dryRun: true, batchSize: 2 }); + assert.equal(dryRun.dryRun, true); + assert.equal(dryRun.currentVersion, 2); + assert.deepEqual(totals(dryRun), { + pending: 7, + rewrapped: 7, + conflicts: 0, + failed: 0, + remaining: 7, + }); + assert.equal(dryRun.columns.length, ENCRYPTED_INTEGRATION_SECRET_COLUMNS.length); + assert.deepEqual(columnReport(dryRun, "linear_installations.access_token_key_version"), { + table: "linear_installations", + column: "linear_installations.access_token_key_version", + pending: 1, + pendingByVersion: { "1": 1 }, + rewrapped: 1, + conflicts: 0, + failed: 0, + remaining: 1, + }); + assert.deepEqual( + columnReport(dryRun, "cloudflare_installations.ingest_key_key_version").pendingByVersion, + { legacy_null: 1 }, + ); + assert.equal(columnReport(dryRun, "linear_installations.refresh_token_key_version").pending, 0); + const untouched = await db.query.linearInstallations.findFirst(); + assert.equal(untouched?.accessTokenKeyVersion, 1); + assert.deepEqual(untouched?.accessTokenCiphertext, seeded.linear.accessTokenCiphertext); + + const run = await rewrapIntegrationSecrets(db, { dryRun: false, batchSize: 2 }); + assert.deepEqual(totals(run), { + pending: 7, + rewrapped: 7, + conflicts: 0, + failed: 0, + remaining: 0, + }); + + // Only the new key remains: every value must still read back. + process.env.AGENT_SECRETS_KEY = `2:${k2}`; + const linear = await db.query.linearInstallations.findFirst(); + const sentry = await db.query.sentryInstallations.findFirst(); + const secret = await db.query.orgIntegrationSecrets.findFirst(); + const cloudflare = await db.query.cloudflareInstallations.findFirst(); + assert.ok(linear && sentry && secret && cloudflare); + + const hydrated = hydrateLinearInstallation(linear); + assert.equal(hydrated.accessToken, "linear-access"); + assert.equal(hydrated.refreshToken, null); + assert.equal(hydrated.webhookSecret, "linear-webhook"); + assert.equal(linear.refreshTokenKeyVersion, null); + assert.equal(linear.refreshTokenCiphertext, null); + assert.equal(sentry.accessTokenKeyVersion, 2); + assert.equal( + decryptIntegrationSecret({ + ciphertext: sentry.relayTokenCiphertext, + nonce: Buffer.from(sentry.relayTokenNonce, "base64"), + keyVersion: sentry.relayTokenKeyVersion, + }), + "sentry-relay", + ); + assert.equal( + decryptIntegrationSecret({ + ciphertext: secret.ciphertext, + nonce: secret.nonce, + keyVersion: secret.keyVersion, + }), + "org-secret", + ); + assert.equal(cloudflare.ingestKeyKeyVersion, 2); + assert.ok(cloudflare.ingestKeyNonce); + assert.equal( + decryptIntegrationSecret({ + ciphertext: cloudflare.ingestKeyCiphertext as Buffer, + nonce: cloudflare.ingestKeyNonce, + keyVersion: 2, + }), + "cloudflare-ingest", + ); + + const again = await rewrapIntegrationSecrets(db, { dryRun: false }); + assert.deepEqual(totals(again), { + pending: 0, + rewrapped: 0, + conflicts: 0, + failed: 0, + remaining: 0, + }); + } finally { + await client.close(); + } +}); + +test("rewrap reports undecryptable rows, keeps going, and leaves them untouched", async () => { + const k1 = key(); + process.env.AGENT_SECRETS_KEY = k1; + const { client, db } = await freshDb(); + try { + const seeded = await seedEncryptedRows(db); + const corrupt = Buffer.from(seeded.secret.ciphertext); + corrupt[0] = (corrupt[0] ?? 0) ^ 0xff; + await db + .update(schema.orgIntegrationSecrets) + .set({ ciphertext: corrupt }) + .where(eq(schema.orgIntegrationSecrets.id, seeded.secret.id)); + + process.env.AGENT_SECRETS_KEY = `2:${key()},1:${k1}`; + const dryRun = await rewrapIntegrationSecrets(db, { dryRun: true }); + assert.equal(dryRun.totals.failed, 1); + assert.deepEqual(dryRun.failures, [ + { + column: "org_integration_secrets.key_version", + id: seeded.secret.id, + keyVersion: 1, + reason: dryRun.failures[0]?.reason, + }, + ]); + + const run = await rewrapIntegrationSecrets(db, { dryRun: false }); + assert.equal(run.totals.rewrapped, 6); + assert.equal(run.totals.failed, 1); + assert.equal(run.totals.remaining, 1); + const secret = await db.query.orgIntegrationSecrets.findFirst(); + assert.equal(secret?.keyVersion, 1); + assert.ok(secret && Buffer.from(secret.ciphertext).equals(corrupt)); + for (const failure of run.failures) { + assert.ok(!failure.reason.includes("org-secret")); + } + } finally { + await client.close(); + } +}); + +test("rewrap reports rows whose key version is not in the keyring", async () => { + const k1 = key(); + process.env.AGENT_SECRETS_KEY = k1; + const { client, db } = await freshDb(); + try { + await seedEncryptedRows(db); + process.env.AGENT_SECRETS_KEY = `3:${key()},2:${key()}`; + + const dryRun = await rewrapIntegrationSecrets(db, { dryRun: true }); + + assert.equal(dryRun.totals.pending, 7); + assert.equal(dryRun.totals.failed, 7); + assert.match(dryRun.failures[0]?.reason ?? "", /key version 1 is not configured/); + assert.ok(!JSON.stringify(dryRun).includes(k1)); + } finally { + await client.close(); + } +}); + +test("a concurrent write between read and rewrap wins over the stale re-encryption", async () => { + const k1 = key(); + process.env.AGENT_SECRETS_KEY = k1; + const { client, db } = await freshDb(); + try { + const seeded = await seedEncryptedRows(db); + const entry = ENCRYPTED_INTEGRATION_SECRET_COLUMNS.find( + (column) => encryptedSecretColumnLabel(column) === "org_integration_secrets.key_version", + ); + assert.ok(entry); + const stale = { + id: seeded.secret.id, + ciphertext: seeded.secret.ciphertext, + nonce: seeded.secret.nonce, + keyVersion: seeded.secret.keyVersion, + }; + + // Another writer (e.g. an older task still encrypting with version 1) + // replaces the secret after the rewrap read the row. + const replacement = encryptIntegrationSecret("org-secret-rotated-by-user"); + await db + .update(schema.orgIntegrationSecrets) + .set({ ciphertext: replacement.ciphertext, nonce: replacement.nonce, keyVersion: 1 }) + .where(eq(schema.orgIntegrationSecrets.id, seeded.secret.id)); + + process.env.AGENT_SECRETS_KEY = `2:${key()},1:${k1}`; + const outcome = await rewrapEncryptedSecretRow(db, entry, stale); + assert.equal(outcome, "conflict"); + + const secret = await db.query.orgIntegrationSecrets.findFirst(); + assert.ok(secret); + assert.equal(secret.keyVersion, 1); + assert.equal( + decryptIntegrationSecret({ + ciphertext: secret.ciphertext, + nonce: secret.nonce, + keyVersion: secret.keyVersion, + }), + "org-secret-rotated-by-user", + ); + + const run = await rewrapIntegrationSecrets(db, { dryRun: false }); + assert.equal(run.totals.failed, 0); + assert.equal(run.totals.remaining, 0); + } finally { + await client.close(); + } +}); + +test("rewrap arguments accept only --dry-run and --batch-size", () => { + assert.deepEqual(parseRewrapArgs([]), { dryRun: false, batchSize: 100 }); + assert.deepEqual(parseRewrapArgs(["--dry-run"]), { dryRun: true, batchSize: 100 }); + assert.deepEqual(parseRewrapArgs(["--batch-size=10", "--dry-run"]), { + dryRun: true, + batchSize: 10, + }); + assert.throws(() => parseRewrapArgs(["--batch-size=0"]), /batch size/); + assert.throws(() => parseRewrapArgs(["--force"]), /unknown argument/); +}); + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) Reflect.deleteProperty(process.env, name); + else process.env[name] = value; +} diff --git a/packages/db/src/integration-secret-rewrap.ts b/packages/db/src/integration-secret-rewrap.ts new file mode 100644 index 00000000..f9b3659b --- /dev/null +++ b/packages/db/src/integration-secret-rewrap.ts @@ -0,0 +1,303 @@ +import { + type SQL, + and, + asc, + count, + eq, + getTableColumns, + gt, + isNotNull, + isNull, + ne, + or, +} from "drizzle-orm"; +import { type PgColumn, type PgTable, getTableConfig } from "drizzle-orm/pg-core"; +import type { DB } from "./client.js"; +import { + ENCRYPTED_INTEGRATION_SECRET_COLUMNS, + type EncryptedSecretColumn, + encryptedSecretColumnLabel, +} from "./integration-secret-columns.js"; +import { + type IntegrationSecretKeyring, + LEGACY_INTEGRATION_SECRET_KEY_VERSION, + decryptIntegrationSecret, + encryptIntegrationSecret, + integrationSecretKeyring, +} from "./integration-secrets.js"; + +/** + * Re-encrypts ("rewraps") every `AGENT_SECRETS_KEY` envelope that is not on + * the keyring's current version. Each row is updated with an optimistic guard + * on its id, old key version and old ciphertext, so a concurrent writer always + * wins and the rewrap never overwrites a newer value. Rows are scanned in id + * order and already-current rows are skipped, so the command is idempotent and + * can be re-run after an interruption. Plaintext and key material are never + * logged or returned. + */ + +const DEFAULT_BATCH_SIZE = 100; + +export type RewrapOptions = { + dryRun: boolean; + batchSize?: number; + keyring?: IntegrationSecretKeyring; +}; + +export type RewrapColumnReport = { + table: string; + column: string; + /** Rows found on a non-current key version during this run. */ + pending: number; + /** Pending rows by key version; `legacy_null` is a ciphertext with no recorded version. */ + pendingByVersion: Record; + /** Rows re-encrypted (or, in a dry run, rows that decrypt and would be). */ + rewrapped: number; + /** Rows changed by another writer between read and update; left as they are. */ + conflicts: number; + /** Rows that could not be decrypted or updated. */ + failed: number; + /** Rows still on a non-current version after this run. */ + remaining: number; +}; + +export type RewrapFailure = { + column: string; + id: string; + keyVersion: number | null; + reason: string; +}; + +export type RewrapReport = { + dryRun: boolean; + currentVersion: number; + configuredVersions: readonly number[]; + columns: RewrapColumnReport[]; + totals: Pick; + failures: RewrapFailure[]; +}; + +export type EncryptedSecretRow = { + id: string; + ciphertext: Buffer | null; + nonce: Buffer | string | null; + keyVersion: number | null; +}; + +export type RewrapRowOutcome = "rewrapped" | "conflict"; + +export function parseRewrapArgs(args: string[]): { dryRun: boolean; batchSize: number } { + let dryRun = false; + let batchSize = DEFAULT_BATCH_SIZE; + for (const arg of args) { + if (arg === "--dry-run") { + dryRun = true; + } else if (arg.startsWith("--batch-size=")) { + batchSize = Number(arg.slice("--batch-size=".length)); + if (!Number.isSafeInteger(batchSize) || batchSize < 1) { + throw new Error("batch size must be a positive integer"); + } + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + return { dryRun, batchSize }; +} + +function propertyKey(table: PgTable, column: PgColumn): string { + for (const [key, candidate] of Object.entries(getTableColumns(table))) { + if (candidate === column) return key; + } + throw new Error(`column ${column.name} is not on table ${getTableConfig(table).name}`); +} + +function staleVersionFilter(entry: EncryptedSecretColumn, currentVersion: number): SQL { + const filter = and( + isNotNull(entry.ciphertext), + or(isNull(entry.keyVersion), ne(entry.keyVersion, currentVersion)), + ); + if (!filter) throw new Error("unreachable: empty rewrap filter"); + return filter; +} + +function decodeNonce(entry: EncryptedSecretColumn, nonce: Buffer | string): Buffer { + if (entry.nonceEncoding === "base64-text") { + if (typeof nonce !== "string") throw new Error("expected a base64 text nonce"); + return Buffer.from(nonce, "base64"); + } + return asBuffer(nonce, "nonce"); +} + +// postgres-js returns bytea as Buffer; other drivers (PGlite) return Uint8Array. +function asBuffer(value: Buffer | Uint8Array | string, name: string): Buffer { + if (Buffer.isBuffer(value)) return value; + if (value instanceof Uint8Array) return Buffer.from(value.buffer, value.byteOffset, value.length); + throw new Error(`expected a bytea ${name}`); +} + +function encodeNonce(entry: EncryptedSecretColumn, nonce: Buffer): Buffer | string { + return entry.nonceEncoding === "base64-text" ? nonce.toString("base64") : nonce; +} + +function decryptRow( + entry: EncryptedSecretColumn, + row: EncryptedSecretRow, + keyring: IntegrationSecretKeyring, +): string { + if (row.ciphertext === null) throw new Error("ciphertext is missing"); + if (row.nonce === null) throw new Error("envelope is incomplete: nonce is missing"); + return decryptIntegrationSecret( + { + ciphertext: asBuffer(row.ciphertext, "ciphertext"), + nonce: decodeNonce(entry, row.nonce), + keyVersion: row.keyVersion ?? LEGACY_INTEGRATION_SECRET_KEY_VERSION, + }, + keyring, + ); +} + +/** + * Re-encrypts one row with the keyring's current version. The update only + * applies while the row still holds exactly the envelope that was read; + * otherwise it reports a conflict and leaves the newer value in place. + */ +export async function rewrapEncryptedSecretRow( + database: DB, + entry: EncryptedSecretColumn, + row: EncryptedSecretRow, + keyring: IntegrationSecretKeyring = integrationSecretKeyring(), +): Promise { + const plaintext = decryptRow(entry, row, keyring); + const next = encryptIntegrationSecret(plaintext, keyring); + if (row.ciphertext === null || row.nonce === null) throw new Error("envelope is incomplete"); + + const updated = await database + .update(entry.table) + .set({ + [propertyKey(entry.table, entry.ciphertext)]: next.ciphertext, + [propertyKey(entry.table, entry.nonce)]: encodeNonce(entry, next.nonce), + [propertyKey(entry.table, entry.keyVersion)]: next.keyVersion, + } as never) + .where( + and( + eq(entry.id, row.id), + row.keyVersion === null ? isNull(entry.keyVersion) : eq(entry.keyVersion, row.keyVersion), + eq(entry.ciphertext, row.ciphertext), + eq(entry.nonce, row.nonce), + ), + ) + .returning({ id: entry.id }); + return updated.length === 1 ? "rewrapped" : "conflict"; +} + +function failureReason(error: unknown): string { + // Crypto and keyring errors carry no plaintext or key bytes; database errors + // are reduced to their message. + return error instanceof Error ? error.message : "unknown error"; +} + +async function rewrapColumn( + database: DB, + entry: EncryptedSecretColumn, + keyring: IntegrationSecretKeyring, + options: { dryRun: boolean; batchSize: number }, + failures: RewrapFailure[], +): Promise { + const label = encryptedSecretColumnLabel(entry); + const report: RewrapColumnReport = { + table: getTableConfig(entry.table).name, + column: label, + pending: 0, + pendingByVersion: {}, + rewrapped: 0, + conflicts: 0, + failed: 0, + remaining: 0, + }; + const filter = staleVersionFilter(entry, keyring.currentVersion); + + let cursor: string | null = null; + for (;;) { + const rows = (await database + .select({ + id: entry.id, + ciphertext: entry.ciphertext, + nonce: entry.nonce, + keyVersion: entry.keyVersion, + }) + .from(entry.table) + .where(cursor === null ? filter : and(filter, gt(entry.id, cursor))) + .orderBy(asc(entry.id)) + .limit(options.batchSize)) as EncryptedSecretRow[]; + if (rows.length === 0) break; + + for (const row of rows) { + cursor = row.id; + report.pending += 1; + const versionKey = row.keyVersion === null ? "legacy_null" : String(row.keyVersion); + report.pendingByVersion[versionKey] = (report.pendingByVersion[versionKey] ?? 0) + 1; + try { + if (options.dryRun) { + decryptRow(entry, row, keyring); + report.rewrapped += 1; + continue; + } + const outcome = await rewrapEncryptedSecretRow(database, entry, row, keyring); + if (outcome === "rewrapped") report.rewrapped += 1; + else report.conflicts += 1; + } catch (error) { + report.failed += 1; + failures.push({ + column: label, + id: row.id, + keyVersion: row.keyVersion, + reason: failureReason(error), + }); + } + } + if (rows.length < options.batchSize) break; + } + + const [remaining] = await database.select({ value: count() }).from(entry.table).where(filter); + report.remaining = Number(remaining?.value ?? 0); + return report; +} + +export async function rewrapIntegrationSecrets( + database: DB, + options: RewrapOptions, +): Promise { + const keyring = options.keyring ?? integrationSecretKeyring(); + const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE; + const failures: RewrapFailure[] = []; + const columns: RewrapColumnReport[] = []; + for (const entry of ENCRYPTED_INTEGRATION_SECRET_COLUMNS) { + columns.push( + await rewrapColumn(database, entry, keyring, { dryRun: options.dryRun, batchSize }, failures), + ); + } + const totals = columns.reduce( + (sum, column) => ({ + pending: sum.pending + column.pending, + rewrapped: sum.rewrapped + column.rewrapped, + conflicts: sum.conflicts + column.conflicts, + failed: sum.failed + column.failed, + remaining: sum.remaining + column.remaining, + }), + { pending: 0, rewrapped: 0, conflicts: 0, failed: 0, remaining: 0 }, + ); + return { + dryRun: options.dryRun, + currentVersion: keyring.currentVersion, + configuredVersions: keyring.versions, + columns, + totals, + failures, + }; +} + +/** True when the run left nothing to do: no failures and, for a real run, nothing remaining. */ +export function rewrapSucceeded(report: RewrapReport): boolean { + if (report.totals.failed > 0) return false; + return report.dryRun || report.totals.remaining === 0; +} diff --git a/packages/db/src/integration-secrets.test.ts b/packages/db/src/integration-secrets.test.ts new file mode 100644 index 00000000..1b0399cb --- /dev/null +++ b/packages/db/src/integration-secrets.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { afterEach, test } from "node:test"; +import { inspect } from "node:util"; +import { + LEGACY_INTEGRATION_SECRET_KEY_VERSION, + currentIntegrationSecretKeyVersion, + decryptIntegrationSecret, + encryptIntegrationSecret, + integrationSecretEncryptionConfigured, + parseIntegrationSecretKeyring, +} from "./integration-secrets.js"; + +const originalKey = process.env.AGENT_SECRETS_KEY; + +afterEach(() => { + if (originalKey === undefined) Reflect.deleteProperty(process.env, "AGENT_SECRETS_KEY"); + else process.env.AGENT_SECRETS_KEY = originalKey; +}); + +function key(): string { + return randomBytes(32).toString("base64"); +} + +test("a single base64 key is the legacy format and means version 1", () => { + const k1 = key(); + const keyring = parseIntegrationSecretKeyring(k1); + + assert.equal(LEGACY_INTEGRATION_SECRET_KEY_VERSION, 1); + assert.equal(keyring.currentVersion, 1); + assert.deepEqual(keyring.versions, [1]); + assert.equal(keyring.key(1).length, 32); +}); + +test("a keyring lists the encrypting version first and decrypt-only versions after it", () => { + const keyring = parseIntegrationSecretKeyring(`2:${key()},1:${key()}`); + + assert.equal(keyring.currentVersion, 2); + assert.deepEqual(keyring.versions, [2, 1]); + assert.notDeepEqual(keyring.key(2), keyring.key(1)); +}); + +test("a keyring may stage a new decrypt-only version behind the current one", () => { + const keyring = parseIntegrationSecretKeyring(`1:${key()},2:${key()}`); + + assert.equal(keyring.currentVersion, 1); + assert.deepEqual(keyring.versions, [1, 2]); +}); + +test("keyring parsing tolerates surrounding whitespace and a trailing newline", () => { + const keyring = parseIntegrationSecretKeyring(` 3:${key()} , 1:${key()}\n`); + + assert.equal(keyring.currentVersion, 3); + assert.deepEqual(keyring.versions, [3, 1]); + assert.equal(parseIntegrationSecretKeyring(`${key()}\n`).currentVersion, 1); +}); + +test("invalid keyrings are rejected without echoing key material", () => { + const k1 = key(); + const k2 = key(); + const short = randomBytes(31).toString("base64"); + const cases: Array<[string | undefined, RegExp]> = [ + [undefined, /AGENT_SECRETS_KEY is required/], + ["", /AGENT_SECRETS_KEY is required/], + [" ", /AGENT_SECRETS_KEY is required/], + [short, /must decode to 32 bytes \(got 31\)/], + ["not-a-key", /must decode to 32 bytes/], + [`2:${k2},1:${k1},2:${k1}`, /version 2 appears more than once/], + [`0:${k1}`, /entry 1 has an invalid version/], + [`-1:${k1}`, /entry 1 has an invalid version/], + [`1.5:${k1}`, /entry 1 has an invalid version/], + [`abc:${k1}`, /entry 1 has an invalid version/], + [`99999999999:${k1}`, /entry 1 has an invalid version/], + [`2:${k2},${k1}`, /entry 2 must be :/], + [`2:${k2},`, /entry 2 is empty/], + ["2:", /entry 1 \(version 2\) must decode to 32 bytes \(got 0\)/], + [`2:${k2},1:${short}`, /entry 2 \(version 1\) must decode to 32 bytes \(got 31\)/], + ]; + + for (const [raw, expected] of cases) { + assert.throws( + () => parseIntegrationSecretKeyring(raw), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, expected, `input case ${expected}`); + for (const secret of [k1, k2, short, "not-a-key"]) { + assert.ok(!error.message.includes(secret), "error message leaks key material"); + } + return true; + }, + ); + assert.equal(integrationSecretEncryptionConfigured(raw), false); + } +}); + +test("a parsed keyring never prints its key bytes", () => { + const k1 = key(); + const keyring = parseIntegrationSecretKeyring(`1:${k1}`); + const rendered = `${inspect(keyring, { depth: 10, showHidden: true })} ${JSON.stringify(keyring)}`; + + assert.ok(!rendered.includes(k1)); + assert.ok(!rendered.includes(Buffer.from(k1, "base64").toString("hex"))); +}); + +test("encryption is configured for both the legacy key and a keyring", () => { + assert.equal(integrationSecretEncryptionConfigured(key()), true); + assert.equal(integrationSecretEncryptionConfigured(`2:${key()},1:${key()}`), true); +}); + +test("encryption writes with the current version and decryption follows each row's version", () => { + const k1 = key(); + const k2 = key(); + process.env.AGENT_SECRETS_KEY = k1; + const legacyRow = encryptIntegrationSecret("written-before-rotation"); + assert.equal(legacyRow.keyVersion, 1); + + process.env.AGENT_SECRETS_KEY = `2:${k2},1:${k1}`; + assert.equal(currentIntegrationSecretKeyVersion(), 2); + const rotatedRow = encryptIntegrationSecret("written-after-rotation"); + + assert.equal(rotatedRow.keyVersion, 2); + assert.equal(decryptIntegrationSecret(legacyRow), "written-before-rotation"); + assert.equal(decryptIntegrationSecret(rotatedRow), "written-after-rotation"); +}); + +test("a legacy single-key deployment still decrypts version 1 rows written by a keyring", () => { + const k1 = key(); + process.env.AGENT_SECRETS_KEY = `1:${k1}`; + const row = encryptIntegrationSecret("value"); + + process.env.AGENT_SECRETS_KEY = k1; + assert.equal(decryptIntegrationSecret(row), "value"); +}); + +test("decrypting a version that is no longer configured fails clearly", () => { + const k1 = key(); + const k2 = key(); + process.env.AGENT_SECRETS_KEY = k1; + const legacyRow = encryptIntegrationSecret("value"); + + process.env.AGENT_SECRETS_KEY = `2:${k2}`; + assert.throws( + () => decryptIntegrationSecret(legacyRow), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /key version 1 is not configured/); + assert.match(error.message, /configured versions: 2/); + assert.ok(!error.message.includes(k1) && !error.message.includes(k2)); + return true; + }, + ); +}); + +test("a key under the wrong version number fails authentication instead of returning garbage", () => { + const k1 = key(); + process.env.AGENT_SECRETS_KEY = k1; + const row = encryptIntegrationSecret("value"); + + process.env.AGENT_SECRETS_KEY = `2:${k1},1:${key()}`; + assert.throws(() => decryptIntegrationSecret(row)); +}); diff --git a/packages/db/src/integration-secrets.ts b/packages/db/src/integration-secrets.ts index 41b90d10..96acd3d8 100644 --- a/packages/db/src/integration-secrets.ts +++ b/packages/db/src/integration-secrets.ts @@ -3,7 +3,15 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; const ALGORITHM = "aes-256-gcm"; const NONCE_LEN = 12; const TAG_LEN = 16; -const CURRENT_KEY_VERSION = 1; +const KEY_LEN = 32; +// key_version columns are Postgres `integer`. +const MAX_KEY_VERSION = 2_147_483_647; + +/** + * The version implied by the single-key `AGENT_SECRETS_KEY` format, and the + * version of any envelope written before key versions were recorded. + */ +export const LEGACY_INTEGRATION_SECRET_KEY_VERSION = 1; export type IntegrationSecretCipher = { ciphertext: Buffer; @@ -11,44 +19,163 @@ export type IntegrationSecretCipher = { keyVersion: number; }; -export function integrationSecretEncryptionConfigured( - raw: string | undefined = process.env.AGENT_SECRETS_KEY, -): boolean { - return raw !== undefined && Buffer.from(raw, "base64").length === 32; -} +/** + * Parsed `AGENT_SECRETS_KEY`. Two formats are accepted: + * + * - a single base64 key (32 bytes decoded), which is version 1; + * - a keyring `":,:,..."`. The first entry is + * the current version used for new encryptions; the rest are decrypt-only. + * + * Key bytes live in a private field so logging or serializing a keyring never + * prints key material. + */ +export class IntegrationSecretKeyring { + readonly currentVersion: number; + readonly versions: readonly number[]; + readonly #keys: ReadonlyMap; -function getKey(version: number): Buffer { - if (version !== CURRENT_KEY_VERSION) { - throw new Error(`unknown integration secret key version: ${version}`); + constructor(entries: ReadonlyArray) { + const first = entries[0]; + if (!first) throw new Error("AGENT_SECRETS_KEY keyring has no entries"); + this.currentVersion = first[0]; + this.versions = entries.map(([version]) => version); + this.#keys = new Map(entries); } - const raw = process.env.AGENT_SECRETS_KEY; - if (!raw) { - throw new Error("AGENT_SECRETS_KEY is required to encrypt/decrypt integration secrets"); + + has(version: number): boolean { + return this.#keys.has(version); + } + + key(version: number): Buffer { + const key = this.#keys.get(version); + if (!key) { + throw new Error( + `integration secret key version ${version} is not configured in AGENT_SECRETS_KEY (configured versions: ${this.versions.join(", ")})`, + ); + } + return key; } - const key = Buffer.from(raw, "base64"); - if (key.length !== 32) { + + toJSON(): { currentVersion: number; versions: readonly number[] } { + return { currentVersion: this.currentVersion, versions: this.versions }; + } +} + +function decodeKey(encoded: string, label: string): Buffer { + const key = Buffer.from(encoded, "base64"); + if (key.length !== KEY_LEN) { throw new Error( - `AGENT_SECRETS_KEY must decode to 32 bytes (got ${key.length}); generate with \`openssl rand -base64 32\``, + `${label} must decode to ${KEY_LEN} bytes (got ${key.length}); generate with \`openssl rand -base64 32\``, ); } return key; } -export function encryptIntegrationSecret(plaintext: string): IntegrationSecretCipher { - const key = getKey(CURRENT_KEY_VERSION); +function parseVersion(token: string, position: number): number { + const version = /^\d+$/.test(token) ? Number(token) : Number.NaN; + if (!Number.isSafeInteger(version) || version < 1 || version > MAX_KEY_VERSION) { + // The token is not echoed: a malformed entry may be a mis-pasted key. + throw new Error( + `AGENT_SECRETS_KEY entry ${position} has an invalid version; expected an integer from 1 to ${MAX_KEY_VERSION}`, + ); + } + return version; +} + +export function parseIntegrationSecretKeyring(raw: string | undefined): IntegrationSecretKeyring { + const value = raw?.trim() ?? ""; + if (value === "") { + throw new Error("AGENT_SECRETS_KEY is required to encrypt/decrypt integration secrets"); + } + // Base64 never contains ":" or ",", so either one marks the keyring format. + if (!value.includes(":") && !value.includes(",")) { + return new IntegrationSecretKeyring([ + [LEGACY_INTEGRATION_SECRET_KEY_VERSION, decodeKey(value, "AGENT_SECRETS_KEY")], + ]); + } + + const entries: Array<[number, Buffer]> = []; + const seen = new Set(); + value.split(",").forEach((rawEntry, index) => { + const position = index + 1; + const entry = rawEntry.trim(); + if (entry === "") throw new Error(`AGENT_SECRETS_KEY entry ${position} is empty`); + const separator = entry.indexOf(":"); + if (separator === -1) { + throw new Error(`AGENT_SECRETS_KEY entry ${position} must be :`); + } + const version = parseVersion(entry.slice(0, separator).trim(), position); + if (seen.has(version)) { + throw new Error(`AGENT_SECRETS_KEY version ${version} appears more than once`); + } + seen.add(version); + const key = decodeKey( + entry.slice(separator + 1).trim(), + `AGENT_SECRETS_KEY entry ${position} (version ${version})`, + ); + entries.push([version, key]); + }); + return new IntegrationSecretKeyring(entries); +} + +let cached: { raw: string; keyring: IntegrationSecretKeyring } | null = null; + +/** The keyring from `process.env.AGENT_SECRETS_KEY`. Throws if it is missing or invalid. */ +export function integrationSecretKeyring(): IntegrationSecretKeyring { + const raw = process.env.AGENT_SECRETS_KEY; + if (raw !== undefined && cached?.raw === raw) return cached.keyring; + const keyring = parseIntegrationSecretKeyring(raw); + if (raw !== undefined) cached = { raw, keyring }; + return keyring; +} + +/** + * True when `raw` is a valid single key or keyring. Takes the value explicitly + * (no environment fallback), for callers that read configuration from an env + * object other than `process.env`. + */ +export function isValidIntegrationSecretKeyring(raw: string | undefined): boolean { + try { + parseIntegrationSecretKeyring(raw); + return true; + } catch { + return false; + } +} + +/** True when `AGENT_SECRETS_KEY` (or `raw`, when given) holds a valid single key or keyring. */ +export function integrationSecretEncryptionConfigured( + raw: string | undefined = process.env.AGENT_SECRETS_KEY, +): boolean { + return isValidIntegrationSecretKeyring(raw); +} + +/** The version new encryptions are written with. */ +export function currentIntegrationSecretKeyVersion(): number { + return integrationSecretKeyring().currentVersion; +} + +export function encryptIntegrationSecret( + plaintext: string, + keyring: IntegrationSecretKeyring = integrationSecretKeyring(), +): IntegrationSecretCipher { + const keyVersion = keyring.currentVersion; const nonce = randomBytes(NONCE_LEN); - const cipher = createCipheriv(ALGORITHM, key, nonce); + const cipher = createCipheriv(ALGORITHM, keyring.key(keyVersion), nonce); const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); return { ciphertext: Buffer.concat([enc, tag]), nonce, - keyVersion: CURRENT_KEY_VERSION, + keyVersion, }; } -export function decryptIntegrationSecret(cipher: IntegrationSecretCipher): string { - const key = getKey(cipher.keyVersion); +export function decryptIntegrationSecret( + cipher: IntegrationSecretCipher, + keyring: IntegrationSecretKeyring = integrationSecretKeyring(), +): string { + const key = keyring.key(cipher.keyVersion); const enc = cipher.ciphertext.subarray(0, cipher.ciphertext.length - TAG_LEN); const tag = cipher.ciphertext.subarray(cipher.ciphertext.length - TAG_LEN); const decipher = createDecipheriv(ALGORITHM, key, cipher.nonce);