feat(twenty-server): introduce ENCRYPTION_KEY env var with versioned envelope (#20528)

## 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:<keyId>:<base64>`
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:<keyId>:<base64>`.
- [ ] Manual E2E: rotate — set `ENCRYPTION_KEY=<new>` and
`FALLBACK_ENCRYPTION_KEY=<old APP_SECRET>`, restart, confirm
pre-rotation rows still decrypt and new rows carry the new `keyId`.
- [ ] Manual E2E: missing key — set `ENCRYPTION_KEY=<new>` without the
fallback, confirm decrypt error names the old `keyId` so the operator
can identify the missing key.
This commit is contained in:
Charles Bochet
2026-05-13 18:15:54 +02:00
committed by GitHub
parent aec2e01662
commit e0b4c9918b
60 changed files with 1687 additions and 394 deletions
@@ -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<void> {
// 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<void> {
// 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}"`,
@@ -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);
});
});
@@ -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',
}),
@@ -248,6 +248,7 @@ export class ConnectionProviderOAuthFlowService {
this.connectedAccountTokenEncryptionService.encryptTokenPair({
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken,
workspaceId,
});
const sharedFields = {
@@ -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,
@@ -91,6 +91,7 @@ export class CreateConnectedAccountService {
this.connectedAccountTokenEncryptionService.encryptTokenPair({
accessToken,
refreshToken,
workspaceId,
});
await input.transactionManager
@@ -38,6 +38,7 @@ export class UpdateConnectedAccountOnReconnectService {
this.connectedAccountTokenEncryptionService.encryptTokenPair({
accessToken,
refreshToken,
workspaceId,
});
const authContext = buildSystemAuthContext(workspaceId);
@@ -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';
@@ -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<SecretEncryptionExceptionCode> {
constructor(
message: string,
code: SecretEncryptionExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getSecretEncryptionExceptionUserFriendlyMessage(code),
});
}
}
@@ -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.',
);
}
}
@@ -0,0 +1,4 @@
export type ResolvedEncryptionKeys = {
primary: string;
fallback: string | null;
};
@@ -0,0 +1,3 @@
export type ParsedSecretEncryptionEnvelope =
| { version: 2; keyId: string; payload: string }
| { version: null };
@@ -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,
);
});
});
@@ -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);
});
});
@@ -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',
});
});
});
@@ -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,
);
});
});
@@ -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'/);
});
});
@@ -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<EnvironmentConfigDriver, 'get'> => ({
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();
});
});
@@ -0,0 +1,7 @@
import { createHash } from 'crypto';
export const computeEncryptionKeyId = ({
rawKey,
}: {
rawKey: string;
}): string => createHash('sha256').update(rawKey).digest('hex').slice(0, 8);
@@ -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();
};
@@ -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');
};
@@ -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,
),
);
@@ -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',
);
};
@@ -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');
};
@@ -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}`;
@@ -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:<keyId>:<payload>.',
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,
);
};
@@ -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,
);
};
@@ -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<EnvironmentConfigDriver, 'get'>;
}): 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 };
};
@@ -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',
@@ -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')
@@ -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."`;
@@ -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();
});
});
});
@@ -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;
}
}
}
@@ -22,7 +22,7 @@ export class GoogleCalendarGetEventsService {
public async getCalendarEvents(
connectedAccount: Pick<
ConnectedAccountEntity,
'provider' | 'refreshToken' | 'id'
'provider' | 'refreshToken' | 'id' | 'workspaceId'
>,
syncCursor?: string,
): Promise<GetCalendarEventsResponse> {
@@ -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<GetCalendarEventsResponse> {
@@ -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<FetchedCalendarEvent[]> {
@@ -36,6 +36,7 @@ export class CalendarGetCalendarEventsService {
| 'id'
| 'connectionParameters'
| 'handle'
| 'workspaceId'
>,
syncCursor?: string,
): Promise<GetCalendarEventsResponse> {
@@ -19,7 +19,10 @@ export class OAuth2ClientManagerService {
) {}
public async getGoogleOAuth2Client(
connectedAccount: Pick<ConnectedAccountEntity, 'provider' | 'refreshToken'>,
connectedAccount: Pick<
ConnectedAccountEntity,
'provider' | 'refreshToken' | 'workspaceId'
>,
): Promise<Auth.OAuth2Client> {
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<ConnectedAccountEntity, 'provider' | 'accessToken'>,
connectedAccount: Pick<
ConnectedAccountEntity,
'provider' | 'accessToken' | 'workspaceId'
>,
): Promise<Client> {
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,
}),
);
}
}
@@ -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),
}),
);
@@ -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);
@@ -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<MessageChannelEntity, 'messageFolderImportPolicy'>,
): Promise<DiscoveredMessageFolder[]> {
@@ -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<MessageChannelEntity, 'messageFolderImportPolicy'>,
): Promise<DiscoveredMessageFolder[]> {
@@ -29,6 +29,7 @@ export type MessageFolderDriver = {
| 'id'
| 'handle'
| 'connectionParameters'
| 'workspaceId'
>,
messageChannel: Pick<MessageChannelEntity, 'messageFolderImportPolicy'>,
): Promise<DiscoveredMessageFolder[]>;
@@ -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,
@@ -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<MessageChannelEntity, 'messageFolderImportPolicy'>,
): Promise<DiscoveredMessageFolder[]> {
@@ -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 () => {
@@ -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,
@@ -33,6 +33,7 @@ export class GmailGetMessagesService {
| 'id'
| 'handle'
| 'handleAliases'
| 'workspaceId'
>,
messageChannel: Pick<
MessageChannelEntity,
@@ -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 = {
@@ -14,7 +14,7 @@ export class MicrosoftFetchByBatchService {
messageIds: string[],
connectedAccount: Pick<
ConnectedAccountEntity,
'accessToken' | 'refreshToken' | 'id' | 'provider'
'accessToken' | 'refreshToken' | 'id' | 'provider' | 'workspaceId'
>,
): Promise<{
messageIdsByBatch: string[][];
@@ -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(
@@ -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 = () => ({
@@ -73,7 +73,7 @@ export class MicrosoftGetMessageListService {
public async getMessageList(
connectedAccount: Pick<
ConnectedAccountEntity,
'provider' | 'accessToken' | 'id'
'provider' | 'accessToken' | 'id' | 'workspaceId'
>,
messageFolder: Pick<
MessageFolderEntity,
@@ -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 () => {
@@ -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,
@@ -24,6 +24,7 @@ type ConnectedAccountType = Pick<
| 'provider'
| 'handle'
| 'handleAliases'
| 'workspaceId'
>;
@Injectable()
@@ -35,6 +35,7 @@ export class MessagingGetMessagesService {
| 'handleAliases'
| 'userWorkspaceId'
| 'connectionParameters'
| 'workspaceId'
>,
messageChannel: Pick<
MessageChannelEntity,
@@ -16,6 +16,7 @@ export type GetMessageListsArgs = {
| 'id'
| 'handle'
| 'connectionParameters'
| 'workspaceId'
>;
messageFolders: MessageFolder[];
};
@@ -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);
});
});
@@ -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<string> => {
// 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<void> => {
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<void> => {
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();
}
});
});