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:
+28
-35
@@ -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}"`,
|
||||
|
||||
+92
-81
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+14
-13
@@ -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',
|
||||
}),
|
||||
|
||||
+1
@@ -248,6 +248,7 @@ export class ConnectionProviderOAuthFlowService {
|
||||
this.connectedAccountTokenEncryptionService.encryptTokenPair({
|
||||
accessToken: tokenResponse.accessToken,
|
||||
refreshToken: tokenResponse.refreshToken,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const sharedFields = {
|
||||
|
||||
+4
-3
@@ -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,
|
||||
|
||||
+1
@@ -91,6 +91,7 @@ export class CreateConnectedAccountService {
|
||||
this.connectedAccountTokenEncryptionService.encryptTokenPair({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await input.transactionManager
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ export class UpdateConnectedAccountOnReconnectService {
|
||||
this.connectedAccountTokenEncryptionService.encryptTokenPair({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
+10
@@ -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';
|
||||
+46
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+86
-14
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type ResolvedEncryptionKeys = {
|
||||
primary: string;
|
||||
fallback: string | null;
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export type ParsedSecretEncryptionEnvelope =
|
||||
| { version: 2; keyId: string; payload: string }
|
||||
| { version: null };
|
||||
+79
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+77
@@ -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);
|
||||
});
|
||||
});
|
||||
+28
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+93
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+66
@@ -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'/);
|
||||
});
|
||||
});
|
||||
+83
@@ -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();
|
||||
});
|
||||
});
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export const computeEncryptionKeyId = ({
|
||||
rawKey,
|
||||
}: {
|
||||
rawKey: string;
|
||||
}): string => createHash('sha256').update(rawKey).digest('hex').slice(0, 8);
|
||||
+23
@@ -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();
|
||||
};
|
||||
+52
@@ -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');
|
||||
};
|
||||
+30
@@ -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,
|
||||
),
|
||||
);
|
||||
+20
@@ -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',
|
||||
);
|
||||
};
|
||||
+26
@@ -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');
|
||||
};
|
||||
+10
@@ -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}`;
|
||||
+49
@@ -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,
|
||||
);
|
||||
};
|
||||
+32
@@ -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,
|
||||
);
|
||||
};
|
||||
+35
@@ -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',
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ export class GoogleCalendarGetEventsService {
|
||||
public async getCalendarEvents(
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountEntity,
|
||||
'provider' | 'refreshToken' | 'id'
|
||||
'provider' | 'refreshToken' | 'id' | 'workspaceId'
|
||||
>,
|
||||
syncCursor?: string,
|
||||
): Promise<GetCalendarEventsResponse> {
|
||||
|
||||
+1
-1
@@ -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> {
|
||||
|
||||
+1
-1
@@ -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[]> {
|
||||
|
||||
+1
@@ -36,6 +36,7 @@ export class CalendarGetCalendarEventsService {
|
||||
| 'id'
|
||||
| 'connectionParameters'
|
||||
| 'handle'
|
||||
| 'workspaceId'
|
||||
>,
|
||||
syncCursor?: string,
|
||||
): Promise<GetCalendarEventsResponse> {
|
||||
|
||||
+16
-8
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+42
-34
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
+13
-10
@@ -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);
|
||||
|
||||
+6
-1
@@ -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[]> {
|
||||
|
||||
+6
-1
@@ -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[]> {
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ export type MessageFolderDriver = {
|
||||
| 'id'
|
||||
| 'handle'
|
||||
| 'connectionParameters'
|
||||
| 'workspaceId'
|
||||
>,
|
||||
messageChannel: Pick<MessageChannelEntity, 'messageFolderImportPolicy'>,
|
||||
): Promise<DiscoveredMessageFolder[]>;
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+2
@@ -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[]> {
|
||||
|
||||
+2
@@ -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 () => {
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
+1
@@ -33,6 +33,7 @@ export class GmailGetMessagesService {
|
||||
| 'id'
|
||||
| 'handle'
|
||||
| 'handleAliases'
|
||||
| 'workspaceId'
|
||||
>,
|
||||
messageChannel: Pick<
|
||||
MessageChannelEntity,
|
||||
|
||||
+2
@@ -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 = {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ export class MicrosoftFetchByBatchService {
|
||||
messageIds: string[],
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountEntity,
|
||||
'accessToken' | 'refreshToken' | 'id' | 'provider'
|
||||
'accessToken' | 'refreshToken' | 'id' | 'provider' | 'workspaceId'
|
||||
>,
|
||||
): Promise<{
|
||||
messageIdsByBatch: string[][];
|
||||
|
||||
+3
@@ -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(
|
||||
|
||||
+2
@@ -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 = () => ({
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export class MicrosoftGetMessageListService {
|
||||
public async getMessageList(
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountEntity,
|
||||
'provider' | 'accessToken' | 'id'
|
||||
'provider' | 'accessToken' | 'id' | 'workspaceId'
|
||||
>,
|
||||
messageFolder: Pick<
|
||||
MessageFolderEntity,
|
||||
|
||||
+1
@@ -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 () => {
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+1
@@ -24,6 +24,7 @@ type ConnectedAccountType = Pick<
|
||||
| 'provider'
|
||||
| 'handle'
|
||||
| 'handleAliases'
|
||||
| 'workspaceId'
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ export class MessagingGetMessagesService {
|
||||
| 'handleAliases'
|
||||
| 'userWorkspaceId'
|
||||
| 'connectionParameters'
|
||||
| 'workspaceId'
|
||||
>,
|
||||
messageChannel: Pick<
|
||||
MessageChannelEntity,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ export type GetMessageListsArgs = {
|
||||
| 'id'
|
||||
| 'handle'
|
||||
| 'connectionParameters'
|
||||
| 'workspaceId'
|
||||
>;
|
||||
messageFolders: MessageFolder[];
|
||||
};
|
||||
|
||||
+134
@@ -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);
|
||||
});
|
||||
});
|
||||
+339
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user