From 6c76cf97299855c140c8789a486fb3981b20e210 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 3 Oct 2026 11:43:39 +0200 Subject: [PATCH 1/2] Encrypt connector variables at rest connectors.env_vars holds every connector's API keys, passwords and tokens, and was stored in clear. It is now stored as {"$enc": ""} with ENCRYPTION_KEY and decrypted on read. - One choke point: a Prisma extension on PrismaService seals envVars on connector writes and opens it on every read (findMany, include from another model, select, transactions), so none of the ~70 call sites changes. A test fails if a nested connector write is ever added. - Rows written before are read as they are and encrypted by an idempotent pass at boot; only rows unchanged since read are replaced. - Keys an operator adds next to $enc with SQL are read (and win) and are folded into the ciphertext at the next start. - A row that cannot be decrypted reads as empty, so calls stop at the placeholder guard instead of sending ciphertext to an API. - Rollback hatch: ENV_VARS_AT_REST=plaintext decrypts every row at boot. --- .env.example | 8 +- docker-compose.cloud.yml | 2 + docker-compose.yml | 2 + docs/operations/backup-restore.md | 14 +- .../common/crypto/env-vars-at-rest.spec.ts | 129 ++++++++++++++++ .../src/common/crypto/env-vars-at-rest.ts | 138 ++++++++++++++++++ packages/backend/src/common/prisma.service.ts | 60 +++++++- 7 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 packages/backend/src/common/crypto/env-vars-at-rest.spec.ts create mode 100644 packages/backend/src/common/crypto/env-vars-at-rest.ts diff --git a/.env.example b/.env.example index 865530ad..49e87d6c 100644 --- a/.env.example +++ b/.env.example @@ -32,9 +32,15 @@ DATABASE_URL=postgresql://amcp:${POSTGRES_PASSWORD}@postgres:5432/anythingmcp # JWT secret for API authentication (min 32 characters) JWT_SECRET=change-me-in-production-min-32-chars -# AES-256-GCM encryption key for stored credentials (exactly 32 characters) +# AES-256-GCM encryption key for stored credentials (exactly 32 characters). +# Encrypts connector auth settings and connector variables. ENCRYPTION_KEY=change-me-in-production-exactly-32 +# Connector variables are stored encrypted. Set to `plaintext` and restart once +# before rolling back to a version that predates their encryption; see +# docs/operations/backup-restore.md. +# ENV_VARS_AT_REST=plaintext + # HMAC secret for signed cookies (OAuth callback flow). Optional — falls back # to JWT_SECRET if unset. Set it separately for defense in depth (rotate it # independently from JWT_SECRET). diff --git a/docker-compose.cloud.yml b/docker-compose.cloud.yml index 4eb319c9..9a22758d 100644 --- a/docker-compose.cloud.yml +++ b/docker-compose.cloud.yml @@ -92,6 +92,8 @@ x-backend: &backend - REDIS_URL=redis://redis:6379 - JWT_SECRET=${JWT_SECRET} - ENCRYPTION_KEY=${ENCRYPTION_KEY} + # `plaintext` only to roll back past encrypted connector variables (docs/operations/backup-restore.md) + - ENV_VARS_AT_REST=${ENV_VARS_AT_REST:-} # Shared secret for the onboarding-reminders GitHub Actions cron. # Must match the repo secret ONBOARDING_CRON_SECRET. Unset = cron # endpoint refuses all calls (self-host default). diff --git a/docker-compose.yml b/docker-compose.yml index 453a80b7..e66d2fd7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,8 @@ services: - DATABASE_URL=postgresql://amcp:${POSTGRES_PASSWORD:-amcp}@postgres:5432/anythingmcp - JWT_SECRET=${JWT_SECRET:-change-me-in-production-min-32-chars} - ENCRYPTION_KEY=${ENCRYPTION_KEY:-change-me-in-production-exactly-32} + # `plaintext` only to roll back past encrypted connector variables (docs/operations/backup-restore.md) + - ENV_VARS_AT_REST=${ENV_VARS_AT_REST:-} - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3000} - SERVER_URL=${SERVER_URL:-http://localhost:4000} - FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000} diff --git a/docs/operations/backup-restore.md b/docs/operations/backup-restore.md index dbbe13a9..5c927ed0 100644 --- a/docs/operations/backup-restore.md +++ b/docs/operations/backup-restore.md @@ -6,8 +6,8 @@ AnythingMCP stores all state in PostgreSQL. Encrypted secrets, audit logs, MCP s | Where | What | How often | |---|---|---| -| PostgreSQL | every table including `Connector.authConfig` (encrypted blob), audit log, OAuth state | continuously / nightly | -| `ENCRYPTION_KEY` | the AES-256-GCM key that decrypts `authConfig` | once, immutably | +| PostgreSQL | every table including `Connector.authConfig` and `Connector.envVars` (encrypted), audit log, OAuth state | continuously / nightly | +| `ENCRYPTION_KEY` | the AES-256-GCM key that decrypts `authConfig` and the connector variables | once, immutably | | `JWT_SECRET` | rotate-able, but losing it logs every user out | once, immutably | | `.env` (or your secret manager) | DB connection string, SMTP creds, OAuth IDs | on change | @@ -69,7 +69,7 @@ What you still need to back up yourself: ## Restoring to a different host -If the new host has a different `ENCRYPTION_KEY`, every encrypted `authConfig` blob in the database becomes unreadable. The connectors will continue to exist as rows, but every API call that needs a credential will fail with a decryption error. +If the new host has a different `ENCRYPTION_KEY`, every encrypted `authConfig` blob and every connector's variables become unreadable. The connectors will continue to exist as rows, but every API call that needs a credential will fail: a decryption error for `authConfig`, "still empty" for the variables. Always migrate the encryption key together with the database. If you've lost the original key, the connectors must be re-created with fresh credentials. @@ -83,3 +83,11 @@ A backup that has never been restored is not a backup. Once a quarter: 4. Tear it down. This is also the cheapest way to discover that retention has silently been broken for two months. + +## Connector variables at rest + +A connector's variables (`connectors.env_vars`: API keys, passwords, tenants) are stored as `{"$enc": ""}` with the same `ENCRYPTION_KEY`. Rows written by an older version are encrypted automatically when the backend starts; the log says `Encrypted the variables of N connectors`. + +- **Reading them with SQL** shows the ciphertext only. Use the dashboard or the API, which decrypt them. +- **Adding a variable with SQL** still works: `UPDATE connectors SET env_vars = env_vars || '{"X":"y"}'::jsonb` stores `X` in clear next to `$enc`; it is read (and wins over an encrypted `X`) and folded into the ciphertext at the next start. +- **Rolling back** to a version older than this needs the variables in clear first: set `ENV_VARS_AT_REST=plaintext`, restart once (the log says `Decrypted the variables of N connectors`), then switch the image. Remove the variable again to go back to encrypted storage. `docker-compose.yml` passes it through; with `docker-compose.quickstart.yml`, add `- ENV_VARS_AT_REST=plaintext` under the app's `environment` for that restart. diff --git a/packages/backend/src/common/crypto/env-vars-at-rest.spec.ts b/packages/backend/src/common/crypto/env-vars-at-rest.spec.ts new file mode 100644 index 00000000..bb62afff --- /dev/null +++ b/packages/backend/src/common/crypto/env-vars-at-rest.spec.ts @@ -0,0 +1,129 @@ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; +import { + ENC_KEY, + envVarsAtRestExtension, + isEncryptedEnvVars, + openEnvVars, + sealEnvVars, +} from './env-vars-at-rest'; +import { encrypt } from './encryption.util'; + +const KEY = 'k'.repeat(48); + +describe('connector variables at rest', () => { + const saved = { key: process.env.ENCRYPTION_KEY, mode: process.env.ENV_VARS_AT_REST }; + beforeEach(() => { + process.env.ENCRYPTION_KEY = KEY; + delete process.env.ENV_VARS_AT_REST; + }); + afterAll(() => { + process.env.ENCRYPTION_KEY = saved.key; + if (saved.mode === undefined) delete process.env.ENV_VARS_AT_REST; + else process.env.ENV_VARS_AT_REST = saved.mode; + }); + + it('stores an object as one ciphertext that holds none of its values', () => { + const sealed = sealEnvVars({ LEXWARE_API_KEY: 'secret-123', TENANT: 'acme' }) as Record; + expect(Object.keys(sealed)).toEqual([ENC_KEY]); + expect(JSON.stringify(sealed)).not.toContain('secret-123'); + expect(JSON.stringify(sealed)).not.toContain('LEXWARE_API_KEY'); + expect(openEnvVars(sealed)).toEqual({ LEXWARE_API_KEY: 'secret-123', TENANT: 'acme' }); + }); + + it('reads rows written before encryption as they are', () => { + expect(openEnvVars({ A: '1' })).toEqual({ A: '1' }); + expect(openEnvVars(null)).toBeNull(); + expect(openEnvVars({})).toEqual({}); + }); + + it('lets keys added next to the ciphertext (by SQL) win, until the next save encrypts them', () => { + const sealed = sealEnvVars({ MOTIS_URL: 'old', OTHER: 'x' }) as Record; + expect(openEnvVars({ ...sealed, MOTIS_URL: 'new' })).toEqual({ MOTIS_URL: 'new', OTHER: 'x' }); + }); + + it('leaves empty objects, nulls, Prisma null markers and already sealed values alone', () => { + class JsonNull {} + const marker = new JsonNull(); + expect(sealEnvVars({})).toEqual({}); + expect(sealEnvVars(null)).toBeNull(); + expect(sealEnvVars(marker)).toBe(marker); + const sealed = sealEnvVars({ A: '1' }); + expect(sealEnvVars(sealed)).toBe(sealed); + }); + + it('does not decrypt a blob encrypted for another field', () => { + const foreign = { [ENC_KEY]: encrypt(JSON.stringify({ A: '1' }), KEY, 'connector-oauth') }; + expect(openEnvVars(foreign)).toEqual({}); + }); + + it('reads as empty, never as ciphertext, under the wrong key', () => { + const sealed = sealEnvVars({ A: '1' }); + process.env.ENCRYPTION_KEY = 'z'.repeat(48); + expect(openEnvVars(sealed)).toEqual({}); + }); + + it('writes plain objects when ENV_VARS_AT_REST=plaintext, and still reads sealed ones', () => { + const sealed = sealEnvVars({ A: '1' }); + process.env.ENV_VARS_AT_REST = 'plaintext'; + expect(sealEnvVars({ A: '1' })).toEqual({ A: '1' }); + expect(isEncryptedEnvVars(sealed)).toBe(true); + expect(openEnvVars(sealed)).toEqual({ A: '1' }); + }); + + describe('the Prisma extension', () => { + const run = async (operation: string, args: any) => { + const query = jest.fn(async (a: any) => a); + await envVarsAtRestExtension.query.connector.$allOperations({ operation, args, query }); + return query.mock.calls[0][0]; + }; + + it.each(['create', 'update', 'updateMany', 'updateManyAndReturn'])('seals data.envVars on %s', async (op) => { + const out = await run(op, { data: { name: 'x', envVars: { K: 'v' } } }); + expect(isEncryptedEnvVars(out.data.envVars)).toBe(true); + expect(out.data.name).toBe('x'); + }); + + it.each(['createMany', 'createManyAndReturn'])('seals every row on %s', async (op) => { + const out = await run(op, { data: [{ envVars: { K: '1' } }, { envVars: { K: '2' } }] }); + expect(out.data.every((d: any) => isEncryptedEnvVars(d.envVars))).toBe(true); + }); + + it('seals both branches of an upsert', async () => { + const out = await run('upsert', { where: { id: 'c1' }, create: { envVars: { K: '1' } }, update: { envVars: { K: '2' } } }); + expect(isEncryptedEnvVars(out.create.envVars)).toBe(true); + expect(isEncryptedEnvVars(out.update.envVars)).toBe(true); + }); + + it('does not touch reads or writes without envVars', async () => { + expect(await run('findMany', { where: { envVars: { not: null } } })).toEqual({ where: { envVars: { not: null } } }); + expect(await run('update', { where: { id: 'c1' }, data: { name: 'y' } })).toEqual({ where: { id: 'c1' }, data: { name: 'y' } }); + }); + + it('decrypts on read', () => { + const sealed = sealEnvVars({ K: 'v' }); + expect(envVarsAtRestExtension.result.connector.envVars.compute({ envVars: sealed })).toEqual({ K: 'v' }); + }); + }); + + it('the codebase writes connectors only at the top level, where the extension seals them', () => { + // A nested write (organization.update({ data: { connectors: { create } } })) + // would bypass the query extension and store variables in clear. + const offenders: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (name === 'generated' || name === 'node_modules') continue; + if (statSync(path).isDirectory()) walk(path); + else if (path.endsWith('.ts') && !path.endsWith('.spec.ts')) { + const text = readFileSync(path, 'utf8'); + if (/connectors?\s*:\s*\{\s*(create|createMany|connectOrCreate|upsert|update|updateMany)\b/.test(text)) { + offenders.push(path); + } + } + } + }; + walk(join(__dirname, '..', '..')); + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/backend/src/common/crypto/env-vars-at-rest.ts b/packages/backend/src/common/crypto/env-vars-at-rest.ts new file mode 100644 index 00000000..0f78b828 --- /dev/null +++ b/packages/backend/src/common/crypto/env-vars-at-rest.ts @@ -0,0 +1,138 @@ +import { Logger } from '@nestjs/common'; +import { decrypt, encrypt } from './encryption.util'; + +/** + * A connector's variables (`connectors.env_vars`) hold its API keys, + * passwords and tokens. They are stored encrypted, as + * + * { "$enc": "" } + * + * and decrypted on every read. Both directions happen in one place, a Prisma + * extension on PrismaService (see `envVarsAtRestExtension`), so the code that + * reads `connector.envVars` keeps seeing a plain object and none of its many + * call sites can forget a step. + * + * Compatibility: + * - Rows written before this existed are plain objects and are read as they + * are; the boot pass in PrismaService encrypts them. + * - Keys stored next to `$enc` (an operator's SQL `env_vars || '{"X":"y"}'`) + * are read too and win over the encrypted ones; the next save encrypts them. + * - `ENV_VARS_AT_REST=plaintext` turns encryption off and makes the boot pass + * decrypt every row: set it and restart once before rolling back to a + * version that does not know `$enc`. + */ +export const ENC_KEY = '$enc'; + +/** Binds the ciphertext to this column: a blob copied from another encrypted field does not decrypt here. */ +const AAD = 'connectors.env_vars'; + +const logger = new Logger('EnvVarsAtRest'); + +type Json = Record; + +/** A JSON object. Not Prisma.JsonNull / DbNull, which are class instances. */ +function isPlainObject(value: unknown): value is Json { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +export function isEncryptedEnvVars(value: unknown): boolean { + return isPlainObject(value) && typeof value[ENC_KEY] === 'string'; +} + +export function plaintextAtRest(): boolean { + return (process.env.ENV_VARS_AT_REST || '').trim().toLowerCase() === 'plaintext'; +} + +function key(): string { + const value = process.env.ENCRYPTION_KEY; + if (!value) throw new Error('ENCRYPTION_KEY is not set: connector variables cannot be encrypted or read'); + return value; +} + +/** What to write to the column. Leaves anything that is not a non-empty object alone. */ +export function sealEnvVars(value: unknown): unknown { + if (plaintextAtRest() || !isPlainObject(value) || isEncryptedEnvVars(value)) return value; + if (Object.keys(value).length === 0) return value; + return { [ENC_KEY]: encrypt(JSON.stringify(value), key(), AAD) }; +} + +/** + * What the application sees. A row that cannot be decrypted (wrong key) + * reads as having no variables: every call then stops at the placeholder + * guard with "still empty", instead of sending ciphertext to an API. + */ +export function openEnvVars(value: unknown): unknown { + if (!isEncryptedEnvVars(value)) return value; + const { [ENC_KEY]: sealed, ...plain } = value as Json; + try { + const opened = JSON.parse(decrypt(sealed as string, key(), AAD)); + return { ...(isPlainObject(opened) ? opened : {}), ...plain }; + } catch (err: any) { + logger.error(`connector variables could not be decrypted (wrong ENCRYPTION_KEY?): ${err?.message ?? err}`); + return { ...plain }; + } +} + +const WRITE_OPERATIONS = new Set([ + 'create', + 'createMany', + 'createManyAndReturn', + 'update', + 'updateMany', + 'updateManyAndReturn', + 'upsert', +]); + +function sealData(data: unknown): void { + if (Array.isArray(data)) { + data.forEach(sealData); + return; + } + if (isPlainObject(data) && 'envVars' in data) { + data.envVars = sealEnvVars(data.envVars); + } +} + +/** + * The extension itself, kept free of the client type so it can be unit + * tested. Reads: a result override of `envVars`, which Prisma applies at + * every level (findMany, include from another model, select, transactions). + * Writes: the top-level connector operations; the codebase has no nested + * connector writes (a test keeps it that way). + */ +export const envVarsAtRestExtension = { + name: 'env-vars-at-rest', + result: { + connector: { + envVars: { + needs: { envVars: true }, + compute: (connector: { envVars: unknown }) => openEnvVars(connector.envVars), + }, + }, + }, + query: { + connector: { + async $allOperations({ + operation, + args, + query, + }: { + operation: string; + args: any; + query: (args: any) => Promise; + }) { + if (WRITE_OPERATIONS.has(operation) && args) { + if (operation === 'upsert') { + sealData(args.create); + sealData(args.update); + } else { + sealData(args.data); + } + } + return query(args); + }, + }, + }, +} as const; diff --git a/packages/backend/src/common/prisma.service.ts b/packages/backend/src/common/prisma.service.ts index 82dd276d..4846e565 100644 --- a/packages/backend/src/common/prisma.service.ts +++ b/packages/backend/src/common/prisma.service.ts @@ -1,6 +1,13 @@ -import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '../generated/prisma/client'; +import { + envVarsAtRestExtension, + isEncryptedEnvVars, + openEnvVars, + plaintextAtRest, + sealEnvVars, +} from './crypto/env-vars-at-rest'; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL, @@ -11,18 +18,69 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PrismaService.name); + constructor() { super({ adapter }); + // Hand out the extended client: connector variables are encrypted on + // write and decrypted on read for every caller (see env-vars-at-rest.ts). + // $extends keeps this instance as its prototype, so the methods below + // stay available on what Nest injects. + return this.$extends(envVarsAtRestExtension as any) as unknown as PrismaService; } async onModuleInit() { await this.$connect(); + await this.reconcileEnvVarsAtRest(); } async onModuleDestroy() { await this.$disconnect(); } + /** + * Bring stored connector variables in line with the mode: encrypt rows that + * are still plain (written before encryption existed, or patched by hand), + * or decrypt them all when ENV_VARS_AT_REST=plaintext. Idempotent, so every + * instance can run it at boot. Each row is only replaced if it is unchanged + * since it was read. Never stops the boot: a row it cannot convert stays as + * it is and is still readable. + */ + async reconcileEnvVarsAtRest(): Promise<{ changed: number }> { + const toPlain = plaintextAtRest(); + let changed = 0; + try { + // Raw on purpose: it bypasses the extension and shows what is stored. + const rows = await this.$queryRaw>` + SELECT id, env_vars FROM connectors + WHERE env_vars IS NOT NULL AND jsonb_typeof(env_vars) = 'object' AND env_vars <> '{}'::jsonb`; + for (const row of rows) { + const encrypted = isEncryptedEnvVars(row.env_vars); + const hasPlainKeys = Object.keys(row.env_vars as object).some((k) => k !== '$enc'); + let next: unknown; + if (toPlain) { + if (!encrypted) continue; + next = openEnvVars(row.env_vars); + } else { + if (encrypted && !hasPlainKeys) continue; + next = sealEnvVars(openEnvVars(row.env_vars)); + } + const updated = await this.$executeRaw` + UPDATE connectors SET env_vars = ${JSON.stringify(next)}::jsonb + WHERE id = ${row.id} AND env_vars = ${JSON.stringify(row.env_vars)}::jsonb`; + changed += updated; + } + if (changed > 0) { + this.logger.log( + `${toPlain ? 'Decrypted' : 'Encrypted'} the variables of ${changed} connector${changed === 1 ? '' : 's'}`, + ); + } + } catch (err: any) { + this.logger.error(`connector variables not reconciled: ${err?.message ?? err}`); + } + return { changed }; + } + /** * Run `fn` inside a transaction that has the tenant context set, so Postgres * Row-Level Security policies (`organization_id = current_setting('app.current_org')`) From b5630febc6018e856fb0579582f63d550827f61c Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 3 Oct 2026 14:04:59 +0200 Subject: [PATCH 2/2] env-vars-at-rest spec: walk the tree without stat-then-read --- .../backend/src/common/crypto/env-vars-at-rest.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/common/crypto/env-vars-at-rest.spec.ts b/packages/backend/src/common/crypto/env-vars-at-rest.spec.ts index bb62afff..40bfb3c3 100644 --- a/packages/backend/src/common/crypto/env-vars-at-rest.spec.ts +++ b/packages/backend/src/common/crypto/env-vars-at-rest.spec.ts @@ -1,4 +1,4 @@ -import { readFileSync, readdirSync, statSync } from 'fs'; +import { readFileSync, readdirSync } from 'fs'; import { join } from 'path'; import { ENC_KEY, @@ -111,10 +111,10 @@ describe('connector variables at rest', () => { // would bypass the query extension and store variables in clear. const offenders: string[] = []; const walk = (dir: string) => { - for (const name of readdirSync(dir)) { - const path = join(dir, name); - if (name === 'generated' || name === 'node_modules') continue; - if (statSync(path).isDirectory()) walk(path); + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.name === 'generated' || entry.name === 'node_modules') continue; + if (entry.isDirectory()) walk(path); else if (path.endsWith('.ts') && !path.endsWith('.spec.ts')) { const text = readFileSync(path, 'utf8'); if (/connectors?\s*:\s*\{\s*(create|createMany|connectOrCreate|upsert|update|updateMany)\b/.test(text)) {