-
Notifications
You must be signed in to change notification settings - Fork 81
Encrypt connector variables at rest #837
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
129 changes: 129 additions & 0 deletions
129
packages/backend/src/common/crypto/env-vars-at-rest.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| if (/connectors?\s*:\s*\{\s*(create|createMany|connectOrCreate|upsert|update|updateMany)\b/.test(text)) { | ||
| offenders.push(path); | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| walk(join(__dirname, '..', '..')); | ||
| expect(offenders).toEqual([]); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.