From e0b4c9918b9e6e2adf89897db43d8d4d72a37aaa Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 13 May 2026 18:15:54 +0200 Subject: [PATCH] feat(twenty-server): introduce ENCRYPTION_KEY env var with versioned envelope (#20528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `ENCRYPTION_KEY` (primary) and `FALLBACK_ENCRYPTION_KEY` (decrypt-only fallback for rotation) env vars to twenty-server, with backward-compatible fallback to `APP_SECRET` when `ENCRYPTION_KEY` is unset. - Introduces a versioned ciphertext envelope `enc:v2::` using AES-256-GCM with HKDF-SHA256 derived per-context keys. The 8-hex `keyId` fingerprint lets every row identify which physical key encrypted it, so rotation routes directly to primary or fallback without trial decryption; GCM's auth tag gives true integrity (legacy CTR has none). - Migrates `ConnectedAccountTokenEncryptionService` to the new envelope and plumbs `workspaceId` through every caller, so per-workspace HKDF context binds each row to its tenant. The remaining encryption sites (`jwt-key-manager`, `config-storage`, `postgres-credentials`, `application-variable`, TOTP) stay on the legacy unprefixed CTR path and will be migrated in follow-up PRs. The operator-facing rotation runbook is out of scope here. ### Format details `enc:v{N}:{keyId}:{base64}` — `N=2` is the only version produced by new writes (`v1` exists for backward-compatible decryption of existing connected-account rows). `keyId = sha256(rawKey).slice(0,4).toString('hex')`. The CHECK constraint on `core.connectedAccount.{accessToken,refreshToken}` is relaxed from `LIKE 'enc:v1:%'` to `LIKE 'enc:v_:%'` so both versions pass. ### Key resolution | `ENCRYPTION_KEY` | `FALLBACK_ENCRYPTION_KEY` | `APP_SECRET` | Encrypt with | Decrypt try order | |---|---|---|---|---| | set | set | (any) | `ENCRYPTION_KEY` | match `keyId` → primary → fallback | | set | unset | (any) | `ENCRYPTION_KEY` | match `keyId` → primary | | unset | set | set | `APP_SECRET` | match `keyId` → `APP_SECRET` → fallback | | unset | unset | set | `APP_SECRET` | match `keyId` → `APP_SECRET` | | unset | unset | unset | startup error | n/a | ## Test plan - [x] `npx nx typecheck twenty-server` — clean - [x] `npx jest 'secret-encryption|connected-account-token-encryption|connected-account-refresh-tokens|encrypt-connected-account-tokens|connection-provider-oauth-flow'` — 87 tests pass - [x] New `secret-encryption.service.versioned.spec.ts` covers: key resolution table (no-key error, APP_SECRET fallback, ENCRYPTION_KEY precedence), v2 round-trip with/without workspaceId, GCM tamper rejection, workspaceId-mismatch rejection, keyId-based primary→fallback routing, missing-key error names the fingerprint, v1 legacy decryption, no-prefix legacy decryption, malformed envelope rejection. - [x] Updated `connected-account-token-encryption.service.spec.ts` covers workspaceId binding and HKDF context isolation. - [x] Updated slow instance command spec verifies workspaceId is threaded through encryption and the relaxed `enc:v_:%` LIKE pattern matches both v1 and v2. - [ ] Manual E2E: connect a Gmail account on a freshly deployed instance with `APP_SECRET` only → confirm `core.connectedAccount.accessToken` is `enc:v2::`. - [ ] Manual E2E: rotate — set `ENCRYPTION_KEY=` and `FALLBACK_ENCRYPTION_KEY=`, restart, confirm pre-rotation rows still decrypt and new rows carry the new `keyId`. - [ ] Manual E2E: missing key — set `ENCRYPTION_KEY=` without the fallback, confirm decrypt error names the old `keyId` so the operator can identify the missing key. --- ...004000-encrypt-connected-account-tokens.ts | 63 ++-- ...0-encrypt-connected-account-tokens.spec.ts | 173 ++++----- ...ection-provider-oauth-flow.service.spec.ts | 27 +- .../connection-provider-oauth-flow.service.ts | 1 + .../services/app-oauth-revoke.service.ts | 7 +- .../create-connected-account.service.ts | 1 + ...-connected-account-on-reconnect.service.ts | 1 + .../constants/secret-encryption.constant.ts | 10 + .../exceptions/secret-encryption.exception.ts | 46 +++ .../secret-encryption.service.ts | 100 +++++- .../types/resolved-encryption-keys.type.ts | 4 + .../types/secret-encryption-envelope.type.ts | 3 + .../decrypt-aes-gcm-v2-or-throw.util.spec.ts | 79 ++++ .../__tests__/encrypt-aes-gcm-v2.util.spec.ts | 77 ++++ ...secret-encryption-envelope-v2.util.spec.ts | 28 ++ ...-encryption-envelope-or-throw.util.spec.ts | 93 +++++ ...yption-key-by-key-id-or-throw.util.spec.ts | 66 ++++ ...olve-encryption-keys-or-throw.util.spec.ts | 83 +++++ .../utils/compute-encryption-key-id.util.ts | 7 + .../utils/decrypt-aes-ctr-or-throw.util.ts | 23 ++ .../utils/decrypt-aes-gcm-v2-or-throw.util.ts | 52 +++ .../utils/derive-gcm-key.util.ts | 30 ++ .../utils/encrypt-aes-ctr.util.ts | 20 ++ .../utils/encrypt-aes-gcm-v2.util.ts | 26 ++ ...rmat-secret-encryption-envelope-v2.util.ts | 10 + ...ecret-encryption-envelope-or-throw.util.ts | 49 +++ ...-encryption-key-by-key-id-or-throw.util.ts | 32 ++ .../resolve-encryption-keys-or-throw.util.ts | 35 ++ .../twenty-config/config-variables.ts | 22 ++ .../entities/connected-account.entity.ts | 4 +- ...ount-token-encryption.service.spec.ts.snap | 5 - ...d-account-token-encryption.service.spec.ts | 151 -------- ...nected-account-token-encryption.service.ts | 105 ++++-- .../google-calendar-get-events.service.ts | 2 +- .../microsoft-calendar-get-events.service.ts | 2 +- ...icrosoft-calendar-import-events.service.ts | 2 +- .../services/calendar-get-events.service.ts | 1 + .../services/oauth2-client-manager.service.ts | 24 +- ...ted-account-refresh-tokens.service.spec.ts | 76 ++-- ...onnected-account-refresh-tokens.service.ts | 23 +- .../services/gmail-get-all-folders.service.ts | 7 +- .../microsoft-get-all-folders.service.ts | 7 +- .../message-folder-driver.interface.ts | 1 + .../sync-message-folders.service.spec.ts | 1 + .../services/sync-message-folders.service.ts | 2 + .../gmail-get-message-list.service.spec.ts | 2 + .../gmail-get-message-list.service.ts | 7 +- .../services/gmail-get-messages.service.ts | 1 + .../imap-get-message-list.service.spec.ts | 2 + .../microsoft-fetch-by-batch.service.ts | 2 +- ...osoft-get-message-list.service.dev.spec.ts | 3 + ...microsoft-get-message-list.service.spec.ts | 2 + .../microsoft-get-message-list.service.ts | 2 +- ...microsoft-get-messages.service.dev.spec.ts | 1 + .../microsoft-get-messages.service.spec.ts | 2 + .../microsoft-get-messages.service.ts | 1 + .../messaging-get-messages.service.ts | 1 + .../types/get-message-lists-args.type.ts | 1 + ...on-variable-encryption.integration-spec.ts | 134 +++++++ ...nnected-account-tokens.integration-spec.ts | 339 ++++++++++++++++++ 60 files changed, 1687 insertions(+), 394 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/constants/secret-encryption.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/types/resolved-encryption-keys.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/types/secret-encryption-envelope.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/decrypt-aes-gcm-v2-or-throw.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/encrypt-aes-gcm-v2.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/format-secret-encryption-envelope-v2.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/parse-secret-encryption-envelope-or-throw.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/pick-encryption-key-by-key-id-or-throw.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/resolve-encryption-keys-or-throw.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/compute-encryption-key-id.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/decrypt-aes-ctr-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/decrypt-aes-gcm-v2-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/derive-gcm-key.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/encrypt-aes-ctr.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/encrypt-aes-gcm-v2.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/format-secret-encryption-envelope-v2.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/parse-secret-encryption-envelope-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/pick-encryption-key-by-key-id-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secret-encryption/utils/resolve-encryption-keys-or-throw.util.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/__snapshots__/connected-account-token-encryption.service.spec.ts.snap delete mode 100644 packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/connected-account-token-encryption.service.spec.ts create mode 100644 packages/twenty-server/test/integration/secret-encryption/application-registration-variable-encryption.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/upgrade/suites/encrypt-connected-account-tokens.integration-spec.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.ts index 43e3006946..8de2f94f12 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.ts @@ -1,11 +1,10 @@ +import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; -import { - CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, - ConnectedAccountTokenEncryptionService, -} from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; const BACKFILL_BATCH_SIZE = 500; @@ -14,12 +13,18 @@ const ACCESS_TOKEN_CHECK_CONSTRAINT_NAME = const REFRESH_TOKEN_CHECK_CONSTRAINT_NAME = 'CHK_connectedAccount_refreshToken_encrypted'; +const V2_ENCRYPTED_LIKE_PATTERN = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%`; + type ConnectedAccountTokenRow = { id: string; + workspaceId: string; accessToken: string | null; refreshToken: string | null; }; +const isPlaintext = (value: string | null): value is string => + isDefined(value) && !value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX); + @RegisteredInstanceCommand('2.5.0', 1798000004000, { type: 'slow' }) export class EncryptConnectedAccountTokensSlowInstanceCommand implements SlowInstanceCommand @@ -29,14 +34,11 @@ export class EncryptConnectedAccountTokensSlowInstanceCommand ) {} async runDataMigration(dataSource: DataSource): Promise { - // Cursor + prefix-filter on the SELECT makes the loop both bounded in - // memory and idempotent: re-runs after a partial failure skip rows that - // were already encrypted on a prior pass. let cursor = '00000000-0000-0000-0000-000000000000'; while (true) { const rows: ConnectedAccountTokenRow[] = await dataSource.query( - `SELECT id, "accessToken", "refreshToken" + `SELECT id, "workspaceId", "accessToken", "refreshToken" FROM "core"."connectedAccount" WHERE id > $1 AND ( @@ -45,11 +47,7 @@ export class EncryptConnectedAccountTokensSlowInstanceCommand ) ORDER BY id LIMIT $3`, - [ - cursor, - `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}%`, - BACKFILL_BATCH_SIZE, - ], + [cursor, V2_ENCRYPTED_LIKE_PATTERN, BACKFILL_BATCH_SIZE], ); if (rows.length === 0) { @@ -59,28 +57,23 @@ export class EncryptConnectedAccountTokensSlowInstanceCommand for (const row of rows) { const sets: string[] = []; const params: unknown[] = [row.id]; - if ( - row.accessToken !== null && - !row.accessToken.startsWith(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX) - ) { + + if (isPlaintext(row.accessToken)) { params.push( - this.connectedAccountTokenEncryptionService.encrypt( - row.accessToken, - ), + this.connectedAccountTokenEncryptionService.encrypt({ + plaintext: row.accessToken, + workspaceId: row.workspaceId, + }), ); sets.push(`"accessToken" = $${params.length}`); } - if ( - row.refreshToken !== null && - !row.refreshToken.startsWith( - CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, - ) - ) { + if (isPlaintext(row.refreshToken)) { params.push( - this.connectedAccountTokenEncryptionService.encrypt( - row.refreshToken, - ), + this.connectedAccountTokenEncryptionService.encrypt({ + plaintext: row.refreshToken, + workspaceId: row.workspaceId, + }), ); sets.push(`"refreshToken" = $${params.length}`); } @@ -105,20 +98,20 @@ export class EncryptConnectedAccountTokensSlowInstanceCommand await queryRunner.query( `ALTER TABLE "core"."connectedAccount" ADD CONSTRAINT "${ACCESS_TOKEN_CHECK_CONSTRAINT_NAME}" - CHECK ("accessToken" IS NULL OR "accessToken" LIKE '${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}%')`, + CHECK ("accessToken" IS NULL OR "accessToken" LIKE '${V2_ENCRYPTED_LIKE_PATTERN}')`, ); await queryRunner.query( `ALTER TABLE "core"."connectedAccount" ADD CONSTRAINT "${REFRESH_TOKEN_CHECK_CONSTRAINT_NAME}" - CHECK ("refreshToken" IS NULL OR "refreshToken" LIKE '${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}%')`, + CHECK ("refreshToken" IS NULL OR "refreshToken" LIKE '${V2_ENCRYPTED_LIKE_PATTERN}')`, ); } + // Deliberately do NOT decrypt rows on rollback — re-introducing plaintext + // tokens to the database would be a security regression. Dropping the + // CHECK constraints is enough; ConnectedAccountTokenEncryptionService can + // still read the encrypted columns whether or not the constraints exist. public async down(queryRunner: QueryRunner): Promise { - // Deliberately do NOT decrypt rows on rollback — re-introducing plaintext - // tokens to the database would be a security regression. Dropping the - // CHECK constraints is enough; ConnectedAccountTokenEncryptionService can - // still read the encrypted columns whether or not the constraints exist. await queryRunner.query( `ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT IF EXISTS "${REFRESH_TOKEN_CHECK_CONSTRAINT_NAME}"`, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/__tests__/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/__tests__/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.spec.ts index 67fece1356..369529f3de 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/__tests__/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.spec.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/__tests__/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens.spec.ts @@ -1,20 +1,22 @@ +import { isDefined } from 'twenty-shared/utils'; import { type DataSource } from 'typeorm'; import { EncryptConnectedAccountTokensSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens'; -import { - CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, - type ConnectedAccountTokenEncryptionService, -} from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { type ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; type FakeRow = { id: string; + workspaceId: string; accessToken: string | null; refreshToken: string | null; }; -// In-memory stand-in that mimics the slow command's exact SELECT / UPDATE -// shape (LIKE filter, cursor, batch) — anything looser would let regressions -// in the SQL slip past these tests. +const FAKE_V2_KEY_ID = 'deadbeef'; + +const wrapAsV2 = (plaintext: string, workspaceId: string): string => + `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}${FAKE_V2_KEY_ID}:CIPHER(${plaintext}|${workspaceId})`; + const buildFakeDataSource = ( initialRows: FakeRow[], { batchSize }: { batchSize: number } = { batchSize: 500 }, @@ -26,23 +28,29 @@ const buildFakeDataSource = ( const rows = [...initialRows].sort((a, b) => a.id.localeCompare(b.id)); let queryCallCount = 0; + const matchesLikePattern = (value: string, pattern: string): boolean => { + const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&'); + const expr = escaped.replace(/%/g, '.*').replace(/_/g, '.'); + + return new RegExp(`^${expr}$`).test(value); + }; + const fakeDataSource = { query: jest.fn(async (sql: string, params?: unknown[]) => { queryCallCount++; if (sql.includes('SELECT id')) { const cursor = params?.[0] as string; - const prefixPattern = params?.[1] as string; - const prefix = prefixPattern.replace(/%$/, ''); + const likePattern = params?.[1] as string; return rows .filter((row) => row.id > cursor) .filter( (row) => - (row.accessToken !== null && - !row.accessToken.startsWith(prefix)) || - (row.refreshToken !== null && - !row.refreshToken.startsWith(prefix)), + (isDefined(row.accessToken) && + !matchesLikePattern(row.accessToken, likePattern)) || + (isDefined(row.refreshToken) && + !matchesLikePattern(row.refreshToken, likePattern)), ) .slice(0, batchSize); } @@ -51,20 +59,19 @@ const buildFakeDataSource = ( const id = params?.[0] as string; const target = rows.find((row) => row.id === id); - if (!target) { + if (!isDefined(target)) { return; } - // Mirror the SQL: SET "accessToken" = $N, "refreshToken" = $M WHERE id = $1 const accessTokenMatch = sql.match(/"accessToken" = \$(\d+)/); const refreshTokenMatch = sql.match(/"refreshToken" = \$(\d+)/); - if (accessTokenMatch) { + if (isDefined(accessTokenMatch)) { target.accessToken = params?.[ Number(accessTokenMatch[1]) - 1 ] as string; } - if (refreshTokenMatch) { + if (isDefined(refreshTokenMatch)) { target.refreshToken = params?.[ Number(refreshTokenMatch[1]) - 1 ] as string; @@ -84,109 +91,118 @@ const buildFakeDataSource = ( }; }; +const buildFakeTokenEncryptionService = + (): ConnectedAccountTokenEncryptionService => + ({ + encrypt: jest.fn( + ({ + plaintext, + workspaceId, + }: { + plaintext: string; + workspaceId: string; + }): string => wrapAsV2(plaintext, workspaceId), + ), + }) as unknown as ConnectedAccountTokenEncryptionService; + +const buildCommand = (): { + command: EncryptConnectedAccountTokensSlowInstanceCommand; +} => { + const command = new EncryptConnectedAccountTokensSlowInstanceCommand( + buildFakeTokenEncryptionService(), + ); + + return { command }; +}; + describe('EncryptConnectedAccountTokensSlowInstanceCommand', () => { - // Real AES round-trip is asserted in ConnectedAccountTokenEncryptionService's - // own spec; here we use a CIPHER(...) wrapper so assertions match exact strings. - const buildFakeTokenEncryptionService = - (): ConnectedAccountTokenEncryptionService => - ({ - encrypt: jest.fn( - (plaintext: string): string => - `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${plaintext})`, - ), - }) as unknown as ConnectedAccountTokenEncryptionService; - - const buildCommand = (): { - command: EncryptConnectedAccountTokensSlowInstanceCommand; - connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService; - } => { - const connectedAccountTokenEncryptionService = - buildFakeTokenEncryptionService(); - const command = new EncryptConnectedAccountTokensSlowInstanceCommand( - connectedAccountTokenEncryptionService, - ); - - return { command, connectedAccountTokenEncryptionService }; - }; - describe('runDataMigration', () => { - it('should encrypt every legacy plaintext row and leave already-prefixed rows untouched', async () => { - const alreadyEncrypted = `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}preexisting-ciphertext`; + it('upgrades plaintext rows to v2 with workspaceId threaded through, and leaves v2 rows untouched', async () => { + const wsA = '11111111-1111-1111-1111-111111111111'; + const wsB = '22222222-2222-2222-2222-222222222222'; + const alreadyV2 = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}cafebabe:preexisting-v2-ciphertext`; const { dataSource, rows } = buildFakeDataSource([ { id: 'aaaaaaaa-0000-0000-0000-000000000001', + workspaceId: wsA, accessToken: 'plaintext-access-1', refreshToken: 'plaintext-refresh-1', }, { id: 'bbbbbbbb-0000-0000-0000-000000000002', - accessToken: alreadyEncrypted, - refreshToken: alreadyEncrypted, + workspaceId: wsB, + accessToken: alreadyV2, + refreshToken: alreadyV2, }, { id: 'cccccccc-0000-0000-0000-000000000003', + workspaceId: wsB, accessToken: 'plaintext-access-3', refreshToken: null, }, ]); - const { command } = buildCommand(); - - await command.runDataMigration(dataSource); + await buildCommand().command.runDataMigration(dataSource); expect(rows()).toEqual([ { id: 'aaaaaaaa-0000-0000-0000-000000000001', - accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-access-1)`, - refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-refresh-1)`, + workspaceId: wsA, + accessToken: wrapAsV2('plaintext-access-1', wsA), + refreshToken: wrapAsV2('plaintext-refresh-1', wsA), }, { id: 'bbbbbbbb-0000-0000-0000-000000000002', - accessToken: alreadyEncrypted, - refreshToken: alreadyEncrypted, + workspaceId: wsB, + accessToken: alreadyV2, + refreshToken: alreadyV2, }, { id: 'cccccccc-0000-0000-0000-000000000003', - accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-access-3)`, + workspaceId: wsB, + accessToken: wrapAsV2('plaintext-access-3', wsB), refreshToken: null, }, ]); }); - // Regression guard: the SELECT filter is per-row (one column unencrypted is - // enough to fetch the row), so the loop body sees rows where one column is - // already prefixed and the other isn't. The per-cell prefix check inside - // the loop is what prevents the prefixed column from being double-encrypted - // into `enc:v1:CIPHER(enc:v1:...)`. If that check ever regresses, this is - // the test that should fail. - it('should only encrypt the plaintext column when a row mixes encrypted and plaintext tokens', async () => { - const alreadyEncryptedAccess = `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}preexisting-access-cipher`; + // Regression guard: the SELECT filter is per-row (one non-v2 column is + // enough to fetch the row), so the loop sees rows where one column is + // already v2 and the other is plaintext. The per-cell guard inside the + // loop is what prevents the v2 column from being double-encrypted. + it('only rewrites the non-v2 column when a row mixes v2 and plaintext', async () => { + const ws = '33333333-3333-3333-3333-333333333333'; + const alreadyV2 = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}cafebabe:preexisting-v2-access`; const { dataSource, rows } = buildFakeDataSource([ { id: 'aaaaaaaa-0000-0000-0000-000000000001', - accessToken: alreadyEncryptedAccess, + workspaceId: ws, + accessToken: alreadyV2, refreshToken: 'plaintext-refresh-mixed', }, ]); - const { command } = buildCommand(); - - await command.runDataMigration(dataSource); + await buildCommand().command.runDataMigration(dataSource); expect(rows()).toEqual([ { id: 'aaaaaaaa-0000-0000-0000-000000000001', - accessToken: alreadyEncryptedAccess, - refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-refresh-mixed)`, + workspaceId: ws, + accessToken: alreadyV2, + refreshToken: wrapAsV2('plaintext-refresh-mixed', ws), }, ]); }); - it('should be idempotent — re-running on already-migrated data leaves it unchanged', async () => { + + it('is idempotent — a second run leaves already-migrated data unchanged', async () => { + const ws = '44444444-4444-4444-4444-444444444444'; + const { dataSource, rows } = buildFakeDataSource([ { id: 'aaaaaaaa-0000-0000-0000-000000000001', + workspaceId: ws, accessToken: 'plaintext-token', refreshToken: null, }, @@ -197,7 +213,8 @@ describe('EncryptConnectedAccountTokensSlowInstanceCommand', () => { const expectedFinalState = [ { id: 'aaaaaaaa-0000-0000-0000-000000000001', - accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-token)`, + workspaceId: ws, + accessToken: wrapAsV2('plaintext-token', ws), refreshToken: null, }, ]; @@ -209,11 +226,11 @@ describe('EncryptConnectedAccountTokensSlowInstanceCommand', () => { expect(rows()).toEqual(expectedFinalState); }); - it('should paginate through more rows than the batch size', async () => { - // 1100 rows + batch size 500 → at least 3 SELECT batches. + it('paginates through more rows than the batch size', async () => { + const ws = '55555555-5555-5555-5555-555555555555'; const initialRows: FakeRow[] = Array.from({ length: 1100 }, (_, idx) => ({ - // Lex-sortable hex IDs so the cursor advance works the way the SQL does. id: `${idx.toString(16).padStart(12, '0')}-0000-0000-0000-000000000000`, + workspaceId: ws, accessToken: `plaintext-${idx}`, refreshToken: null, })); @@ -223,21 +240,15 @@ describe('EncryptConnectedAccountTokensSlowInstanceCommand', () => { { batchSize: 500 }, ); - const { command } = buildCommand(); + await buildCommand().command.runDataMigration(dataSource); - await command.runDataMigration(dataSource); - - // Every row got encrypted expect( rows().every((row) => - row.accessToken!.startsWith( - CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, - ), + row.accessToken!.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX), ), ).toBe(true); - // Sanity check: at least the expected number of SELECT batches happened - // (3 SELECTs for 500/500/100 + 1 final empty SELECT + 1100 UPDATEs) + // 3 SELECT batches (500/500/100) + 1 final empty SELECT + 1100 UPDATEs. expect(queryCallCount()).toBeGreaterThanOrEqual(1100 + 4); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts index 0375720f0f..d64bafa1e3 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts @@ -13,6 +13,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ConnectedAccountProvider } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; import { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service'; @@ -21,11 +22,11 @@ import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-contex import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; -import { - CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, - ConnectedAccountTokenEncryptionService, -} from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; + +const FAKE_CIPHER_PREFIX = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}keyid:`; describe('ConnectionProviderOAuthFlowService', () => { let service: ConnectionProviderOAuthFlowService; @@ -125,12 +126,12 @@ describe('ConnectionProviderOAuthFlowService', () => { }: { accessToken: string; refreshToken: string | null; + workspaceId: string; }) => ({ - encryptedAccessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${accessToken})`, - encryptedRefreshToken: - refreshToken === null - ? null - : `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${refreshToken})`, + encryptedAccessToken: `${FAKE_CIPHER_PREFIX}CIPHER(${accessToken})`, + encryptedRefreshToken: isDefined(refreshToken) + ? `${FAKE_CIPHER_PREFIX}CIPHER(${refreshToken})` + : null, }), ), }, @@ -344,8 +345,8 @@ describe('ConnectionProviderOAuthFlowService', () => { expect(connectedAccountRepository.create).toHaveBeenCalledWith( expect.objectContaining({ provider: ConnectedAccountProvider.APP, - accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(new_access)`, - refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(new_refresh)`, + accessToken: `${FAKE_CIPHER_PREFIX}CIPHER(new_access)`, + refreshToken: `${FAKE_CIPHER_PREFIX}CIPHER(new_refresh)`, connectionProviderId: 'provider-1', applicationId: 'app-1', workspaceId: 'workspace-1', @@ -372,8 +373,8 @@ describe('ConnectionProviderOAuthFlowService', () => { expect(connectedAccountRepository.update).toHaveBeenCalledWith( { id: 'existing-account-id', workspaceId: 'workspace-1' }, expect.objectContaining({ - accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(new_access)`, - refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(new_refresh)`, + accessToken: `${FAKE_CIPHER_PREFIX}CIPHER(new_access)`, + refreshToken: `${FAKE_CIPHER_PREFIX}CIPHER(new_refresh)`, authFailedAt: null, visibility: 'user', }), diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts index 39c902c2b3..34e5f6c241 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts @@ -248,6 +248,7 @@ export class ConnectionProviderOAuthFlowService { this.connectedAccountTokenEncryptionService.encryptTokenPair({ accessToken: tokenResponse.accessToken, refreshToken: tokenResponse.refreshToken, + workspaceId, }); const sharedFields = { diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service.ts index e7caedcc0c..242f384a07 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service.ts @@ -44,9 +44,10 @@ export class AppOAuthRevokeService { try { const decryptedAccessToken = - this.connectedAccountTokenEncryptionService.decrypt( - connectedAccount.accessToken, - ); + this.connectedAccountTokenEncryptionService.decrypt({ + ciphertext: connectedAccount.accessToken, + workspaceId: connectedAccount.workspaceId, + }); const response = await this.secureHttpClientService.createSsrfSafeFetch()( revokeEndpoint, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/create-connected-account.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/create-connected-account.service.ts index 0eded98118..e1673d7f5b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/create-connected-account.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/create-connected-account.service.ts @@ -91,6 +91,7 @@ export class CreateConnectedAccountService { this.connectedAccountTokenEncryptionService.encryptTokenPair({ accessToken, refreshToken, + workspaceId, }); await input.transactionManager diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts index 8edee9421d..30b57ec7c0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts @@ -38,6 +38,7 @@ export class UpdateConnectedAccountOnReconnectService { this.connectedAccountTokenEncryptionService.encryptTokenPair({ accessToken, refreshToken, + workspaceId, }); const authContext = buildSystemAuthContext(workspaceId); diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/constants/secret-encryption.constant.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/constants/secret-encryption.constant.ts new file mode 100644 index 0000000000..8129236a41 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/constants/secret-encryption.constant.ts @@ -0,0 +1,10 @@ +export const SECRET_ENCRYPTION_ENVELOPE_PREFIX = 'enc:'; +export const SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX = 'enc:v2:'; + +export const SECRET_ENCRYPTION_KEY_ID_REGEX = /^[0-9a-f]{8}$/; + +export const SECRET_ENCRYPTION_GCM_IV_LENGTH = 12; +export const SECRET_ENCRYPTION_GCM_TAG_LENGTH = 16; +export const SECRET_ENCRYPTION_DERIVED_KEY_LENGTH = 32; +export const SECRET_ENCRYPTION_HKDF_INFO_PREFIX = 'twenty:enc:v2:'; +export const SECRET_ENCRYPTION_INSTANCE_CONTEXT = 'instance'; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception.ts new file mode 100644 index 0000000000..2609b7352b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception.ts @@ -0,0 +1,46 @@ +import { type MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { assertUnreachable } from 'twenty-shared/utils'; + +import { CustomException } from 'src/utils/custom-exception'; + +export enum SecretEncryptionExceptionCode { + NO_ENCRYPTION_KEY_CONFIGURED = 'NO_ENCRYPTION_KEY_CONFIGURED', + UNKNOWN_KEY_ID = 'UNKNOWN_KEY_ID', + MALFORMED_ENVELOPE = 'MALFORMED_ENVELOPE', + UNKNOWN_ENVELOPE_VERSION = 'UNKNOWN_ENVELOPE_VERSION', + INVALID_KEY_ID_FORMAT = 'INVALID_KEY_ID_FORMAT', + CIPHERTEXT_TOO_SHORT = 'CIPHERTEXT_TOO_SHORT', + ALREADY_ENCRYPTED = 'ALREADY_ENCRYPTED', +} + +const getSecretEncryptionExceptionUserFriendlyMessage = ( + code: SecretEncryptionExceptionCode, +) => { + switch (code) { + case SecretEncryptionExceptionCode.NO_ENCRYPTION_KEY_CONFIGURED: + case SecretEncryptionExceptionCode.UNKNOWN_KEY_ID: + case SecretEncryptionExceptionCode.MALFORMED_ENVELOPE: + case SecretEncryptionExceptionCode.UNKNOWN_ENVELOPE_VERSION: + case SecretEncryptionExceptionCode.INVALID_KEY_ID_FORMAT: + case SecretEncryptionExceptionCode.CIPHERTEXT_TOO_SHORT: + case SecretEncryptionExceptionCode.ALREADY_ENCRYPTED: + return msg`An internal error occurred while handling encrypted data.`; + default: + assertUnreachable(code); + } +}; + +export class SecretEncryptionException extends CustomException { + constructor( + message: string, + code: SecretEncryptionExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, + ) { + super(message, code, { + userFriendlyMessage: + userFriendlyMessage ?? + getSecretEncryptionExceptionUserFriendlyMessage(code), + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.ts index 53587d9407..57ec01b2cf 100644 --- a/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.ts +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.ts @@ -1,41 +1,57 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; -import { - decryptText, - encryptText, -} from 'src/engine/core-modules/auth/auth.util'; import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; +import { computeEncryptionKeyId } from './utils/compute-encryption-key-id.util'; +import { decryptAesCtrOrThrow } from './utils/decrypt-aes-ctr-or-throw.util'; +import { decryptAesGcmV2OrThrow } from './utils/decrypt-aes-gcm-v2-or-throw.util'; +import { encryptAesCtr } from './utils/encrypt-aes-ctr.util'; +import { encryptAesGcmV2 } from './utils/encrypt-aes-gcm-v2.util'; +import { formatSecretEncryptionEnvelopeV2 } from './utils/format-secret-encryption-envelope-v2.util'; +import { parseSecretEncryptionEnvelopeOrThrow } from './utils/parse-secret-encryption-envelope-or-throw.util'; +import { pickEncryptionKeyByKeyIdOrThrow } from './utils/pick-encryption-key-by-key-id-or-throw.util'; +import { resolveEncryptionKeysOrThrow } from './utils/resolve-encryption-keys-or-throw.util'; + +type VersionedOptions = { + workspaceId?: string; +}; + @Injectable() export class SecretEncryptionService { + private readonly logger = new Logger(SecretEncryptionService.name); + private hasLoggedLegacyDecryption = false; + constructor( private readonly environmentConfigDriver: EnvironmentConfigDriver, ) {} - private getAppSecret(): string { - return this.environmentConfigDriver.get('APP_SECRET'); - } - public encrypt(value: string): string { if (!isDefined(value)) { return value; } - const appSecret = this.getAppSecret(); + const { primary } = resolveEncryptionKeysOrThrow({ + environmentConfigDriver: this.environmentConfigDriver, + }); - return encryptText(value, appSecret); + return encryptAesCtr({ plaintext: value, rawKey: primary }); } + // Legacy CTR has no integrity tag, so a wrong key produces an arbitrary + // byte sequence rather than throwing. Rotation of these rows requires + // migrating the consumer to the versioned envelope first. public decrypt(value: string): string { if (!isDefined(value)) { return value; } - const appSecret = this.getAppSecret(); + const { primary } = resolveEncryptionKeysOrThrow({ + environmentConfigDriver: this.environmentConfigDriver, + }); - return decryptText(value, appSecret); + return decryptAesCtrOrThrow({ ciphertext: value, rawKey: primary }); } public decryptAndMask({ @@ -50,7 +66,6 @@ export class SecretEncryptionService { } const decryptedValue = this.decrypt(value); - const visibleCharsCount = Math.min( 5, Math.floor(decryptedValue.length / 10), @@ -58,4 +73,61 @@ export class SecretEncryptionService { return `${decryptedValue.slice(0, visibleCharsCount)}${mask}`; } + + public encryptVersioned(value: string, opts: VersionedOptions = {}): string { + if (!isDefined(value)) { + return value; + } + + const { primary } = resolveEncryptionKeysOrThrow({ + environmentConfigDriver: this.environmentConfigDriver, + }); + const payloadBase64 = encryptAesGcmV2({ + plaintext: value, + rawKey: primary, + workspaceId: opts.workspaceId, + }); + const keyId = computeEncryptionKeyId({ rawKey: primary }); + + return formatSecretEncryptionEnvelopeV2({ keyId, payloadBase64 }); + } + + public decryptVersioned(value: string, opts: VersionedOptions = {}): string { + if (!isDefined(value)) { + return value; + } + + const parsed = parseSecretEncryptionEnvelopeOrThrow({ value }); + + if (parsed.version === 2) { + const keys = resolveEncryptionKeysOrThrow({ + environmentConfigDriver: this.environmentConfigDriver, + }); + const rawKey = pickEncryptionKeyByKeyIdOrThrow({ + keyId: parsed.keyId, + keys, + }); + + return decryptAesGcmV2OrThrow({ + payloadBase64: parsed.payload, + rawKey, + workspaceId: opts.workspaceId, + }); + } + + this.warnLegacyDecryptionOnce(); + + return this.decrypt(value); + } + + private warnLegacyDecryptionOnce(): void { + if (this.hasLoggedLegacyDecryption) { + return; + } + + this.hasLoggedLegacyDecryption = true; + this.logger.warn( + 'Decrypted a legacy unprefixed ciphertext. These rows should be re-encrypted into the enc:v2 envelope in a follow-up migration.', + ); + } } diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/types/resolved-encryption-keys.type.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/types/resolved-encryption-keys.type.ts new file mode 100644 index 0000000000..cb6a6494a1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/types/resolved-encryption-keys.type.ts @@ -0,0 +1,4 @@ +export type ResolvedEncryptionKeys = { + primary: string; + fallback: string | null; +}; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/types/secret-encryption-envelope.type.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/types/secret-encryption-envelope.type.ts new file mode 100644 index 0000000000..61b39c0049 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/types/secret-encryption-envelope.type.ts @@ -0,0 +1,3 @@ +export type ParsedSecretEncryptionEnvelope = + | { version: 2; keyId: string; payload: string } + | { version: null }; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/decrypt-aes-gcm-v2-or-throw.util.spec.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/decrypt-aes-gcm-v2-or-throw.util.spec.ts new file mode 100644 index 0000000000..9ade1e6935 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/decrypt-aes-gcm-v2-or-throw.util.spec.ts @@ -0,0 +1,79 @@ +import { + SecretEncryptionException, + SecretEncryptionExceptionCode, +} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception'; +import { decryptAesGcmV2OrThrow } from 'src/engine/core-modules/secret-encryption/utils/decrypt-aes-gcm-v2-or-throw.util'; +import { encryptAesGcmV2 } from 'src/engine/core-modules/secret-encryption/utils/encrypt-aes-gcm-v2.util'; + +describe('decryptAesGcmV2OrThrow', () => { + const KEY = 'gcm-test-key-zzzz1234567890abcdefghijkl'; + + it('throws when decrypting with a different workspaceId (HKDF context binding)', () => { + const ciphertext = encryptAesGcmV2({ + plaintext: 'plaintext', + rawKey: KEY, + workspaceId: 'ws-1', + }); + + expect(() => + decryptAesGcmV2OrThrow({ + payloadBase64: ciphertext, + rawKey: KEY, + workspaceId: 'ws-2', + }), + ).toThrow(); + }); + + it('throws when decrypting with a different key', () => { + const ciphertext = encryptAesGcmV2({ + plaintext: 'plaintext', + rawKey: KEY, + workspaceId: 'ws-1', + }); + + expect(() => + decryptAesGcmV2OrThrow({ + payloadBase64: ciphertext, + rawKey: 'wrong-key', + workspaceId: 'ws-1', + }), + ).toThrow(); + }); + + it('throws when the ciphertext payload has been tampered with (GCM auth tag)', () => { + const ciphertext = encryptAesGcmV2({ + plaintext: 'plaintext', + rawKey: KEY, + workspaceId: 'ws-1', + }); + // Base64 alphabet collisions and padding can make a 1-char flip a no-op. + // Decode, flip one byte in the middle, re-encode. + const buffer = Buffer.from(ciphertext, 'base64'); + const middle = Math.floor(buffer.length / 2); + + buffer[middle] = buffer[middle] ^ 0xff; + const tampered = buffer.toString('base64'); + + expect(() => + decryptAesGcmV2OrThrow({ + payloadBase64: tampered, + rawKey: KEY, + workspaceId: 'ws-1', + }), + ).toThrow(); + }); + + it('throws CIPHERTEXT_TOO_SHORT on a payload that cannot contain IV + tag', () => { + expect(() => + decryptAesGcmV2OrThrow({ + payloadBase64: 'AAAA', + rawKey: KEY, + workspaceId: 'ws-1', + }), + ).toThrow( + expect.objectContaining({ + code: SecretEncryptionExceptionCode.CIPHERTEXT_TOO_SHORT, + }) as SecretEncryptionException, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/encrypt-aes-gcm-v2.util.spec.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/encrypt-aes-gcm-v2.util.spec.ts new file mode 100644 index 0000000000..a7e5221147 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/encrypt-aes-gcm-v2.util.spec.ts @@ -0,0 +1,77 @@ +import { decryptAesGcmV2OrThrow } from 'src/engine/core-modules/secret-encryption/utils/decrypt-aes-gcm-v2-or-throw.util'; +import { encryptAesGcmV2 } from 'src/engine/core-modules/secret-encryption/utils/encrypt-aes-gcm-v2.util'; + +describe('encryptAesGcmV2', () => { + const KEY = 'gcm-test-key-zzzz1234567890abcdefghijkl'; + + it('produces a base64 payload that round-trips with workspaceId context', () => { + const ciphertext = encryptAesGcmV2({ + plaintext: 'plaintext', + rawKey: KEY, + workspaceId: 'ws-1', + }); + + expect( + decryptAesGcmV2OrThrow({ + payloadBase64: ciphertext, + rawKey: KEY, + workspaceId: 'ws-1', + }), + ).toBe('plaintext'); + }); + + it('round-trips with no workspaceId (instance context)', () => { + const ciphertext = encryptAesGcmV2({ plaintext: 'plaintext', rawKey: KEY }); + + expect( + decryptAesGcmV2OrThrow({ payloadBase64: ciphertext, rawKey: KEY }), + ).toBe('plaintext'); + }); + + it('produces a different ciphertext for the same plaintext under a different workspaceId', () => { + const a = encryptAesGcmV2({ + plaintext: 'plaintext', + rawKey: KEY, + workspaceId: 'ws-1', + }); + const b = encryptAesGcmV2({ + plaintext: 'plaintext', + rawKey: KEY, + workspaceId: 'ws-2', + }); + + expect(a).not.toBe(b); + }); + + it('produces a different ciphertext on every call (random IV)', () => { + const a = encryptAesGcmV2({ + plaintext: 'plaintext', + rawKey: KEY, + workspaceId: 'ws-1', + }); + const b = encryptAesGcmV2({ + plaintext: 'plaintext', + rawKey: KEY, + workspaceId: 'ws-1', + }); + + expect(a).not.toBe(b); + }); + + it('handles unicode and long plaintexts', () => { + const plaintext = 'secret-with-émojis-🔐-and-中文-' + 'a'.repeat(2000); + const ciphertext = encryptAesGcmV2({ + plaintext, + rawKey: KEY, + workspaceId: 'ws-1', + }); + + expect( + decryptAesGcmV2OrThrow({ + payloadBase64: ciphertext, + rawKey: KEY, + workspaceId: 'ws-1', + }), + ).toBe(plaintext); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/format-secret-encryption-envelope-v2.util.spec.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/format-secret-encryption-envelope-v2.util.spec.ts new file mode 100644 index 0000000000..70969860db --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/format-secret-encryption-envelope-v2.util.spec.ts @@ -0,0 +1,28 @@ +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { formatSecretEncryptionEnvelopeV2 } from 'src/engine/core-modules/secret-encryption/utils/format-secret-encryption-envelope-v2.util'; +import { parseSecretEncryptionEnvelopeOrThrow } from 'src/engine/core-modules/secret-encryption/utils/parse-secret-encryption-envelope-or-throw.util'; + +describe('formatSecretEncryptionEnvelopeV2', () => { + it('concatenates the v2 prefix, keyId, and payload', () => { + expect( + formatSecretEncryptionEnvelopeV2({ + keyId: 'abcd1234', + payloadBase64: 'payload', + }), + ).toBe(`${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}abcd1234:payload`); + }); + + it('round-trips with parseSecretEncryptionEnvelopeOrThrow', () => { + const envelope = formatSecretEncryptionEnvelopeV2({ + keyId: 'deadbeef', + payloadBase64: 'cipherpayload', + }); + const parsed = parseSecretEncryptionEnvelopeOrThrow({ value: envelope }); + + expect(parsed).toEqual({ + version: 2, + keyId: 'deadbeef', + payload: 'cipherpayload', + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/parse-secret-encryption-envelope-or-throw.util.spec.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/parse-secret-encryption-envelope-or-throw.util.spec.ts new file mode 100644 index 0000000000..f87181fc8c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/parse-secret-encryption-envelope-or-throw.util.spec.ts @@ -0,0 +1,93 @@ +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { + SecretEncryptionException, + SecretEncryptionExceptionCode, +} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception'; +import { parseSecretEncryptionEnvelopeOrThrow } from 'src/engine/core-modules/secret-encryption/utils/parse-secret-encryption-envelope-or-throw.util'; + +describe('parseSecretEncryptionEnvelopeOrThrow', () => { + it('returns version: null for an unprefixed value', () => { + expect( + parseSecretEncryptionEnvelopeOrThrow({ value: 'opaque-base64-string' }), + ).toEqual({ version: null }); + }); + + it('returns version: null for the empty string', () => { + expect(parseSecretEncryptionEnvelopeOrThrow({ value: '' })).toEqual({ + version: null, + }); + }); + + it('parses a v2 envelope, splitting keyId and payload', () => { + expect( + parseSecretEncryptionEnvelopeOrThrow({ + value: `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}deadbeef:cipherpayload`, + }), + ).toEqual({ version: 2, keyId: 'deadbeef', payload: 'cipherpayload' }); + }); + + it('throws MALFORMED_ENVELOPE on a v2 envelope missing the keyId separator', () => { + expect(() => + parseSecretEncryptionEnvelopeOrThrow({ + value: `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}no-separator`, + }), + ).toThrow( + expect.objectContaining({ + code: SecretEncryptionExceptionCode.MALFORMED_ENVELOPE, + }) as SecretEncryptionException, + ); + }); + + it('throws MALFORMED_ENVELOPE on a v2 envelope with an empty keyId', () => { + expect(() => + parseSecretEncryptionEnvelopeOrThrow({ + value: `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}:payload`, + }), + ).toThrow( + expect.objectContaining({ + code: SecretEncryptionExceptionCode.MALFORMED_ENVELOPE, + }) as SecretEncryptionException, + ); + }); + + it('throws INVALID_KEY_ID_FORMAT when keyId is not 8 hex characters', () => { + expect(() => + parseSecretEncryptionEnvelopeOrThrow({ + value: `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}NOTHEX!!:payload`, + }), + ).toThrow( + expect.objectContaining({ + code: SecretEncryptionExceptionCode.INVALID_KEY_ID_FORMAT, + }) as SecretEncryptionException, + ); + }); + + it('throws INVALID_KEY_ID_FORMAT when keyId is shorter than 8 chars', () => { + expect(() => + parseSecretEncryptionEnvelopeOrThrow({ + value: `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}abc:payload`, + }), + ).toThrow( + expect.objectContaining({ + code: SecretEncryptionExceptionCode.INVALID_KEY_ID_FORMAT, + }) as SecretEncryptionException, + ); + }); + + it('throws UNKNOWN_ENVELOPE_VERSION on an unknown envelope version (including the dropped v1)', () => { + expect(() => + parseSecretEncryptionEnvelopeOrThrow({ value: 'enc:v1:legacy' }), + ).toThrow( + expect.objectContaining({ + code: SecretEncryptionExceptionCode.UNKNOWN_ENVELOPE_VERSION, + }) as SecretEncryptionException, + ); + expect(() => + parseSecretEncryptionEnvelopeOrThrow({ value: 'enc:v99:whatever' }), + ).toThrow( + expect.objectContaining({ + code: SecretEncryptionExceptionCode.UNKNOWN_ENVELOPE_VERSION, + }) as SecretEncryptionException, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/pick-encryption-key-by-key-id-or-throw.util.spec.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/pick-encryption-key-by-key-id-or-throw.util.spec.ts new file mode 100644 index 0000000000..df1f973878 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/pick-encryption-key-by-key-id-or-throw.util.spec.ts @@ -0,0 +1,66 @@ +import { + SecretEncryptionException, + SecretEncryptionExceptionCode, +} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception'; +import { computeEncryptionKeyId } from 'src/engine/core-modules/secret-encryption/utils/compute-encryption-key-id.util'; +import { pickEncryptionKeyByKeyIdOrThrow } from 'src/engine/core-modules/secret-encryption/utils/pick-encryption-key-by-key-id-or-throw.util'; + +describe('pickEncryptionKeyByKeyIdOrThrow', () => { + const PRIMARY = 'primary-key-1234567890abcdefghij'; + const FALLBACK = 'fallback-key-zyxwvutsrqponmlkjihgf'; + + it('returns the primary key when its fingerprint matches', () => { + const keyId = computeEncryptionKeyId({ rawKey: PRIMARY }); + + expect( + pickEncryptionKeyByKeyIdOrThrow({ + keyId, + keys: { primary: PRIMARY, fallback: null }, + }), + ).toBe(PRIMARY); + }); + + it('returns the fallback key when its fingerprint matches', () => { + const keyId = computeEncryptionKeyId({ rawKey: FALLBACK }); + + expect( + pickEncryptionKeyByKeyIdOrThrow({ + keyId, + keys: { primary: PRIMARY, fallback: FALLBACK }, + }), + ).toBe(FALLBACK); + }); + + it('prefers primary when both fingerprints would match', () => { + const keyId = computeEncryptionKeyId({ rawKey: PRIMARY }); + + expect( + pickEncryptionKeyByKeyIdOrThrow({ + keyId, + keys: { primary: PRIMARY, fallback: PRIMARY }, + }), + ).toBe(PRIMARY); + }); + + it('throws UNKNOWN_KEY_ID when no configured key matches', () => { + expect(() => + pickEncryptionKeyByKeyIdOrThrow({ + keyId: 'deadbeef', + keys: { primary: PRIMARY, fallback: null }, + }), + ).toThrow( + expect.objectContaining({ + code: SecretEncryptionExceptionCode.UNKNOWN_KEY_ID, + }) as SecretEncryptionException, + ); + }); + + it('error message names the missing keyId for operator diagnostics', () => { + expect(() => + pickEncryptionKeyByKeyIdOrThrow({ + keyId: 'deadbeef', + keys: { primary: PRIMARY, fallback: null }, + }), + ).toThrow(/keyId 'deadbeef'/); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/resolve-encryption-keys-or-throw.util.spec.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/resolve-encryption-keys-or-throw.util.spec.ts new file mode 100644 index 0000000000..bccf5059ea --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/__tests__/resolve-encryption-keys-or-throw.util.spec.ts @@ -0,0 +1,83 @@ +import { + SecretEncryptionException, + SecretEncryptionExceptionCode, +} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception'; +import { resolveEncryptionKeysOrThrow } from 'src/engine/core-modules/secret-encryption/utils/resolve-encryption-keys-or-throw.util'; +import { type EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; + +type EnvMap = Partial<{ + ENCRYPTION_KEY: string; + FALLBACK_ENCRYPTION_KEY: string; + APP_SECRET: string; +}>; + +const buildDriver = (env: EnvMap): Pick => ({ + get: jest.fn((key: keyof EnvMap) => env[key]) as never, +}); + +describe('resolveEncryptionKeysOrThrow', () => { + it('throws NO_ENCRYPTION_KEY_CONFIGURED when no key is set', () => { + expect(() => + resolveEncryptionKeysOrThrow({ + environmentConfigDriver: buildDriver({}), + }), + ).toThrow( + expect.objectContaining({ + code: SecretEncryptionExceptionCode.NO_ENCRYPTION_KEY_CONFIGURED, + }) as SecretEncryptionException, + ); + }); + + it('uses APP_SECRET as primary when ENCRYPTION_KEY is unset', () => { + const keys = resolveEncryptionKeysOrThrow({ + environmentConfigDriver: buildDriver({ APP_SECRET: 'app' }), + }); + + expect(keys.primary).toBe('app'); + expect(keys.fallback).toBeNull(); + }); + + it('prefers ENCRYPTION_KEY over APP_SECRET when both are set', () => { + const keys = resolveEncryptionKeysOrThrow({ + environmentConfigDriver: buildDriver({ + ENCRYPTION_KEY: 'new', + APP_SECRET: 'old', + }), + }); + + expect(keys.primary).toBe('new'); + }); + + it('exposes FALLBACK_ENCRYPTION_KEY when set', () => { + const keys = resolveEncryptionKeysOrThrow({ + environmentConfigDriver: buildDriver({ + ENCRYPTION_KEY: 'new', + FALLBACK_ENCRYPTION_KEY: 'old', + }), + }); + + expect(keys.primary).toBe('new'); + expect(keys.fallback).toBe('old'); + }); + + it('returns null fallback when FALLBACK_ENCRYPTION_KEY is unset', () => { + const keys = resolveEncryptionKeysOrThrow({ + environmentConfigDriver: buildDriver({ ENCRYPTION_KEY: 'new' }), + }); + + expect(keys.fallback).toBeNull(); + }); + + it('treats empty-string env vars as unset', () => { + const keys = resolveEncryptionKeysOrThrow({ + environmentConfigDriver: buildDriver({ + ENCRYPTION_KEY: '', + APP_SECRET: 'app', + FALLBACK_ENCRYPTION_KEY: '', + }), + }); + + expect(keys.primary).toBe('app'); + expect(keys.fallback).toBeNull(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/compute-encryption-key-id.util.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/compute-encryption-key-id.util.ts new file mode 100644 index 0000000000..42362a7c9c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/compute-encryption-key-id.util.ts @@ -0,0 +1,7 @@ +import { createHash } from 'crypto'; + +export const computeEncryptionKeyId = ({ + rawKey, +}: { + rawKey: string; +}): string => createHash('sha256').update(rawKey).digest('hex').slice(0, 8); diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/decrypt-aes-ctr-or-throw.util.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/decrypt-aes-ctr-or-throw.util.ts new file mode 100644 index 0000000000..577f30fe60 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/decrypt-aes-ctr-or-throw.util.ts @@ -0,0 +1,23 @@ +import { createDecipheriv, createHash } from 'crypto'; + +const deriveCtrKey = (rawKey: string): string => + createHash('sha512').update(rawKey).digest('hex').substring(0, 32); + +// AES-CTR has no integrity tag, so a wrong key produces an arbitrary byte +// sequence instead of throwing. `OrThrow` reflects only the malformed-input +// failures from Node crypto (e.g. invalid base64). +export const decryptAesCtrOrThrow = ({ + ciphertext, + rawKey, +}: { + ciphertext: string; + rawKey: string; +}): string => { + const buffer = Buffer.from(ciphertext, 'base64'); + const iv = buffer.subarray(0, 16); + const payload = buffer.subarray(16); + const keyHash = deriveCtrKey(rawKey); + const decipher = createDecipheriv('aes-256-ctr', keyHash, iv); + + return Buffer.concat([decipher.update(payload), decipher.final()]).toString(); +}; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/decrypt-aes-gcm-v2-or-throw.util.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/decrypt-aes-gcm-v2-or-throw.util.ts new file mode 100644 index 0000000000..351d891b9d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/decrypt-aes-gcm-v2-or-throw.util.ts @@ -0,0 +1,52 @@ +import { createDecipheriv } from 'crypto'; + +import { + SECRET_ENCRYPTION_GCM_IV_LENGTH, + SECRET_ENCRYPTION_GCM_TAG_LENGTH, +} from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { + SecretEncryptionException, + SecretEncryptionExceptionCode, +} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception'; +import { deriveGcmKey } from 'src/engine/core-modules/secret-encryption/utils/derive-gcm-key.util'; + +export const decryptAesGcmV2OrThrow = ({ + payloadBase64, + rawKey, + workspaceId, +}: { + payloadBase64: string; + rawKey: string; + workspaceId?: string; +}): string => { + const buffer = Buffer.from(payloadBase64, 'base64'); + + if ( + buffer.length < + SECRET_ENCRYPTION_GCM_IV_LENGTH + SECRET_ENCRYPTION_GCM_TAG_LENGTH + ) { + throw new SecretEncryptionException( + 'v2 ciphertext payload is too short to contain an IV and an auth tag.', + SecretEncryptionExceptionCode.CIPHERTEXT_TOO_SHORT, + ); + } + + const iv = buffer.subarray(0, SECRET_ENCRYPTION_GCM_IV_LENGTH); + const authTag = buffer.subarray( + buffer.length - SECRET_ENCRYPTION_GCM_TAG_LENGTH, + ); + const ciphertext = buffer.subarray( + SECRET_ENCRYPTION_GCM_IV_LENGTH, + buffer.length - SECRET_ENCRYPTION_GCM_TAG_LENGTH, + ); + + const key = deriveGcmKey({ rawKey, workspaceId }); + const decipher = createDecipheriv('aes-256-gcm', key, iv); + + decipher.setAuthTag(authTag); + + return Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString('utf8'); +}; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/derive-gcm-key.util.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/derive-gcm-key.util.ts new file mode 100644 index 0000000000..880fa4e3f3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/derive-gcm-key.util.ts @@ -0,0 +1,30 @@ +import { hkdfSync } from 'crypto'; + +import { + SECRET_ENCRYPTION_DERIVED_KEY_LENGTH, + SECRET_ENCRYPTION_HKDF_INFO_PREFIX, + SECRET_ENCRYPTION_INSTANCE_CONTEXT, +} from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; + +const ZERO_SALT = Buffer.alloc(32); + +export const deriveGcmKey = ({ + rawKey, + workspaceId, +}: { + rawKey: string; + workspaceId?: string; +}): Buffer => + Buffer.from( + hkdfSync( + 'sha256', + Buffer.from(rawKey), + ZERO_SALT, + Buffer.from( + `${SECRET_ENCRYPTION_HKDF_INFO_PREFIX}${ + workspaceId ?? SECRET_ENCRYPTION_INSTANCE_CONTEXT + }`, + ), + SECRET_ENCRYPTION_DERIVED_KEY_LENGTH, + ), + ); diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/encrypt-aes-ctr.util.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/encrypt-aes-ctr.util.ts new file mode 100644 index 0000000000..22cd5989a2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/encrypt-aes-ctr.util.ts @@ -0,0 +1,20 @@ +import { createCipheriv, createHash, randomBytes } from 'crypto'; + +const deriveCtrKey = (rawKey: string): string => + createHash('sha512').update(rawKey).digest('hex').substring(0, 32); + +export const encryptAesCtr = ({ + plaintext, + rawKey, +}: { + plaintext: string; + rawKey: string; +}): string => { + const keyHash = deriveCtrKey(rawKey); + const iv = randomBytes(16); + const cipher = createCipheriv('aes-256-ctr', keyHash, iv); + + return Buffer.concat([iv, cipher.update(plaintext), cipher.final()]).toString( + 'base64', + ); +}; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/encrypt-aes-gcm-v2.util.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/encrypt-aes-gcm-v2.util.ts new file mode 100644 index 0000000000..e2afe33744 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/encrypt-aes-gcm-v2.util.ts @@ -0,0 +1,26 @@ +import { createCipheriv, randomBytes } from 'crypto'; + +import { SECRET_ENCRYPTION_GCM_IV_LENGTH } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { deriveGcmKey } from 'src/engine/core-modules/secret-encryption/utils/derive-gcm-key.util'; + +export const encryptAesGcmV2 = ({ + plaintext, + rawKey, + workspaceId, +}: { + plaintext: string; + rawKey: string; + workspaceId?: string; +}): string => { + const key = deriveGcmKey({ rawKey, workspaceId }); + const iv = randomBytes(SECRET_ENCRYPTION_GCM_IV_LENGTH); + const cipher = createCipheriv('aes-256-gcm', key, iv); + + const ciphertext = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + return Buffer.concat([iv, ciphertext, authTag]).toString('base64'); +}; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/format-secret-encryption-envelope-v2.util.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/format-secret-encryption-envelope-v2.util.ts new file mode 100644 index 0000000000..f4ac7c15fb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/format-secret-encryption-envelope-v2.util.ts @@ -0,0 +1,10 @@ +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; + +export const formatSecretEncryptionEnvelopeV2 = ({ + keyId, + payloadBase64, +}: { + keyId: string; + payloadBase64: string; +}): string => + `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}${keyId}:${payloadBase64}`; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/parse-secret-encryption-envelope-or-throw.util.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/parse-secret-encryption-envelope-or-throw.util.ts new file mode 100644 index 0000000000..67a97a5c86 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/parse-secret-encryption-envelope-or-throw.util.ts @@ -0,0 +1,49 @@ +import { + SECRET_ENCRYPTION_ENVELOPE_PREFIX, + SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX, + SECRET_ENCRYPTION_KEY_ID_REGEX, +} from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { + SecretEncryptionException, + SecretEncryptionExceptionCode, +} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception'; +import { type ParsedSecretEncryptionEnvelope } from 'src/engine/core-modules/secret-encryption/types/secret-encryption-envelope.type'; + +export const parseSecretEncryptionEnvelopeOrThrow = ({ + value, +}: { + value: string; +}): ParsedSecretEncryptionEnvelope => { + if (!value.startsWith(SECRET_ENCRYPTION_ENVELOPE_PREFIX)) { + return { version: null }; + } + + if (value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)) { + const rest = value.slice(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX.length); + const separatorIndex = rest.indexOf(':'); + + if (separatorIndex <= 0) { + throw new SecretEncryptionException( + 'Malformed enc:v2 envelope: missing keyId separator. Expected enc:v2::.', + SecretEncryptionExceptionCode.MALFORMED_ENVELOPE, + ); + } + + const keyId = rest.slice(0, separatorIndex); + const payload = rest.slice(separatorIndex + 1); + + if (!SECRET_ENCRYPTION_KEY_ID_REGEX.test(keyId)) { + throw new SecretEncryptionException( + `Malformed enc:v2 envelope: keyId '${keyId}' is not 8 hex characters.`, + SecretEncryptionExceptionCode.INVALID_KEY_ID_FORMAT, + ); + } + + return { version: 2, keyId, payload }; + } + + throw new SecretEncryptionException( + `Unknown ciphertext envelope version. Value starts with '${value.slice(0, 16)}'.`, + SecretEncryptionExceptionCode.UNKNOWN_ENVELOPE_VERSION, + ); +}; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/pick-encryption-key-by-key-id-or-throw.util.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/pick-encryption-key-by-key-id-or-throw.util.ts new file mode 100644 index 0000000000..b6c8004bed --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/pick-encryption-key-by-key-id-or-throw.util.ts @@ -0,0 +1,32 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { + SecretEncryptionException, + SecretEncryptionExceptionCode, +} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception'; +import { type ResolvedEncryptionKeys } from 'src/engine/core-modules/secret-encryption/types/resolved-encryption-keys.type'; +import { computeEncryptionKeyId } from 'src/engine/core-modules/secret-encryption/utils/compute-encryption-key-id.util'; + +export const pickEncryptionKeyByKeyIdOrThrow = ({ + keyId, + keys, +}: { + keyId: string; + keys: ResolvedEncryptionKeys; +}): string => { + if (computeEncryptionKeyId({ rawKey: keys.primary }) === keyId) { + return keys.primary; + } + + if ( + isDefined(keys.fallback) && + computeEncryptionKeyId({ rawKey: keys.fallback }) === keyId + ) { + return keys.fallback; + } + + throw new SecretEncryptionException( + `No encryption key matches keyId '${keyId}'. Configure FALLBACK_ENCRYPTION_KEY with the key that encrypted this row.`, + SecretEncryptionExceptionCode.UNKNOWN_KEY_ID, + ); +}; diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/resolve-encryption-keys-or-throw.util.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/resolve-encryption-keys-or-throw.util.ts new file mode 100644 index 0000000000..99a72ab928 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/utils/resolve-encryption-keys-or-throw.util.ts @@ -0,0 +1,35 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { + SecretEncryptionException, + SecretEncryptionExceptionCode, +} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception'; +import { type ResolvedEncryptionKeys } from 'src/engine/core-modules/secret-encryption/types/resolved-encryption-keys.type'; +import { type EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; + +export const resolveEncryptionKeysOrThrow = ({ + environmentConfigDriver, +}: { + environmentConfigDriver: Pick; +}): ResolvedEncryptionKeys => { + const encryptionKey = environmentConfigDriver.get('ENCRYPTION_KEY'); + const fallbackEncryptionKey = environmentConfigDriver.get( + 'FALLBACK_ENCRYPTION_KEY', + ); + const appSecret = environmentConfigDriver.get('APP_SECRET'); + + const primary = isNonEmptyString(encryptionKey) ? encryptionKey : appSecret; + + if (!isNonEmptyString(primary)) { + throw new SecretEncryptionException( + 'No encryption key configured: set ENCRYPTION_KEY (or APP_SECRET for legacy deployments).', + SecretEncryptionExceptionCode.NO_ENCRYPTION_KEY_CONFIGURED, + ); + } + + const fallback = isNonEmptyString(fallbackEncryptionKey) + ? fallbackEncryptionKey + : null; + + return { primary, fallback }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index dba1bcb8ed..0aa09e7d4f 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -1151,6 +1151,28 @@ export class ConfigVariables { }) APP_SECRET: string; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.SERVER_CONFIG, + isSensitive: true, + description: + 'Primary key for at-rest encryption of secrets. Falls back to APP_SECRET when unset.', + isEnvOnly: true, + type: ConfigVariableType.STRING, + }) + @IsOptional() + ENCRYPTION_KEY: string; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.SERVER_CONFIG, + isSensitive: true, + description: + 'Decrypt-only fallback key. During rotation, set this to the previous ENCRYPTION_KEY so rows encrypted with the old key remain readable.', + isEnvOnly: true, + type: ConfigVariableType.STRING, + }) + @IsOptional() + FALLBACK_ENCRYPTION_KEY: string; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.RATE_LIMITING, description: 'Maximum number of records affected by mutations', diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts index b1323dc022..e6fd39941e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts @@ -28,11 +28,11 @@ export type ConnectedAccountVisibility = 'user' | 'workspace'; @Index('IDX_CONNECTED_ACCOUNT_APPLICATION_ID', ['applicationId']) @Check( 'CHK_connectedAccount_accessToken_encrypted', - `"accessToken" IS NULL OR "accessToken" LIKE 'enc:v1:%'`, + `"accessToken" IS NULL OR "accessToken" LIKE 'enc:v2:%'`, ) @Check( 'CHK_connectedAccount_refreshToken_encrypted', - `"refreshToken" IS NULL OR "refreshToken" LIKE 'enc:v1:%'`, + `"refreshToken" IS NULL OR "refreshToken" LIKE 'enc:v2:%'`, ) export class ConnectedAccountEntity extends WorkspaceRelatedEntity { @PrimaryGeneratedColumn('uuid') diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/__snapshots__/connected-account-token-encryption.service.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/__snapshots__/connected-account-token-encryption.service.spec.ts.snap deleted file mode 100644 index e17dd49d9c..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/__snapshots__/connected-account-token-encryption.service.spec.ts.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ConnectedAccountTokenEncryptionService encrypt should throw when given an already-prefixed value 1`] = `"ConnectedAccountTokenEncryptionService.encrypt received an already-prefixed value. This indicates a double-encryption bug — the caller is encrypting ciphertext."`; - -exports[`ConnectedAccountTokenEncryptionService encryptTokenPair should throw when accessToken is already encrypted 1`] = `"ConnectedAccountTokenEncryptionService.encrypt received an already-prefixed value. This indicates a double-encryption bug — the caller is encrypting ciphertext."`; diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/connected-account-token-encryption.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/connected-account-token-encryption.service.spec.ts deleted file mode 100644 index cc4aa37d77..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/__tests__/connected-account-token-encryption.service.spec.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; -import { type EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; -import { - CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, - ConnectedAccountTokenEncryptionService, -} from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; - -describe('ConnectedAccountTokenEncryptionService', () => { - const buildEncryptionService = (): ConnectedAccountTokenEncryptionService => { - const environmentConfigDriver = { - get: jest.fn().mockReturnValue('mock-app-secret-for-testing-12345678'), - } as unknown as EnvironmentConfigDriver; - - return new ConnectedAccountTokenEncryptionService( - new SecretEncryptionService(environmentConfigDriver), - ); - }; - - describe('encrypt', () => { - it('should produce a value that starts with the enc:v1: prefix and hides the plaintext', () => { - const service = buildEncryptionService(); - const plaintext = 'plaintext-token'; - - const ciphertext = service.encrypt(plaintext); - - expect( - ciphertext.startsWith(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX), - ).toBe(true); - expect(ciphertext).not.toContain(plaintext); - }); - - it('should throw when given an already-prefixed value', () => { - const service = buildEncryptionService(); - - expect(() => - service.encrypt( - `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}already-encrypted`, - ), - ).toThrowErrorMatchingSnapshot(); - }); - }); - - describe('encryptNullable', () => { - it('should pass null through unchanged', () => { - const service = buildEncryptionService(); - - expect(service.encryptNullable(null)).toBeNull(); - }); - - it('should encrypt non-null values like encrypt()', () => { - const service = buildEncryptionService(); - - const ciphertext = service.encryptNullable('plaintext'); - - expect(ciphertext).not.toBeNull(); - expect( - ciphertext!.startsWith(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX), - ).toBe(true); - }); - }); - - describe('decrypt', () => { - it('should roundtrip cleanly with encrypt()', () => { - const service = buildEncryptionService(); - const plaintext = 'roundtrip-token-value'; - - expect(service.decrypt(service.encrypt(plaintext))).toBe(plaintext); - }); - - // v2.4.0 deployment-window tolerance. Should be patch to throw after v2.4.1 - it.failing( - 'should throw when given a value without the enc:v1: prefix', - () => { - const service = buildEncryptionService(); - - expect(() => - service.decrypt('raw-plaintext-without-prefix'), - ).toThrowErrorMatchingSnapshot(); - }, - ); - }); - - describe('decryptNullable', () => { - it('should pass null through unchanged', () => { - const service = buildEncryptionService(); - - expect(service.decryptNullable(null)).toBeNull(); - }); - - it('should decrypt non-null values like decrypt()', () => { - const service = buildEncryptionService(); - const plaintext = 'rt-value'; - const ciphertext = service.encrypt(plaintext); - - expect(service.decryptNullable(ciphertext)).toBe(plaintext); - }); - }); - - describe('encryptTokenPair', () => { - it('should encrypt both tokens and return them keyed as encrypted*', () => { - const service = buildEncryptionService(); - - const { encryptedAccessToken, encryptedRefreshToken } = - service.encryptTokenPair({ - accessToken: 'at-plaintext', - refreshToken: 'rt-plaintext', - }); - - expect( - encryptedAccessToken.startsWith( - CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, - ), - ).toBe(true); - expect( - encryptedRefreshToken!.startsWith( - CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, - ), - ).toBe(true); - expect(service.decrypt(encryptedAccessToken)).toBe('at-plaintext'); - expect(service.decrypt(encryptedRefreshToken!)).toBe('rt-plaintext'); - }); - - it('should pass a null refreshToken through unencrypted', () => { - const service = buildEncryptionService(); - - const { encryptedAccessToken, encryptedRefreshToken } = - service.encryptTokenPair({ - accessToken: 'at-plaintext', - refreshToken: null, - }); - - expect( - encryptedAccessToken.startsWith( - CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, - ), - ).toBe(true); - expect(encryptedRefreshToken).toBeNull(); - }); - - it('should throw when accessToken is already encrypted', () => { - const service = buildEncryptionService(); - - expect(() => - service.encryptTokenPair({ - accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}already-encrypted`, - refreshToken: 'rt-plaintext', - }), - ).toThrowErrorMatchingSnapshot(); - }); - }); -}); diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts index 53b75d9e72..89bbddaef5 100644 --- a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts @@ -1,8 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; -import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { isDefined } from 'twenty-shared/utils'; -export const CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX = 'enc:v1:'; +import { + SecretEncryptionException, + SecretEncryptionExceptionCode, +} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception'; +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { parseSecretEncryptionEnvelopeOrThrow } from 'src/engine/core-modules/secret-encryption/utils/parse-secret-encryption-envelope-or-throw.util'; @Injectable() export class ConnectedAccountTokenEncryptionService { @@ -14,69 +19,109 @@ export class ConnectedAccountTokenEncryptionService { private readonly secretEncryptionService: SecretEncryptionService, ) {} - encrypt(plaintext: string): string { - if (plaintext.startsWith(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX)) { - throw new Error( - 'ConnectedAccountTokenEncryptionService.encrypt received an already-prefixed value. ' + - 'This indicates a double-encryption bug — the caller is encrypting ciphertext.', + encrypt({ + plaintext, + workspaceId, + }: { + plaintext: string; + workspaceId: string; + }): string { + if (this.looksLikeCiphertext(plaintext)) { + throw new SecretEncryptionException( + 'ConnectedAccountTokenEncryptionService.encrypt received an already-encrypted envelope. This indicates a double-encryption bug — the caller is encrypting ciphertext.', + SecretEncryptionExceptionCode.ALREADY_ENCRYPTED, ); } - return `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}${this.secretEncryptionService.encrypt(plaintext)}`; + return this.secretEncryptionService.encryptVersioned(plaintext, { + workspaceId, + }); } - encryptNullable(plaintext: string | null): string | null { - if (plaintext === null) { + encryptNullable({ + plaintext, + workspaceId, + }: { + plaintext: string | null; + workspaceId: string; + }): string | null { + if (!isDefined(plaintext)) { return null; } - return this.encrypt(plaintext); + return this.encrypt({ plaintext, workspaceId }); } - decrypt(ciphertext: string): string { - if (!ciphertext.startsWith(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX)) { - // v2.4.0 deployment-window tolerance. Should be patch to throw after v2.4.1 - // throw new Error( - // 'ConnectedAccountTokenEncryptionService.decrypt received a value without the ' + - // `'${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}' prefix. ` + - // 'This indicates the column was written without going through encrypt(), ' + - // 'or the value was read from a source other than core.connectedAccount.', - // ); + // v2.4.0 rollout-window tolerance: rows written before the encryption + // backfill ran may still be plaintext. Returning them as-is lets the slow + // command finish; once it has run everywhere this branch can throw. + decrypt({ + ciphertext, + workspaceId, + }: { + ciphertext: string; + workspaceId: string; + }): string { + const parsed = parseSecretEncryptionEnvelopeOrThrow({ value: ciphertext }); + if (!isDefined(parsed.version)) { this.logger.warn( - 'Decrypted a legacy plaintext token. Expected during the 2.4.0 ' + - 'rollout window until the slow instance command finishes backfilling.', + 'Decrypted a legacy plaintext token. Expected during the rollout window until the slow instance command finishes backfilling.', ); return ciphertext; } - return this.secretEncryptionService.decrypt( - ciphertext.slice(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX.length), - ); + return this.secretEncryptionService.decryptVersioned(ciphertext, { + workspaceId, + }); } - decryptNullable(ciphertext: string | null): string | null { - if (ciphertext === null) { + decryptNullable({ + ciphertext, + workspaceId, + }: { + ciphertext: string | null; + workspaceId: string; + }): string | null { + if (!isDefined(ciphertext)) { return null; } - return this.decrypt(ciphertext); + return this.decrypt({ ciphertext, workspaceId }); } encryptTokenPair({ accessToken, refreshToken, + workspaceId, }: { accessToken: string; refreshToken: string | null; + workspaceId: string; }): { encryptedAccessToken: string; encryptedRefreshToken: string | null; } { return { - encryptedAccessToken: this.encrypt(accessToken), - encryptedRefreshToken: this.encryptNullable(refreshToken), + encryptedAccessToken: this.encrypt({ + plaintext: accessToken, + workspaceId, + }), + encryptedRefreshToken: this.encryptNullable({ + plaintext: refreshToken, + workspaceId, + }), }; } + + private looksLikeCiphertext(value: string): boolean { + try { + const parsed = parseSecretEncryptionEnvelopeOrThrow({ value }); + + return parsed.version === 2; + } catch { + return false; + } + } } diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service.ts index e54157c4da..f2f4661bb8 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service.ts @@ -22,7 +22,7 @@ export class GoogleCalendarGetEventsService { public async getCalendarEvents( connectedAccount: Pick< ConnectedAccountEntity, - 'provider' | 'refreshToken' | 'id' + 'provider' | 'refreshToken' | 'id' | 'workspaceId' >, syncCursor?: string, ): Promise { diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-get-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-get-events.service.ts index b0e548f9ea..92b1c771c3 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-get-events.service.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-get-events.service.ts @@ -20,7 +20,7 @@ export class MicrosoftCalendarGetEventsService { public async getCalendarEvents( connectedAccount: Pick< ConnectedAccountEntity, - 'provider' | 'accessToken' | 'refreshToken' | 'id' + 'provider' | 'accessToken' | 'refreshToken' | 'id' | 'workspaceId' >, syncCursor?: string, ): Promise { diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-import-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-import-events.service.ts index ae3f300c8b..33afcb7083 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-import-events.service.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-import-events.service.ts @@ -17,7 +17,7 @@ export class MicrosoftCalendarImportEventsService { public async getCalendarEvents( connectedAccount: Pick< ConnectedAccountEntity, - 'provider' | 'accessToken' | 'refreshToken' | 'id' + 'provider' | 'accessToken' | 'refreshToken' | 'id' | 'workspaceId' >, changedEventIds: string[], ): Promise { diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service.ts index 526493e9aa..d0a0a160a1 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service.ts @@ -36,6 +36,7 @@ export class CalendarGetCalendarEventsService { | 'id' | 'connectionParameters' | 'handle' + | 'workspaceId' >, syncCursor?: string, ): Promise { diff --git a/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service.ts b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service.ts index fade1ac2a8..370a2b9f6d 100644 --- a/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service.ts +++ b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service.ts @@ -19,7 +19,10 @@ export class OAuth2ClientManagerService { ) {} public async getGoogleOAuth2Client( - connectedAccount: Pick, + connectedAccount: Pick< + ConnectedAccountEntity, + 'provider' | 'refreshToken' | 'workspaceId' + >, ): Promise { if (!isDefined(connectedAccount.refreshToken)) { throw new CustomError( @@ -29,14 +32,18 @@ export class OAuth2ClientManagerService { } return this.googleOAuth2ClientManagerService.getOAuth2Client( - this.connectedAccountTokenEncryptionService.decrypt( - connectedAccount.refreshToken, - ), + this.connectedAccountTokenEncryptionService.decrypt({ + ciphertext: connectedAccount.refreshToken, + workspaceId: connectedAccount.workspaceId, + }), ); } public async getMicrosoftOAuth2Client( - connectedAccount: Pick, + connectedAccount: Pick< + ConnectedAccountEntity, + 'provider' | 'accessToken' | 'workspaceId' + >, ): Promise { if (!isDefined(connectedAccount.accessToken)) { throw new CustomError( @@ -46,9 +53,10 @@ export class OAuth2ClientManagerService { } return this.microsoftOAuth2ClientManagerService.getOAuth2Client( - this.connectedAccountTokenEncryptionService.decrypt( - connectedAccount.accessToken, - ), + this.connectedAccountTokenEncryptionService.decrypt({ + ciphertext: connectedAccount.accessToken, + workspaceId: connectedAccount.workspaceId, + }), ); } } diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts index 9db19fc6ae..8223a8788d 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts @@ -2,23 +2,24 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ConnectedAccountProvider } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service'; +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; -import { - CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX, - ConnectedAccountTokenEncryptionService, -} from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; -import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; -import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service'; -import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service'; import { ConnectedAccountRefreshAccessTokenException, ConnectedAccountRefreshAccessTokenExceptionCode, } from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service'; +import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service'; import { ConnectedAccountRefreshTokensService } from './connected-account-refresh-tokens.service'; +const FAKE_CIPHER_PREFIX = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}keyid:`; + describe('ConnectedAccountRefreshTokensService', () => { let service: ConnectedAccountRefreshTokensService; let googleAPIRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService; @@ -36,8 +37,8 @@ describe('ConnectedAccountRefreshTokensService', () => { const mockRefreshTokenPlaintext = 'valid-refresh-token'; const mockNewAccessTokenPlaintext = 'new-access-token'; - const mockEncryptedAccessToken = `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockAccessTokenPlaintext})`; - const mockEncryptedRefreshToken = `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`; + const mockEncryptedAccessToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockAccessTokenPlaintext})`; + const mockEncryptedRefreshToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`; // Real prefix/round-trip invariants are asserted in // connected-account-token-encryption.service.spec.ts. @@ -45,25 +46,24 @@ describe('ConnectedAccountRefreshTokensService', () => { decrypt: jest.Mock; encryptTokenPair: jest.Mock; } => { - const wrap = (value: string) => - `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${value})`; + const wrap = (value: string) => `${FAKE_CIPHER_PREFIX}CIPHER(${value})`; return { - decrypt: jest.fn((value: string) => { - const match = value.match( - new RegExp( - `^${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER\\((.*)\\)$`, - ), - ); - - if (match === null) { - throw new Error( - `fake encryption stub: decrypt called with a non-CIPHER value: ${value}`, + decrypt: jest.fn( + ({ ciphertext }: { ciphertext: string; workspaceId: string }) => { + const match = ciphertext.match( + new RegExp(`^${FAKE_CIPHER_PREFIX}CIPHER\\((.*)\\)$`), ); - } - return match[1]; - }), + if (!isDefined(match)) { + throw new Error( + `fake encryption stub: decrypt called with a non-CIPHER value: ${ciphertext}`, + ); + } + + return match[1]; + }, + ), encryptTokenPair: jest.fn( ({ accessToken, @@ -71,10 +71,12 @@ describe('ConnectedAccountRefreshTokensService', () => { }: { accessToken: string; refreshToken: string | null; + workspaceId: string; }) => ({ encryptedAccessToken: wrap(accessToken), - encryptedRefreshToken: - refreshToken === null ? null : wrap(refreshToken), + encryptedRefreshToken: isDefined(refreshToken) + ? wrap(refreshToken) + : null, }), ), }; @@ -167,10 +169,16 @@ describe('ConnectedAccountRefreshTokensService', () => { }); expect( connectedAccountTokenEncryptionService.decrypt, - ).toHaveBeenCalledWith(mockEncryptedAccessToken); + ).toHaveBeenCalledWith({ + ciphertext: mockEncryptedAccessToken, + workspaceId: mockWorkspaceId, + }); expect( connectedAccountTokenEncryptionService.decrypt, - ).toHaveBeenCalledWith(mockEncryptedRefreshToken); + ).toHaveBeenCalledWith({ + ciphertext: mockEncryptedRefreshToken, + workspaceId: mockWorkspaceId, + }); expect( microsoftAPIRefreshAccessTokenService.refreshTokens, ).not.toHaveBeenCalled(); @@ -207,8 +215,8 @@ describe('ConnectedAccountRefreshTokensService', () => { expect(connectedAccountRepository.update).toHaveBeenCalledWith( { id: mockConnectedAccountId, workspaceId: mockWorkspaceId }, expect.objectContaining({ - accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`, - refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`, + accessToken: `${FAKE_CIPHER_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`, + refreshToken: `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`, lastCredentialsRefreshedAt: expect.any(Date), }), ); @@ -244,8 +252,8 @@ describe('ConnectedAccountRefreshTokensService', () => { expect(connectedAccountRepository.update).toHaveBeenCalledWith( { id: mockConnectedAccountId, workspaceId: mockWorkspaceId }, expect.objectContaining({ - accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`, - refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`, + accessToken: `${FAKE_CIPHER_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`, + refreshToken: `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`, lastCredentialsRefreshedAt: expect.any(Date), }), ); @@ -281,8 +289,8 @@ describe('ConnectedAccountRefreshTokensService', () => { expect(connectedAccountRepository.update).toHaveBeenCalledWith( { id: mockConnectedAccountId, workspaceId: mockWorkspaceId }, expect.objectContaining({ - accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`, - refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`, + accessToken: `${FAKE_CIPHER_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`, + refreshToken: `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`, lastCredentialsRefreshedAt: expect.any(Date), }), ); diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts index 04a6fa56b4..97c029a65d 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts @@ -71,13 +71,14 @@ export class ConnectedAccountRefreshTokensService { } return { - accessToken: - this.connectedAccountTokenEncryptionService.decrypt( - encryptedAccessToken, - ), - refreshToken: this.connectedAccountTokenEncryptionService.decrypt( - encryptedRefreshToken, - ), + accessToken: this.connectedAccountTokenEncryptionService.decrypt({ + ciphertext: encryptedAccessToken, + workspaceId, + }), + refreshToken: this.connectedAccountTokenEncryptionService.decrypt({ + ciphertext: encryptedRefreshToken, + workspaceId, + }), }; } @@ -86,9 +87,10 @@ export class ConnectedAccountRefreshTokensService { ); const decryptedRefreshTokenForRefreshCall = - this.connectedAccountTokenEncryptionService.decrypt( - encryptedRefreshToken, - ); + this.connectedAccountTokenEncryptionService.decrypt({ + ciphertext: encryptedRefreshToken, + workspaceId, + }); const connectedAccountTokens = await this.refreshTokens( connectedAccount, @@ -102,6 +104,7 @@ export class ConnectedAccountRefreshTokensService { } = this.connectedAccountTokenEncryptionService.encryptTokenPair({ accessToken: connectedAccountTokens.accessToken, refreshToken: connectedAccountTokens.refreshToken, + workspaceId, }); const authContext = buildSystemAuthContext(workspaceId); diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/services/gmail-get-all-folders.service.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/services/gmail-get-all-folders.service.ts index e025095d10..d6a419c416 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/services/gmail-get-all-folders.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/services/gmail-get-all-folders.service.ts @@ -28,7 +28,12 @@ export class GmailGetAllFoldersService implements MessageFolderDriver { async getAllMessageFolders( connectedAccount: Pick< ConnectedAccountEntity, - 'provider' | 'refreshToken' | 'accessToken' | 'id' | 'handle' + | 'provider' + | 'refreshToken' + | 'accessToken' + | 'id' + | 'handle' + | 'workspaceId' >, messageChannel: Pick, ): Promise { diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/microsoft/services/microsoft-get-all-folders.service.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/microsoft/services/microsoft-get-all-folders.service.ts index a3d128a6ea..d9d2d60c0c 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/microsoft/services/microsoft-get-all-folders.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/microsoft/services/microsoft-get-all-folders.service.ts @@ -38,7 +38,12 @@ export class MicrosoftGetAllFoldersService implements MessageFolderDriver { async getAllMessageFolders( connectedAccount: Pick< ConnectedAccountEntity, - 'accessToken' | 'refreshToken' | 'id' | 'handle' | 'provider' + | 'accessToken' + | 'refreshToken' + | 'id' + | 'handle' + | 'provider' + | 'workspaceId' >, messageChannel: Pick, ): Promise { diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface.ts index 17537ebd69..1f485e3bbb 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface.ts @@ -29,6 +29,7 @@ export type MessageFolderDriver = { | 'id' | 'handle' | 'connectionParameters' + | 'workspaceId' >, messageChannel: Pick, ): Promise; diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.spec.ts index 7b2eeb522c..d8fc41318f 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.spec.ts @@ -49,6 +49,7 @@ const createMockMessageChannel = ( accessToken: 'mock-access-token', refreshToken: 'mock-refresh-token', connectionParameters: {}, + workspaceId: 'workspace-123', }, messageFolders: overrides.messageFolders ?? [], visibility: MessageChannelVisibility.SHARE_EVERYTHING, diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts index 981e536af9..e72866b98c 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts @@ -53,6 +53,7 @@ export class SyncMessageFoldersService { | 'id' | 'handle' | 'connectionParameters' + | 'workspaceId' >; messageFolders: MessageFolder[]; }; @@ -83,6 +84,7 @@ export class SyncMessageFoldersService { | 'handle' | 'provider' | 'connectionParameters' + | 'workspaceId' >, messageChannel: Pick, ): Promise { diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.spec.ts index ecd7508946..de91eb5539 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.spec.ts @@ -40,6 +40,7 @@ describe('GmailGetMessageListService', () => { | 'id' | 'handle' | 'connectionParameters' + | 'workspaceId' > = { id: 'connected-account-id', provider: ConnectedAccountProvider.GOOGLE, @@ -47,6 +48,7 @@ describe('GmailGetMessageListService', () => { refreshToken: 'refresh-token', handle: 'test@gmail.com', connectionParameters: {}, + workspaceId: 'workspace-id', }; beforeEach(async () => { diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.ts index 5e47acfe7f..1519c500d5 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.ts @@ -31,7 +31,12 @@ export class GmailGetMessageListService { private async getMessageListWithoutCursor( connectedAccount: Pick< ConnectedAccountEntity, - 'provider' | 'accessToken' | 'refreshToken' | 'id' | 'handle' + | 'provider' + | 'accessToken' + | 'refreshToken' + | 'id' + | 'handle' + | 'workspaceId' >, messageFolders: Pick< MessageFolderEntity, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-messages.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-messages.service.ts index 77e41e6ba8..74822f5167 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-messages.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-messages.service.ts @@ -33,6 +33,7 @@ export class GmailGetMessagesService { | 'id' | 'handle' | 'handleAliases' + | 'workspaceId' >, messageChannel: Pick< MessageChannelEntity, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service.spec.ts index 82a0a96cfa..cc0cbcd002 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service.spec.ts @@ -38,6 +38,7 @@ describe('ImapGetMessageListService', () => { | 'id' | 'handle' | 'connectionParameters' + | 'workspaceId' > = { id: 'connected-account-id', provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV, @@ -45,6 +46,7 @@ describe('ImapGetMessageListService', () => { refreshToken: 'refresh-token', handle: 'test@example.com', connectionParameters: {}, + workspaceId: 'workspace-id', }; const mockImapClient = { diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-fetch-by-batch.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-fetch-by-batch.service.ts index 5326b463b5..c802104000 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-fetch-by-batch.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-fetch-by-batch.service.ts @@ -14,7 +14,7 @@ export class MicrosoftFetchByBatchService { messageIds: string[], connectedAccount: Pick< ConnectedAccountEntity, - 'accessToken' | 'refreshToken' | 'id' | 'provider' + 'accessToken' | 'refreshToken' | 'id' | 'provider' | 'workspaceId' >, ): Promise<{ messageIdsByBatch: string[][]; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.dev.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.dev.spec.ts index a7a03cc1a7..e6eb195bc6 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.dev.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.dev.spec.ts @@ -32,6 +32,7 @@ const mockConnectedAccount: Pick< | 'id' | 'handle' | 'connectionParameters' + | 'workspaceId' > = { id: 'connected-account-id', provider: ConnectedAccountProvider.MICROSOFT, @@ -39,6 +40,7 @@ const mockConnectedAccount: Pick< refreshToken: refreshToken, handle: 'test@gmail.com', connectionParameters: {}, + workspaceId: 'workspace-id', }; const mockMessageChannel: Pick< @@ -103,6 +105,7 @@ xdescribe('Microsoft dev tests : get message list service', () => { refreshToken: 'invalid-token', handle: 'test@microsoft.com', connectionParameters: {}, + workspaceId: 'workspace-id', }; await expect( diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.spec.ts index 9c3d916721..ddd3b42f8e 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.spec.ts @@ -37,6 +37,7 @@ describe('MicrosoftGetMessageListService', () => { | 'id' | 'handle' | 'connectionParameters' + | 'workspaceId' > = { id: 'connected-account-id', provider: ConnectedAccountProvider.MICROSOFT, @@ -44,6 +45,7 @@ describe('MicrosoftGetMessageListService', () => { refreshToken: 'refresh-token', handle: 'test@outlook.com', connectionParameters: {}, + workspaceId: 'workspace-id', }; const createMockMicrosoftClient = () => ({ diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.ts index 854e00fa56..880c6ab786 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service.ts @@ -73,7 +73,7 @@ export class MicrosoftGetMessageListService { public async getMessageList( connectedAccount: Pick< ConnectedAccountEntity, - 'provider' | 'accessToken' | 'id' + 'provider' | 'accessToken' | 'id' | 'workspaceId' >, messageFolder: Pick< MessageFolderEntity, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.dev.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.dev.spec.ts index e02235a53a..fd0f81c693 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.dev.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.dev.spec.ts @@ -52,6 +52,7 @@ xdescribe('Microsoft dev tests : get messages service', () => { handleAliases: [] as string[], accessToken: accessToken, refreshToken: refreshToken, + workspaceId: 'workspace-id', }; it('should fetch and format messages successfully', async () => { diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.spec.ts index 92cab8c9d0..cc0c939a92 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.spec.ts @@ -72,6 +72,7 @@ describe('Microsoft get messages service', () => { refreshToken: 'refresh-token', handle: 'John.l@outlook.fr', handleAliases: [] as string[], + workspaceId: 'workspace-id', }; const messages = service.formatBatchResponsesAsMessages( batchResponses, @@ -173,6 +174,7 @@ describe('Microsoft get messages service', () => { refreshToken: 'refresh-token', handle: 'John.l@outlook.fr', handleAliases: [] as string[], + workspaceId: 'workspace-id', }; const messages = service.formatBatchResponsesAsMessages( batchResponses, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.ts index a3759b7b1f..ee78ec826f 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.ts @@ -24,6 +24,7 @@ type ConnectedAccountType = Pick< | 'provider' | 'handle' | 'handleAliases' + | 'workspaceId' >; @Injectable() diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-get-messages.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-get-messages.service.ts index 219e5df48a..745e3aba05 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-get-messages.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-get-messages.service.ts @@ -35,6 +35,7 @@ export class MessagingGetMessagesService { | 'handleAliases' | 'userWorkspaceId' | 'connectionParameters' + | 'workspaceId' >, messageChannel: Pick< MessageChannelEntity, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/types/get-message-lists-args.type.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/types/get-message-lists-args.type.ts index 590f4c6562..a051798e1f 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/types/get-message-lists-args.type.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/types/get-message-lists-args.type.ts @@ -16,6 +16,7 @@ export type GetMessageListsArgs = { | 'id' | 'handle' | 'connectionParameters' + | 'workspaceId' >; messageFolders: MessageFolder[]; }; diff --git a/packages/twenty-server/test/integration/secret-encryption/application-registration-variable-encryption.integration-spec.ts b/packages/twenty-server/test/integration/secret-encryption/application-registration-variable-encryption.integration-spec.ts new file mode 100644 index 0000000000..0d2fb34c7c --- /dev/null +++ b/packages/twenty-server/test/integration/secret-encryption/application-registration-variable-encryption.integration-spec.ts @@ -0,0 +1,134 @@ +import gql from 'graphql-tag'; +import { isDefined } from 'twenty-shared/utils'; +import { type DataSource } from 'typeorm'; + +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +// Real integration test for the legacy CTR encryption path: drive the +// full create/read/delete lifecycle through the GraphQL API and peek +// into Postgres mid-test to verify the stored value is ciphertext. +// applicationRegistrationVariable uses SecretEncryptionService.encrypt +// (unprefixed CTR), the same legacy path as applicationVariable and +// every other non-connected-account encrypted column. + +describe('ApplicationRegistrationVariable encryption (integration)', () => { + let dataSource: DataSource; + let applicationRegistrationId: string; + + beforeAll(async () => { + dataSource = global.testDataSource; + + const createRegistrationResponse = await makeMetadataAPIRequest({ + query: gql` + mutation CreateRegistrationForEncryptionTest( + $input: CreateApplicationRegistrationInput! + ) { + createApplicationRegistration(input: $input) { + applicationRegistration { + id + } + } + } + `, + variables: { + input: { name: 'enc-integration-test-registration' }, + }, + }); + + const registrationId = + createRegistrationResponse.body?.data?.createApplicationRegistration + ?.applicationRegistration?.id; + + if (!isDefined(registrationId)) { + throw new Error( + `createApplicationRegistration failed: ${JSON.stringify( + createRegistrationResponse.body, + )}`, + ); + } + + applicationRegistrationId = registrationId; + }); + + afterAll(async () => { + await makeMetadataAPIRequest({ + query: gql` + mutation DeleteRegistrationForEncryptionTest($id: String!) { + deleteApplicationRegistration(id: $id) + } + `, + variables: { id: applicationRegistrationId }, + }); + }); + + it('encrypts the value on the API write path, persists ciphertext in Postgres, and decrypts back via the API read path', async () => { + const plaintext = 'this-is-a-legacy-ctr-secret-value'; + + const createVariableResponse = await makeMetadataAPIRequest({ + query: gql` + mutation CreateVariableForEncryptionTest( + $input: CreateApplicationRegistrationVariableInput! + ) { + createApplicationRegistrationVariable(input: $input) { + id + } + } + `, + variables: { + input: { + applicationRegistrationId, + key: 'TEST_LEGACY_PLAIN', + value: plaintext, + isSecret: false, + }, + }, + }); + + expect(createVariableResponse.body.errors).toBeUndefined(); + const variableId = + createVariableResponse.body.data.createApplicationRegistrationVariable.id; + + const [dbRow] = await dataSource.query( + `SELECT "encryptedValue" FROM "core"."applicationRegistrationVariable" WHERE id = $1`, + [variableId], + ); + + // The legacy CTR envelope is base64(IV || ciphertext) — no enc: prefix. + // Two invariants: the column does NOT contain the plaintext, and the + // value looks like a base64 blob (proving encryption actually ran). + expect(dbRow.encryptedValue).not.toContain(plaintext); + expect(dbRow.encryptedValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); + + const findResponse = await makeMetadataAPIRequest({ + query: gql` + query FindVariablesForEncryptionTest( + $applicationRegistrationId: String! + ) { + findApplicationRegistrationVariables( + applicationRegistrationId: $applicationRegistrationId + ) { + id + key + value + isSecret + } + } + `, + variables: { applicationRegistrationId }, + }); + + expect(findResponse.body.errors).toBeUndefined(); + + const variable = + findResponse.body.data.findApplicationRegistrationVariables.find( + (v: { id: string }) => v.id === variableId, + ); + + expect(variable).toBeDefined(); + expect(variable.isSecret).toBe(false); + // For non-secret variables the resolver decrypts and returns the + // plaintext directly — proves the legacy CTR encrypt + decrypt + // round-trip works end-to-end via the live API. + expect(variable.value).toBe(plaintext); + }); +}); diff --git a/packages/twenty-server/test/integration/upgrade/suites/encrypt-connected-account-tokens.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/encrypt-connected-account-tokens.integration-spec.ts new file mode 100644 index 0000000000..f5f7a82c22 --- /dev/null +++ b/packages/twenty-server/test/integration/upgrade/suites/encrypt-connected-account-tokens.integration-spec.ts @@ -0,0 +1,339 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { config } from 'dotenv'; +import { isDefined } from 'twenty-shared/utils'; +import { DataSource } from 'typeorm'; + +import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { type EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; +import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service'; + +import { EncryptConnectedAccountTokensSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens'; + +jest.useRealTimers(); + +config({ + path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env', + override: true, +}); + +const TEST_ROW_HANDLE_PREFIX = 'encrypt-slow-cmd-test-'; + +const buildSecretEncryptionService = (): SecretEncryptionService => { + const appSecret = process.env.APP_SECRET; + + if (!isNonEmptyString(appSecret)) { + throw new Error( + 'APP_SECRET must be set in the integration test environment to run this suite.', + ); + } + + const driver = { + get: (key: string) => (key === 'APP_SECRET' ? appSecret : undefined), + } as unknown as EnvironmentConfigDriver; + + return new SecretEncryptionService(driver); +}; + +const seedRow = async ({ + dataSource, + workspaceId, + userWorkspaceId, + handle, + accessToken, + refreshToken, +}: { + dataSource: DataSource; + workspaceId: string; + userWorkspaceId: string; + handle: string; + accessToken: string | null; + refreshToken: string | null; +}): Promise => { + // Insert directly with raw SQL to bypass the CHECK constraint while we + // seed the plaintext rows that the slow command is meant to upgrade. + await dataSource.query( + `ALTER TABLE "core"."connectedAccount" + DROP CONSTRAINT IF EXISTS "CHK_connectedAccount_accessToken_encrypted"`, + ); + await dataSource.query( + `ALTER TABLE "core"."connectedAccount" + DROP CONSTRAINT IF EXISTS "CHK_connectedAccount_refreshToken_encrypted"`, + ); + + const result = await dataSource.query( + `INSERT INTO "core"."connectedAccount" + ("handle", "provider", "accessToken", "refreshToken", + "userWorkspaceId", "workspaceId") + VALUES ($1, 'google', $2, $3, $4, $5) + RETURNING id`, + [handle, accessToken, refreshToken, userWorkspaceId, workspaceId], + ); + + return result[0].id as string; +}; + +const dropEncryptionCheckConstraints = async ( + dataSource: DataSource, +): Promise => { + await dataSource.query( + `ALTER TABLE "core"."connectedAccount" + DROP CONSTRAINT IF EXISTS "CHK_connectedAccount_accessToken_encrypted"`, + ); + await dataSource.query( + `ALTER TABLE "core"."connectedAccount" + DROP CONSTRAINT IF EXISTS "CHK_connectedAccount_refreshToken_encrypted"`, + ); +}; + +const restoreEncryptionCheckConstraints = async ( + dataSource: DataSource, +): Promise => { + await dropEncryptionCheckConstraints(dataSource); + await dataSource.query( + `ALTER TABLE "core"."connectedAccount" + ADD CONSTRAINT "CHK_connectedAccount_accessToken_encrypted" + CHECK ("accessToken" IS NULL OR "accessToken" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%')`, + ); + await dataSource.query( + `ALTER TABLE "core"."connectedAccount" + ADD CONSTRAINT "CHK_connectedAccount_refreshToken_encrypted" + CHECK ("refreshToken" IS NULL OR "refreshToken" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%')`, + ); +}; + +describe('EncryptConnectedAccountTokensSlowInstanceCommand (integration)', () => { + let dataSource: DataSource; + let secretEncryptionService: SecretEncryptionService; + let connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService; + let command: EncryptConnectedAccountTokensSlowInstanceCommand; + let workspaceId: string; + let userWorkspaceId: string; + const seededRowIds: string[] = []; + + beforeAll(async () => { + dataSource = new DataSource({ + type: 'postgres', + url: process.env.PG_DATABASE_URL, + schema: 'core', + entities: [], + synchronize: false, + }); + await dataSource.initialize(); + + secretEncryptionService = buildSecretEncryptionService(); + connectedAccountTokenEncryptionService = + new ConnectedAccountTokenEncryptionService(secretEncryptionService); + command = new EncryptConnectedAccountTokensSlowInstanceCommand( + connectedAccountTokenEncryptionService, + ); + + const seedWorkspaceRow = await dataSource.query( + `SELECT uw.id AS "userWorkspaceId", uw."workspaceId" AS "workspaceId" + FROM "core"."userWorkspace" uw + LIMIT 1`, + ); + + if (!isDefined(seedWorkspaceRow[0])) { + throw new Error( + 'No seeded userWorkspace row found; run database:reset before the integration suite.', + ); + } + + userWorkspaceId = seedWorkspaceRow[0].userWorkspaceId as string; + workspaceId = seedWorkspaceRow[0].workspaceId as string; + }, 30000); + + afterEach(async () => { + if (seededRowIds.length > 0) { + await dataSource.query( + `DELETE FROM "core"."connectedAccount" WHERE id = ANY($1::uuid[])`, + [seededRowIds], + ); + seededRowIds.length = 0; + } + // Always leave the schema with the production-shape constraints so + // subsequent integration suites see the expected state. + await restoreEncryptionCheckConstraints(dataSource); + }); + + afterAll(async () => { + await dataSource?.destroy(); + }); + + it('upgrades plaintext rows to enc:v2 with workspaceId-bound HKDF', async () => { + const handle = `${TEST_ROW_HANDLE_PREFIX}plaintext`; + const accessTokenPlaintext = 'plaintext-access-token'; + const refreshTokenPlaintext = 'plaintext-refresh-token'; + + const id = await seedRow({ + dataSource, + workspaceId, + userWorkspaceId, + handle, + accessToken: accessTokenPlaintext, + refreshToken: refreshTokenPlaintext, + }); + + seededRowIds.push(id); + + await command.runDataMigration(dataSource); + + const [row] = await dataSource.query( + `SELECT "accessToken", "refreshToken" + FROM "core"."connectedAccount" WHERE id = $1`, + [id], + ); + + expect( + row.accessToken.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX), + ).toBe(true); + expect( + row.refreshToken.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX), + ).toBe(true); + expect( + connectedAccountTokenEncryptionService.decrypt({ + ciphertext: row.accessToken, + workspaceId, + }), + ).toBe(accessTokenPlaintext); + expect( + connectedAccountTokenEncryptionService.decrypt({ + ciphertext: row.refreshToken, + workspaceId, + }), + ).toBe(refreshTokenPlaintext); + }); + + it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => { + const handle = `${TEST_ROW_HANDLE_PREFIX}v2`; + const plaintext = 'v2-token'; + const preexistingV2Ciphertext = secretEncryptionService.encryptVersioned( + plaintext, + { workspaceId }, + ); + + const id = await seedRow({ + dataSource, + workspaceId, + userWorkspaceId, + handle, + accessToken: preexistingV2Ciphertext, + refreshToken: null, + }); + + seededRowIds.push(id); + + await command.runDataMigration(dataSource); + const [afterFirstRun] = await dataSource.query( + `SELECT "accessToken" FROM "core"."connectedAccount" WHERE id = $1`, + [id], + ); + + expect(afterFirstRun.accessToken).toBe(preexistingV2Ciphertext); + + await command.runDataMigration(dataSource); + const [afterSecondRun] = await dataSource.query( + `SELECT "accessToken" FROM "core"."connectedAccount" WHERE id = $1`, + [id], + ); + + expect(afterSecondRun.accessToken).toBe(preexistingV2Ciphertext); + }); + + it('handles a row that mixes a v2 column and a plaintext column (per-cell guard)', async () => { + const handle = `${TEST_ROW_HANDLE_PREFIX}mixed`; + const accessPlaintext = 'mixed-access'; + const refreshPlaintext = 'mixed-refresh'; + const preexistingV2Access = secretEncryptionService.encryptVersioned( + accessPlaintext, + { workspaceId }, + ); + + const id = await seedRow({ + dataSource, + workspaceId, + userWorkspaceId, + handle, + accessToken: preexistingV2Access, + refreshToken: refreshPlaintext, + }); + + seededRowIds.push(id); + + await command.runDataMigration(dataSource); + + const [row] = await dataSource.query( + `SELECT "accessToken", "refreshToken" + FROM "core"."connectedAccount" WHERE id = $1`, + [id], + ); + + expect(row.accessToken).toBe(preexistingV2Access); + expect( + row.refreshToken.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX), + ).toBe(true); + expect( + connectedAccountTokenEncryptionService.decrypt({ + ciphertext: row.refreshToken, + workspaceId, + }), + ).toBe(refreshPlaintext); + }); + + it('rejects a plaintext insert once the CHECK constraint is in place', async () => { + await dropEncryptionCheckConstraints(dataSource); + + const queryRunner = dataSource.createQueryRunner(); + + try { + await command.up(queryRunner); + + await expect( + dataSource.query( + `INSERT INTO "core"."connectedAccount" + ("handle", "provider", "accessToken", "refreshToken", + "userWorkspaceId", "workspaceId") + VALUES ($1, 'google', $2, NULL, $3, $4)`, + [ + `${TEST_ROW_HANDLE_PREFIX}rejected`, + 'plaintext-should-be-rejected', + userWorkspaceId, + workspaceId, + ], + ), + ).rejects.toThrow(/check constraint/i); + } finally { + await queryRunner.release(); + } + }); + + it('down() removes the CHECK constraint and lets plaintext through (for rollback safety only)', async () => { + const queryRunner = dataSource.createQueryRunner(); + + try { + await command.down(queryRunner); + + const handle = `${TEST_ROW_HANDLE_PREFIX}down`; + const id = await seedRow({ + dataSource, + workspaceId, + userWorkspaceId, + handle, + accessToken: 'plaintext-allowed-after-down', + refreshToken: null, + }); + + seededRowIds.push(id); + + const [row] = await dataSource.query( + `SELECT "accessToken" FROM "core"."connectedAccount" WHERE id = $1`, + [id], + ); + + expect(row.accessToken).toBe('plaintext-allowed-after-down'); + } finally { + await queryRunner.release(); + } + }); +});