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:
+2
-2
@@ -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')
|
||||
|
||||
-5
@@ -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."`;
|
||||
-151
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+75
-30
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user