Compare commits

...
Author SHA1 Message Date
prastoin b9daf40fb2 remove legacy encryption pattenrs 2026-06-04 15:19:12 +02:00
14 changed files with 98 additions and 397 deletions
@@ -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. ${
@@ -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;
@@ -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"
@@ -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"
@@ -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<void> {
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`,
);
@@ -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 {}
@@ -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();
const keys = resolveEncryptionKeysOrThrow({
environmentConfigDriver: this.environmentConfigDriver,
});
const rawKey = pickEncryptionKeyByKeyIdOrThrow({
keyId: parsed.keyId,
keys,
});
return this.decrypt(value) as PlaintextString;
}
private warnLegacyCtrDecryptionOnce(): void {
if (this.hasLoggedLegacyCtrDecryption) {
return;
}
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;
}
}
@@ -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],
@@ -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>(
SecretEncryptionService,
);
simpleSecretEncryptionUtil = module.get<SimpleSecretEncryptionUtil>(
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);
@@ -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<TwoFactorAuthenticationMethodEntity>,
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<PlaintextString> {
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,
});
@@ -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>(SimpleSecretEncryptionUtil);
jwtWrapperService = module.get<JwtWrapperService>(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();
});
});
});
@@ -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<PlaintextString> {
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;
}
}
@@ -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({
@@ -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);