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:
+94
@@ -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}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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 {}
|
||||
|
||||
+2
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user