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 babaf6545f..23f70f04f5 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 @@ -1,3 +1,5 @@ +import { Logger } from '@nestjs/common'; + import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; @@ -12,30 +14,47 @@ const VALUE_CHECK_CONSTRAINT_NAME = 'CHK_applicationVariable_value_encrypted'; const V2_ENCRYPTED_LIKE_PATTERN = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%`; +// 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. +// Node's `Buffer.from(value, 'base64')` silently skips invalid chars, so a +// URL like `https://hooks.slack.com/...` would otherwise decode into enough +// bytes to "decrypt" to garbage without throwing. +const LEGACY_CTR_LOOKS_LIKE_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/; +const LEGACY_CTR_MIN_LENGTH = 22; + type ApplicationVariableRow = { id: string; workspaceId: string; value: string; + isSecret: boolean; }; +const looksLikeLegacyCtrCiphertext = (value: string): boolean => + value.length >= LEGACY_CTR_MIN_LENGTH && + LEGACY_CTR_LOOKS_LIKE_BASE64_RE.test(value); + @RegisteredInstanceCommand('2.5.0', 1798000005000, { type: 'slow' }) export class EncryptApplicationVariableSlowInstanceCommand implements SlowInstanceCommand { + private readonly logger = new Logger( + EncryptApplicationVariableSlowInstanceCommand.name, + ); + constructor( private readonly secretEncryptionService: SecretEncryptionService, ) {} - // Re-encrypts every secret application variable into the versioned envelope - // bound to its row's workspaceId. Non-secret rows are left untouched — - // their `value` is plaintext by design. Idempotent: the SELECT filter - // skips rows already in v2 form. + // Re-encrypts secret application variables into the v2 envelope. Rows + // marked isSecret=true with a plaintext value (instead of legacy CTR + // ciphertext) are treated as plaintext and encrypted, mirroring + // EncryptConnectedAccountTokensSlowInstanceCommand. async runDataMigration(dataSource: DataSource): Promise { let cursor = '00000000-0000-0000-0000-000000000000'; while (true) { const rows: ApplicationVariableRow[] = await dataSource.query( - `SELECT id, "workspaceId", "value" + `SELECT id, "workspaceId", "value", "isSecret" FROM "core"."applicationVariable" WHERE id > $1 AND "isSecret" = true @@ -51,13 +70,32 @@ export class EncryptApplicationVariableSlowInstanceCommand } for (const row of rows) { - // decryptVersioned handles legacy unprefixed CTR ciphertext by - // falling through to the raw-key decrypt path — exactly what we - // need to read the pre-migration rows. - const plaintext = this.secretEncryptionService.decryptVersioned( - row.value, - { workspaceId: row.workspaceId }, - ); + if (!row.isSecret) { + continue; + } + + let plaintext: string; + + if (looksLikeLegacyCtrCiphertext(row.value)) { + try { + plaintext = this.secretEncryptionService.decryptVersioned( + row.value, + { workspaceId: row.workspaceId }, + ); + } catch (error) { + this.logger.warn( + `applicationVariable row ${row.id} value not valid ciphertext; treating as plaintext. ${ + error instanceof Error ? error.message : String(error) + }`, + ); + plaintext = row.value; + } + } else { + this.logger.warn( + `applicationVariable row ${row.id} value is not base64; treating as plaintext.`, + ); + plaintext = row.value; + } if (!isDefined(plaintext)) { continue; diff --git a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts index 7bdacc3fd1..2d630ecb18 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts @@ -164,6 +164,26 @@ describe('2-5 slow instance command 1798000005000 - EncryptApplicationVariableSl expect(row.value).toBe(plaintext); }); + it('treats plaintext-under-isSecret=true as plaintext and re-encrypts as v2', async () => { + const plaintext = + 'https://hooks.slack.com/services/T09QGPB2ZP1/B09QUQ5LY2Z/abc'; + const id = await seedRow({ isSecret: true, value: plaintext }); + + await command.runDataMigration(dataSource); + + const [row] = await dataSource.query( + `SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`, + [id], + ); + + expect(row.value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe( + true, + ); + expect( + secretEncryptionService.decryptVersioned(row.value, { workspaceId }), + ).toBe(plaintext); + }); + it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => { const plaintext = 'already-v2-secret'; const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext, {