EncryptedString PlaintextString branded string types (#21001)

## Summary

closes https://github.com/twentyhq/core-team-issues/issues/2464

Introduces compile-time branded types to distinguish encrypted
ciphertext from plaintext strings, preventing mix-ups like the one fixed
in #20819 — but at the type level rather in addition to the one existing
at runtime.

### Branded string primitives

- Created `EncryptedString` and `PlaintextString` as hard nominal brands
using `z.string().brand(...)`, making them non-assignable to each other
or to raw `string`
- Created `isEncryptedString` type predicate to narrow `string` to
`EncryptedString` based on the `enc:v2:` envelope prefix
- Retyped `SecretEncryptionService`: `encryptVersioned` accepts
`PlaintextString`, `decryptVersioned` returns `PlaintextString`

### Entity typing

- Typed encrypted columns across entities:
`SigningKeyEntity.privateKey`,
`TwoFactorAuthenticationMethodEntity.secret`,
`ApplicationRegistrationVariableEntity.encryptedValue`,
`ApplicationVariableEntity.value`
- Parameterized JSONB types for connected account connection parameters
(`ImapSmtpCaldavParams<Pwd>`) with reusable aliases
`EncryptedImapSmtpCaldavParams` / `DecryptedImapSmtpCaldavParams`
- Typed DTOs (`CreateApplicationRegistrationVariableInput`,
`UpdateApplicationRegistrationVariablePayload`,
`UpdateApplicationVariableEntityInput`) with `PlaintextString`

### ApplicationVariable always-encrypt uniformization

- Retyped `ApplicationVariableEntity.value` to `EncryptedString | ''` —
all values are now encrypted regardless of `isSecret`
- Updated `ApplicationVariableEntityService` to always encrypt on write
and always decrypt on read
- Simplified `UpdateApplicationVariableActionHandlerService` by removing
conditional encrypt/decrypt-on-isSecret-toggle logic
- Added slow instance command (`2.9.0`) to backfill-encrypt existing
`isSecret=false` plaintext rows and tighten the `CHECK` constraint

### ConfigStorageService refactor

- Split `convertAndSecureValue` (which used `any`) into two well-typed
methods: `convertAndDecrypt` and `convertAndEncrypt`
- Introduced `isSensitiveStringValue` type predicate to narrow values
before encryption/decryption

### What's next
- Typeorm entity derivation to strictly type sitemap configuration as
code + handler logic for encryption rotation
- https://github.com/twentyhq/core-team-issues/issues/2465
This commit is contained in:
Paul Rastoin
2026-05-28 17:41:16 +02:00
committed by GitHub
parent 9b54200d8c
commit ebfaca5b3d
85 changed files with 1528 additions and 937 deletions
@@ -8,6 +8,8 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
@Entity({ name: 'signingKey', schema: 'core' })
@Index('IDX_SIGNING_KEY_IS_CURRENT_UNIQUE', ['isCurrent'], {
unique: true,
@@ -27,7 +29,7 @@ export class SigningKeyEntity {
publicKey: string;
@Column({ type: 'varchar', nullable: true })
privateKey: string | null;
privateKey: EncryptedString | null;
@Column({ type: 'boolean', default: false })
isCurrent: boolean;
@@ -13,6 +13,8 @@ import {
JwtKeyManagerException,
JwtKeyManagerExceptionCode,
} from 'src/engine/core-modules/jwt/jwt-key-manager.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 { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
export type CurrentSigningKey = {
@@ -179,7 +181,7 @@ export class JwtKeyManagerService {
}
private decryptPrivateKey(
encryptedPrivateKey: string | null,
encryptedPrivateKey: EncryptedString | null,
id: string,
): string {
if (!isDefined(encryptedPrivateKey)) {
@@ -230,7 +232,7 @@ export class JwtKeyManagerService {
}
private generateEcP256KeyPair(): {
privateKeyPem: string;
privateKeyPem: PlaintextString;
publicKeyPem: string;
} {
const { privateKey, publicKey } = generateKeyPairSync('ec', {
@@ -239,7 +241,7 @@ export class JwtKeyManagerService {
const privateKeyPem = privateKey
.export({ format: 'pem', type: 'pkcs8' })
.toString();
.toString() as PlaintextString;
const publicKeyPem = publicKey
.export({ format: 'pem', type: 'spki' })
.toString();