Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9daf40fb2 |
+3
-5
@@ -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. ${
|
||||
|
||||
+4
-4
@@ -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;
|
||||
|
||||
+4
-7
@@ -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"
|
||||
|
||||
+4
-7
@@ -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"
|
||||
|
||||
+39
-3
@@ -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`,
|
||||
);
|
||||
|
||||
+4
-5
@@ -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 {}
|
||||
|
||||
+22
-31
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-6
@@ -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],
|
||||
|
||||
-72
@@ -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);
|
||||
|
||||
+7
-30
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
-135
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
-51
@@ -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
-29
@@ -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({
|
||||
|
||||
+10
-12
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user