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:
Paul Rastoin
2026-06-19 13:32:39 +02:00
committed by GitHub
parent 4de9f45015
commit 26db3f5735
32 changed files with 206 additions and 249 deletions
@@ -255,7 +255,7 @@ export class ApplicationRegistrationVariableService {
encryptedValue !== ''
? variable.isSecret
? '•••••••••••••'
: this.encryptionService.decryptVersioned(encryptedValue)
: this.encryptionService.decryptVersionedOrThrow(encryptedValue)
: null,
};
}
@@ -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(/\|.*$/, ''),
),
@@ -36,7 +36,7 @@ export class ApplicationVariableEntityService {
});
}
return this.secretEncryptionService.decryptVersioned(
return this.secretEncryptionService.decryptVersionedOrThrow(
applicationVariable.value,
{ workspaceId: applicationVariable.workspaceId },
);
@@ -53,7 +53,9 @@ export class ConnectionProviderService {
variables.map((v) => [
v.key,
v.encryptedValue !== ''
? this.secretEncryptionService.decryptVersioned(v.encryptedValue)
? this.secretEncryptionService.decryptVersionedOrThrow(
v.encryptedValue,
)
: '',
]),
);
@@ -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,
);
@@ -191,7 +191,9 @@ export class JwtKeyManagerService {
);
}
return this.secretEncryptionService.decryptVersioned(encryptedPrivateKey);
return this.secretEncryptionService.decryptVersionedOrThrow(
encryptedPrivateKey,
);
}
private async generateAndPersistCurrent(): Promise<CurrentSigningKey> {
@@ -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,
);
}
}
@@ -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', () => {
@@ -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;
@@ -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,
);
});
});
});
@@ -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 {
@@ -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');
});
});
@@ -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];
}
@@ -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({
@@ -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,
@@ -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,
});