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
@@ -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,