Deprecate legacy encryption (#21831)
# Introduction Still preserving the cross-upgrade flow close https://github.com/twentyhq/core-team-issues/issues/2465 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21831?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+1
-1
@@ -255,7 +255,7 @@ export class ApplicationRegistrationVariableService {
|
||||
encryptedValue !== ''
|
||||
? variable.isSecret
|
||||
? '•••••••••••••'
|
||||
: this.encryptionService.decryptVersioned(encryptedValue)
|
||||
: this.encryptionService.decryptVersionedOrThrow(encryptedValue)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ describe('ApplicationVariableEntityService', () => {
|
||||
(value: string, opts?: { workspaceId?: string }) =>
|
||||
`enc:v2:deadbeef:${value}|${opts?.workspaceId ?? 'instance'}`,
|
||||
),
|
||||
decryptVersioned: jest.fn(
|
||||
decryptVersionedOrThrow: jest.fn(
|
||||
(value: string, _opts?: { workspaceId?: string }) =>
|
||||
value.replace(/^enc:v2:[0-9a-f]+:/, '').replace(/\|.*$/, ''),
|
||||
),
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ export class ApplicationVariableEntityService {
|
||||
});
|
||||
}
|
||||
|
||||
return this.secretEncryptionService.decryptVersioned(
|
||||
return this.secretEncryptionService.decryptVersionedOrThrow(
|
||||
applicationVariable.value,
|
||||
{ workspaceId: applicationVariable.workspaceId },
|
||||
);
|
||||
|
||||
+3
-1
@@ -53,7 +53,9 @@ export class ConnectionProviderService {
|
||||
variables.map((v) => [
|
||||
v.key,
|
||||
v.encryptedValue !== ''
|
||||
? this.secretEncryptionService.decryptVersioned(v.encryptedValue)
|
||||
? this.secretEncryptionService.decryptVersionedOrThrow(
|
||||
v.encryptedValue,
|
||||
)
|
||||
: '',
|
||||
]),
|
||||
);
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export class UnsubscribeTokenService {
|
||||
|
||||
verify(token: string): UnsubscribeTokenPayload | null {
|
||||
try {
|
||||
const decrypted = this.secretEncryptionService.decryptVersioned(
|
||||
const decrypted = this.secretEncryptionService.decryptVersionedOrThrow(
|
||||
Buffer.from(token, 'base64url').toString('utf8') as EncryptedString,
|
||||
);
|
||||
|
||||
|
||||
+3
-1
@@ -191,7 +191,9 @@ export class JwtKeyManagerService {
|
||||
);
|
||||
}
|
||||
|
||||
return this.secretEncryptionService.decryptVersioned(encryptedPrivateKey);
|
||||
return this.secretEncryptionService.decryptVersionedOrThrow(
|
||||
encryptedPrivateKey,
|
||||
);
|
||||
}
|
||||
|
||||
private async generateAndPersistCurrent(): Promise<CurrentSigningKey> {
|
||||
|
||||
+4
-3
@@ -359,9 +359,10 @@ export class LogicFunctionExecutorService {
|
||||
|
||||
for (const variable of serverVariables) {
|
||||
if (variable.encryptedValue !== '') {
|
||||
envMap[variable.key] = this.secretEncryptionService.decryptVersioned(
|
||||
variable.encryptedValue,
|
||||
);
|
||||
envMap[variable.key] =
|
||||
this.secretEncryptionService.decryptVersionedOrThrow(
|
||||
variable.encryptedValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-12
@@ -12,7 +12,7 @@ describe('buildEnvVar', () => {
|
||||
(value: string, opts?: { workspaceId?: string }) =>
|
||||
`enc:v2:deadbeef:${value}|${opts?.workspaceId ?? 'instance'}`,
|
||||
),
|
||||
decryptVersioned: jest.fn(
|
||||
decryptVersionedOrThrow: jest.fn(
|
||||
(value: string, _opts?: { workspaceId?: string }) =>
|
||||
value.replace(/^enc:v2:[0-9a-f]+:/, '').replace(/\|.*$/, ''),
|
||||
),
|
||||
@@ -79,9 +79,9 @@ describe('buildEnvVar', () => {
|
||||
API_SECRET: 'secret-123',
|
||||
DEBUG: 'true',
|
||||
});
|
||||
expect(mockSecretEncryptionService.decryptVersioned).toHaveBeenCalledTimes(
|
||||
3,
|
||||
);
|
||||
expect(
|
||||
mockSecretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('routes each secret variable to its own workspace HKDF context', () => {
|
||||
@@ -116,14 +116,16 @@ describe('buildEnvVar', () => {
|
||||
|
||||
buildEnvVar(flatVariables, mockSecretEncryptionService);
|
||||
|
||||
expect(mockSecretEncryptionService.decryptVersioned).toHaveBeenCalledWith(
|
||||
`enc:v2:deadbeef:value-a|${workspaceA}`,
|
||||
{ workspaceId: workspaceA },
|
||||
);
|
||||
expect(mockSecretEncryptionService.decryptVersioned).toHaveBeenCalledWith(
|
||||
`enc:v2:deadbeef:value-b|${workspaceB}`,
|
||||
{ workspaceId: workspaceB },
|
||||
);
|
||||
expect(
|
||||
mockSecretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledWith(`enc:v2:deadbeef:value-a|${workspaceA}`, {
|
||||
workspaceId: workspaceA,
|
||||
});
|
||||
expect(
|
||||
mockSecretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledWith(`enc:v2:deadbeef:value-b|${workspaceB}`, {
|
||||
workspaceId: workspaceB,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null or undefined values', () => {
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ export const buildEnvVar = (
|
||||
// the else branch into an invariant violation for non-empty values.
|
||||
acc[flatApplicationVariable.key] =
|
||||
isNonEmptyString(value) && isEncryptedString(value)
|
||||
? secretEncryptionService.decryptVersioned(value, {
|
||||
? secretEncryptionService.decryptVersionedOrThrow(value, {
|
||||
workspaceId: flatApplicationVariable.workspaceId,
|
||||
})
|
||||
: value;
|
||||
|
||||
+45
@@ -239,4 +239,49 @@ describe('SecretEncryptionService', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decryptVersionedOrThrow', () => {
|
||||
it('round-trips a v2 envelope', () => {
|
||||
const secret = 'sk-strict-secret-value';
|
||||
const encrypted = service.encryptVersioned(secret as PlaintextString);
|
||||
|
||||
expect(service.decryptVersionedOrThrow(encrypted)).toBe(secret);
|
||||
});
|
||||
|
||||
it('throws on a legacy non-v2 value instead of falling back to CTR', () => {
|
||||
const legacyCiphertext = service.encrypt(testValue) as EncryptedString;
|
||||
|
||||
expect(() => service.decryptVersionedOrThrow(legacyCiphertext)).toThrow();
|
||||
});
|
||||
|
||||
it('returns null/undefined values as-is', () => {
|
||||
expect(
|
||||
service.decryptVersionedOrThrow(null as unknown as EncryptedString),
|
||||
).toBeNull();
|
||||
expect(
|
||||
service.decryptVersionedOrThrow(
|
||||
undefined as unknown as EncryptedString,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacyDecryptVersionedWithFallback', () => {
|
||||
it('round-trips a v2 envelope', () => {
|
||||
const secret = 'sk-legacy-secret-value';
|
||||
const encrypted = service.encryptVersioned(secret as PlaintextString);
|
||||
|
||||
expect(service.legacyDecryptVersionedWithFallback(encrypted)).toBe(
|
||||
secret,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to legacy CTR decryption for non-v2 values', () => {
|
||||
const legacyCiphertext = service.encrypt(testValue) as EncryptedString;
|
||||
|
||||
expect(service.legacyDecryptVersionedWithFallback(legacyCiphertext)).toBe(
|
||||
testValue,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+44
-2
@@ -4,6 +4,10 @@ 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';
|
||||
@@ -87,7 +91,7 @@ export class SecretEncryptionService {
|
||||
}
|
||||
|
||||
return this.maskDecryptedValue(
|
||||
this.decryptVersioned(value, { workspaceId }),
|
||||
this.decryptVersionedOrThrow(value, { workspaceId }),
|
||||
mask,
|
||||
);
|
||||
}
|
||||
@@ -127,7 +131,45 @@ export class SecretEncryptionService {
|
||||
}) as EncryptedString;
|
||||
}
|
||||
|
||||
public decryptVersioned(
|
||||
public decryptVersionedOrThrow(
|
||||
value: EncryptedString,
|
||||
opts: VersionedOptions = {},
|
||||
): PlaintextString {
|
||||
if (!isDefined(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const parsed = parseSecretEncryptionEnvelopeOrThrow({ value });
|
||||
|
||||
if (parsed.version !== 2) {
|
||||
throw new SecretEncryptionException(
|
||||
'Expected an enc:v2 envelope but received a non-versioned value. The 2.5 encryption backfill instance commands must have run before this value can be decrypted.',
|
||||
SecretEncryptionExceptionCode.UNKNOWN_ENVELOPE_VERSION,
|
||||
);
|
||||
}
|
||||
|
||||
const keys = resolveEncryptionKeysOrThrow({
|
||||
environmentConfigDriver: this.environmentConfigDriver,
|
||||
});
|
||||
const rawKey = pickEncryptionKeyByKeyIdOrThrow({
|
||||
keyId: parsed.keyId,
|
||||
keys,
|
||||
});
|
||||
|
||||
return decryptAesGcmV2OrThrow({
|
||||
payloadBase64: parsed.payload,
|
||||
rawKey,
|
||||
workspaceId: opts.workspaceId,
|
||||
}) as PlaintextString;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Legacy variant kept only for the 2.5 encryption backfill
|
||||
* instance commands, which read pre-v2 rows (legacy AES-CTR ciphertext or
|
||||
* plaintext) and re-encrypt them into the enc:v2 envelope. Runtime and
|
||||
* rotation paths must use `decryptVersionedOrThrow` instead.
|
||||
*/
|
||||
public legacyDecryptVersionedWithFallback(
|
||||
value: EncryptedString,
|
||||
opts: VersionedOptions = {},
|
||||
): PlaintextString {
|
||||
|
||||
+7
-7
@@ -82,7 +82,7 @@ describe('ConfigStorageService', () => {
|
||||
{
|
||||
provide: SecretEncryptionService,
|
||||
useValue: {
|
||||
decryptVersioned: jest.fn((value) => value),
|
||||
decryptVersionedOrThrow: jest.fn((value) => value),
|
||||
encryptVersioned: jest.fn((value) => value),
|
||||
},
|
||||
},
|
||||
@@ -197,9 +197,9 @@ describe('ConfigStorageService', () => {
|
||||
const result = await service.get(key);
|
||||
|
||||
expect(result).toBe(encryptedValue);
|
||||
expect(secretEncryptionService.decryptVersioned).toHaveBeenCalledWith(
|
||||
encryptedValue,
|
||||
);
|
||||
expect(
|
||||
secretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledWith(encryptedValue);
|
||||
});
|
||||
|
||||
it('should handle decryption errors gracefully', async () => {
|
||||
@@ -572,9 +572,9 @@ describe('ConfigStorageService', () => {
|
||||
expect(result.get('NORMAL_CONFIG' as keyof ConfigVariables)).toBe(
|
||||
'normal-value',
|
||||
);
|
||||
expect(secretEncryptionService.decryptVersioned).toHaveBeenCalledWith(
|
||||
'enc:v2:deadbeef:sensitive-value',
|
||||
);
|
||||
expect(
|
||||
secretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledWith('enc:v2:deadbeef:sensitive-value');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
|
||||
this.isSensitiveStringValue(convertedValue, key) &&
|
||||
isEncryptedString(convertedValue)
|
||||
) {
|
||||
return this.secretEncryptionService.decryptVersioned(
|
||||
return this.secretEncryptionService.decryptVersionedOrThrow(
|
||||
convertedValue,
|
||||
) as unknown as ConfigVariables[T];
|
||||
}
|
||||
|
||||
-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],
|
||||
|
||||
+24
-88
@@ -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({
|
||||
@@ -96,13 +93,7 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
provide: SecretEncryptionService,
|
||||
useValue: {
|
||||
encryptVersioned: jest.fn(),
|
||||
decryptVersioned: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SimpleSecretEncryptionUtil,
|
||||
useValue: {
|
||||
decryptSecret: jest.fn(),
|
||||
decryptVersionedOrThrow: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -119,9 +110,6 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
secretEncryptionService = module.get<SecretEncryptionService>(
|
||||
SecretEncryptionService,
|
||||
);
|
||||
simpleSecretEncryptionUtil = module.get<SimpleSecretEncryptionUtil>(
|
||||
SimpleSecretEncryptionUtil,
|
||||
);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
@@ -295,7 +283,9 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
};
|
||||
|
||||
repository.findOne.mockResolvedValue(existingMethod);
|
||||
secretEncryptionService.decryptVersioned.mockReturnValue(rawSecret);
|
||||
secretEncryptionService.decryptVersionedOrThrow.mockReturnValue(
|
||||
rawSecret,
|
||||
);
|
||||
|
||||
const expectedUri =
|
||||
'otpauth://totp/test@example.com?secret=RAW_OTP_SECRET&issuer=Twenty%20-%20Test%20Workspace';
|
||||
@@ -308,47 +298,14 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
);
|
||||
|
||||
expect(uri).toBe(expectedUri);
|
||||
expect(secretEncryptionService.decryptVersioned).toHaveBeenCalledWith(
|
||||
encryptedSecret,
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
expect(simpleSecretEncryptionUtil.decryptSecret).not.toHaveBeenCalled();
|
||||
expect(
|
||||
secretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledWith(encryptedSecret, { workspaceId: workspace.id });
|
||||
// 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 = {
|
||||
@@ -398,7 +355,7 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
repository.findOne.mockResolvedValue(existingMethod);
|
||||
const decryptionError = new Error('Decryption failed');
|
||||
|
||||
secretEncryptionService.decryptVersioned.mockImplementation(() => {
|
||||
secretEncryptionService.decryptVersionedOrThrow.mockImplementation(() => {
|
||||
throw decryptionError;
|
||||
});
|
||||
|
||||
@@ -463,7 +420,9 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
|
||||
it('should successfully validate a valid token', async () => {
|
||||
repository.findOne.mockResolvedValue(mock2FAMethod);
|
||||
secretEncryptionService.decryptVersioned.mockReturnValue(rawSecret);
|
||||
secretEncryptionService.decryptVersionedOrThrow.mockReturnValue(
|
||||
rawSecret,
|
||||
);
|
||||
|
||||
totpStrategyMocks.validate.mockReturnValue({
|
||||
isValid: true,
|
||||
@@ -477,11 +436,9 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
TwoFactorAuthenticationStrategy.TOTP,
|
||||
);
|
||||
|
||||
expect(secretEncryptionService.decryptVersioned).toHaveBeenCalledWith(
|
||||
encryptedSecret,
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
expect(simpleSecretEncryptionUtil.decryptSecret).not.toHaveBeenCalled();
|
||||
expect(
|
||||
secretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledWith(encryptedSecret, { workspaceId: workspace.id });
|
||||
expect(totpStrategyMocks.validate).toHaveBeenCalledWith(otpToken, {
|
||||
status: mock2FAMethod.status,
|
||||
secret: rawSecret,
|
||||
@@ -495,36 +452,11 @@ 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);
|
||||
secretEncryptionService.decryptVersionedOrThrow.mockReturnValue(
|
||||
rawSecret,
|
||||
);
|
||||
totpStrategyMocks.validate.mockReturnValue({
|
||||
isValid: false,
|
||||
context: mock2FAMethod,
|
||||
@@ -587,7 +519,7 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
|
||||
it('should handle secret decryption errors', async () => {
|
||||
repository.findOne.mockResolvedValue(mock2FAMethod);
|
||||
secretEncryptionService.decryptVersioned.mockImplementation(() => {
|
||||
secretEncryptionService.decryptVersionedOrThrow.mockImplementation(() => {
|
||||
throw new Error('Secret decryption failed');
|
||||
});
|
||||
|
||||
@@ -614,7 +546,9 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
|
||||
it('should successfully verify and return success', async () => {
|
||||
repository.findOne.mockResolvedValue(mock2FAMethod);
|
||||
secretEncryptionService.decryptVersioned.mockReturnValue(rawSecret);
|
||||
secretEncryptionService.decryptVersionedOrThrow.mockReturnValue(
|
||||
rawSecret,
|
||||
);
|
||||
|
||||
totpStrategyMocks.validate.mockReturnValue({
|
||||
isValid: true,
|
||||
@@ -644,7 +578,9 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
|
||||
it('should throw if the token is invalid', async () => {
|
||||
repository.findOne.mockResolvedValue(mock2FAMethod);
|
||||
secretEncryptionService.decryptVersioned.mockReturnValue(rawSecret);
|
||||
secretEncryptionService.decryptVersionedOrThrow.mockReturnValue(
|
||||
rawSecret,
|
||||
);
|
||||
totpStrategyMocks.validate.mockReturnValue({
|
||||
isValid: false,
|
||||
context: mock2FAMethod,
|
||||
|
||||
+3
-26
@@ -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({
|
||||
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),
|
||||
);
|
||||
return this.secretEncryptionService.decryptVersionedOrThrow(storedSecret, {
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,7 +120,6 @@ export class TwoFactorAuthenticationService {
|
||||
) {
|
||||
const existingSecret = await this.decryptStoredSecret({
|
||||
storedSecret: existing2FAMethod.secret,
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
@@ -207,7 +185,6 @@ export class TwoFactorAuthenticationService {
|
||||
|
||||
const originalSecret = await this.decryptStoredSecret({
|
||||
storedSecret: userTwoFactorAuthenticationMethod.secret,
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user