feat(server): migrate TOTP secret encryption to SecretEncryptionService (#20577)

## Summary

Removes the last `APP_SECRET`-derived at-rest encryption site by
migrating `core.twoFactorAuthenticationMethod.secret` from
`SimpleSecretEncryptionUtil` (AES-256-CBC with key derived from
`sha256(APP_SECRET + userId + workspaceId + 'otp-secret' +
'KEY_ENCRYPTION_KEY')`) to the versioned `enc:v2:` envelope
(ENCRYPTION_KEY → HKDF-SHA256 bound to `workspaceId` → AES-256-GCM).

- New `decrypt-legacy-aes-cbc.util.ts` faithfully reproduces the
pre-migration CBC derivation byte-for-byte;
`SecretEncryptionService.decryptVersioned` dispatches to it when callers
pass `legacyAesCbcPurpose`, with a dedicated one-shot WARN log family.
- `TwoFactorAuthenticationService` now uses `encryptVersioned` /
`decryptVersioned` (passing the legacy purpose so existing rows still
decrypt). `SimpleSecretEncryptionUtil` and its spec are deleted;
`TwoFactorAuthenticationModule` imports `SecretEncryptionModule` in
their place.
- `TwoFactorAuthenticationMethodEntity` gets a `@Check` decorator
(`CHK_twoFactorAuthenticationMethod_secret_encrypted`) restricting
`secret` to the `enc:v2:` envelope; the matching 2.5 slow instance
command (`1798000009000-encrypt-totp-secrets`) cursor-paginates `JOIN`ed
`userWorkspace` rows to recover the legacy `userId`, re-encrypts to
`enc:v2`, and applies the CHECK constraint in `up()`.

### Deviation note

The plan suggested wiring a workspace-only legacy derivation directly
into `decryptVersioned`. In practice the production rows are
user-and-workspace-scoped (the legacy purpose is
`\${userId}\${workspaceId}otp-secret`), so a workspace-only derivation
could not recover them. The PR keeps the public `decryptVersioned` API
intact and adds an optional `legacyAesCbcPurpose` so callers that can
reconstruct the legacy context (the 2FA service and the slow command)
opt in.

### Final state of remaining `APP_SECRET` usages

- HS256 JWT verify (read-only, self-retiring once asymmetric migration
completes).
- Express-session cookie signing.
- Approved-access-domain HMAC (signing root, not at-rest).
- Zero-friction fallback in `resolveEncryptionKeysOrThrow`
(intentional).

No production at-rest data is encrypted with `APP_SECRET`-derived keys
anymore.

## Test plan

- [x] `npx jest src/engine/core-modules/secret-encryption
src/engine/core-modules/two-factor-authentication` — 170 unit tests
pass, including new unit tests for the legacy CBC util and the new
`SecretEncryptionService` fallback branch.
- [x] `npx jest --config ./jest-integration.config.ts
test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts`
— 4 integration tests cover legacy-CBC seed → slow command → `enc:v2`
round-trip, idempotency, CHECK constraint enforcement on `up()`, and
rollback via `down()`.
- [x] `npx oxlint --type-aware` and `npx prettier --check` clean on all
touched files.
- [ ] CI on this PR (server validation, tests, lint, typecheck).
This commit is contained in:
Charles Bochet
2026-05-14 13:24:06 +02:00
committed by GitHub
parent fc53f18a9f
commit a941f6fe01
11 changed files with 599 additions and 141 deletions
@@ -0,0 +1,94 @@
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 { 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';
const BACKFILL_BATCH_SIZE = 500;
const SECRET_CHECK_CONSTRAINT_NAME =
'CHK_twoFactorAuthenticationMethod_secret_encrypted';
const V2_ENCRYPTED_LIKE_PATTERN = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%`;
type TwoFactorMethodRow = {
id: string;
workspaceId: string;
userId: string;
secret: string;
};
@RegisteredInstanceCommand('2.5.0', 1798000009000, { type: 'slow' })
export class EncryptTotpSecretsSlowInstanceCommand
implements SlowInstanceCommand
{
constructor(
private readonly secretEncryptionService: SecretEncryptionService,
private readonly simpleSecretEncryptionUtil: SimpleSecretEncryptionUtil,
) {}
async runDataMigration(dataSource: DataSource): Promise<void> {
let cursor = '00000000-0000-0000-0000-000000000000';
while (true) {
const rows: TwoFactorMethodRow[] = await dataSource.query(
`SELECT m.id, m."workspaceId", uw."userId", m."secret"
FROM "core"."twoFactorAuthenticationMethod" m
JOIN "core"."userWorkspace" uw
ON uw.id = m."userWorkspaceId"
WHERE m.id > $1
AND m."secret" NOT LIKE $2
ORDER BY m.id
LIMIT $3`,
[cursor, V2_ENCRYPTED_LIKE_PATTERN, BACKFILL_BATCH_SIZE],
);
if (rows.length === 0) {
break;
}
for (const row of rows) {
const plaintext = await this.simpleSecretEncryptionUtil.decryptSecret(
row.secret,
`${row.userId}${row.workspaceId}otp-secret`,
);
if (!isDefined(plaintext)) {
continue;
}
const encryptedValue = this.secretEncryptionService.encryptVersioned(
plaintext,
{ workspaceId: row.workspaceId },
);
await dataSource.query(
`UPDATE "core"."twoFactorAuthenticationMethod"
SET "secret" = $2
WHERE id = $1`,
[row.id, encryptedValue],
);
}
cursor = rows[rows.length - 1].id;
}
}
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."twoFactorAuthenticationMethod"
ADD CONSTRAINT "${SECRET_CHECK_CONSTRAINT_NAME}"
CHECK ("secret" LIKE '${V2_ENCRYPTED_LIKE_PATTERN}')`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."twoFactorAuthenticationMethod"
DROP CONSTRAINT IF EXISTS "${SECRET_CHECK_CONSTRAINT_NAME}"`,
);
}
}
@@ -1,11 +1,20 @@
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],
providers: [...INSTANCE_COMMANDS],
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,
],
providers: [...INSTANCE_COMMANDS, SimpleSecretEncryptionUtil],
})
export class InstanceCommandProviderModule {}
@@ -38,6 +38,7 @@ import { EncryptApplicationVariableSlowInstanceCommand } from 'src/database/comm
import { EncryptApplicationRegistrationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable';
import { EncryptSigningKeyPrivateKeysSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys';
import { EncryptSensitiveConfigStorageSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage';
import { EncryptTotpSecretsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets';
import { AddSubFieldNameToViewSortFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778502963794-add-sub-field-name-to-view-sort';
import { DropPostgresCredentialsTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1798500000000-drop-postgres-credentials-table';
@@ -80,6 +81,7 @@ export const INSTANCE_COMMANDS = [
EncryptApplicationRegistrationVariableSlowInstanceCommand,
EncryptSigningKeyPrivateKeysSlowInstanceCommand,
EncryptSensitiveConfigStorageSlowInstanceCommand,
EncryptTotpSecretsSlowInstanceCommand,
AddSubFieldNameToViewSortFastInstanceCommand,
DropPostgresCredentialsTableFastInstanceCommand,
];