Commit Graph

4 Commits

Author SHA1 Message Date
Charles Bochet a941f6fe01 feat(server): migrate TOTP secret encryption to SecretEncryptionService (#20577)
## Summary

Removes the last `APP_SECRET`-derived at-rest encryption site by
migrating `core.twoFactorAuthenticationMethod.secret` from
`SimpleSecretEncryptionUtil` (AES-256-CBC with key derived from
`sha256(APP_SECRET + userId + workspaceId + 'otp-secret' +
'KEY_ENCRYPTION_KEY')`) to the versioned `enc:v2:` envelope
(ENCRYPTION_KEY → HKDF-SHA256 bound to `workspaceId` → AES-256-GCM).

- New `decrypt-legacy-aes-cbc.util.ts` faithfully reproduces the
pre-migration CBC derivation byte-for-byte;
`SecretEncryptionService.decryptVersioned` dispatches to it when callers
pass `legacyAesCbcPurpose`, with a dedicated one-shot WARN log family.
- `TwoFactorAuthenticationService` now uses `encryptVersioned` /
`decryptVersioned` (passing the legacy purpose so existing rows still
decrypt). `SimpleSecretEncryptionUtil` and its spec are deleted;
`TwoFactorAuthenticationModule` imports `SecretEncryptionModule` in
their place.
- `TwoFactorAuthenticationMethodEntity` gets a `@Check` decorator
(`CHK_twoFactorAuthenticationMethod_secret_encrypted`) restricting
`secret` to the `enc:v2:` envelope; the matching 2.5 slow instance
command (`1798000009000-encrypt-totp-secrets`) cursor-paginates `JOIN`ed
`userWorkspace` rows to recover the legacy `userId`, re-encrypts to
`enc:v2`, and applies the CHECK constraint in `up()`.

### Deviation note

The plan suggested wiring a workspace-only legacy derivation directly
into `decryptVersioned`. In practice the production rows are
user-and-workspace-scoped (the legacy purpose is
`\${userId}\${workspaceId}otp-secret`), so a workspace-only derivation
could not recover them. The PR keeps the public `decryptVersioned` API
intact and adds an optional `legacyAesCbcPurpose` so callers that can
reconstruct the legacy context (the 2FA service and the slow command)
opt in.

### Final state of remaining `APP_SECRET` usages

- HS256 JWT verify (read-only, self-retiring once asymmetric migration
completes).
- Express-session cookie signing.
- Approved-access-domain HMAC (signing root, not at-rest).
- Zero-friction fallback in `resolveEncryptionKeysOrThrow`
(intentional).

No production at-rest data is encrypted with `APP_SECRET`-derived keys
anymore.

## Test plan

- [x] `npx jest src/engine/core-modules/secret-encryption
src/engine/core-modules/two-factor-authentication` — 170 unit tests
pass, including new unit tests for the legacy CBC util and the new
`SecretEncryptionService` fallback branch.
- [x] `npx jest --config ./jest-integration.config.ts
test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts`
— 4 integration tests cover legacy-CBC seed → slow command → `enc:v2`
round-trip, idempotency, CHECK constraint enforcement on `up()`, and
rollback via `down()`.
- [x] `npx oxlint --type-aware` and `npx prettier --check` clean on all
touched files.
- [ ] CI on this PR (server validation, tests, lint, typecheck).
2026-05-14 13:24:06 +02:00
Charles Bochet 7fa136f305 feat(twenty-server): migrate remaining at-rest encryption sites to versioned envelope (#20550)
## Summary

Second PR in the encryption key rotation series. The previous PR
(#20528) introduced `ENCRYPTION_KEY` + the versioned
`enc:v2:<keyId>:<base64>` envelope inside `SecretEncryptionService` and
migrated `ConnectedAccountTokenEncryptionService` as the first consumer.
This PR routes every remaining at-rest encryption site through the
versioned envelope so that `ENCRYPTION_KEY` (and the future
`FALLBACK_ENCRYPTION_KEY`) actually covers them. The legacy unprefixed
CTR ciphertext remains readable as a fallback during the rollout window
— every migrated read site uses `decryptVersioned`, which transparently
delegates to the legacy CTR decrypt when it sees an unprefixed payload.

### Service migrations
- **`ApplicationVariableEntityService` (#8)** — workspace-scoped. HKDF
info is bound to each row's `workspaceId`. A new
`decryptAndMaskVersioned` helper lands on `SecretEncryptionService` for
the resolver display path.
- **`ApplicationRegistrationVariableService` (#7)** + consumers —
**instance-scoped**. Registration variables are server-level config
readable by every workspace that installs the application, so HKDF info
is `instance`. Updated consumers:
  - `LogicFunctionExecutorService.buildServerVariableEnvMap`
  - `ConnectionProviderService.getClientCredentials`
- **`LogicFunctionExecutorService.buildEnvVar` (#9)** —
workspace-scoped. Each variable's `workspaceId` is threaded into
`decryptVersioned`, so per-workspace HKDF contexts are honoured at
execution time.
- **`UpdateApplicationVariableActionHandlerService`**
(workspace-migration runner) — threads `workspaceId` through the
secret/non-secret toggle.
- **`JwtKeyManagerService` (#3)** — instance-scoped. Signing keys are
shared across the JWKS.
- **`ConfigStorageService` (#6)** — instance-scoped sensitive STRING
config variables.

### Slow instance commands (2.5.0)

Each migrated site has a paired backfill that re-encrypts existing rows
into the v2 envelope before the column is constrained:

| timestamp | command | scope | CHECK constraint |
|---|---|---|---|
| `1798000005000` | encrypt-application-variable | workspaceId |
`"isSecret" = false OR value = '' OR value LIKE 'enc:v2:%'` |
| `1798000006000` | encrypt-application-registration-variable | instance
| `"encryptedValue" = '' OR value LIKE 'enc:v2:%'` |
| `1798000007000` | encrypt-signing-key-private-keys | instance |
`"privateKey" IS NULL OR value LIKE 'enc:v2:%'` |
| `1798000008000` | encrypt-sensitive-config-storage | instance | _none_
— heterogeneous jsonb column |

All backfills are idempotent (the SELECT filter skips rows already in v2
form) and run before their respective `up()` adds the CHECK constraint.
Every `down()` deliberately stops at dropping the CHECK constraint —
they intentionally do not re-introduce plaintext on rollback.

### Tests

- Unit specs for each new slow command cover the v2 upgrade path, the
idempotency invariant, and the instance vs workspace HKDF scope.
- New `JwtKeyManagerService` spec asserts
`decryptVersioned`/`encryptVersioned` are called without `workspaceId`
(instance scope).
- Updated existing specs for `ApplicationVariableEntityService`,
`ConfigStorageService`, and `buildEnvVar` to assert the versioned API
and the workspace HKDF context plumbing.
- New `SecretEncryptionService.decryptAndMaskVersioned` cases in the
service spec.
- Updated the `applicationRegistrationVariable` integration spec to
assert the column now stores `enc:v2:<keyId>:<base64>` instead of raw
legacy CTR.

### Out of scope (future PRs)
- `PostgresCredentialsService` — bespoke
`jwtWrapperService.generateAppSecret`–derived key +
`encryptText`/`decryptText` from `auth.util.ts`; deserves its own
migration.
- `SimpleSecretEncryptionUtil` (TOTP) — entirely different `aes-256-cbc`
`iv:enc` format; deserves its own migration.

## Test plan

- [x] `npx nx typecheck twenty-server`
- [x] `npx nx lint:diff-with-main twenty-server` (oxlint + prettier)
- [x] Local jest run for `secret-encryption | connected-account-token |
application-variable | application-registration-variable | build-env-var
| jwt-key-manager | config-storage | encrypt-application-variable |
encrypt-application-registration-variable | encrypt-signing-key |
encrypt-sensitive-config-storage` — 17 suites, 106 tests pass.
- [x] Local jest run for `upgrade | instance-command` — 12 suites, 86
tests pass.
- [ ] CI green
- [ ] Manual review of CHECK constraint shapes by a server reviewer
(each one matches `enc:v2:%` rather than `enc:v_:%` since none of the
migrated columns can legitimately hold `enc:v1:` ciphertext).
2026-05-14 06:10:52 +00:00
Paul Rastoin 6c18bacb93 Encrypt connected account accessToken and refreshToken (#20441)
# Introduction
Encrypt the `connectedAccount` `accessToken` and `refreshToken` using
`APP_SECRET` in order to mitigate potential data leak or `core` table
compromise

## Decrypt
Temporary allow already plain text stored token to be retrieve without
decryption until the slow instance has been passed
Will uncomment the invariant check in a patch when the instance slow has
fully be run

## Standards
- Token are encrypted as quickly as possible
- A token cannot be written in database non encrypted by mistake using a
custom constraint ( `enc:` prefix )

## What's next
We should standardize not managing secret as is in the the services and
layer, they should be encrypted on the flight the earliest and should
never be logged
Will create a dedicated pattern afterwards for `applicationVariables`
secrets too

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-05-11 15:16:12 +00:00
Paul Rastoin 23874848a4 Instance commands and upgrade_migrations table (#19356)
# Introduction
Now only using typeorm to generate migrations up and down statement
We handle and maintain our own migration table history

## What's new
Now all the instance commands will live within the same module and
folder than the upgrade commands
Sequentiality comes from the timestamp located in the filename
Same sequentiality also applies to the workspace commands in the future,
for the moment still expected a as code explicit declaration

( below screen is an example see below section )
<img width="1382" height="634" alt="image"
src="https://github.com/user-attachments/assets/5610a246-4eae-485e-99f4-98fb89ad5ac8"
/>

## Existing 1.21 migrations
We won't start following this pattern in 1.21 yet at least not with the
migration that has already been released as typeorm migrations in cloud
production as they would rerun


## Small duplication
Duplicating the legacy typeorm and instance commands run in the
`run-instance-commands` to avoid any merge of interest for the moment

## Concurrency
Not handling any run in parrallel of the upgrade for the moment
2026-04-07 08:55:17 +00:00