From 80fa1a3a55186217f5abc124d2b3d18682e21a19 Mon Sep 17 00:00:00 2001 From: prastoin Date: Thu, 4 Jun 2026 15:19:12 +0200 Subject: [PATCH 1/5] remove legacy encryption pattenrs --- ...8000005000-encrypt-application-variable.ts | 8 +- ...crypt-application-registration-variable.ts | 8 +- ...007000-encrypt-signing-key-private-keys.ts | 11 +- ...008000-encrypt-sensitive-config-storage.ts | 11 +- ...slow-1798000009000-encrypt-totp-secrets.ts | 42 +++++- .../instance-command-provider.module.ts | 9 +- .../secret-encryption.service.ts | 53 +++---- .../two-factor-authentication.module.ts | 6 - .../two-factor-authentication.service.spec.ts | 72 ---------- .../two-factor-authentication.service.ts | 37 +---- .../simple-secret-encryption.util.spec.ts | 135 ------------------ .../utils/simple-secret-encryption.util.ts | 51 ------- ...nected-account-token-encryption.service.ts | 30 +--- ...0-encrypt-totp-secrets.integration-spec.ts | 22 ++- 14 files changed, 98 insertions(+), 397 deletions(-) delete mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts index 0fb13b6a8a591..eb910d22ec699 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts @@ -3,7 +3,6 @@ import { Logger } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; -import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; @@ -85,10 +84,9 @@ export class EncryptApplicationVariableSlowInstanceCommand if (looksLikeLegacyCtrCiphertext(row.value)) { try { - plaintext = this.secretEncryptionService.decryptVersioned( - row.value as EncryptedString, - { workspaceId: row.workspaceId }, - ); + plaintext = this.secretEncryptionService.decrypt( + row.value, + ) as PlaintextString; } catch (error) { this.logger.warn( `applicationVariable row ${row.id} value not valid ciphertext; treating as plaintext. ${ diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts index 9630798f56de1..8dc631bf27346 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts @@ -1,8 +1,8 @@ import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; -import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; +import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; 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 { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; @@ -57,9 +57,9 @@ export class EncryptApplicationRegistrationVariableSlowInstanceCommand continue; } - const plaintext = this.secretEncryptionService.decryptVersioned( - row.encryptedValue as EncryptedString, - ); + const plaintext = this.secretEncryptionService.decrypt( + row.encryptedValue, + ) as PlaintextString; if (!isDefined(plaintext)) { continue; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.ts index fdc1e2c26ceef..f4f165b10bdff 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys.ts @@ -1,7 +1,6 @@ import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; -import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; @@ -54,18 +53,16 @@ export class EncryptSigningKeyPrivateKeysSlowInstanceCommand implements SlowInst continue; } - const plaintext = this.secretEncryptionService.decryptVersioned( - row.privateKey as EncryptedString, - ); + const plaintext = this.secretEncryptionService.decrypt( + row.privateKey, + ) as PlaintextString; if (!isDefined(plaintext)) { continue; } const encryptedPrivateKey = - this.secretEncryptionService.encryptVersioned( - plaintext as PlaintextString, - ); + this.secretEncryptionService.encryptVersioned(plaintext); await dataSource.query( `UPDATE "core"."signingKey" diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts index 220e0fbb53b2d..7e541a98c5e3f 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts @@ -2,7 +2,6 @@ import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; import { KeyValuePairType } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; -import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util'; import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; @@ -57,18 +56,16 @@ export class EncryptSensitiveConfigStorageSlowInstanceCommand implements SlowIns continue; } - const plaintext = this.secretEncryptionService.decryptVersioned( - rawValue as EncryptedString, - ); + const plaintext = this.secretEncryptionService.decrypt( + rawValue, + ) as PlaintextString; if (!isDefined(plaintext)) { continue; } const encrypted = - this.secretEncryptionService.encryptVersioned( - plaintext as PlaintextString, - ); + this.secretEncryptionService.encryptVersioned(plaintext); await dataSource.query( `UPDATE "core"."keyValuePair" diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.ts index 4a4da98a6c777..9f2e6e29590de 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.ts @@ -1,10 +1,13 @@ +import { createDecipheriv, createHash } from 'crypto'; + import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; 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 { SimpleSecretEncryptionUtil } from 'src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util'; 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'; @@ -15,6 +18,9 @@ const SECRET_CHECK_CONSTRAINT_NAME = const V2_ENCRYPTED_LIKE_PATTERN = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%`; +const LEGACY_TOTP_CBC_ALGORITHM = 'aes-256-cbc'; +const LEGACY_TOTP_CBC_KEY_LENGTH = 32; + type TwoFactorMethodRow = { id: string; workspaceId: string; @@ -26,9 +32,39 @@ type TwoFactorMethodRow = { export class EncryptTotpSecretsSlowInstanceCommand implements SlowInstanceCommand { constructor( private readonly secretEncryptionService: SecretEncryptionService, - private readonly simpleSecretEncryptionUtil: SimpleSecretEncryptionUtil, + private readonly jwtWrapperService: JwtWrapperService, ) {} + // Legacy pre-2.5 TOTP secrets are stored as AES-256-CBC keyed off + // `APP_SECRET + userId + workspaceId + 'otp-secret' + 'KEY_ENCRYPTION_KEY'`. + // This is inlined here (previously in the now-deleted SimpleSecretEncryptionUtil) + // so the cross-upgrade backfill stays functional without a shared legacy util. + private decryptLegacyCbcSecret(encryptedSecret: string, purpose: string): string { + const appSecret = this.jwtWrapperService.generateAppSecret( + JwtTokenTypeEnum.KEY_ENCRYPTION_KEY, + purpose, + ); + + const encryptionKey = createHash('sha256') + .update(appSecret) + .digest() + .subarray(0, LEGACY_TOTP_CBC_KEY_LENGTH); + + const [ivHex, encryptedData] = encryptedSecret.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + + const decipher = createDecipheriv( + LEGACY_TOTP_CBC_ALGORITHM, + encryptionKey, + iv, + ); + let decrypted = decipher.update(encryptedData, 'hex', 'utf8'); + + decrypted += decipher.final('utf8'); + + return decrypted; + } + async runDataMigration(dataSource: DataSource): Promise { let cursor = '00000000-0000-0000-0000-000000000000'; @@ -50,7 +86,7 @@ export class EncryptTotpSecretsSlowInstanceCommand implements SlowInstanceComman } for (const row of rows) { - const plaintext = await this.simpleSecretEncryptionUtil.decryptSecret( + const plaintext = this.decryptLegacyCbcSecret( row.secret, `${row.userId}${row.workspaceId}otp-secret`, ); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts index a526a911303ff..604c1ffeaf975 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts @@ -3,18 +3,17 @@ import { Module } from '@nestjs/common'; import { INSTANCE_COMMANDS } from 'src/database/commands/upgrade-version-command/instance-commands.constant'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; -import { SimpleSecretEncryptionUtil } from 'src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util'; import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module'; @Module({ imports: [ ConnectedAccountTokenEncryptionModule, SecretEncryptionModule, - // JwtModule is required by SimpleSecretEncryptionUtil. Drop both once the - // 2.5 cross-upgrade window closes and the encrypt-totp-secrets slow command - // is retired. + // JwtModule provides JwtWrapperService, required by the 2.5 + // encrypt-totp-secrets slow command to read legacy AES-CBC TOTP secrets + // during the cross-upgrade window. Drop once that command is retired. JwtModule, ], - providers: [...INSTANCE_COMMANDS, SimpleSecretEncryptionUtil], + providers: [...INSTANCE_COMMANDS], }) export class InstanceCommandProviderModule {} diff --git a/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.ts b/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.ts index ff89ff7693c69..1f21ad6943fed 100644 --- a/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.ts +++ b/packages/twenty-server/src/engine/core-modules/secret-encryption/secret-encryption.service.ts @@ -1,9 +1,13 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; +import { + SecretEncryptionException, + SecretEncryptionExceptionCode, +} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception'; import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; import { computeEncryptionKeyId } from './utils/compute-encryption-key-id.util'; @@ -22,9 +26,6 @@ type VersionedOptions = { @Injectable() export class SecretEncryptionService { - private readonly logger = new Logger(SecretEncryptionService.name); - private hasLoggedLegacyCtrDecryption = false; - constructor( private readonly environmentConfigDriver: EnvironmentConfigDriver, ) {} @@ -137,35 +138,25 @@ export class SecretEncryptionService { 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, - }) as PlaintextString; + if (parsed.version !== 2) { + throw new SecretEncryptionException( + 'Expected an enc:v2 ciphertext envelope but received an unversioned value.', + SecretEncryptionExceptionCode.MALFORMED_ENVELOPE, + ); } - this.warnLegacyCtrDecryptionOnce(); - - return this.decrypt(value) as PlaintextString; - } - - private warnLegacyCtrDecryptionOnce(): void { - if (this.hasLoggedLegacyCtrDecryption) { - return; - } + const keys = resolveEncryptionKeysOrThrow({ + environmentConfigDriver: this.environmentConfigDriver, + }); + const rawKey = pickEncryptionKeyByKeyIdOrThrow({ + keyId: parsed.keyId, + keys, + }); - this.hasLoggedLegacyCtrDecryption = true; - this.logger.warn( - 'Decrypted a legacy unprefixed AES-CTR ciphertext. These rows should be re-encrypted into the enc:v2 envelope in a follow-up migration.', - ); + return decryptAesGcmV2OrThrow({ + payloadBase64: parsed.payload, + rawKey, + workspaceId: opts.workspaceId, + }) as PlaintextString; } } diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.module.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.module.ts index eeefe4c46e9cf..e10d915d68e5f 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.module.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.module.ts @@ -3,7 +3,6 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; -import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; @@ -15,7 +14,6 @@ import { TwoFactorAuthenticationResolver } from './two-factor-authentication.res import { TwoFactorAuthenticationService } from './two-factor-authentication.service'; import { TwoFactorAuthenticationMethodEntity } from './entities/two-factor-authentication-method.entity'; -import { SimpleSecretEncryptionUtil } from './utils/simple-secret-encryption.util'; @Module({ imports: [ @@ -23,9 +21,6 @@ import { SimpleSecretEncryptionUtil } from './utils/simple-secret-encryption.uti WorkspaceDomainsModule, MetricsModule, TokenModule, - // JwtModule is required by the deprecated SimpleSecretEncryptionUtil; drop - // it together with the util once the 2.5 cross-upgrade window closes. - JwtModule, SecretEncryptionModule, TypeOrmModule.forFeature([ UserEntity, @@ -37,7 +32,6 @@ import { SimpleSecretEncryptionUtil } from './utils/simple-secret-encryption.uti providers: [ TwoFactorAuthenticationService, TwoFactorAuthenticationResolver, - SimpleSecretEncryptionUtil, provideWorkspaceScopedRepository(TwoFactorAuthenticationMethodEntity), ], exports: [TwoFactorAuthenticationService], diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.spec.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.spec.ts index df1e36c1b0c31..d343f7fe8d1e9 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.spec.ts @@ -18,7 +18,6 @@ import { TwoFactorAuthenticationService } from './two-factor-authentication.serv import { TwoFactorAuthenticationMethodEntity } from './entities/two-factor-authentication-method.entity'; import { OTPStatus } from './strategies/otp/otp.constants'; -import { SimpleSecretEncryptionUtil } from './utils/simple-secret-encryption.util'; const V2_ENVELOPE_PREFIX = 'enc:v2:'; @@ -60,7 +59,6 @@ describe('TwoFactorAuthenticationService', () => { let repository: any; let userWorkspaceService: any; let secretEncryptionService: any; - let simpleSecretEncryptionUtil: any; const mockUser = { id: 'user_123', email: 'test@example.com' }; const workspace = { id: 'ws_123', displayName: 'Test Workspace' }; @@ -71,7 +69,6 @@ describe('TwoFactorAuthenticationService', () => { const rawSecret = 'RAW_OTP_SECRET'; const encryptedSecret = `${V2_ENVELOPE_PREFIX}abcdef12:payload`; - const legacyCbcSecret = '0123456789abcdef0123456789abcdef:cafebabe'; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -99,12 +96,6 @@ describe('TwoFactorAuthenticationService', () => { decryptVersioned: jest.fn(), }, }, - { - provide: SimpleSecretEncryptionUtil, - useValue: { - decryptSecret: jest.fn(), - }, - }, ], }).compile(); @@ -119,9 +110,6 @@ describe('TwoFactorAuthenticationService', () => { secretEncryptionService = module.get( SecretEncryptionService, ); - simpleSecretEncryptionUtil = module.get( - SimpleSecretEncryptionUtil, - ); jest.clearAllMocks(); }); @@ -312,43 +300,11 @@ describe('TwoFactorAuthenticationService', () => { encryptedSecret, { workspaceId: workspace.id }, ); - expect(simpleSecretEncryptionUtil.decryptSecret).not.toHaveBeenCalled(); // Should not create new method or call initiate expect(totpStrategyMocks.initiate).not.toHaveBeenCalled(); expect(repository.save).not.toHaveBeenCalled(); }); - it('falls back to SimpleSecretEncryptionUtil when the stored secret is in the legacy AES-CBC format', async () => { - const recentTime = new Date(Date.now() - 5 * 60 * 1000); - const existingMethod = { - id: 'existing_method_id', - status: 'PENDING', - secret: legacyCbcSecret, - createdAt: recentTime, - }; - - repository.findOne.mockResolvedValue(existingMethod); - simpleSecretEncryptionUtil.decryptSecret.mockResolvedValue(rawSecret); - - const uri = await service.initiateStrategyConfiguration( - mockUser.id, - mockUser.email, - workspace.id, - workspace.displayName, - ); - - expect(uri).toBe( - 'otpauth://totp/test@example.com?secret=RAW_OTP_SECRET&issuer=Twenty%20-%20Test%20Workspace', - ); - expect(simpleSecretEncryptionUtil.decryptSecret).toHaveBeenCalledWith( - legacyCbcSecret, - `${mockUser.id}${workspace.id}otp-secret`, - ); - expect(secretEncryptionService.decryptVersioned).not.toHaveBeenCalled(); - expect(totpStrategyMocks.initiate).not.toHaveBeenCalled(); - expect(repository.save).not.toHaveBeenCalled(); - }); - it('should create new method when existing pending method is too old', async () => { const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000); const existingMethod = { @@ -481,7 +437,6 @@ describe('TwoFactorAuthenticationService', () => { encryptedSecret, { workspaceId: workspace.id }, ); - expect(simpleSecretEncryptionUtil.decryptSecret).not.toHaveBeenCalled(); expect(totpStrategyMocks.validate).toHaveBeenCalledWith(otpToken, { status: mock2FAMethod.status, secret: rawSecret, @@ -495,33 +450,6 @@ describe('TwoFactorAuthenticationService', () => { ); }); - it('dispatches to SimpleSecretEncryptionUtil for legacy AES-CBC secrets', async () => { - const legacyMethod = { - ...mock2FAMethod, - secret: legacyCbcSecret, - }; - - repository.findOne.mockResolvedValue(legacyMethod); - simpleSecretEncryptionUtil.decryptSecret.mockResolvedValue(rawSecret); - totpStrategyMocks.validate.mockReturnValue({ - isValid: true, - context: { status: legacyMethod.status, secret: rawSecret }, - }); - - await service.validateStrategy( - mockUser.id, - otpToken, - workspace.id, - TwoFactorAuthenticationStrategy.TOTP, - ); - - expect(simpleSecretEncryptionUtil.decryptSecret).toHaveBeenCalledWith( - legacyCbcSecret, - `${mockUser.id}${workspace.id}otp-secret`, - ); - expect(secretEncryptionService.decryptVersioned).not.toHaveBeenCalled(); - }); - it('should throw if the token is invalid', async () => { repository.findOne.mockResolvedValue(mock2FAMethod); secretEncryptionService.decryptVersioned.mockReturnValue(rawSecret); diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts index 80e56df3d84b6..995b1ee8f0b84 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts @@ -10,7 +10,6 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type'; import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; -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 { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity'; @@ -28,19 +27,9 @@ import { import { twoFactorAuthenticationMethodsValidator } from './two-factor-authentication.validation'; import { OTPStatus } from './strategies/otp/otp.constants'; -import { SimpleSecretEncryptionUtil } from './utils/simple-secret-encryption.util'; const PENDING_METHOD_REUSE_WINDOW_MS = 60 * 60 * 1000; -// TODO: drop this helper, the `simpleSecretEncryptionUtil` dep, and the legacy -// branch in `decryptStoredSecret` below once the 2.5 cross-upgrade window -// closes and every TOTP secret row has been backfilled to enc:v2 by the -// matching slow instance command. -const buildLegacyTotpCbcPurpose = ( - userId: string, - workspaceId: string, -): string => `${userId}${workspaceId}otp-secret`; - @Injectable() // oxlint-disable-next-line twenty/inject-workspace-repository export class TwoFactorAuthenticationService { @@ -49,28 +38,18 @@ export class TwoFactorAuthenticationService { private readonly twoFactorAuthenticationMethodRepository: WorkspaceScopedRepository, private readonly userWorkspaceService: UserWorkspaceService, private readonly secretEncryptionService: SecretEncryptionService, - private readonly simpleSecretEncryptionUtil: SimpleSecretEncryptionUtil, ) {} - private async decryptStoredSecret({ + private decryptStoredSecret({ storedSecret, - userId, workspaceId, }: { storedSecret: EncryptedString; - userId: string; workspaceId: string; - }): Promise { - if (storedSecret.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)) { - return this.secretEncryptionService.decryptVersioned(storedSecret, { - workspaceId, - }); - } - - return this.simpleSecretEncryptionUtil.decryptSecret( - storedSecret, - buildLegacyTotpCbcPurpose(userId, workspaceId), - ); + }): PlaintextString { + return this.secretEncryptionService.decryptVersioned(storedSecret, { + workspaceId, + }); } /** @@ -139,9 +118,8 @@ export class TwoFactorAuthenticationService { Date.now() - existing2FAMethod.createdAt.getTime() < PENDING_METHOD_REUSE_WINDOW_MS ) { - const existingSecret = await this.decryptStoredSecret({ + const existingSecret = this.decryptStoredSecret({ storedSecret: existing2FAMethod.secret, - userId, workspaceId, }); @@ -205,9 +183,8 @@ export class TwoFactorAuthenticationService { ); } - const originalSecret = await this.decryptStoredSecret({ + const originalSecret = this.decryptStoredSecret({ storedSecret: userTwoFactorAuthenticationMethod.secret, - userId, workspaceId, }); diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.spec.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.spec.ts deleted file mode 100644 index bc80a76f216b0..0000000000000 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Test, type TestingModule } from '@nestjs/testing'; - -import { createCipheriv, createHash, randomBytes } from 'crypto'; - -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; - -import { SimpleSecretEncryptionUtil } from './simple-secret-encryption.util'; - -// Mirrors the production write path the util used to perform; kept inside -// the spec so the deprecated decryptSecret can still be exercised end-to-end -// without shipping an encrypt method. -const encryptLegacySecret = ({ - plaintext, - appSecret, -}: { - plaintext: string; - appSecret: string; -}): string => { - const encryptionKey = createHash('sha256') - .update(appSecret) - .digest() - .slice(0, 32); - const iv = randomBytes(16); - const cipher = createCipheriv('aes-256-cbc', encryptionKey, iv); - const encrypted = Buffer.concat([ - cipher.update(plaintext, 'utf8'), - cipher.final(), - ]); - - return `${iv.toString('hex')}:${encrypted.toString('hex')}`; -}; - -describe('SimpleSecretEncryptionUtil', () => { - let util: SimpleSecretEncryptionUtil; - let jwtWrapperService: any; - - const mockAppSecret = 'mock-app-secret-for-testing-purposes-12345678'; - const testSecret = 'KVKFKRCPNZQUYMLXOVYDSKLMNBVCXZ'; - const testPurpose = 'user123workspace456otp-secret'; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - SimpleSecretEncryptionUtil, - { - provide: JwtWrapperService, - useValue: { - generateAppSecret: jest - .fn() - .mockImplementation( - (_type, purpose) => `${mockAppSecret}-${purpose}`, - ), - }, - }, - ], - }).compile(); - - util = module.get(SimpleSecretEncryptionUtil); - jwtWrapperService = module.get(JwtWrapperService); - - jest.clearAllMocks(); - }); - - it('should be defined', () => { - expect(util).toBeDefined(); - }); - - describe('decryptSecret', () => { - it('decrypts a legacy ciphertext produced with the matching purpose', async () => { - const appSecret = `${mockAppSecret}-${testPurpose}`; - const encrypted = encryptLegacySecret({ - plaintext: testSecret, - appSecret, - }); - - const decrypted = await util.decryptSecret(encrypted, testPurpose); - - expect(decrypted).toBe(testSecret); - }); - - it('uses the KEY_ENCRYPTION_KEY JWT token type and the provided purpose', async () => { - const appSecret = `${mockAppSecret}-${testPurpose}`; - const encrypted = encryptLegacySecret({ - plaintext: testSecret, - appSecret, - }); - - await util.decryptSecret(encrypted, testPurpose); - - expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith( - JwtTokenTypeEnum.KEY_ENCRYPTION_KEY, - testPurpose, - ); - }); - - it('handles special characters in plaintext', async () => { - const specialSecret = 'SECRET-WITH_SPECIAL@CHARS#123!'; - const appSecret = `${mockAppSecret}-${testPurpose}`; - const encrypted = encryptLegacySecret({ - plaintext: specialSecret, - appSecret, - }); - - const decrypted = await util.decryptSecret(encrypted, testPurpose); - - expect(decrypted).toBe(specialSecret); - }); - - it('does not recover the plaintext when the purpose is wrong', async () => { - const appSecret = `${mockAppSecret}-${testPurpose}`; - const encrypted = encryptLegacySecret({ - plaintext: testSecret, - appSecret, - }); - - // AES-256-CBC with a different key may either throw (invalid padding) - // or produce garbage. Both outcomes are acceptable - the key property is - // that the original secret is never returned. - try { - const decrypted = await util.decryptSecret(encrypted, 'wrong-purpose'); - - expect(decrypted).not.toBe(testSecret); - } catch { - // Expected: wrong key produced invalid padding. - } - }); - - it('throws on malformed ciphertext', async () => { - await expect( - util.decryptSecret('invalid-encrypted-data', testPurpose), - ).rejects.toThrow(); - }); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts deleted file mode 100644 index cc2f541988606..0000000000000 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { createDecipheriv, createHash } from 'crypto'; - -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; - -// TODO: delete this util once the 2.5 cross-upgrade window closes and every -// `core.twoFactorAuthenticationMethod.secret` row is known to be in the -// `enc:v2:` envelope. Also drop the call sites in TwoFactorAuthenticationService -// and the matching slow instance command, and stop providing this util in -// TwoFactorAuthenticationModule and InstanceCommandProviderModule. -/** - * @deprecated Legacy TOTP secret decryption (AES-256-CBC keyed off - * `APP_SECRET + userId + workspaceId + 'otp-secret' + 'KEY_ENCRYPTION_KEY'`). - * Kept only to read pre-2.5 rows during the cross-upgrade window. New rows are - * written by `SecretEncryptionService.encryptVersioned` (enc:v2 envelope). - */ -@Injectable() -export class SimpleSecretEncryptionUtil { - private readonly algorithm = 'aes-256-cbc'; - private readonly keyLength = 32; - - constructor(private readonly jwtWrapperService: JwtWrapperService) {} - - async decryptSecret( - encryptedSecret: string, - purpose: string, - ): Promise { - const appSecret = this.jwtWrapperService.generateAppSecret( - JwtTokenTypeEnum.KEY_ENCRYPTION_KEY, - purpose, - ); - - const encryptionKey = createHash('sha256') - .update(appSecret) - .digest() - .slice(0, this.keyLength); - - const [ivHex, encryptedData] = encryptedSecret.split(':'); - const iv = Buffer.from(ivHex, 'hex'); - - const decipher = createDecipheriv(this.algorithm, encryptionKey, iv); - let decrypted = decipher.update(encryptedData, 'hex', 'utf8'); - - decrypted += decipher.final('utf8'); - - return decrypted as PlaintextString; - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts index 4837f5fa77516..1055c4e75440c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; @@ -20,10 +20,6 @@ import { ACCOUNT_TYPES } from 'twenty-shared/constants'; @Injectable() export class ConnectedAccountTokenEncryptionService { - private readonly logger = new Logger( - ConnectedAccountTokenEncryptionService.name, - ); - constructor( private readonly secretEncryptionService: SecretEncryptionService, ) {} @@ -179,30 +175,6 @@ export class ConnectedAccountTokenEncryptionService { protocolParams: EncryptedConnectionParameters; workspaceId: string; }): PlaintextConnectionParameters { - const isEncrypted = protocolParams.password.startsWith( - SECRET_ENCRYPTION_ENVELOPE_PREFIX, - ); - - // TODO: Remove in follow-up PR once all legacy encryption fallbacks are dropped. - // TODO: Remove after 2-5 slow instance command has been run everywhere. - // During the rollout window protocolParams.password may be a legacy - // unencrypted plaintext value living in the same column. We trust the - // entity-level brand at the type layer (column is EncryptedString) but - // still re-validate at runtime to handle the un-backfilled tail; the - // assert above splits the two. - if (!isEncrypted) { - this.logger.warn( - 'Protocol password is not encrypted. Expected during the rollout window until the slow instance command finishes backfilling.', - ); - - const rawPassword: string = protocolParams.password; - - return { - ...protocolParams, - password: rawPassword as PlaintextString, - }; - } - return { ...protocolParams, password: this.decrypt({ diff --git a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts index e603357f43c04..6ca19608be17b 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts @@ -11,7 +11,6 @@ import { type JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; 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 { SimpleSecretEncryptionUtil } from 'src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util'; import { EncryptTotpSecretsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets'; @@ -69,9 +68,9 @@ const restoreCheckConstraint = async ( ); }; -// Stand-in for the real JwtWrapperService used by SimpleSecretEncryptionUtil. -// Reproduces JwtWrapperService.generateAppSecret byte-for-byte so the legacy -// CBC key derivation matches what production rows were sealed with. +// Stand-in for the real JwtWrapperService used by the command's legacy CBC +// decryption. Reproduces JwtWrapperService.generateAppSecret byte-for-byte so +// the legacy CBC key derivation matches what production rows were sealed with. const buildJwtWrapperServiceStub = (appSecret: string): JwtWrapperService => { return { generateAppSecret: ( @@ -87,7 +86,6 @@ const buildJwtWrapperServiceStub = (appSecret: string): JwtWrapperService => { describe('2-5 slow instance command 1798000009000 - EncryptTotpSecretsSlowInstanceCommand (integration)', () => { let dataSource: DataSource; let secretEncryptionService: SecretEncryptionService; - let simpleSecretEncryptionUtil: SimpleSecretEncryptionUtil; let command: EncryptTotpSecretsSlowInstanceCommand; let appSecret: string; let userId: string; @@ -130,12 +128,9 @@ describe('2-5 slow instance command 1798000009000 - EncryptTotpSecretsSlowInstan appSecret = process.env.APP_SECRET; secretEncryptionService = buildSecretEncryptionServiceFromEnv(); - simpleSecretEncryptionUtil = new SimpleSecretEncryptionUtil( - buildJwtWrapperServiceStub(appSecret), - ); command = new EncryptTotpSecretsSlowInstanceCommand( secretEncryptionService, - simpleSecretEncryptionUtil, + buildJwtWrapperServiceStub(appSecret), ); const [seedUserWorkspace] = await dataSource.query( @@ -196,9 +191,12 @@ describe('2-5 slow instance command 1798000009000 - EncryptTotpSecretsSlowInstan it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => { const plaintext = 'already-v2-totp-secret'; - const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext as PlaintextString, { - workspaceId, - }); + const preexistingV2 = secretEncryptionService.encryptVersioned( + plaintext as PlaintextString, + { + workspaceId, + }, + ); const id = await seedRow({ secret: preexistingV2 }); await command.runDataMigration(dataSource); From 6e065248df654e90a26b792649074d4a4710a299 Mon Sep 17 00:00:00 2001 From: prastoin Date: Wed, 17 Jun 2026 17:34:59 +0200 Subject: [PATCH 2/5] test: update legacy CTR integration tests for removed encryption fallback decryptVersioned now throws on non-enc:v2 envelopes instead of falling back to legacy AES-CTR. Update the application-variable and application-registration-variable integration tests to assert the live read path surfaces an error for legacy ciphertext rather than transparently decrypting them. --- ...ion-variable-encryption.integration-spec.ts | 18 ++++++------------ ...ion-variable-encryption.integration-spec.ts | 18 ++++++------------ 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/packages/twenty-server/test/integration/secret-encryption/application-registration-variable-encryption.integration-spec.ts b/packages/twenty-server/test/integration/secret-encryption/application-registration-variable-encryption.integration-spec.ts index 0e48f3075d69f..e592a9239589c 100644 --- a/packages/twenty-server/test/integration/secret-encryption/application-registration-variable-encryption.integration-spec.ts +++ b/packages/twenty-server/test/integration/secret-encryption/application-registration-variable-encryption.integration-spec.ts @@ -135,7 +135,7 @@ describe('ApplicationRegistrationVariable encryption (integration)', () => { expect(variable.value).toBe(plaintext); }); - describe('legacy CTR fallback', () => { + describe('legacy CTR values (fallback removed)', () => { let legacyVariableId: string; beforeAll(async () => { @@ -156,7 +156,7 @@ describe('ApplicationRegistrationVariable encryption (integration)', () => { ); }); - it('decrypts a legacy CTR-encrypted value through the live API', async () => { + it('no longer decrypts a legacy CTR-encrypted value and surfaces an error through the live API', async () => { legacyVariableId = crypto.randomUUID(); const plaintext = 'legacy-ctr-registration-variable-secret'; @@ -189,16 +189,10 @@ describe('ApplicationRegistrationVariable encryption (integration)', () => { variables: { applicationRegistrationId }, }); - expect(findResponse.body.errors).toBeUndefined(); - - const variable = - findResponse.body.data.findApplicationRegistrationVariables.find( - (v: { id: string }) => v.id === legacyVariableId, - ); - - expect(variable).toBeDefined(); - expect(variable.isSecret).toBe(false); - expect(variable.value).toBe(plaintext); + expect(findResponse.body.errors).toBeDefined(); + expect(findResponse.body.errors[0].message).toContain( + 'enc:v2 ciphertext envelope', + ); }); }); }); diff --git a/packages/twenty-server/test/integration/secret-encryption/application-variable-encryption.integration-spec.ts b/packages/twenty-server/test/integration/secret-encryption/application-variable-encryption.integration-spec.ts index 2fd42d190c4b4..3baaf5ced9d9a 100644 --- a/packages/twenty-server/test/integration/secret-encryption/application-variable-encryption.integration-spec.ts +++ b/packages/twenty-server/test/integration/secret-encryption/application-variable-encryption.integration-spec.ts @@ -243,7 +243,7 @@ describe('ApplicationVariable encryption (integration)', () => { expect(variable.value).toBe(plaintext); }); - describe('legacy CTR fallback', () => { + describe('legacy CTR values (fallback removed)', () => { beforeAll(async () => { await dataSource.query( `ALTER TABLE core."applicationVariable" @@ -264,7 +264,7 @@ describe('ApplicationVariable encryption (integration)', () => { ); }); - it('decrypts a legacy CTR-encrypted value through the live API read path', async () => { + it('no longer decrypts a legacy CTR-encrypted value and surfaces an error through the live API read path', async () => { const plaintext = 'legacy-ctr-application-variable-secret-value-here'; await dataSource.query( @@ -293,16 +293,10 @@ describe('ApplicationVariable encryption (integration)', () => { variables: { id: applicationId }, }); - expect(findResponse.body.errors).toBeUndefined(); - - const variable = - findResponse.body.data.findOneApplication.applicationVariables.find( - (v: { key: string }) => v.key === LEGACY_VARIABLE_KEY, - ); - - expect(variable).toBeDefined(); - expect(variable.isSecret).toBe(true); - expect(variable.value).toBe(buildExpectedMask(plaintext)); + expect(findResponse.body.errors).toBeDefined(); + expect(findResponse.body.errors[0].message).toContain( + 'enc:v2 ciphertext envelope', + ); }); }); }); From 3c6342ee124406e8827e1dea67089e9d46664d3a Mon Sep 17 00:00:00 2001 From: prastoin Date: Wed, 17 Jun 2026 18:23:47 +0200 Subject: [PATCH 3/5] chore: re-trigger CI From 36ff3102181ea1a886ce400564f99667130c506d Mon Sep 17 00:00:00 2001 From: prastoin Date: Thu, 18 Jun 2026 14:10:25 +0200 Subject: [PATCH 4/5] trigger ci From 84551f9b481e56f1d6167d4fe305a98ac868dfa0 Mon Sep 17 00:00:00 2001 From: prastoin Date: Thu, 18 Jun 2026 14:38:39 +0200 Subject: [PATCH 5/5] cubic --- ...crypt-application-registration-variable.ts | 41 ++++++++++++++-- ...008000-encrypt-sensitive-config-storage.ts | 48 ++++++++++++++++--- ...-registration-variable.integration-spec.ts | 25 ++++++++++ ...nsitive-config-storage.integration-spec.ts | 18 +++++++ 4 files changed, 123 insertions(+), 9 deletions(-) diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts index 8dc631bf27346..5cae09be56214 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.ts @@ -1,3 +1,5 @@ +import { Logger } from '@nestjs/common'; + import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; @@ -20,10 +22,25 @@ type ApplicationRegistrationVariableRow = { encryptedValue: string; }; +// Legacy CTR ciphertext is base64-encoded and at least 16 bytes (one IV +// block) — i.e. ≥ 22 base64 chars. Anything outside that shape is treated as +// plaintext and encrypted as-is: CTR decrypt has no integrity tag and would +// silently turn a non-ciphertext value into garbage instead of throwing. +const LEGACY_CTR_LOOKS_LIKE_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/; +const LEGACY_CTR_MIN_LENGTH = 22; + +const looksLikeLegacyCtrCiphertext = (value: string): boolean => + value.length >= LEGACY_CTR_MIN_LENGTH && + LEGACY_CTR_LOOKS_LIKE_BASE64_RE.test(value); + @RegisteredInstanceCommand('2.5.0', 1798000006000, { type: 'slow' }) export class EncryptApplicationRegistrationVariableSlowInstanceCommand implements SlowInstanceCommand { + private readonly logger = new Logger( + EncryptApplicationRegistrationVariableSlowInstanceCommand.name, + ); + constructor( private readonly secretEncryptionService: SecretEncryptionService, ) {} @@ -57,9 +74,27 @@ export class EncryptApplicationRegistrationVariableSlowInstanceCommand continue; } - const plaintext = this.secretEncryptionService.decrypt( - row.encryptedValue, - ) as PlaintextString; + let plaintext: PlaintextString; + + if (looksLikeLegacyCtrCiphertext(row.encryptedValue)) { + try { + plaintext = this.secretEncryptionService.decrypt( + row.encryptedValue, + ) as PlaintextString; + } catch (error) { + this.logger.warn( + `applicationRegistrationVariable row ${row.id} encryptedValue not valid ciphertext; treating as plaintext. ${ + error instanceof Error ? error.message : String(error) + }`, + ); + plaintext = row.encryptedValue as PlaintextString; + } + } else { + this.logger.warn( + `applicationRegistrationVariable row ${row.id} encryptedValue is not legacy CTR ciphertext; treating as plaintext.`, + ); + plaintext = row.encryptedValue as PlaintextString; + } if (!isDefined(plaintext)) { continue; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts index 7e541a98c5e3f..b91e7f363d7f9 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.ts @@ -1,3 +1,5 @@ +import { Logger } from '@nestjs/common'; + import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; @@ -14,8 +16,23 @@ import { TypedReflect } from 'src/utils/typed-reflect'; type SensitiveConfigRow = { id: string; value: unknown }; +// Legacy CTR ciphertext is base64-encoded and at least 16 bytes (one IV +// block) — i.e. ≥ 22 base64 chars. Anything outside that shape is plaintext +// that must be encrypted as-is: CTR decrypt has no integrity tag and would +// silently turn a real secret into garbage instead of throwing. +const LEGACY_CTR_LOOKS_LIKE_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/; +const LEGACY_CTR_MIN_LENGTH = 22; + +const looksLikeLegacyCtrCiphertext = (value: string): boolean => + value.length >= LEGACY_CTR_MIN_LENGTH && + LEGACY_CTR_LOOKS_LIKE_BASE64_RE.test(value); + @RegisteredInstanceCommand('2.5.0', 1798000008000, { type: 'slow' }) export class EncryptSensitiveConfigStorageSlowInstanceCommand implements SlowInstanceCommand { + private readonly logger = new Logger( + EncryptSensitiveConfigStorageSlowInstanceCommand.name, + ); + constructor( private readonly secretEncryptionService: SecretEncryptionService, ) {} @@ -24,9 +41,10 @@ export class EncryptSensitiveConfigStorageSlowInstanceCommand implements SlowIns // user/feature-flag entries and with non-sensitive config — so a CHECK // constraint cannot be added column-wide. The backfill walks only the // CONFIG_VARIABLE rows whose key is declared `isSensitive` + STRING in - // the ConfigVariables metadata, decrypts the legacy CTR ciphertext, and - // re-encrypts it into the instance-scoped versioned envelope. Idempotent: - // already-v2 rows are left untouched. + // the ConfigVariables metadata, decrypts legacy CTR ciphertext (or treats a + // non-ciphertext value as plaintext), and re-encrypts it into the + // instance-scoped versioned envelope. Idempotent: already-v2 rows are left + // untouched. async runDataMigration(dataSource: DataSource): Promise { const sensitiveStringKeys = this.collectSensitiveStringConfigKeys(); @@ -56,9 +74,27 @@ export class EncryptSensitiveConfigStorageSlowInstanceCommand implements SlowIns continue; } - const plaintext = this.secretEncryptionService.decrypt( - rawValue, - ) as PlaintextString; + let plaintext: PlaintextString; + + if (looksLikeLegacyCtrCiphertext(rawValue)) { + try { + plaintext = this.secretEncryptionService.decrypt( + rawValue, + ) as PlaintextString; + } catch (error) { + this.logger.warn( + `keyValuePair config row ${row.id} (key "${key}") value not valid ciphertext; treating as plaintext. ${ + error instanceof Error ? error.message : String(error) + }`, + ); + plaintext = rawValue as PlaintextString; + } + } else { + this.logger.warn( + `keyValuePair config row ${row.id} (key "${key}") value is not legacy CTR ciphertext; treating as plaintext.`, + ); + plaintext = rawValue as PlaintextString; + } if (!isDefined(plaintext)) { continue; diff --git a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.integration-spec.ts index 88f65bc24bca9..b71615d3fef13 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable.integration-spec.ts @@ -162,6 +162,31 @@ describe('2-5 slow instance command 1798000006000 - EncryptApplicationRegistrati ); }); + // A non-ciphertext value (e.g. a corrupted/tampered row, or a future code + // path writing raw values) must be encrypted as-is rather than fed through + // the integrity-less CTR decrypt, which would silently corrupt it into + // garbage before re-encrypting. + it('encrypts a non-ciphertext plaintext value as-is instead of corrupting it', async () => { + const plaintext = 'plain-registration-value@example.com'; + const id = await seedVariable({ encryptedValue: plaintext }); + + await command.runDataMigration(dataSource); + + const [row] = await dataSource.query( + `SELECT "encryptedValue" + FROM "core"."applicationRegistrationVariable" + WHERE id = $1`, + [id], + ); + + expect( + row.encryptedValue.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX), + ).toBe(true); + expect(secretEncryptionService.decryptVersioned(row.encryptedValue)).toBe( + plaintext, + ); + }); + it('leaves unfilled rows (encryptedValue = "") untouched', async () => { const id = await seedVariable({ encryptedValue: '' }); diff --git a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.integration-spec.ts index 2e6cdd03b2f35..99748444d63dc 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage.integration-spec.ts @@ -128,4 +128,22 @@ describe('2-5 slow instance command 1798000008000 - EncryptSensitiveConfigStorag expect(await readValue(id)).toBe(''); }); + + // A value that predates encryption (or whose key only later became + // sensitive) is stored as plaintext. It must be encrypted as-is rather + // than fed through the integrity-less CTR decrypt, which would silently + // corrupt the secret into garbage before re-encrypting it. + it('encrypts a non-ciphertext plaintext value as-is instead of corrupting it', async () => { + const plaintext = 'plain-smtp-user@example.com'; + const id = await seedRow(plaintext); + + await command.runDataMigration(dataSource); + + const value = await readValue(id); + + expect(value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe(true); + expect( + secretEncryptionService.decryptVersioned(value as EncryptedString), + ).toBe(plaintext); + }); });