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,
];
@@ -21,7 +21,7 @@ type VersionedOptions = {
@Injectable()
export class SecretEncryptionService {
private readonly logger = new Logger(SecretEncryptionService.name);
private hasLoggedLegacyDecryption = false;
private hasLoggedLegacyCtrDecryption = false;
constructor(
private readonly environmentConfigDriver: EnvironmentConfigDriver,
@@ -139,19 +139,19 @@ export class SecretEncryptionService {
});
}
this.warnLegacyDecryptionOnce();
this.warnLegacyCtrDecryptionOnce();
return this.decrypt(value);
}
private warnLegacyDecryptionOnce(): void {
if (this.hasLoggedLegacyDecryption) {
private warnLegacyCtrDecryptionOnce(): void {
if (this.hasLoggedLegacyCtrDecryption) {
return;
}
this.hasLoggedLegacyDecryption = true;
this.hasLoggedLegacyCtrDecryption = 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.',
'Decrypted a legacy unprefixed AES-CTR ciphertext. These rows should be re-encrypted into the enc:v2 envelope in a follow-up migration.',
);
}
}
@@ -1,5 +1,6 @@
import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types';
import {
Check,
Column,
CreateDateColumn,
Entity,
@@ -17,6 +18,10 @@ import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
@Index(['userWorkspaceId', 'strategy'], { unique: true })
@Entity({ name: 'twoFactorAuthenticationMethod', schema: 'core' })
@Check(
'CHK_twoFactorAuthenticationMethod_secret_encrypted',
`"secret" LIKE 'enc:v2:%'`,
)
export class TwoFactorAuthenticationMethodEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -5,6 +5,7 @@ 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';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -22,7 +23,10 @@ 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,
TwoFactorAuthenticationMethodEntity,
@@ -7,7 +7,7 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { SimpleSecretEncryptionUtil } from 'src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -19,6 +19,9 @@ 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:';
const totpStrategyMocks = {
validate: jest.fn(),
@@ -57,6 +60,7 @@ describe('TwoFactorAuthenticationService', () => {
let service: TwoFactorAuthenticationService;
let repository: any;
let userWorkspaceService: any;
let secretEncryptionService: any;
let simpleSecretEncryptionUtil: any;
const mockUser = { id: 'user_123', email: 'test@example.com' };
@@ -67,7 +71,8 @@ describe('TwoFactorAuthenticationService', () => {
};
const rawSecret = 'RAW_OTP_SECRET';
const encryptedSecret = 'ENCRYPTED_SECRET_STRING';
const encryptedSecret = `${V2_ENVELOPE_PREFIX}abcdef12:payload`;
const legacyCbcSecret = '0123456789abcdef0123456789abcdef:cafebabe';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -86,10 +91,16 @@ describe('TwoFactorAuthenticationService', () => {
getUserWorkspaceForUserOrThrow: jest.fn(),
},
},
{
provide: SecretEncryptionService,
useValue: {
encryptVersioned: jest.fn(),
decryptVersioned: jest.fn(),
},
},
{
provide: SimpleSecretEncryptionUtil,
useValue: {
encryptSecret: jest.fn(),
decryptSecret: jest.fn(),
},
},
@@ -104,6 +115,9 @@ describe('TwoFactorAuthenticationService', () => {
);
userWorkspaceService =
module.get<UserWorkspaceService>(UserWorkspaceService);
secretEncryptionService = module.get<SecretEncryptionService>(
SecretEncryptionService,
);
simpleSecretEncryptionUtil = module.get<SimpleSecretEncryptionUtil>(
SimpleSecretEncryptionUtil,
);
@@ -171,9 +185,7 @@ describe('TwoFactorAuthenticationService', () => {
it('should initiate configuration for a new user', async () => {
repository.findOne.mockResolvedValue(null);
simpleSecretEncryptionUtil.encryptSecret.mockResolvedValue(
encryptedSecret,
);
secretEncryptionService.encryptVersioned.mockReturnValue(encryptedSecret);
const uri = await service.initiateStrategyConfiguration(
mockUser.id,
@@ -185,9 +197,9 @@ describe('TwoFactorAuthenticationService', () => {
expect(uri).toBe(
'otpauth://totp/test@example.com?secret=RAW_OTP_SECRET&issuer=Twenty%20-%20Test%20Workspace',
);
expect(simpleSecretEncryptionUtil.encryptSecret).toHaveBeenCalledWith(
expect(secretEncryptionService.encryptVersioned).toHaveBeenCalledWith(
rawSecret,
mockUser.id + workspace.id + 'otp-secret',
{ workspaceId: workspace.id },
);
expect(repository.save).toHaveBeenCalledWith({
id: undefined,
@@ -226,9 +238,7 @@ describe('TwoFactorAuthenticationService', () => {
};
repository.findOne.mockResolvedValue(existingMethod);
simpleSecretEncryptionUtil.encryptSecret.mockResolvedValue(
encryptedSecret,
);
secretEncryptionService.encryptVersioned.mockReturnValue(encryptedSecret);
const uri = await service.initiateStrategyConfiguration(
mockUser.id,
@@ -283,9 +293,8 @@ describe('TwoFactorAuthenticationService', () => {
};
repository.findOne.mockResolvedValue(existingMethod);
simpleSecretEncryptionUtil.decryptSecret.mockResolvedValue(rawSecret);
secretEncryptionService.decryptVersioned.mockReturnValue(rawSecret);
// Mock authenticator.keyuri to return a URI
const expectedUri =
'otpauth://totp/test@example.com?secret=RAW_OTP_SECRET&issuer=Twenty%20-%20Test%20Workspace';
@@ -297,17 +306,48 @@ describe('TwoFactorAuthenticationService', () => {
);
expect(uri).toBe(expectedUri);
expect(simpleSecretEncryptionUtil.decryptSecret).toHaveBeenCalledWith(
expect(secretEncryptionService.decryptVersioned).toHaveBeenCalledWith(
encryptedSecret,
mockUser.id + workspace.id + 'otp-secret',
{ 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 () => {
// Create a method that was created 2 hours ago (outside 1 hour window)
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000);
const existingMethod = {
id: 'existing_method_id',
@@ -317,9 +357,7 @@ describe('TwoFactorAuthenticationService', () => {
};
repository.findOne.mockResolvedValue(existingMethod);
simpleSecretEncryptionUtil.encryptSecret.mockResolvedValue(
encryptedSecret,
);
secretEncryptionService.encryptVersioned.mockReturnValue(encryptedSecret);
const uri = await service.initiateStrategyConfiguration(
mockUser.id,
@@ -346,23 +384,21 @@ describe('TwoFactorAuthenticationService', () => {
});
it('should throw error when decryption of existing method fails', async () => {
// Create a recent method but decryption will fail
const recentTime = new Date(Date.now() - 5 * 60 * 1000);
const existingMethod = {
id: 'existing_method_id',
status: 'PENDING',
secret: 'corrupted_secret',
secret: `${V2_ENVELOPE_PREFIX}corrupted:payload`,
createdAt: recentTime,
};
repository.findOne.mockResolvedValue(existingMethod);
const decryptionError = new Error('Decryption failed');
simpleSecretEncryptionUtil.decryptSecret.mockRejectedValue(
decryptionError,
);
secretEncryptionService.decryptVersioned.mockImplementation(() => {
throw decryptionError;
});
// Should throw the decryption error instead of silently handling it
await expect(
service.initiateStrategyConfiguration(
mockUser.id,
@@ -372,7 +408,6 @@ describe('TwoFactorAuthenticationService', () => {
),
).rejects.toThrow(decryptionError);
// Should not save anything since we errored out
expect(repository.save).not.toHaveBeenCalled();
});
@@ -381,13 +416,11 @@ describe('TwoFactorAuthenticationService', () => {
id: 'existing_method_id',
status: 'PENDING',
secret: encryptedSecret,
createdAt: null, // No timestamp
createdAt: null,
};
repository.findOne.mockResolvedValue(existingMethod);
simpleSecretEncryptionUtil.encryptSecret.mockResolvedValue(
encryptedSecret,
);
secretEncryptionService.encryptVersioned.mockReturnValue(encryptedSecret);
const uri = await service.initiateStrategyConfiguration(
mockUser.id,
@@ -426,7 +459,7 @@ describe('TwoFactorAuthenticationService', () => {
it('should successfully validate a valid token', async () => {
repository.findOne.mockResolvedValue(mock2FAMethod);
simpleSecretEncryptionUtil.decryptSecret.mockResolvedValue(rawSecret);
secretEncryptionService.decryptVersioned.mockReturnValue(rawSecret);
totpStrategyMocks.validate.mockReturnValue({
isValid: true,
@@ -440,6 +473,11 @@ describe('TwoFactorAuthenticationService', () => {
TwoFactorAuthenticationStrategy.TOTP,
);
expect(secretEncryptionService.decryptVersioned).toHaveBeenCalledWith(
encryptedSecret,
{ workspaceId: workspace.id },
);
expect(simpleSecretEncryptionUtil.decryptSecret).not.toHaveBeenCalled();
expect(totpStrategyMocks.validate).toHaveBeenCalledWith(otpToken, {
status: mock2FAMethod.status,
secret: rawSecret,
@@ -452,9 +490,36 @@ 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);
simpleSecretEncryptionUtil.decryptSecret.mockResolvedValue(rawSecret);
secretEncryptionService.decryptVersioned.mockReturnValue(rawSecret);
totpStrategyMocks.validate.mockReturnValue({
isValid: false,
context: mock2FAMethod,
@@ -517,9 +582,9 @@ describe('TwoFactorAuthenticationService', () => {
it('should handle secret decryption errors', async () => {
repository.findOne.mockResolvedValue(mock2FAMethod);
simpleSecretEncryptionUtil.decryptSecret.mockRejectedValue(
new Error('Secret decryption failed'),
);
secretEncryptionService.decryptVersioned.mockImplementation(() => {
throw new Error('Secret decryption failed');
});
await expect(
service.validateStrategy(
@@ -544,7 +609,7 @@ describe('TwoFactorAuthenticationService', () => {
it('should successfully verify and return success', async () => {
repository.findOne.mockResolvedValue(mock2FAMethod);
simpleSecretEncryptionUtil.decryptSecret.mockResolvedValue(rawSecret);
secretEncryptionService.decryptVersioned.mockReturnValue(rawSecret);
totpStrategyMocks.validate.mockReturnValue({
isValid: true,
@@ -573,7 +638,7 @@ describe('TwoFactorAuthenticationService', () => {
it('should throw if the token is invalid', async () => {
repository.findOne.mockResolvedValue(mock2FAMethod);
simpleSecretEncryptionUtil.decryptSecret.mockResolvedValue(rawSecret);
secretEncryptionService.decryptVersioned.mockReturnValue(rawSecret);
totpStrategyMocks.validate.mockReturnValue({
isValid: false,
context: mock2FAMethod,
@@ -10,11 +10,12 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
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';
import { TOTP_DEFAULT_CONFIGURATION } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/totp/constants/totp.strategy.constants';
import { TotpStrategy } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/totp/totp.strategy';
import { SimpleSecretEncryptionUtil } from 'src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -25,9 +26,19 @@ 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 {
@@ -35,17 +46,29 @@ export class TwoFactorAuthenticationService {
@InjectRepository(TwoFactorAuthenticationMethodEntity)
private readonly twoFactorAuthenticationMethodRepository: Repository<TwoFactorAuthenticationMethodEntity>,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly secretEncryptionService: SecretEncryptionService,
private readonly simpleSecretEncryptionUtil: SimpleSecretEncryptionUtil,
) {}
/**
* Generates encryption key for OTP secret based on user and workspace identifiers.
*/
private generateOtpSecretEncryptionKey(
userId: string,
workspaceId: string,
): string {
return userId + workspaceId + 'otp-secret';
private async decryptStoredSecret({
storedSecret,
userId,
workspaceId,
}: {
storedSecret: string;
userId: string;
workspaceId: string;
}): Promise<string> {
if (storedSecret.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)) {
return this.secretEncryptionService.decryptVersioned(storedSecret, {
workspaceId,
});
}
return this.simpleSecretEncryptionUtil.decryptSecret(
storedSecret,
buildLegacyTotpCbcPurpose(userId, workspaceId),
);
}
/**
@@ -114,11 +137,11 @@ export class TwoFactorAuthenticationService {
Date.now() - existing2FAMethod.createdAt.getTime() <
PENDING_METHOD_REUSE_WINDOW_MS
) {
const existingSecret =
await this.simpleSecretEncryptionUtil.decryptSecret(
existing2FAMethod.secret,
this.generateOtpSecretEncryptionKey(userId, workspaceId),
);
const existingSecret = await this.decryptStoredSecret({
storedSecret: existing2FAMethod.secret,
userId,
workspaceId,
});
const issuer = `Twenty${workspaceDisplayName ? ` - ${workspaceDisplayName}` : ''}`;
const reuseUri = authenticator.keyuri(userEmail, issuer, existingSecret);
@@ -133,9 +156,9 @@ export class TwoFactorAuthenticationService {
`Twenty${workspaceDisplayName ? ` - ${workspaceDisplayName}` : ''}`,
);
const encryptedSecret = await this.simpleSecretEncryptionUtil.encryptSecret(
const encryptedSecret = this.secretEncryptionService.encryptVersioned(
context.secret,
this.generateOtpSecretEncryptionKey(userId, workspaceId),
{ workspaceId },
);
await this.twoFactorAuthenticationMethodRepository.save({
@@ -181,10 +204,11 @@ export class TwoFactorAuthenticationService {
);
}
const originalSecret = await this.simpleSecretEncryptionUtil.decryptSecret(
userTwoFactorAuthenticationMethod.secret,
this.generateOtpSecretEncryptionKey(userId, workspaceId),
);
const originalSecret = await this.decryptStoredSecret({
storedSecret: userTwoFactorAuthenticationMethod.secret,
userId,
workspaceId,
});
const otpContext = {
status: userTwoFactorAuthenticationMethod.status,
@@ -1,10 +1,36 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { createCipheriv, createHash, randomBytes } from 'crypto';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
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;
@@ -22,10 +48,9 @@ describe('SimpleSecretEncryptionUtil', () => {
useValue: {
generateAppSecret: jest
.fn()
.mockImplementation((_type, purpose) => {
// Return different secrets for different purposes to simulate real behavior
return `${mockAppSecret}-${purpose}`;
}),
.mockImplementation(
(_type, purpose) => `${mockAppSecret}-${purpose}`,
),
},
},
],
@@ -41,31 +66,27 @@ describe('SimpleSecretEncryptionUtil', () => {
expect(util).toBeDefined();
});
describe('encryptSecret and decryptSecret', () => {
it('should encrypt and decrypt a secret correctly', async () => {
const encrypted = await util.encryptSecret(testSecret, testPurpose);
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);
expect(encrypted).not.toBe(testSecret);
expect(encrypted).toContain(':'); // Should contain IV separator
});
it('should generate different encrypted values for the same secret', async () => {
const encrypted1 = await util.encryptSecret(testSecret, testPurpose);
const encrypted2 = await util.encryptSecret(testSecret, testPurpose);
it('uses the KEY_ENCRYPTION_KEY JWT token type and the provided purpose', async () => {
const appSecret = `${mockAppSecret}-${testPurpose}`;
const encrypted = encryptLegacySecret({
plaintext: testSecret,
appSecret,
});
expect(encrypted1).not.toBe(encrypted2); // Different IVs should produce different results
const decrypted1 = await util.decryptSecret(encrypted1, testPurpose);
const decrypted2 = await util.decryptSecret(encrypted2, testPurpose);
expect(decrypted1).toBe(testSecret);
expect(decrypted2).toBe(testSecret);
});
it('should use the correct JWT token type and purpose', async () => {
await util.encryptSecret(testSecret, testPurpose);
await util.decryptSecret(encrypted, testPurpose);
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
JwtTokenTypeEnum.KEY_ENCRYPTION_KEY,
@@ -73,43 +94,42 @@ describe('SimpleSecretEncryptionUtil', () => {
);
});
it('should handle special characters in secrets', async () => {
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 encrypted = await util.encryptSecret(specialSecret, testPurpose);
const decrypted = await util.decryptSecret(encrypted, testPurpose);
expect(decrypted).toBe(specialSecret);
});
it('should not recover original secret with wrong purpose', async () => {
const encrypted = await util.encryptSecret(testSecret, testPurpose);
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 may either throw (invalid padding) or produce garbage.
// Both outcomes are acceptable the key property is that the original
// secret is never returned.
// 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
// Expected: wrong key produced invalid padding.
}
});
it('should fail to decrypt malformed encrypted data', async () => {
it('throws on malformed ciphertext', async () => {
await expect(
util.decryptSecret('invalid-encrypted-data', testPurpose),
).rejects.toThrow();
});
it('should handle empty secrets', async () => {
const emptySecret = '';
const encrypted = await util.encryptSecret(emptySecret, testPurpose);
const decrypted = await util.decryptSecret(encrypted, testPurpose);
expect(decrypted).toBe(emptySecret);
});
});
});
@@ -1,53 +1,28 @@
import { Injectable } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'crypto';
import { createDecipheriv, createHash } from 'crypto';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
// 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.
/**
* Simplified encryption utility for TOTP secrets.
* @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;
private readonly ivLength = 16;
constructor(private readonly jwtWrapperService: JwtWrapperService) {}
/**
* Encrypts a TOTP secret string
*/
async encryptSecret(secret: string, purpose: string): Promise<string> {
const appSecret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.KEY_ENCRYPTION_KEY,
purpose,
);
const encryptionKey = createHash('sha256')
.update(appSecret)
.digest()
.slice(0, this.keyLength);
const iv = randomBytes(this.ivLength);
const cipher = createCipheriv(this.algorithm, encryptionKey, iv);
let encrypted = cipher.update(secret, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
/**
* Decrypts a TOTP secret string
*/
async decryptSecret(
encryptedSecret: string,
purpose: string,
@@ -0,0 +1,260 @@
import { createCipheriv, createHash, randomBytes, randomUUID } from 'crypto';
import { config } from 'dotenv';
import { isDefined } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
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';
jest.useRealTimers();
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
const CHECK_CONSTRAINT_NAME =
'CHK_twoFactorAuthenticationMethod_secret_encrypted';
const CHECK_CONSTRAINT_EXPR = `"secret" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%'`;
const buildLegacyAesCbcCiphertext = ({
plaintext,
appSecret,
purpose,
}: {
plaintext: string;
appSecret: string;
purpose: string;
}): string => {
const appSecretHex = createHash('sha256')
.update(`${appSecret}${purpose}KEY_ENCRYPTION_KEY`)
.digest('hex');
const key = createHash('sha256')
.update(appSecretHex)
.digest()
.subarray(0, 32);
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-cbc', key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
};
const dropCheckConstraint = (dataSource: DataSource): Promise<unknown> =>
dataSource.query(
`ALTER TABLE "core"."twoFactorAuthenticationMethod"
DROP CONSTRAINT IF EXISTS "${CHECK_CONSTRAINT_NAME}"`,
);
const restoreCheckConstraint = async (
dataSource: DataSource,
): Promise<void> => {
await dropCheckConstraint(dataSource);
await dataSource.query(
`ALTER TABLE "core"."twoFactorAuthenticationMethod"
ADD CONSTRAINT "${CHECK_CONSTRAINT_NAME}"
CHECK (${CHECK_CONSTRAINT_EXPR})`,
);
};
// 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.
const buildJwtWrapperServiceStub = (appSecret: string): JwtWrapperService => {
return {
generateAppSecret: (type: JwtTokenTypeEnum, appSecretBody: string): string =>
createHash('sha256')
.update(`${appSecret}${appSecretBody}${type}`)
.digest('hex'),
} as unknown as 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;
let workspaceId: string;
let userWorkspaceId: string;
const seededRowIds: string[] = [];
const seedRow = async ({ secret }: { secret: string }): Promise<string> => {
await dropCheckConstraint(dataSource);
const id = randomUUID();
await dataSource.query(
`INSERT INTO "core"."twoFactorAuthenticationMethod"
(id, "workspaceId", "userWorkspaceId", "secret", "status", "strategy")
VALUES ($1, $2, $3, $4, 'VERIFIED', 'TOTP')`,
[id, workspaceId, userWorkspaceId, secret],
);
seededRowIds.push(id);
return id;
};
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [],
synchronize: false,
});
await dataSource.initialize();
if (!isDefined(process.env.APP_SECRET) || process.env.APP_SECRET === '') {
throw new Error(
'APP_SECRET must be set in the integration test environment to build legacy CBC fixtures.',
);
}
appSecret = process.env.APP_SECRET;
secretEncryptionService = buildSecretEncryptionServiceFromEnv();
simpleSecretEncryptionUtil = new SimpleSecretEncryptionUtil(
buildJwtWrapperServiceStub(appSecret),
);
command = new EncryptTotpSecretsSlowInstanceCommand(
secretEncryptionService,
simpleSecretEncryptionUtil,
);
const [seedUserWorkspace] = await dataSource.query(
`SELECT id, "userId", "workspaceId"
FROM "core"."userWorkspace"
LIMIT 1`,
);
if (!isDefined(seedUserWorkspace)) {
throw new Error(
'No seeded userWorkspace row found; run database:reset before the integration suite.',
);
}
userWorkspaceId = seedUserWorkspace.id as string;
userId = seedUserWorkspace.userId as string;
workspaceId = seedUserWorkspace.workspaceId as string;
}, 30000);
afterEach(async () => {
if (seededRowIds.length > 0) {
await dataSource.query(
`DELETE FROM "core"."twoFactorAuthenticationMethod" WHERE id = ANY($1::uuid[])`,
[seededRowIds],
);
seededRowIds.length = 0;
}
await restoreCheckConstraint(dataSource);
});
afterAll(async () => {
await dataSource?.destroy();
});
it('upgrades legacy AES-CBC TOTP secrets to enc:v2 with workspaceId-bound HKDF', async () => {
const plaintext = 'KVKFKRCPNZQUYMLXOVYDSKLMNBVCXZ';
const legacyCiphertext = buildLegacyAesCbcCiphertext({
plaintext,
appSecret,
purpose: `${userId}${workspaceId}otp-secret`,
});
const id = await seedRow({ secret: legacyCiphertext });
await command.runDataMigration(dataSource);
const [row] = await dataSource.query(
`SELECT "secret" FROM "core"."twoFactorAuthenticationMethod" WHERE id = $1`,
[id],
);
expect(row.secret.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe(
true,
);
expect(
secretEncryptionService.decryptVersioned(row.secret, { workspaceId }),
).toBe(plaintext);
});
it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => {
const plaintext = 'already-v2-totp-secret';
const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext, {
workspaceId,
});
const id = await seedRow({ secret: preexistingV2 });
await command.runDataMigration(dataSource);
const [afterFirstRun] = await dataSource.query(
`SELECT "secret" FROM "core"."twoFactorAuthenticationMethod" WHERE id = $1`,
[id],
);
expect(afterFirstRun.secret).toBe(preexistingV2);
await command.runDataMigration(dataSource);
const [afterSecondRun] = await dataSource.query(
`SELECT "secret" FROM "core"."twoFactorAuthenticationMethod" WHERE id = $1`,
[id],
);
expect(afterSecondRun.secret).toBe(preexistingV2);
});
it('up() applies the CHECK constraint that rejects plaintext secret inserts', async () => {
await dropCheckConstraint(dataSource);
const queryRunner = dataSource.createQueryRunner();
try {
await command.up(queryRunner);
const id = randomUUID();
seededRowIds.push(id);
await expect(
dataSource.query(
`INSERT INTO "core"."twoFactorAuthenticationMethod"
(id, "workspaceId", "userWorkspaceId", "secret", "status", "strategy")
VALUES ($1, $2, $3, 'plaintext-should-be-rejected', 'VERIFIED', 'TOTP')`,
[id, workspaceId, userWorkspaceId],
),
).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 id = await seedRow({ secret: 'plaintext-allowed-after-down' });
const [row] = await dataSource.query(
`SELECT "secret" FROM "core"."twoFactorAuthenticationMethod" WHERE id = $1`,
[id],
);
expect(row.secret).toBe('plaintext-allowed-after-down');
} finally {
await queryRunner.release();
}
});
});