Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.cloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
14 changes: 11 additions & 3 deletions docs/operations/backup-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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.

Expand All @@ -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": "<AES-256-GCM>"}` 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.
129 changes: 129 additions & 0 deletions packages/backend/src/common/crypto/env-vars-at-rest.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { readFileSync, readdirSync } 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<string, string>;
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<string, string>;
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 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');
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if (/connectors?\s*:\s*\{\s*(create|createMany|connectOrCreate|upsert|update|updateMany)\b/.test(text)) {
offenders.push(path);
}
}
}
};
walk(join(__dirname, '..', '..'));
expect(offenders).toEqual([]);
});
});
138 changes: 138 additions & 0 deletions packages/backend/src/common/crypto/env-vars-at-rest.ts
Original file line number Diff line number Diff line change
@@ -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": "<AES-256-GCM of the JSON object>" }
*
* 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<string, unknown>;

/** 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<unknown>;
}) {
if (WRITE_OPERATIONS.has(operation) && args) {
if (operation === 'upsert') {
sealData(args.create);
sealData(args.update);
} else {
sealData(args.data);
}
}
return query(args);
},
},
},
} as const;
Loading
Loading