e0b4c9918b
## 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.
135 lines
4.3 KiB
TypeScript
135 lines
4.3 KiB
TypeScript
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);
|
|
});
|
|
});
|