Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. ${
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
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';
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';
Expand All @@ -20,10 +22,25 @@ type ApplicationRegistrationVariableRow = {
encryptedValue: string;
};

// Legacy CTR ciphertext is base64-encoded and at least 16 bytes (one IV
// block) — i.e. ≥ 22 base64 chars. Anything outside that shape is treated as
// plaintext and encrypted as-is: CTR decrypt has no integrity tag and would
// silently turn a non-ciphertext value into garbage instead of throwing.
const LEGACY_CTR_LOOKS_LIKE_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
const LEGACY_CTR_MIN_LENGTH = 22;

const looksLikeLegacyCtrCiphertext = (value: string): boolean =>
value.length >= LEGACY_CTR_MIN_LENGTH &&
LEGACY_CTR_LOOKS_LIKE_BASE64_RE.test(value);

@RegisteredInstanceCommand('2.5.0', 1798000006000, { type: 'slow' })
export class EncryptApplicationRegistrationVariableSlowInstanceCommand
implements SlowInstanceCommand
{
private readonly logger = new Logger(
EncryptApplicationRegistrationVariableSlowInstanceCommand.name,
);

constructor(
private readonly secretEncryptionService: SecretEncryptionService,
) {}
Expand Down Expand Up @@ -57,9 +74,27 @@ export class EncryptApplicationRegistrationVariableSlowInstanceCommand
continue;
}

const plaintext = this.secretEncryptionService.decryptVersioned(
row.encryptedValue as EncryptedString,
);
let plaintext: PlaintextString;

if (looksLikeLegacyCtrCiphertext(row.encryptedValue)) {
try {
plaintext = this.secretEncryptionService.decrypt(
row.encryptedValue,
) as PlaintextString;
} catch (error) {
this.logger.warn(
`applicationRegistrationVariable row ${row.id} encryptedValue not valid ciphertext; treating as plaintext. ${
error instanceof Error ? error.message : String(error)
}`,
);
plaintext = row.encryptedValue as PlaintextString;
}
} else {
this.logger.warn(
`applicationRegistrationVariable row ${row.id} encryptedValue is not legacy CTR ciphertext; treating as plaintext.`,
);
plaintext = row.encryptedValue as PlaintextString;
}

if (!isDefined(plaintext)) {
continue;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Logger } from '@nestjs/common';

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';
Expand All @@ -15,8 +16,23 @@ import { TypedReflect } from 'src/utils/typed-reflect';

type SensitiveConfigRow = { id: string; value: unknown };

// Legacy CTR ciphertext is base64-encoded and at least 16 bytes (one IV
// block) — i.e. ≥ 22 base64 chars. Anything outside that shape is plaintext
// that must be encrypted as-is: CTR decrypt has no integrity tag and would
// silently turn a real secret into garbage instead of throwing.
const LEGACY_CTR_LOOKS_LIKE_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
const LEGACY_CTR_MIN_LENGTH = 22;

const looksLikeLegacyCtrCiphertext = (value: string): boolean =>
value.length >= LEGACY_CTR_MIN_LENGTH &&
LEGACY_CTR_LOOKS_LIKE_BASE64_RE.test(value);

@RegisteredInstanceCommand('2.5.0', 1798000008000, { type: 'slow' })
export class EncryptSensitiveConfigStorageSlowInstanceCommand implements SlowInstanceCommand {
private readonly logger = new Logger(
EncryptSensitiveConfigStorageSlowInstanceCommand.name,
);

constructor(
private readonly secretEncryptionService: SecretEncryptionService,
) {}
Expand All @@ -25,9 +41,10 @@ export class EncryptSensitiveConfigStorageSlowInstanceCommand implements SlowIns
// user/feature-flag entries and with non-sensitive config — so a CHECK
// constraint cannot be added column-wide. The backfill walks only the
// CONFIG_VARIABLE rows whose key is declared `isSensitive` + STRING in
// the ConfigVariables metadata, decrypts the legacy CTR ciphertext, and
// re-encrypts it into the instance-scoped versioned envelope. Idempotent:
// already-v2 rows are left untouched.
// the ConfigVariables metadata, decrypts legacy CTR ciphertext (or treats a
// non-ciphertext value as plaintext), and re-encrypts it into the
// instance-scoped versioned envelope. Idempotent: already-v2 rows are left
// untouched.
async runDataMigration(dataSource: DataSource): Promise<void> {
const sensitiveStringKeys = this.collectSensitiveStringConfigKeys();

Expand Down Expand Up @@ -57,18 +74,34 @@ export class EncryptSensitiveConfigStorageSlowInstanceCommand implements SlowIns
continue;
}

const plaintext = this.secretEncryptionService.decryptVersioned(
rawValue as EncryptedString,
);
let plaintext: PlaintextString;

if (looksLikeLegacyCtrCiphertext(rawValue)) {
try {
plaintext = this.secretEncryptionService.decrypt(
rawValue,
) as PlaintextString;
} catch (error) {
this.logger.warn(
`keyValuePair config row ${row.id} (key "${key}") value not valid ciphertext; treating as plaintext. ${
error instanceof Error ? error.message : String(error)
}`,
);
plaintext = rawValue as PlaintextString;
}
} else {
this.logger.warn(
`keyValuePair config row ${row.id} (key "${key}") value is not legacy CTR ciphertext; treating as plaintext.`,
);
plaintext = 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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
Expand All @@ -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';

Expand All @@ -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`,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
) {}
Expand Down Expand Up @@ -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();

return this.decrypt(value) as PlaintextString;
}

private warnLegacyCtrDecryptionOnce(): void {
if (this.hasLoggedLegacyCtrDecryption) {
return;
}
const keys = resolveEncryptionKeysOrThrow({
environmentConfigDriver: this.environmentConfigDriver,
});
const rawKey = pickEncryptionKeyByKeyIdOrThrow({
keyId: parsed.keyId,
keys,
});

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;
}
}
Loading
Loading