diff --git a/.claude/skills/frigg-canary-test/SKILL.md b/.claude/skills/frigg-canary-test/SKILL.md index 2b8807f2d..261716fe6 100644 --- a/.claude/skills/frigg-canary-test/SKILL.md +++ b/.claude/skills/frigg-canary-test/SKILL.md @@ -78,7 +78,8 @@ replica set (`rs0`) because Prisma requires one; `mongo-init.sh` initialises it. `node_modules/@friggframework/core/generated/prisma-`. Run the matching `prisma:generate:*` before syncing schema or testing, or core fails to load. - **`DB_TYPE` must be set** — core's `prisma.js` picks the client from it. -- **`STAGE=dev`** bypasses field-level encryption, so persisted data is readable. +- **`STAGE=dev`** skips field-level encryption on this local run, so persisted data is readable. + (Only local runs skip it; a deployed stage with no key refuses to start.) - **Pin a matching canary.** Canaries publish per commit as `2.0.0--canary...0`; check `npm view @friggframework/core dist-tags.canary` and use the hash matching the PR diff --git a/.claude/skills/frigg-canary-test/assets/harness/.env.example b/.claude/skills/frigg-canary-test/assets/harness/.env.example index 060bb04ca..38aea35b8 100644 --- a/.claude/skills/frigg-canary-test/assets/harness/.env.example +++ b/.claude/skills/frigg-canary-test/assets/harness/.env.example @@ -9,5 +9,6 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5433/frigg_canary_test # DB_TYPE=mongodb # DATABASE_URL=mongodb://localhost:27018/frigg_canary_test?replicaSet=rs0&directConnection=true -# dev stage bypasses field-level encryption so persisted data is readable. +# On a local run the dev stage skips field-level encryption, so persisted data +# is readable. (A deployed stage with no key refuses to start instead.) STAGE=dev diff --git a/.claude/skills/frigg/references/security-encryption.md b/.claude/skills/frigg/references/security-encryption.md index 95d3b34ba..82c40edf7 100644 --- a/.claude/skills/frigg/references/security-encryption.md +++ b/.claude/skills/frigg/references/security-encryption.md @@ -17,22 +17,25 @@ Field-level encryption protects sensitive data at the application layer (databas 4. Cryptor performs the actual encryption via AWS KMS or AES (envelope pattern: a per-operation Data Encryption Key wrapped by a master key; format `keyId:encryptedText:encryptedKey`) 5. The database stores encrypted data -Auto-bypassed in dev/test/local stages. +Fails closed: a deployed stage (running in AWS, not under `frigg start`/serverless-offline or Jest) with no key refuses to start with `EncryptionConfigurationError`, whatever the stage name. Only local runs on dev/test/local stages skip encryption. The rule lives in `database/encryption/encryption-config.js`. ## Environment Configuration ```bash -# Production (AWS KMS - recommended) +# AWS KMS (recommended): set automatically on every stage, dev included, when +# the app definition has encryption: { fieldLevelEncryptionMethod: 'kms' } KMS_KEY_ARN=arn:aws:kms:... -STAGE=production -# AES Encryption (any environment) -AES_KEY_ID=local-dev-key +# AES encryption (any environment); both required when deployed +AES_KEY_ID=my-key-id AES_KEY=your-32-char-key -STAGE=production -# Bypass (dev/test/local stages) -STAGE=dev +# Explicit plaintext opt-out for a deployed stage with no key. Warns on every +# cold start. Also set by fieldLevelEncryptionMethod: 'none'. Never a default. +FRIGG_ENCRYPTION_DISABLED=true + +# Local runs only (frigg start, tests): these stages skip encryption +STAGE=dev # or test, local ``` ## Encrypted Fields diff --git a/CLAUDE.md b/CLAUDE.md index 07cc9a0ff..d31e86342 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,7 +225,7 @@ the OpenAPI documents and the API Gateway routes devtools generates. - **Field-Level Encryption**: Transparent database-agnostic encryption via Prisma Client Extensions - **AWS KMS Integration**: Enterprise-grade encryption with envelope encryption pattern (recommended for production) - **AES Encryption**: Alternative encryption method for any environment including production -- **Environment-Based**: Auto-bypass in dev/test/local stages +- **Fails Closed**: A deployed stage with no key refuses to start; only local runs (`frigg start`, tests) on dev/test/local stages skip encryption - **OAuth2 Standardization**: Framework handles OAuth flows across API modules - **Signature Validation**: HMAC signature validation for webhook security - **VPC Support**: Lambda functions deployed in private subnets @@ -272,12 +272,13 @@ Database (encrypted storage) - **Infrastructure Layer**: Prisma extension handles encryption transparently - **External Services**: Cryptor adapts AWS KMS and crypto library -**Configuration** (`packages/core/database/prisma.js`): +**Configuration** (`packages/core/database/prisma.js`, rule in `packages/core/database/encryption/encryption-config.js`): ```javascript -// Automatic based on environment variables +// Automatic based on environment variables. +// Throws EncryptionConfigurationError in a deployed runtime with no key. const encryptionConfig = getEncryptionConfig(); -// Returns: { enabled: boolean, method: 'kms' | 'aes' } +// Returns: { enabled: boolean, method: 'kms' | 'aes' | undefined, runtime: 'deployed' | 'local', ... } if (encryptionConfig.enabled) { const cryptor = new Cryptor({ @@ -292,18 +293,24 @@ if (encryptionConfig.enabled) { **Environment Variables**: ```bash -# Production (AWS KMS - recommended) -KMS_KEY_ARN=arn:aws:kms:... # AWS KMS key (auto-discovered) -STAGE=production +# AWS KMS (recommended) - set by infrastructure when the app definition has +# encryption: { fieldLevelEncryptionMethod: 'kms' }, on every stage incl. dev +KMS_KEY_ARN=arn:aws:kms:... -# AES Encryption (valid for any environment) -AES_KEY_ID=local-dev-key +# AES encryption (valid for any environment) - both are required when deployed +AES_KEY_ID=my-key-id AES_KEY=your-32-char-key -STAGE=production # Can be used in production -# Stages that bypass: dev, test, local +# Explicit plaintext opt-out for a deployed stage with no key (warns on every +# cold start). Also set by fieldLevelEncryptionMethod: 'none'. Never a default. +FRIGG_ENCRYPTION_DISABLED=true ``` +**When encryption runs** — decided by where the code runs, not by the stage name: + +- **Deployed** (in AWS Lambda: `AWS_LAMBDA_FUNCTION_NAME` / `LAMBDA_TASK_ROOT` / `AWS_EXECUTION_ENV=AWS_Lambda_*`; or on another provider: `FRIGG_PROVIDER` other than `aws`, which provider-netlify's functions set; and not `IS_OFFLINE` / `IS_LOCAL` / `NETLIFY_DEV` / Jest): a key encrypts; no key throws `EncryptionConfigurationError` at Prisma client creation unless `FRIGG_ENCRYPTION_DISABLED=true`. +- **Local** (`frigg start` via serverless-offline, Jest, scripts): `STAGE` dev/test/local skips encryption; any other stage encrypts when a key is set and stays plaintext (with a warning) when not. + **Encrypted Fields** (defined in `encryption-schema-registry.js`): - **Credential**: `data.access_token`, `data.refresh_token`, `data.domain`, `data.id_token` diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index f0f593628..e385f11aa 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,3 +1,17 @@ +# Unreleased + +#### Behaviour change: module credential fields are now encrypted + +- `credentialFields` declared in an API module's `encryption` config (for example `Definition.encryption.credentialFields`) were never registered for encryption: `loadModuleEncryptionSchemas` required a path that does not exist and the error was swallowed. They are now registered, for modules bound by integrations and for modules registered in `appDefinition.entities` (ADR-059). +- Existing rows are not rewritten. A credential saved before this release stays plaintext until it is next written, and is read as stored. +- Reads now tolerate plaintext: a value is only treated as ciphertext when it has the exact `keyId:iv:ciphertext:encryptedKey` shape (previously any string with three or more colons, such as `https://user:pw@host:443`), and a value with that shape that fails to decrypt throws `FieldDecryptionError` in a deployed runtime (any stage, AWS Lambda or Netlify) and is returned as stored with a warning on a local run. +- An app's own `encryption.schema` is no longer replaced when module fields are added to it. +- A schema the app declares (`encryption.schema`, `encryption.disable`, module `credentialFields`) that cannot be registered no longer leaves those fields in plaintext silently: a deployed function refuses to start, and a local run logs a warning (it was a debug line). +- A local run now needs both `AES_KEY_ID` and `AES_KEY` to encrypt with AES, as a deployed one already did; `AES_KEY_ID` alone keeps the run plaintext and warns that `AES_KEY` is missing. +- The DocumentDB decrypt error no longer logs the first characters of the ciphertext. + +--- + # v1.2.2 (Fri Aug 09 2024) #### 🐛 Bug Fix diff --git a/packages/core/database/__tests__/documentdb-encryption-service.test.js b/packages/core/database/__tests__/documentdb-encryption-service.test.js index d5e4ac0b9..f6ad7bd6a 100644 --- a/packages/core/database/__tests__/documentdb-encryption-service.test.js +++ b/packages/core/database/__tests__/documentdb-encryption-service.test.js @@ -375,4 +375,66 @@ describe('DocumentDBEncryptionService', () => { ); }); }); + + describe('configuration from the environment', () => { + const ENV_KEYS = [ + 'AWS_LAMBDA_FUNCTION_NAME', + 'LAMBDA_TASK_ROOT', + 'AWS_EXECUTION_ENV', + 'IS_OFFLINE', + 'IS_LOCAL', + 'JEST_WORKER_ID', + 'STAGE', + 'NODE_ENV', + 'KMS_KEY_ARN', + 'AES_KEY_ID', + 'AES_KEY', + 'FRIGG_ENCRYPTION_DISABLED', + ]; + let savedEnv; + + beforeEach(() => { + savedEnv = Object.fromEntries( + ENV_KEYS.map((key) => [key, process.env[key]]) + ); + for (const key of ENV_KEYS) delete process.env[key]; + }); + + afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it('refuses to construct when deployed with no key, even on STAGE=dev', () => { + process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-app-dev-auth'; + process.env.STAGE = 'dev'; + + expect(() => new DocumentDBEncryptionService()).toThrow( + /No field-level encryption key/ + ); + }); + + it('encrypts when deployed with a KMS key on STAGE=dev', () => { + process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-app-dev-auth'; + process.env.STAGE = 'dev'; + process.env.KMS_KEY_ARN = 'arn:aws:kms:us-east-1:123456789012:key/abc'; + + const envService = new DocumentDBEncryptionService(); + + expect(envService.enabled).toBe(true); + expect(envService.cryptor.shouldUseAws).toBe(true); + }); + + it('stays disabled under frigg start on STAGE=dev', () => { + process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-app-dev-auth'; + process.env.IS_OFFLINE = 'true'; + process.env.STAGE = 'dev'; + + const envService = new DocumentDBEncryptionService(); + + expect(envService.enabled).toBe(false); + }); + }); }); diff --git a/packages/core/database/documentdb-encryption-service.js b/packages/core/database/documentdb-encryption-service.js index 68063fffc..0196f7ead 100644 --- a/packages/core/database/documentdb-encryption-service.js +++ b/packages/core/database/documentdb-encryption-service.js @@ -4,6 +4,7 @@ const { getFieldsToEncryptOnWrite, loadCustomEncryptionSchema, } = require('./encryption/encryption-schema-registry'); +const { getEncryptionConfig } = require('./encryption/encryption-config'); /** * Encryption service specifically for DocumentDB repositories @@ -41,43 +42,29 @@ class DocumentDBEncryptionService { } /** - * Initialize Cryptor with environment-based configuration. - * Matches the logic from @friggframework/core/database/prisma.js + * Initialize Cryptor with environment-based configuration, using the + * same rule as the Prisma client (see encryption/encryption-config.js). * - * Encryption is bypassed in dev/test/local stages. - * Production uses AWS KMS (if available) or AES encryption. + * Local runs skip encryption on the dev, test and local stages. A + * deployed runtime with no key throws instead of writing plaintext. * * @private + * @throws {EncryptionConfigurationError} deployed with no usable key */ _initializeCryptor() { // Load custom encryption schema from app definition BEFORE checking configuration // This ensures custom fields (like User.username) are registered before any encryption operations loadCustomEncryptionSchema(); - // Match logic from packages/core/database/prisma.js - const stage = process.env.STAGE || process.env.NODE_ENV || 'development'; - const bypassEncryption = ['dev', 'test', 'local'].includes(stage.toLowerCase()); + const config = getEncryptionConfig(); - if (bypassEncryption) { + if (!config.enabled) { this.cryptor = null; this.enabled = false; return; } - // Determine encryption method (ensure boolean values) - const hasKMS = !!(process.env.KMS_KEY_ARN && process.env.KMS_KEY_ARN.trim() !== ''); - const hasAES = !!(process.env.AES_KEY_ID && process.env.AES_KEY_ID.trim() !== ''); - - if (!hasKMS && !hasAES) { - console.warn('[DocumentDBEncryptionService] No encryption keys configured. Encryption disabled.'); - this.cryptor = null; - this.enabled = false; - return; - } - - // KMS takes precedence over AES - const shouldUseAws = hasKMS; - this.cryptor = new Cryptor({ shouldUseAws }); + this.cryptor = new Cryptor({ shouldUseAws: config.method === 'kms' }); this.enabled = true; } @@ -264,7 +251,6 @@ class DocumentDBEncryptionService { const errorContext = { modelName, fieldPath, - encryptedValuePrefix: encryptedValue.substring(0, 20), errorMessage: error.message }; diff --git a/packages/core/database/encryption/README.md b/packages/core/database/encryption/README.md index 8c609caf2..0eed091de 100644 --- a/packages/core/database/encryption/README.md +++ b/packages/core/database/encryption/README.md @@ -13,7 +13,7 @@ This module provides **transparent field-level encryption** for sensitive data i - ✅ **Hexagonal architecture**: Clean separation of concerns - ✅ **AWS KMS support**: Enterprise-grade encryption with AWS Key Management Service - ✅ **Local AES fallback**: Development mode using local encryption keys -- ✅ **Environment-based**: Automatic bypass in dev/test/local environments +- ✅ **Fails closed**: A deployed stage with no key refuses to start; only local runs (`frigg start`, tests) skip encryption - ✅ **Envelope encryption**: Secure key management pattern ## Architecture @@ -107,18 +107,46 @@ AES_KEY=your-32-character-secret-key-here STAGE=production # or development, staging, etc. ``` -**⚠️ Important**: Encryption is automatically **disabled** when `STAGE` is set to `dev`, `test`, or `local`, regardless of key configuration. +### When Encryption Runs -### Bypass Encryption +The rule lives in `encryption-config.js` and is shared by the Prisma client, the DocumentDB encryption service, the integration-mapping helpers and the `/health/detailed` check. It depends on **where the code runs**, not on the stage name. -To explicitly disable encryption: +A process is **deployed** when it runs in AWS Lambda (`AWS_LAMBDA_FUNCTION_NAME` or `LAMBDA_TASK_ROOT` is set, or `AWS_EXECUTION_ENV` starts with `AWS_Lambda_`) or on another provider's platform (`FRIGG_PROVIDER` names a provider other than `aws`; provider-netlify's generated functions set `FRIGG_PROVIDER=netlify`), and is **not** a local run (`IS_OFFLINE=true` from serverless-offline / `frigg start`, `IS_LOCAL=true` from `serverless invoke local`, `NETLIFY_DEV=true` from `netlify dev`, or `JEST_WORKER_ID` from Jest). Everything else is **local**. Netlify's `SITE_ID`, `CONTEXT` and `NETLIFY` are build-time variables, so they are not used. + +| Runtime | Key configured | `FRIGG_ENCRYPTION_DISABLED=true` | Result | +| --- | --- | --- | --- | +| Deployed, any stage (including `dev`) | `KMS_KEY_ARN` | any | Encrypt with KMS | +| Deployed, any stage | `AES_KEY_ID` + `AES_KEY` | any | Encrypt with AES | +| Deployed, any stage | `AES_KEY_ID` without `AES_KEY` | no | **Refuses to start** (`EncryptionConfigurationError`) | +| Deployed, any stage | none | no | **Refuses to start** (`EncryptionConfigurationError`) | +| Deployed, any stage | none | yes | Plaintext, with a warning on every cold start | +| Local, `STAGE` (or `NODE_ENV`) is `dev`, `test` or `local` | any | any | Skipped | +| Local, any other stage | `KMS_KEY_ARN` or `AES_KEY_ID` | any | Encrypt | +| Local, any other stage | none | any | Plaintext, with a warning | + +KMS wins when both keys are set. A configured key always wins over `FRIGG_ENCRYPTION_DISABLED`: switching encryption off while a key exists would leave already-encrypted data unreadable, so the opt-out is ignored (with a warning). + +"Refuses to start" means the Prisma client is never created: the first database access on a cold start throws -```bash -# Disable encryption (development only) -STAGE=development # or dev, test, local ``` +EncryptionConfigurationError: [Frigg] No field-level encryption key is configured for stage "dev". A deployed Frigg app will not write credentials and other sensitive fields in plaintext. Fix: ... +``` + +and `/health/detailed` reports `checks.encryption.status: "unhealthy"` with the same message. If a key is configured but the encryption extension fails to initialise, the client also refuses to start rather than continuing without encryption. + +To encrypt a local run, use a stage other than `dev`, `test` or `local` and configure a key. + +### Running a Deployed Stage Without Encryption -Or simply don't configure any encryption keys. In Production field level encryption **must** be enabled. +Only do this on purpose, for a stage that never holds real credentials. Either set it in the app definition: + +```javascript +const appDefinition = { + encryption: { fieldLevelEncryptionMethod: 'none' }, +}; +``` + +which the infrastructure builders turn into `FRIGG_ENCRYPTION_DISABLED=true` on every function, or set `FRIGG_ENCRYPTION_DISABLED=true` in the Lambda environment yourself. Every cold start logs a warning that sensitive fields are stored in plaintext. There is no default that does this. ## Encrypted Fields @@ -428,6 +456,8 @@ Format: "keyId:encryptedText:encryptedKey" Example: "base64KeyId:iv:ciphertext:base64EncryptedDataKey" ``` +**Recognising ciphertext on read.** A value is decrypted only when it has exactly four colon-separated parts: base64 `keyId`, 32-hex-character `iv`, hex ciphertext, base64 `encryptedKey`. Plaintext that merely contains colons (`https://user:pw@host:443`) is left alone. A value with that shape that still fails to decrypt (a wrong, rotated or missing key) fails the read with `FieldDecryptionError` in a deployed runtime (see [When Encryption Runs](#when-encryption-runs)), so ciphertext never reaches an API module as if it were a token. Locally it is returned as stored and logged as a warning, so one bad field does not fail a whole read. A field that becomes encrypted later (for example a module's `credentialFields`) stays plaintext in existing rows until they are next written. + **Why Envelope Encryption?** - Reduces KMS API calls (one DEK per field, cached) @@ -553,8 +583,8 @@ class MyRepositoryDocumentDB { #### Configuration -Uses the same environment variables and Cryptor as the Prisma Extension: -- `STAGE`: Bypasses encryption for dev/test/local +Uses the same rule (`encryption-config.js`, see [When Encryption Runs](#when-encryption-runs)) and Cryptor as the Prisma Extension. Constructing the service in a deployed runtime with no key throws `EncryptionConfigurationError`. +- `STAGE`: Skips encryption for dev/test/local on local runs only - `KMS_KEY_ARN`: AWS KMS encryption (production) - `AES_KEY_ID` + `AES_KEY`: AES encryption (fallback) @@ -615,10 +645,27 @@ curl http://localhost:3000/health/test-encryption "encryptionWorks": true } -# Response when encryption disabled: +# Response on a local run with STAGE=dev: { "status": "disabled", - "reason": "Encryption bypassed for stage: development" + "bypassed": true, + "runtime": "local", + "testResult": "Encryption bypassed for this stage" +} + +# Response in a deployed stage with no key (the app itself refuses to start): +{ + "status": "unhealthy", + "mode": "none", + "runtime": "deployed", + "testResult": "[Frigg] No field-level encryption key is configured for stage \"dev\". ..." +} + +# Response in a deployed stage with FRIGG_ENCRYPTION_DISABLED=true and no key: +{ + "status": "disabled", + "optedOut": true, + "testResult": "Encryption explicitly disabled (FRIGG_ENCRYPTION_DISABLED=true); sensitive fields are stored in plaintext" } ``` @@ -732,9 +779,10 @@ FRIGG_LOG_LEVEL=INFO **Check environment variables:** ```bash -echo $STAGE # Should be 'production' (not dev/test/local) echo $KMS_KEY_ARN # Should be set (for KMS) -echo $AES_KEY_ID # Should be set (for AES) +echo $AES_KEY_ID $AES_KEY # Both should be set (for AES) +echo $STAGE # Local runs only: dev/test/local skip encryption +echo $FRIGG_ENCRYPTION_DISABLED # Should be unset ``` **Check console logs:** @@ -749,6 +797,22 @@ or [Frigg] Field-level encryption disabled ``` +### Deployed Stage Refuses to Start + +**Error: "EncryptionConfigurationError: [Frigg] No field-level encryption key is configured for stage ..."** + +The function runs in AWS and has neither `KMS_KEY_ARN` nor `AES_KEY_ID` + `AES_KEY`. Earlier versions skipped encryption on stages named `dev`, `test` or `local` and wrote plaintext when no key was set; deployed stages now refuse instead. Fix one of: + +1. Set `encryption: { fieldLevelEncryptionMethod: 'kms' }` in the app definition and redeploy. Frigg creates or discovers a KMS key and sets `KMS_KEY_ARN` on every function, whatever the stage name. +2. Provide `AES_KEY_ID` and `AES_KEY` (32 characters) through the app definition's `environment` and the deploy environment. +3. To keep the stage in plaintext on purpose, set `encryption: { fieldLevelEncryptionMethod: 'none' }` (or `FRIGG_ENCRYPTION_DISABLED=true`). + +Data written in plaintext before the key existed stays readable: reads leave values that are not in the encrypted `keyId:iv:cipher:encKey` format untouched, and the next write of each record encrypts it. Records that are never rewritten stay in plaintext until you re-save them. + +**Error: "[Frigg] AES_KEY_ID is set but AES_KEY is not"** + +Provide `AES_KEY` alongside `AES_KEY_ID`, or switch to KMS. + ### AWS KMS Errors **Error: "User is not authorized to perform: kms:GenerateDataKey"** diff --git a/packages/core/database/encryption/__tests__/entity-registry-encryption.test.js b/packages/core/database/encryption/__tests__/entity-registry-encryption.test.js new file mode 100644 index 000000000..29c261143 --- /dev/null +++ b/packages/core/database/encryption/__tests__/entity-registry-encryption.test.js @@ -0,0 +1,244 @@ +/** + * ADR-059 at the encryption schema: credential fields declared by a module + * that is registered in appDefinition.entities (and bound by no integration) + * are encrypted, through the same path an app takes at load. + */ +jest.mock('../../../utils', () => ({ + findNearestBackendPackageJson: () => '/virtual/backend/package.json', +})); + +const mockBackend = { Definition: null }; +jest.mock('/virtual/backend/index.js', () => mockBackend, { virtual: true }); + +const { + loadCustomEncryptionSchema, + loadModuleEncryptionSchemas, + registerCustomSchema, + getEncryptedFields, + resetCustomSchema, +} = require('../encryption-schema-registry'); +const { + EntityRegistryError, +} = require('../../../modules/entity-type-registry'); +const { logger } = require('../logger'); + +const xero = { + moduleName: 'xero', + encryption: { credentialFields: ['xero_tenant_secret'] }, +}; +const hubspot = { + moduleName: 'hubspot', + encryption: { credentialFields: ['hub_private_token'] }, +}; +const integration = (definition) => ({ + Definition: { name: 'sync', modules: { crm: { definition } } }, +}); + +describe('encryption schema and the entity registry (ADR-059)', () => { + afterEach(() => { + resetCustomSchema(); + mockBackend.Definition = null; + }); + + it('encrypts a registry-only module’s credential fields when the app loads', () => { + mockBackend.Definition = { + integrations: [integration(hubspot)], + entities: { xero: { definition: xero } }, + }; + + loadCustomEncryptionSchema(); + + const fields = getEncryptedFields('Credential'); + expect(fields).toContain('data.xero_tenant_secret'); + expect(fields).toContain('data.hub_private_token'); + }); + + it('encrypts it for an app with no integrations at all', () => { + mockBackend.Definition = { + integrations: [], + entities: { xero: { definition: xero } }, + }; + + loadCustomEncryptionSchema(); + + expect(getEncryptedFields('Credential')).toContain( + 'data.xero_tenant_secret' + ); + }); + + it('an app with no entities key still gets only its integrations’ fields', () => { + mockBackend.Definition = { integrations: [integration(hubspot)] }; + + loadCustomEncryptionSchema(); + + const fields = getEncryptedFields('Credential'); + expect(fields).toContain('data.hub_private_token'); + expect(fields).not.toContain('data.xero_tenant_secret'); + }); + + it('adds module fields to the app’s own custom Credential fields instead of replacing them', () => { + mockBackend.Definition = { + integrations: [integration(hubspot)], + entities: { xero: { definition: xero } }, + encryption: { + schema: { Credential: { fields: ['data.app_owned_secret'] } }, + }, + }; + + loadCustomEncryptionSchema(); + + const fields = getEncryptedFields('Credential'); + expect(fields).toEqual( + expect.arrayContaining([ + 'data.app_owned_secret', + 'data.xero_tenant_secret', + 'data.hub_private_token', + ]) + ); + }); + + it('keeps an app custom schema for another model when module fields are added', () => { + mockBackend.Definition = { + integrations: [integration(hubspot)], + entities: { xero: { definition: xero } }, + encryption: { + schema: { + Integration: { fields: ['config.appOwnedSecret'] }, + Credential: { fields: ['data.app_owned_secret'] }, + }, + }, + }; + + loadCustomEncryptionSchema(); + + expect(getEncryptedFields('Integration')).toContain( + 'config.appOwnedSecret' + ); + expect(getEncryptedFields('Credential')).toEqual( + expect.arrayContaining([ + 'data.app_owned_secret', + 'data.xero_tenant_secret', + 'data.hub_private_token', + ]) + ); + }); + + it('keeps an app custom schema for another model when only entities declare fields', () => { + mockBackend.Definition = { + integrations: [], + entities: { xero: { definition: xero } }, + encryption: { + schema: { Integration: { fields: ['config.appOwnedSecret'] } }, + }, + }; + + loadCustomEncryptionSchema(); + + expect(getEncryptedFields('Integration')).toContain( + 'config.appOwnedSecret' + ); + expect(getEncryptedFields('Credential')).toContain( + 'data.xero_tenant_secret' + ); + }); + + it('does not let a module field that core already encrypts abort the registration', () => { + mockBackend.Definition = { + integrations: [], + entities: { + xero: { + definition: { + ...xero, + encryption: { + credentialFields: ['api_key', 'xero_tenant_secret'], + }, + }, + }, + }, + }; + + loadCustomEncryptionSchema(); + + expect(getEncryptedFields('Credential')).toContain( + 'data.xero_tenant_secret' + ); + }); + + it('surfaces a registry error instead of swallowing it as "no custom schema"', () => { + mockBackend.Definition = { + integrations: [], + entities: { crm: { definition: xero } }, + }; + + expect(() => loadCustomEncryptionSchema()).toThrow(EntityRegistryError); + }); + + describe('a schema the app declares but that cannot be registered', () => { + const savedEnv = { ...process.env }; + const badSchema = () => { + mockBackend.Definition = { + encryption: { schema: { Credential: { fields: 'data.secret' } } }, + integrations: [], + }; + }; + let warn; + + beforeEach(() => { + warn = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + process.env = { ...savedEnv }; + }); + + it('refuses to start a deployed function, which would store those fields in plaintext', () => { + badSchema(); + delete process.env.JEST_WORKER_ID; + process.env.AWS_LAMBDA_FUNCTION_NAME = 'app-prod-auth'; + + expect(() => loadCustomEncryptionSchema()).toThrow( + /Invalid custom encryption schema/ + ); + }); + + it('warns (not a debug line) on a local run', () => { + badSchema(); + + expect(() => loadCustomEncryptionSchema()).not.toThrow(); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/could not be registered; those fields stay plaintext/) + ); + }); + + it('still carries on quietly when there is no app definition to read', () => { + mockBackend.Definition = null; + + expect(() => loadCustomEncryptionSchema()).not.toThrow(); + expect(warn).not.toHaveBeenCalled(); + }); + }); + + it('loadModuleEncryptionSchemas(integrations, entities) takes the registry directly', () => { + loadModuleEncryptionSchemas([], { xero: { definition: xero } }); + + expect(getEncryptedFields('Credential')).toContain( + 'data.xero_tenant_secret' + ); + }); + + it('keeps the legacy one-argument call working', () => { + loadModuleEncryptionSchemas([integration(hubspot)]); + + expect(getEncryptedFields('Credential')).toContain( + 'data.hub_private_token' + ); + }); + + it('registerCustomSchema is untouched by module loading when nothing declares fields', () => { + registerCustomSchema({ Other: { fields: ['x'] } }); + loadModuleEncryptionSchemas([integration({ moduleName: 'plain' })]); + + expect(getEncryptedFields('Other')).toEqual(['x']); + }); +}); diff --git a/packages/core/database/encryption/documentdb-encryption-service.md b/packages/core/database/encryption/documentdb-encryption-service.md index 88f433546..137116e9a 100644 --- a/packages/core/database/encryption/documentdb-encryption-service.md +++ b/packages/core/database/encryption/documentdb-encryption-service.md @@ -225,7 +225,84 @@ Database (ENCRYPTED STORAGE) ✅ SECURE 1. **Consistency**: Same encryption format and Cryptor as Prisma Extension 2. **Reusability**: Single service used by all DocumentDB repositories 3. **Schema-Driven**: Uses `encryption-schema-registry.js` (same as Prisma) -4. **Environment-Aware**: Respects STAGE-based bypass (dev/test/local) +4. **Fails Closed**: Same rule as the Prisma client; a deployed runtime with no key throws, only local runs skip encryption +5. **Error-Tolerant**: Graceful handling of decryption failures +6. **Testable**: Can be unit tested independently of repositories + +--- + +## Technical Specification + +### Class Design + +```javascript +/** + * Encryption service specifically for DocumentDB repositories + * that use $runCommandRaw and bypass Prisma Extensions. + * + * Provides document-level encryption/decryption, + * handling nested fields according to the encryption schema registry. + */ +class DocumentDBEncryptionService { + constructor() + _initializeCryptor() + async encryptFields(modelName, document) + async decryptFields(modelName, document) + async _encryptFieldPath(document, fieldPath, modelName) + async _decryptFieldPath(document, fieldPath, modelName) + _isEncryptedValue(value) +} +``` + +### Method Specifications + +#### `constructor()` + +**Purpose**: Initialize the service and configure Cryptor + +**Behavior**: + +- Calls `_initializeCryptor()` immediately +- Sets up `this.cryptor` and `this.enabled` properties + +**No parameters** + +--- + +#### `_initializeCryptor()` + +**Purpose**: Initialize Cryptor with environment-based configuration + +**Logic**: + +```javascript +1. loadCustomEncryptionSchema() +2. config = getEncryptionConfig() // encryption/encryption-config.js, shared with the Prisma client + - throws EncryptionConfigurationError in a deployed runtime with no key +3. If !config.enabled (local dev/test/local stage, local run without keys, + or FRIGG_ENCRYPTION_DISABLED=true opt-out): + - Set this.cryptor = null + - Set this.enabled = false + - Return +4. Create Cryptor({ shouldUseAws: config.method === 'kms' }) +5. Set this.enabled = true +``` + +**Environment Variables Used** (see "When Encryption Runs" in README.md): + +- `AWS_LAMBDA_FUNCTION_NAME` / `LAMBDA_TASK_ROOT` / `AWS_EXECUTION_ENV=AWS_Lambda_*`, or a non-`aws` `FRIGG_PROVIDER`, minus `IS_OFFLINE` / `IS_LOCAL` / `NETLIFY_DEV` / `JEST_WORKER_ID`: deployed or local run +- `STAGE` or `NODE_ENV`: dev/test/local skip encryption on local runs only +- `KMS_KEY_ARN`: AWS KMS key ARN (enables KMS encryption) +- `AES_KEY_ID`: AES key identifier (enables AES encryption) +- `AES_KEY`: AES encryption key (required with AES_KEY_ID in a deployed runtime) +- `FRIGG_ENCRYPTION_DISABLED`: explicit plaintext opt-out for a deployed stage with no key + +### Design Principles + +1. **Consistency**: Same encryption format and Cryptor as Prisma Extension +2. **Reusability**: Single service used by all DocumentDB repositories +3. **Schema-Driven**: Uses `encryption-schema-registry.js` (same as Prisma) +4. **Fails Closed**: Same rule as the Prisma client; a deployed runtime with no key throws, only local runs skip encryption 5. **Error-Tolerant**: Graceful handling of decryption failures 6. **Testable**: Can be unit tested independently of repositories diff --git a/packages/core/database/encryption/encryption-config.js b/packages/core/database/encryption/encryption-config.js new file mode 100644 index 000000000..90665658e --- /dev/null +++ b/packages/core/database/encryption/encryption-config.js @@ -0,0 +1,250 @@ +/** + * Field-level encryption configuration: decides whether sensitive fields are + * encrypted, and with which key, from the process environment. + * + * The rule fails closed: + * + * - **Deployed** (running in AWS Lambda, and not under + * serverless-offline, `serverless invoke local` or Jest): the stage name is + * ignored. A configured key (KMS_KEY_ARN, or AES_KEY_ID with AES_KEY) turns + * encryption on. With no key, the configuration is an error and the Prisma + * client refuses to start, unless FRIGG_ENCRYPTION_DISABLED=true opts out + * explicitly. + * - **Local** (`frigg start`, tests, scripts on a laptop): the stages dev, + * test and local skip encryption. Any other stage encrypts when a key is + * configured and stays plaintext, with a warning, when none is. + * + * Every module that encrypts or reports on encryption reads this one rule. + */ + +const { logger } = require('./logger'); + +const LOCAL_BYPASS_STAGES = ['dev', 'test', 'local']; +const OPT_OUT_VAR = 'FRIGG_ENCRYPTION_DISABLED'; + +class EncryptionConfigurationError extends Error { + constructor(message) { + super(message); + this.name = 'EncryptionConfigurationError'; + } +} + +function isSet(value) { + return typeof value === 'string' && value.trim() !== ''; +} + +function isTrue(value) { + if (typeof value !== 'string') return false; + const normalized = value.trim().toLowerCase(); + return normalized === 'true' || normalized === '1'; +} + +/** + * True when the process runs as a deployed function, on AWS Lambda or on + * another provider's platform. + * + * - AWS Lambda sets AWS_LAMBDA_FUNCTION_NAME, LAMBDA_TASK_ROOT and + * AWS_EXECUTION_ENV (AWS_Lambda_). Other AWS hosts (CloudShell, + * CodeBuild, ECS) set AWS_EXECUTION_ENV to other values and count as local: + * CLI commands run from CI must not trip the deployed-stage check. + * - Other providers: FRIGG_PROVIDER names a non-AWS provider. Frigg's + * generated function entry points for that provider set it (provider-netlify + * sets 'netlify'), so it is present in every deployed function. Netlify + * exposes SITE_ID, CONTEXT and NETLIFY at build time only, so they cannot + * tell a running function from a laptop. + * + * A local run is never deployed: serverless-offline and `serverless invoke + * local` (which set the Lambda variables themselves) mark IS_OFFLINE / + * IS_LOCAL, `netlify dev` marks NETLIFY_DEV, and Jest marks JEST_WORKER_ID. + * + * @param {Object} [env=process.env] + * @returns {boolean} + */ +function isDeployedRuntime(env = process.env) { + const localRun = + isTrue(env.IS_OFFLINE) || + isTrue(env.IS_LOCAL) || + isTrue(env.NETLIFY_DEV) || + isSet(env.JEST_WORKER_ID); + if (localRun) return false; + + const onAwsRuntime = + isSet(env.AWS_LAMBDA_FUNCTION_NAME) || + isSet(env.LAMBDA_TASK_ROOT) || + String(env.AWS_EXECUTION_ENV ?? '').startsWith('AWS_Lambda_'); + if (onAwsRuntime) return true; + + const provider = String(env.FRIGG_PROVIDER ?? '').trim().toLowerCase(); + return provider !== '' && provider !== 'aws'; +} + +function missingKeyMessage(stage) { + return ( + `[Frigg] No field-level encryption key is configured for stage "${stage}". ` + + 'A deployed Frigg app will not write credentials and other sensitive fields in plaintext. ' + + "Fix: set encryption: { fieldLevelEncryptionMethod: 'kms' } in the app definition and redeploy " + + '(Frigg creates or discovers a KMS key and sets KMS_KEY_ARN), ' + + 'or provide AES_KEY_ID and AES_KEY (32 characters) in the Lambda environment. ' + + `To run this stage without encryption on purpose, set ${OPT_OUT_VAR}=true ` + + "(or encryption: { fieldLevelEncryptionMethod: 'none' } in the app definition)." + ); +} + +function incompleteAesMessage(stage) { + return ( + `[Frigg] AES_KEY_ID is set but AES_KEY is not (stage "${stage}"). ` + + 'Provide AES_KEY (32 characters) alongside AES_KEY_ID, ' + + "or use KMS with encryption: { fieldLevelEncryptionMethod: 'kms' }." + ); +} + +/** + * Resolves the encryption configuration without side effects. + * + * @param {Object} [env=process.env] + * @returns {{ + * enabled: boolean, + * method: 'kms'|'aes'|undefined, + * mode: 'kms'|'aes'|'none', + * runtime: 'deployed'|'local', + * stage: string, + * bypassed: boolean, + * optedOut: boolean, + * optOutIgnored: boolean, + * hasKMS: boolean, + * hasAES: boolean, + * error: string|null, + * }} `error` is set when a deployed runtime has no usable key. + */ +function resolveEncryptionConfig(env = process.env) { + const stage = env.STAGE || env.NODE_ENV || 'development'; + const runtime = isDeployedRuntime(env) ? 'deployed' : 'local'; + const hasKMS = isSet(env.KMS_KEY_ARN); + const hasAES = isSet(env.AES_KEY_ID); + // AES needs the key as well as its id, locally too: a cryptor built from + // the id alone cannot encrypt, and a local run reads failed decrypts as + // stored values, so an incomplete pair would hand back blobs as tokens. + const hasCompleteAES = hasAES && isSet(env.AES_KEY); + const optOutRequested = isTrue(env[OPT_OUT_VAR]); + + const base = { + enabled: false, + method: undefined, + mode: 'none', + runtime, + stage, + bypassed: false, + optedOut: false, + optOutIgnored: false, + hasKMS, + hasAES, + error: null, + }; + + if (runtime === 'local') { + if (LOCAL_BYPASS_STAGES.includes(String(stage).toLowerCase())) { + return { ...base, bypassed: true }; + } + if (hasKMS || hasCompleteAES) { + const method = hasKMS ? 'kms' : 'aes'; + return { ...base, enabled: true, method, mode: method }; + } + return base; + } + + if (hasKMS || hasCompleteAES) { + const method = hasKMS ? 'kms' : 'aes'; + return { + ...base, + enabled: true, + method, + mode: method, + optOutIgnored: optOutRequested, + }; + } + + if (optOutRequested) { + return { ...base, optedOut: true }; + } + + return { + ...base, + error: hasAES ? incompleteAesMessage(stage) : missingKeyMessage(stage), + }; +} + +const warned = new Set(); + +function warnOnce(key, message) { + if (warned.has(key)) return; + warned.add(key); + logger.warn(message); +} + +/** + * Resolves the encryption configuration for use: throws when a deployed + * runtime has no usable key, and warns once per process (once per Lambda + * cold start) when encryption is explicitly disabled. + * + * @param {Object} [env=process.env] + * @returns {ReturnType} + * @throws {EncryptionConfigurationError} + */ +function getEncryptionConfig(env = process.env) { + const config = resolveEncryptionConfig(env); + + if (config.error) { + throw new EncryptionConfigurationError(config.error); + } + + if (config.optedOut) { + warnOnce( + 'opted-out', + `[Frigg] ${OPT_OUT_VAR}=true: field-level encryption is OFF for stage "${config.stage}". ` + + 'Credentials and other sensitive fields are stored in PLAINTEXT. ' + + `Remove ${OPT_OUT_VAR} and configure a key (fieldLevelEncryptionMethod: 'kms') to encrypt them.` + ); + } + + if (config.optOutIgnored) { + warnOnce( + 'opt-out-ignored', + `[Frigg] ${OPT_OUT_VAR}=true is ignored because an encryption key is configured; ` + + `fields are encrypted with ${config.method.toUpperCase()}. ` + + 'Turning encryption off while a key is present would leave encrypted data unreadable.' + ); + } + + if (config.runtime === 'local' && !config.enabled && !config.bypassed && config.hasAES) { + warnOnce( + 'local-incomplete-aes', + `AES_KEY_ID is set but AES_KEY is not for local stage "${config.stage}". ` + + 'Field-level encryption disabled for this local run; set both to encrypt. ' + + 'A deployed stage refuses to start without both.' + ); + } else if (config.runtime === 'local' && !config.enabled && !config.bypassed) { + warnOnce( + 'local-no-keys', + `No encryption keys configured (KMS_KEY_ARN or AES_KEY_ID) for local stage "${config.stage}". ` + + 'Field-level encryption disabled for this local run. ' + + 'A deployed stage without a key refuses to start.' + ); + } + + return config; +} + +/** Test helper: let the once-per-process warnings fire again. */ +function resetEncryptionConfigWarnings() { + warned.clear(); +} + +module.exports = { + EncryptionConfigurationError, + LOCAL_BYPASS_STAGES, + OPT_OUT_VAR, + getEncryptionConfig, + isDeployedRuntime, + resetEncryptionConfigWarnings, + resolveEncryptionConfig, +}; diff --git a/packages/core/database/encryption/encryption-config.test.js b/packages/core/database/encryption/encryption-config.test.js new file mode 100644 index 000000000..fbcbe4263 --- /dev/null +++ b/packages/core/database/encryption/encryption-config.test.js @@ -0,0 +1,413 @@ +const { logger } = require('./logger'); +const { + EncryptionConfigurationError, + getEncryptionConfig, + isDeployedRuntime, + resetEncryptionConfigWarnings, + resolveEncryptionConfig, +} = require('./encryption-config'); + +const KMS = { KMS_KEY_ARN: 'arn:aws:kms:us-east-1:123456789012:key/abc' }; +const AES = { + AES_KEY_ID: 'key-1', + AES_KEY: '0123456789abcdef0123456789abcdef', +}; +const OPT_OUT = { FRIGG_ENCRYPTION_DISABLED: 'true' }; + +// What each runtime looks like from inside the process. +const LAMBDA = { + AWS_LAMBDA_FUNCTION_NAME: 'my-app-dev-auth', + LAMBDA_TASK_ROOT: '/var/task', + AWS_EXECUTION_ENV: 'AWS_Lambda_nodejs20.x', +}; +// serverless-offline (frigg start) sets the Lambda variables too, plus IS_OFFLINE. +const OFFLINE = { ...LAMBDA, IS_OFFLINE: 'true' }; +// `serverless invoke local` sets IS_LOCAL. +const INVOKE_LOCAL = { ...LAMBDA, IS_LOCAL: 'true' }; +const JEST = { JEST_WORKER_ID: '1', NODE_ENV: 'test' }; +const SCRIPT = {}; // plain `node script.js` on a laptop +// A deployed Netlify function: no AWS Lambda variables are relied on, only the +// FRIGG_PROVIDER that provider-netlify's generated entry points set. +const NETLIFY = { FRIGG_PROVIDER: 'netlify' }; + +describe('isDeployedRuntime', () => { + it.each([ + ['a deployed Lambda', LAMBDA, true], + [ + 'a Lambda seen only through AWS_EXECUTION_ENV', + { AWS_EXECUTION_ENV: 'AWS_Lambda_nodejs20.x' }, + true, + ], + [ + 'a Lambda seen only through LAMBDA_TASK_ROOT', + { LAMBDA_TASK_ROOT: '/var/task' }, + true, + ], + // Frigg deploys to Lambda only; CI and CloudShell also set AWS_EXECUTION_ENV. + [ + 'an ECS task or CodeBuild job', + { AWS_EXECUTION_ENV: 'AWS_ECS_FARGATE' }, + false, + ], + ['AWS CloudShell', { AWS_EXECUTION_ENV: 'CloudShell' }, false], + ['serverless-offline', OFFLINE, false], + [ + 'serverless-offline with IS_OFFLINE=1', + { ...LAMBDA, IS_OFFLINE: '1' }, + false, + ], + ['serverless invoke local', INVOKE_LOCAL, false], + ['Jest, even with Lambda variables set', { ...LAMBDA, ...JEST }, false], + ['a local script', SCRIPT, false], + [ + 'IS_OFFLINE=false inside Lambda', + { ...LAMBDA, IS_OFFLINE: 'false' }, + true, + ], + ['a deployed Netlify function', NETLIFY, true], + [ + 'a deployed Netlify function, provider name in another case', + { FRIGG_PROVIDER: ' Netlify ' }, + true, + ], + ['`netlify dev`', { ...NETLIFY, NETLIFY_DEV: 'true' }, false], + ['Jest with FRIGG_PROVIDER=netlify', { ...NETLIFY, ...JEST }, false], + // Build-time variables only: a laptop with them set is still local. + [ + "Netlify's build-time variables without FRIGG_PROVIDER", + { NETLIFY: 'true', SITE_ID: 'abc', CONTEXT: 'production' }, + false, + ], + ['FRIGG_PROVIDER=aws outside Lambda', { FRIGG_PROVIDER: 'aws' }, false], + ['an empty FRIGG_PROVIDER', { FRIGG_PROVIDER: ' ' }, false], + ])('%s → %p', (_label, env, expected) => { + expect(isDeployedRuntime(env)).toBe(expected); + }); +}); + +describe('resolveEncryptionConfig', () => { + describe('deployed (in Lambda, not offline): the stage name never matters', () => { + it.each(['dev', 'test', 'local', 'production', undefined])( + 'STAGE=%p with KMS_KEY_ARN → KMS', + (STAGE) => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + ...KMS, + STAGE, + }); + expect(config).toMatchObject({ + enabled: true, + method: 'kms', + mode: 'kms', + runtime: 'deployed', + bypassed: false, + error: null, + }); + } + ); + + it('AES_KEY_ID + AES_KEY → AES', () => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + ...AES, + STAGE: 'dev', + }); + expect(config).toMatchObject({ + enabled: true, + method: 'aes', + error: null, + }); + }); + + it('prefers KMS when both keys are set', () => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + ...KMS, + ...AES, + }); + expect(config.method).toBe('kms'); + }); + + it('treats a whitespace-only key as missing', () => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + KMS_KEY_ARN: ' ', + STAGE: 'prod', + }); + expect(config.enabled).toBe(false); + expect(config.error).toMatch(/No field-level encryption key/); + }); + + it.each(['dev', 'test', 'local', 'production'])( + 'STAGE=%p with no key → error, not plaintext', + (STAGE) => { + const config = resolveEncryptionConfig({ ...LAMBDA, STAGE }); + expect(config.enabled).toBe(false); + expect(config.mode).toBe('none'); + expect(config.bypassed).toBe(false); + expect(config.error).toMatch(/No field-level encryption key/); + expect(config.error).toMatch( + /fieldLevelEncryptionMethod: 'kms'/ + ); + expect(config.error).toMatch(/AES_KEY_ID/); + expect(config.error).toMatch(/FRIGG_ENCRYPTION_DISABLED=true/); + } + ); + + it('AES_KEY_ID without AES_KEY → error naming AES_KEY', () => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + AES_KEY_ID: 'key-1', + STAGE: 'prod', + }); + expect(config.enabled).toBe(false); + expect(config.error).toMatch( + /AES_KEY_ID is set but AES_KEY is not/ + ); + }); + + it('opt-out with no key → disabled without error', () => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + ...OPT_OUT, + STAGE: 'prod', + }); + expect(config).toMatchObject({ + enabled: false, + mode: 'none', + optedOut: true, + bypassed: false, + error: null, + }); + }); + + it.each(['TRUE', '1', ' true '])('opt-out accepts %p', (value) => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + FRIGG_ENCRYPTION_DISABLED: value, + }); + expect(config.optedOut).toBe(true); + expect(config.error).toBeNull(); + }); + + it.each(['false', '0', '', 'yes'])( + 'FRIGG_ENCRYPTION_DISABLED=%p is not an opt-out', + (value) => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + FRIGG_ENCRYPTION_DISABLED: value, + }); + expect(config.optedOut).toBe(false); + expect(config.error).not.toBeNull(); + } + ); + + it('opt-out does not override a configured key', () => { + const config = resolveEncryptionConfig({ + ...LAMBDA, + ...KMS, + ...OPT_OUT, + }); + expect(config).toMatchObject({ + enabled: true, + method: 'kms', + optedOut: false, + optOutIgnored: true, + }); + }); + }); + + describe('deployed on Netlify: the same rule as Lambda', () => { + it.each(['dev', 'test', 'local', 'production'])( + 'STAGE=%p with no key → error, not plaintext', + (STAGE) => { + const config = resolveEncryptionConfig({ ...NETLIFY, STAGE }); + expect(config).toMatchObject({ + enabled: false, + runtime: 'deployed', + bypassed: false, + }); + expect(config.error).toMatch(/No field-level encryption key/); + } + ); + + it('STAGE=dev with AES_KEY_ID + AES_KEY → AES', () => { + expect( + resolveEncryptionConfig({ ...NETLIFY, ...AES, STAGE: 'dev' }) + ).toMatchObject({ enabled: true, method: 'aes', runtime: 'deployed' }); + }); + + it('`netlify dev` with STAGE=dev is a local run and skips encryption', () => { + expect( + resolveEncryptionConfig({ + ...NETLIFY, + NETLIFY_DEV: 'true', + STAGE: 'dev', + }) + ).toMatchObject({ runtime: 'local', bypassed: true, error: null }); + }); + + it('getEncryptionConfig throws for a deployed dev stage with no key', () => { + expect(() => + getEncryptionConfig({ ...NETLIFY, STAGE: 'dev' }) + ).toThrow(EncryptionConfigurationError); + }); + }); + + describe.each([ + ['frigg start (serverless-offline)', OFFLINE], + ['serverless invoke local', INVOKE_LOCAL], + ['Jest', JEST], + ['a local script', SCRIPT], + ])('local: %s', (_label, runtimeEnv) => { + it.each(['dev', 'test', 'local', 'DEV'])( + 'STAGE=%p bypasses even with a key', + (STAGE) => { + const config = resolveEncryptionConfig({ + ...runtimeEnv, + ...KMS, + STAGE, + }); + expect(config).toMatchObject({ + enabled: false, + bypassed: true, + runtime: 'local', + error: null, + }); + } + ); + + it('a non-local stage with KMS_KEY_ARN encrypts', () => { + const config = resolveEncryptionConfig({ + ...runtimeEnv, + ...KMS, + STAGE: 'production', + }); + expect(config).toMatchObject({ enabled: true, method: 'kms' }); + }); + + it('a non-local stage with AES_KEY_ID encrypts', () => { + const config = resolveEncryptionConfig({ + ...runtimeEnv, + ...AES, + STAGE: 'production', + }); + expect(config).toMatchObject({ enabled: true, method: 'aes' }); + }); + + it('a non-local stage with AES_KEY_ID but no AES_KEY stays plaintext', () => { + const config = resolveEncryptionConfig({ + ...runtimeEnv, + AES_KEY_ID: AES.AES_KEY_ID, + STAGE: 'production', + }); + expect(config).toMatchObject({ + enabled: false, + method: undefined, + hasAES: true, + error: null, + }); + }); + + it('a non-local stage with no key stays plaintext, without error', () => { + const config = resolveEncryptionConfig({ + ...runtimeEnv, + STAGE: 'production', + }); + expect(config).toMatchObject({ + enabled: false, + bypassed: false, + mode: 'none', + error: null, + }); + }); + }); + + it('falls back to NODE_ENV, then development, for the stage', () => { + expect( + resolveEncryptionConfig({ NODE_ENV: 'test', ...KMS }).bypassed + ).toBe(true); + expect(resolveEncryptionConfig({ ...KMS }).stage).toBe('development'); + expect(resolveEncryptionConfig({ ...KMS }).enabled).toBe(true); + }); +}); + +describe('getEncryptionConfig', () => { + let warn; + + beforeEach(() => { + resetEncryptionConfigWarnings(); + warn = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('returns the resolved config when encryption is on', () => { + expect(getEncryptionConfig({ ...LAMBDA, ...KMS })).toMatchObject({ + enabled: true, + method: 'kms', + }); + }); + + it('throws EncryptionConfigurationError when deployed with no key', () => { + expect(() => getEncryptionConfig({ ...LAMBDA, STAGE: 'dev' })).toThrow( + EncryptionConfigurationError + ); + expect(() => getEncryptionConfig({ ...LAMBDA, STAGE: 'dev' })).toThrow( + /No field-level encryption key/ + ); + }); + + it('does not throw locally with no key', () => { + expect(getEncryptionConfig({ ...OFFLINE, STAGE: 'dev' })).toMatchObject( + { + enabled: false, + } + ); + }); + + it('warns locally that AES_KEY is missing when only AES_KEY_ID is set', () => { + const config = getEncryptionConfig({ + ...OFFLINE, + AES_KEY_ID: AES.AES_KEY_ID, + STAGE: 'production', + }); + expect(config.enabled).toBe(false); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/AES_KEY is not/); + }); + + it('warns once per process on opt-out', () => { + const env = { ...LAMBDA, ...OPT_OUT }; + expect(getEncryptionConfig(env).enabled).toBe(false); + getEncryptionConfig(env); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/FRIGG_ENCRYPTION_DISABLED/); + expect(warn.mock.calls[0][0]).toMatch(/PLAINTEXT/); + }); + + it('warns when an opt-out is ignored because a key is set', () => { + getEncryptionConfig({ ...LAMBDA, ...KMS, ...OPT_OUT }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/ignored/); + }); + + it('reads process.env by default', () => { + const saved = { ...process.env }; + try { + process.env.AWS_LAMBDA_FUNCTION_NAME = 'fn'; + delete process.env.JEST_WORKER_ID; + delete process.env.IS_OFFLINE; + delete process.env.KMS_KEY_ARN; + delete process.env.AES_KEY_ID; + delete process.env.FRIGG_ENCRYPTION_DISABLED; + expect(() => getEncryptionConfig()).toThrow( + EncryptionConfigurationError + ); + } finally { + process.env = saved; + } + }); +}); diff --git a/packages/core/database/encryption/encryption-schema-registry.js b/packages/core/database/encryption/encryption-schema-registry.js index e4c94173c..696815856 100644 --- a/packages/core/database/encryption/encryption-schema-registry.js +++ b/packages/core/database/encryption/encryption-schema-registry.js @@ -9,7 +9,13 @@ */ const { logger } = require('./logger'); +const { isDeployedRuntime } = require('./encryption-config'); const { registerDeniedKeys } = require('../../logs/denied-keys'); +const { + getEntityTypeRegistry, + getEntityTypeDefinitions, + EntityRegistryError, +} = require('../../modules/entity-type-registry'); /** * Core encryption schema (immutable - cannot be overridden by custom schemas) @@ -166,8 +172,9 @@ function extractCredentialFieldsFromModules(moduleDefinitions) { * Each module can declare credentialFields to encrypt in its encryption config. * * @param {Array} integrations - Array of integration classes with modules + * @param {object} [entities] - `appDefinition.entities` (ADR-059 registry) */ -function loadModuleEncryptionSchemas(integrations) { +function loadModuleEncryptionSchemas(integrations, entities) { if (!integrations) { throw new Error('integrations parameter is required'); } @@ -176,34 +183,39 @@ function loadModuleEncryptionSchemas(integrations) { throw new Error('integrations must be an array'); } - if (integrations.length === 0) { + // `entities` is the app definition's registry (ADR-059): a module an app + // registers without any integration still carries credentials to encrypt. + if (integrations.length === 0 && !entities) { return; } - // NOTE: this path does not exist ('../integrations' resolves to - // database/integrations), so today this function throws MODULE_NOT_FOUND, - // loadCustomEncryptionSchema swallows it, and module credentialFields are - // effectively NOT registered. Fixing it changes which fields are encrypted - // and needs a decrypt that tolerates plaintext, so it ships separately: - // branch claude/internal-mcp-03b-module-credential-encryption. Left as is - // here on purpose (ADR-059 must not change what gets encrypted). - const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto'); - - const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations); + const moduleDefinitions = getEntityTypeDefinitions( + getEntityTypeRegistry({ integrations, entities }) + ); const credentialFields = extractCredentialFieldsFromModules(moduleDefinitions); - if (credentialFields.length === 0) { + // Core fields are encrypted already and a custom schema may not repeat + // them. Everything the app registered itself stays: module fields are added + // to the custom schema, never substituted for it (other models included). + const coreFields = new Set(CORE_ENCRYPTION_SCHEMA.Credential?.fields || []); + const fields = [ + ...new Set([ + ...(customSchema.Credential?.fields || []), + ...credentialFields.filter((field) => !coreFields.has(field)), + ]), + ]; + + if (fields.length === 0) { return; } const moduleSchema = { - Credential: { - fields: credentialFields - } + ...customSchema, + Credential: { ...customSchema.Credential, fields }, }; logger.info( - `Registering module-level encryption for ${credentialFields.length} credential fields` + `Registering module-level encryption for ${fields.length} credential fields` ); registerCustomSchema(moduleSchema); @@ -219,6 +231,7 @@ function loadModuleEncryptionSchemas(integrations) { * Used by both Prisma (MongoDB/PostgreSQL) and DocumentDB encryption services. */ function loadCustomEncryptionSchema() { + let appDefinition; try { // Lazy require to avoid circular dependency issues const path = require('node:path'); @@ -233,12 +246,19 @@ function loadCustomEncryptionSchema() { const backendIndexPath = path.join(backendDir, 'index.js'); const backendModule = require(backendIndexPath); - const appDefinition = backendModule?.Definition; + appDefinition = backendModule?.Definition; + } catch (error) { + // No app definition to read (tests, standalone usage): there is + // nothing to register, so nothing is lost by carrying on. + logger.debug('Could not load custom encryption schema:', error.message); + return; + } - if (!appDefinition) { - return; // No app definition found - } + if (!appDefinition) { + return; // No app definition found + } + try { // Load app-level custom schema const customSchema = appDefinition.encryption?.schema; if (customSchema && Object.keys(customSchema).length > 0) { @@ -255,16 +275,23 @@ function loadCustomEncryptionSchema() { // Load module-level encryption schemas from integrations const integrations = appDefinition.integrations; - if (integrations && Array.isArray(integrations)) { - loadModuleEncryptionSchemas(integrations); + if (Array.isArray(integrations) || appDefinition.entities) { + loadModuleEncryptionSchemas( + Array.isArray(integrations) ? integrations : [], + appDefinition.entities + ); } } catch (error) { - // Silently ignore errors - custom schema is optional - // This handles cases like: - // - Backend package.json not found (tests, standalone usage) - // - No appDefinition defined - // - No custom encryption schema specified - logger.debug('Could not load custom encryption schema:', error.message); + // The app declared fields to encrypt and they could not be registered: + // carrying on would store them in plaintext. A bad entity registry is a + // configuration error anywhere; a deployed function refuses to start + // for any registration failure, and a local run says so loudly. + if (error instanceof EntityRegistryError || isDeployedRuntime()) { + throw error; + } + logger.warn( + `Encryption schema from the app definition could not be registered; those fields stay plaintext in this local run: ${error.message}` + ); } } diff --git a/packages/core/database/encryption/field-encryption-service.js b/packages/core/database/encryption/field-encryption-service.js index 86f677e01..c23b60d1c 100644 --- a/packages/core/database/encryption/field-encryption-service.js +++ b/packages/core/database/encryption/field-encryption-service.js @@ -4,8 +4,41 @@ * Infrastructure layer service that orchestrates field-level encryption/decryption. * Handles nested JSON paths (e.g., 'data.access_token') and bulk operations. */ +const { logger } = require('./logger'); +const { isDeployedRuntime } = require('./encryption-config'); + +/** + * Thrown when a value that looks like ciphertext cannot be decrypted in a + * deployed runtime (a wrong, rotated or missing key). Returning the stored + * ciphertext would hand it to API modules as if it were a token. + */ +class FieldDecryptionError extends Error { + constructor(message, options) { + super(message, options); + this.name = 'FieldDecryptionError'; + } +} + +// Cryptor.encrypt() returns `${keyId}:${iv}:${ciphertext}:${encryptedKey}`: +// keyId and encryptedKey are base64, iv is 16 bytes of hex, ciphertext is hex. +// Plaintext with three colons (`https://user:pw@host:443`, `a:b:c:d`) must not +// be mistaken for it. +const BASE64_PART = /^[A-Za-z0-9+/]+={0,2}$/; +const IV_PART = /^[0-9a-f]{32}$/i; +const CIPHERTEXT_PART = /^(?:[0-9a-f]{2})+$/i; + class FieldEncryptionService { - constructor({ cryptor, schema }) { + /** + * @param {Object} params + * @param {Object} params.cryptor + * @param {Object} params.schema + * @param {() => boolean} [params.failClosedOnDecryptError] - whether a + * ciphertext-shaped value that fails to decrypt throws. Defaults to + * "running deployed" (encryption-config's isDeployedRuntime), read on + * each failure. Locally such a value is returned as stored, with a + * warning. + */ + constructor({ cryptor, schema, failClosedOnDecryptError }) { if (!cryptor) { throw new Error('Cryptor instance required'); } @@ -15,6 +48,10 @@ class FieldEncryptionService { this.cryptor = cryptor; this.schema = schema; + this.failClosedOnDecryptError = + typeof failClosedOnDecryptError === 'function' + ? failClosedOnDecryptError + : () => isDeployedRuntime(); } /** @@ -97,12 +134,33 @@ class FieldEncryptionService { const decryptionPromises = fields.map(async (fieldPath) => { const value = this._getNestedValue(decrypted, fieldPath); - if (this._isEncrypted(value)) { + if (!this._isEncrypted(value)) { + return null; + } + try { const decryptedValue = await this.cryptor.decrypt(value); const deserializedValue = this._deserializeAfterDecryption(decryptedValue); return { fieldPath, decryptedValue: deserializedValue }; + } catch (error) { + // Deployed: a ciphertext-shaped value under a wrong, rotated or + // missing key fails the read rather than reaching an API + // module as a token. Legacy plaintext never gets here: it does + // not look like ciphertext (_isEncrypted). + if (this.failClosedOnDecryptError()) { + throw new FieldDecryptionError( + `Could not decrypt ${modelName}.${fieldPath}`, + { cause: error } + ); + } + // Local: a value that looks like ciphertext but is not is read + // as it is stored. One such field must not fail the whole + // read, findMany included. + logger.warn( + `Could not decrypt ${modelName}.${fieldPath}; returning the stored value`, + { errorName: error?.name } + ); + return null; } - return null; }); const results = await Promise.all(decryptionPromises); @@ -152,7 +210,13 @@ class FieldEncryptionService { } const parts = value.split(':'); - return parts.length >= 4; + return ( + parts.length === 4 && + BASE64_PART.test(parts[0]) && + IV_PART.test(parts[1]) && + CIPHERTEXT_PART.test(parts[2]) && + BASE64_PART.test(parts[3]) + ); } _getNestedValue(obj, path) { @@ -251,4 +315,4 @@ class FieldEncryptionService { } } -module.exports = { FieldEncryptionService }; +module.exports = { FieldEncryptionService, FieldDecryptionError }; diff --git a/packages/core/database/encryption/field-encryption-service.test.js b/packages/core/database/encryption/field-encryption-service.test.js index 6d7a7e30a..965b85f64 100644 --- a/packages/core/database/encryption/field-encryption-service.test.js +++ b/packages/core/database/encryption/field-encryption-service.test.js @@ -1,4 +1,8 @@ -const { FieldEncryptionService } = require('./field-encryption-service'); +const { + FieldEncryptionService, + FieldDecryptionError, +} = require('./field-encryption-service'); +const { fakeCiphertext, fakePlaintext } = require('./test-support/fake-ciphertext'); describe('FieldEncryptionService', () => { let mockCryptor; @@ -8,34 +12,8 @@ describe('FieldEncryptionService', () => { beforeEach(() => { // Mock Cryptor mockCryptor = { - encrypt: jest - .fn() - .mockImplementation( - (value) => `encrypted:${value}:keydata:enckey` - ), - decrypt: jest - .fn() - .mockImplementation((value) => { - // Handle multiple encrypted formats - // Format 1: "encrypted:ORIGINAL:keydata:enckey" - // Format 2: "keyId:ORIGINAL:iv:enckey" - - // Try format 1 (from our new tests) - const prefix1 = 'encrypted:'; - const suffix1 = ':keydata:enckey'; - if (value.startsWith(prefix1) && value.endsWith(suffix1)) { - return value.slice(prefix1.length, -suffix1.length); - } - - // Try format 2 (from existing tests) - const prefix2 = 'keyId:'; - const suffix2 = ':iv:enckey'; - if (value.startsWith(prefix2) && value.endsWith(suffix2)) { - return value.slice(prefix2.length, -suffix2.length); - } - - return value; // Fallback for non-standard format - }), + encrypt: jest.fn().mockImplementation((value) => fakeCiphertext(value)), + decrypt: jest.fn().mockImplementation((value) => fakePlaintext(value)), }; // Mock Schema Registry @@ -102,10 +80,10 @@ describe('FieldEncryptionService', () => { expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret123'); expect(mockCryptor.encrypt).toHaveBeenCalledWith('refresh456'); expect(result.data.access_token).toBe( - 'encrypted:secret123:keydata:enckey' + fakeCiphertext('secret123') ); expect(result.data.refresh_token).toBe( - 'encrypted:refresh456:keydata:enckey' + fakeCiphertext('refresh456') ); expect(result.data.other).toBe('public'); // Not encrypted }); @@ -120,7 +98,7 @@ describe('FieldEncryptionService', () => { expect(mockCryptor.encrypt).toHaveBeenCalledWith('password_hash'); expect(result.hashword).toBe( - 'encrypted:password_hash:keydata:enckey' + fakeCiphertext('password_hash') ); expect(result.id).toBe('123'); // Not encrypted }); @@ -179,7 +157,7 @@ describe('FieldEncryptionService', () => { it('should skip already encrypted values', async () => { const document = { data: { - access_token: 'already:encrypted:data:key', + access_token: fakeCiphertext('already'), refresh_token: 'plain', }, }; @@ -231,7 +209,7 @@ describe('FieldEncryptionService', () => { // The encrypted value should be the JSON string encrypted expect(encrypted.mapping).toBe( - `encrypted:${JSON.stringify(mappingObject)}:keydata:enckey` + fakeCiphertext(JSON.stringify(mappingObject)) ); // Now decrypt and verify object is restored @@ -277,8 +255,8 @@ describe('FieldEncryptionService', () => { const document = { id: '123', data: { - access_token: 'keyId:secret123:iv:enckey', - refresh_token: 'keyId:refresh456:iv:enckey', + access_token: fakeCiphertext('secret123'), + refresh_token: fakeCiphertext('refresh456'), other: 'public', }, }; @@ -286,10 +264,10 @@ describe('FieldEncryptionService', () => { const result = await service.decryptFields('Credential', document); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'keyId:secret123:iv:enckey' + fakeCiphertext('secret123') ); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'keyId:refresh456:iv:enckey' + fakeCiphertext('refresh456') ); expect(result.data.access_token).toBe('secret123'); expect(result.data.refresh_token).toBe('refresh456'); @@ -299,13 +277,13 @@ describe('FieldEncryptionService', () => { it('should decrypt top-level fields', async () => { const document = { id: '123', - hashword: 'keyId:password_hash:iv:enckey', + hashword: fakeCiphertext('password_hash'), }; const result = await service.decryptFields('User', document); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'keyId:password_hash:iv:enckey' + fakeCiphertext('password_hash') ); expect(result.hashword).toBe('password_hash'); }); @@ -314,7 +292,7 @@ describe('FieldEncryptionService', () => { const document = { data: { access_token: 'plaintext', // Not encrypted format - refresh_token: 'keyId:encrypted:iv:enckey', + refresh_token: fakeCiphertext('encrypted'), }, }; @@ -322,13 +300,13 @@ describe('FieldEncryptionService', () => { expect(mockCryptor.decrypt).toHaveBeenCalledTimes(1); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'keyId:encrypted:iv:enckey' + fakeCiphertext('encrypted') ); }); it('should not mutate original document', async () => { const document = { - data: { access_token: 'keyId:secret:iv:enckey' }, + data: { access_token: fakeCiphertext('secret') }, }; const original = JSON.parse(JSON.stringify(document)); @@ -337,21 +315,27 @@ describe('FieldEncryptionService', () => { expect(document).toEqual(original); }); - it('should throw on decryption errors', async () => { + it('returns the stored value, and warns, when a field fails to decrypt', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); mockCryptor.decrypt.mockRejectedValueOnce( new Error('Decryption failed') ); const document = { data: { - access_token: 'keyId:secret:iv:enckey', - refresh_token: 'keyId:valid:iv:enckey', + access_token: fakeCiphertext('secret'), + refresh_token: fakeCiphertext('valid'), }, }; - await expect( - service.decryptFields('Credential', document) - ).rejects.toThrow('Decryption failed'); + const result = await service.decryptFields('Credential', document); + + const failed = [result.data.access_token, result.data.refresh_token].filter( + (value) => value.startsWith('a2V5SWQ=') + ); + expect(failed).toHaveLength(1); + expect(console.warn).toHaveBeenCalled(); + console.warn.mockRestore(); }); }); @@ -369,10 +353,10 @@ describe('FieldEncryptionService', () => { expect(result).toHaveLength(2); expect(result[0].data.access_token).toBe( - 'encrypted:secret1:keydata:enckey' + fakeCiphertext('secret1') ); expect(result[1].data.access_token).toBe( - 'encrypted:secret2:keydata:enckey' + fakeCiphertext('secret2') ); }); @@ -394,8 +378,8 @@ describe('FieldEncryptionService', () => { describe('decryptFieldsInBulk', () => { it('should decrypt multiple documents', async () => { const documents = [ - { data: { access_token: 'keyId:secret1:iv:enckey' } }, - { data: { access_token: 'keyId:secret2:iv:enckey' } }, + { data: { access_token: fakeCiphertext('secret1') } }, + { data: { access_token: fakeCiphertext('secret2') } }, ]; const result = await service.decryptFieldsInBulk( @@ -410,14 +394,16 @@ describe('FieldEncryptionService', () => { }); describe('_isEncrypted', () => { - it('should detect encrypted format', () => { - expect(service._isEncrypted('keyId:data:iv:enckey')).toBe(true); + it('detects the Cryptor output shape (keyId:iv:ciphertext:encryptedKey)', () => { + expect(service._isEncrypted(fakeCiphertext('data'))).toBe(true); expect( - service._isEncrypted('keyId:longer:data:with:colons:enckey') + service._isEncrypted( + 'a2V5SWQ=:00112233445566778899aabbccddeeff:0a1b2c:ZW5jS2V5' + ) ).toBe(true); }); - it('should reject non-encrypted formats', () => { + it('rejects non-encrypted formats', () => { expect(service._isEncrypted('plaintext')).toBe(false); expect(service._isEncrypted('one:two:three')).toBe(false); expect(service._isEncrypted('one:two')).toBe(false); @@ -425,6 +411,210 @@ describe('FieldEncryptionService', () => { expect(service._isEncrypted(undefined)).toBe(false); expect(service._isEncrypted(123)).toBe(false); }); + + it.each([ + 'https://user:pw@host:443', + 'a:b:c:d', + 'host:8080:path:x', + 'keyId:longer:data:with:colons:enckey', + // right shape, wrong parts + 'a2V5SWQ=:tooshort:0a1b2c:ZW5jS2V5', + 'a2V5SWQ=:00112233:0a1b2c:ZW5jS2V5', // iv too short (hex, but not 16 bytes) + 'a2V5SWQ=:00112233445566778899aabbccddeeff00:0a1b2c:ZW5jS2V5', // iv too long + 'not base64!:00112233445566778899aabbccddeeff:0a1b2c:ZW5jS2V5', // keyId + 'a2V5SWQ=:00112233445566778899aabbccddeeff:0a1:ZW5jS2V5', + 'a2V5SWQ=:00112233445566778899aabbccddeeff:0a1b2c:not base64!', + // five parts: one colon too many + 'a2V5SWQ=:00112233445566778899aabbccddeeff:0a1b2c:ZW5jS2V5:extra', + '', + ])('rejects plaintext that merely has colons: %p', (value) => { + expect(service._isEncrypted(value)).toBe(false); + }); + }); + + describe('plaintext that looks a little like ciphertext (3+ colons)', () => { + const colonPlaintext = { + url: 'https://user:pw@host:443', + letters: 'a:b:c:d', + // matches the ciphertext SHAPE; only decrypt can tell it is not + shaped: 'a2V5SWQ=:00112233445566778899aabbccddeeff:0a1b2c:ZW5jS2V5', + }; + + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => console.warn.mockRestore()); + + it('decryptFields returns plain values with colons untouched, without calling the cryptor', async () => { + const document = { + data: { + access_token: colonPlaintext.url, + refresh_token: colonPlaintext.letters, + }, + }; + + const result = await service.decryptFields('Credential', document); + + expect(result.data).toEqual(document.data); + expect(mockCryptor.decrypt).not.toHaveBeenCalled(); + }); + + it('decryptFieldsInBulk reads every row, plaintext ones included', async () => { + const documents = [ + { data: { access_token: colonPlaintext.url } }, + { data: { access_token: fakeCiphertext('real-secret') } }, + { data: { refresh_token: colonPlaintext.letters } }, + ]; + + const result = await service.decryptFieldsInBulk('Credential', documents); + + expect(result[0].data.access_token).toBe(colonPlaintext.url); + expect(result[1].data.access_token).toBe('real-secret'); + expect(result[2].data.refresh_token).toBe(colonPlaintext.letters); + }); + + it('a value shaped like ciphertext that fails to decrypt is returned as stored, with a warning', async () => { + mockCryptor.decrypt.mockRejectedValueOnce(new Error('bad key')); + const document = { + data: { + access_token: colonPlaintext.shaped, + refresh_token: fakeCiphertext('still-decrypted'), + }, + }; + + const result = await service.decryptFields('Credential', document); + + expect(result.data.access_token).toBe(colonPlaintext.shaped); + expect(result.data.refresh_token).toBe('still-decrypted'); + expect(console.warn).toHaveBeenCalledWith( + '[Frigg]', + expect.stringContaining('Could not decrypt Credential.data.access_token'), + expect.anything() + ); + }); + + it('one undecryptable row does not fail decryptFieldsInBulk', async () => { + mockCryptor.decrypt + .mockRejectedValueOnce(new Error('bad key')) + .mockImplementation((value) => fakePlaintext(value)); + const documents = [ + { data: { access_token: colonPlaintext.shaped } }, + { data: { access_token: fakeCiphertext('ok') } }, + ]; + + const result = await service.decryptFieldsInBulk('Credential', documents); + + expect(result[0].data.access_token).toBe(colonPlaintext.shaped); + expect(result[1].data.access_token).toBe('ok'); + }); + + it('encrypts colon plaintext on write instead of skipping it as "already encrypted"', async () => { + const result = await service.encryptFields('Credential', { + data: { access_token: colonPlaintext.url }, + }); + + expect(mockCryptor.encrypt).toHaveBeenCalledWith(colonPlaintext.url); + expect(result.data.access_token).toBe(fakeCiphertext(colonPlaintext.url)); + }); + }); + + describe('deployed: a ciphertext-shaped value that fails to decrypt fails the read', () => { + const ciphertext = fakeCiphertext('secret'); + let deployed; + + beforeEach(() => { + deployed = new FieldEncryptionService({ + cryptor: mockCryptor, + schema: mockSchema, + failClosedOnDecryptError: () => true, + }); + }); + + it('decryptFields throws FieldDecryptionError, with the cause, instead of returning ciphertext', async () => { + const cause = new Error('bad key'); + mockCryptor.decrypt.mockRejectedValueOnce(cause); + + const read = deployed.decryptFields('Credential', { + data: { access_token: ciphertext }, + }); + + await expect(read).rejects.toBeInstanceOf(FieldDecryptionError); + await expect(read).rejects.toMatchObject({ + message: 'Could not decrypt Credential.data.access_token', + cause, + }); + }); + + it('decryptFieldsInBulk fails too', async () => { + mockCryptor.decrypt.mockRejectedValueOnce(new Error('bad key')); + + await expect( + deployed.decryptFieldsInBulk('Credential', [ + { data: { access_token: ciphertext } }, + ]) + ).rejects.toBeInstanceOf(FieldDecryptionError); + }); + + it('legacy plaintext is still read as stored: it never reaches the cryptor', async () => { + const result = await deployed.decryptFields('Credential', { + data: { access_token: 'plain-legacy-token' }, + }); + + expect(result.data.access_token).toBe('plain-legacy-token'); + expect(mockCryptor.decrypt).not.toHaveBeenCalled(); + }); + + it('a value that decrypts is returned decrypted', async () => { + const result = await deployed.decryptFields('Credential', { + data: { access_token: ciphertext }, + }); + + expect(result.data.access_token).toBe('secret'); + }); + }); + + describe('fail-closed default follows the runtime', () => { + const saved = {}; + const keys = ['JEST_WORKER_ID', 'FRIGG_PROVIDER', 'NETLIFY_DEV']; + + beforeEach(() => { + for (const key of keys) saved[key] = process.env[key]; + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + for (const key of keys) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + console.warn.mockRestore(); + }); + + const readFailing = () => { + mockCryptor.decrypt.mockRejectedValueOnce(new Error('bad key')); + return new FieldEncryptionService({ + cryptor: mockCryptor, + schema: mockSchema, + }).decryptFields('Credential', { + data: { access_token: fakeCiphertext('secret') }, + }); + }; + + it('throws in a deployed Netlify function', async () => { + delete process.env.JEST_WORKER_ID; + delete process.env.NETLIFY_DEV; + process.env.FRIGG_PROVIDER = 'netlify'; + + await expect(readFailing()).rejects.toBeInstanceOf(FieldDecryptionError); + }); + + it('returns the stored value under `netlify dev`', async () => { + delete process.env.JEST_WORKER_ID; + process.env.FRIGG_PROVIDER = 'netlify'; + process.env.NETLIFY_DEV = 'true'; + + const result = await readFailing(); + expect(result.data.access_token).toBe(fakeCiphertext('secret')); + }); }); describe('_getNestedValue', () => { @@ -575,7 +765,7 @@ describe('FieldEncryptionService', () => { }); it('should still decrypt opted-out fields on read (legacy data)', async () => { - const encryptedBlob = 'encrypted:{"crmId":"abc"}:keydata:enckey'; + const encryptedBlob = fakeCiphertext('{"crmId":"abc"}'); const document = { id: '123', mapping: encryptedBlob, diff --git a/packages/core/database/encryption/integration-mapping-encryption.js b/packages/core/database/encryption/integration-mapping-encryption.js index e936afe93..9ad9e15fc 100644 --- a/packages/core/database/encryption/integration-mapping-encryption.js +++ b/packages/core/database/encryption/integration-mapping-encryption.js @@ -1,4 +1,4 @@ -const { getEncryptionConfig } = require('../prisma'); +const { getEncryptionConfig } = require('./encryption-config'); const { Cryptor } = require('../../encrypt/Cryptor'); const { getFieldsToDecryptOnRead, diff --git a/packages/core/database/encryption/integration-mapping-encryption.test.js b/packages/core/database/encryption/integration-mapping-encryption.test.js index 423a903fb..0bd622624 100644 --- a/packages/core/database/encryption/integration-mapping-encryption.test.js +++ b/packages/core/database/encryption/integration-mapping-encryption.test.js @@ -18,7 +18,7 @@ const { } = require('./integration-mapping-encryption'); describe('getMappingFieldsEncryptedOnWrite', () => { - const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'KMS_KEY_ARN']; + const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'AES_KEY', 'KMS_KEY_ARN']; let savedEnv; beforeEach(() => { @@ -45,6 +45,7 @@ describe('getMappingFieldsEncryptedOnWrite', () => { const enableEncryption = () => { process.env.STAGE = 'production'; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; delete process.env.KMS_KEY_ARN; }; @@ -100,6 +101,7 @@ describe('getMappingFieldsEncryptedOnWrite', () => { (stage) => { process.env.STAGE = stage; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; expect(getMappingFieldsEncryptedOnWrite()).toEqual([]); } @@ -140,7 +142,7 @@ describe('getMappingFieldsEncryptedOnWrite', () => { }); describe('decryptQueriedMappings', () => { - const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'KMS_KEY_ARN']; + const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'AES_KEY', 'KMS_KEY_ARN']; const rows = [{ id: '1', mapping: { externalId: '1' } }]; let savedEnv; @@ -173,6 +175,7 @@ describe('decryptQueriedMappings', () => { it('hands back the same rows when the schema lists nothing besides mapping', async () => { process.env.STAGE = 'production'; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; delete process.env.KMS_KEY_ARN; registerEncryptionOptOut({ IntegrationMapping: ['mapping'] }); diff --git a/packages/core/database/encryption/prisma-encryption-extension.test.js b/packages/core/database/encryption/prisma-encryption-extension.test.js index f67e0a85d..5d3e23e61 100644 --- a/packages/core/database/encryption/prisma-encryption-extension.test.js +++ b/packages/core/database/encryption/prisma-encryption-extension.test.js @@ -1,4 +1,5 @@ const { createEncryptionExtension } = require('./prisma-encryption-extension'); +const { fakeCiphertext, fakePlaintext } = require('./test-support/fake-ciphertext'); describe('Prisma Encryption Extension', () => { let mockCryptor; @@ -9,15 +10,10 @@ describe('Prisma Encryption Extension', () => { mockCryptor = { encrypt: jest .fn() - .mockImplementation( - (value) => `encrypted:${value}:iv:enckey` - ), + .mockImplementation((value) => fakeCiphertext(value)), decrypt: jest .fn() - .mockImplementation((value) => { - const parts = value.split(':'); - return parts[1]; // Extract original value - }), + .mockImplementation((value) => fakePlaintext(value)), }; // Mock Prisma query function @@ -83,7 +79,7 @@ describe('Prisma Encryption Extension', () => { }, mockResult: { id: '1', - data: { access_token: 'encrypted:secret123:iv:enckey' }, + data: { access_token: fakeCiphertext('secret123') }, }, }; @@ -96,7 +92,7 @@ describe('Prisma Encryption Extension', () => { expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret123'); expect(args.data.data.access_token).toBe( - 'encrypted:secret123:iv:enckey' + fakeCiphertext('secret123') ); }); @@ -105,7 +101,7 @@ describe('Prisma Encryption Extension', () => { data: { data: { access_token: 'secret123' } }, mockResult: { id: '1', - data: { access_token: 'encrypted:secret123:iv:enckey' }, + data: { access_token: fakeCiphertext('secret123') }, }, }; @@ -117,7 +113,7 @@ describe('Prisma Encryption Extension', () => { }); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'encrypted:secret123:iv:enckey' + fakeCiphertext('secret123') ); expect(result.data.access_token).toBe('secret123'); }); @@ -159,10 +155,10 @@ describe('Prisma Encryption Extension', () => { expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret1'); expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret2'); expect(args.data[0].data.access_token).toBe( - 'encrypted:secret1:iv:enckey' + fakeCiphertext('secret1') ); expect(args.data[1].data.access_token).toBe( - 'encrypted:secret2:iv:enckey' + fakeCiphertext('secret2') ); }); @@ -189,7 +185,7 @@ describe('Prisma Encryption Extension', () => { data: { data: { access_token: 'newsecret' } }, mockResult: { id: '1', - data: { access_token: 'encrypted:newsecret:iv:enckey' }, + data: { access_token: fakeCiphertext('newsecret') }, }, }; @@ -208,7 +204,7 @@ describe('Prisma Encryption Extension', () => { data: { data: { access_token: 'newsecret' } }, mockResult: { id: '1', - data: { access_token: 'encrypted:newsecret:iv:enckey' }, + data: { access_token: fakeCiphertext('newsecret') }, }, }; @@ -230,7 +226,7 @@ describe('Prisma Encryption Extension', () => { update: { data: { access_token: 'updatesecret' } }, mockResult: { id: '1', - data: { access_token: 'encrypted:createsecret:iv:enckey' }, + data: { access_token: fakeCiphertext('createsecret') }, }, }; @@ -252,7 +248,7 @@ describe('Prisma Encryption Extension', () => { where: { id: '1' }, mockResult: { id: '1', - data: { access_token: 'encrypted:secret:iv:enckey' }, + data: { access_token: fakeCiphertext('secret') }, }, }; @@ -264,7 +260,7 @@ describe('Prisma Encryption Extension', () => { }); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'encrypted:secret:iv:enckey' + fakeCiphertext('secret') ); expect(result.data.access_token).toBe('secret'); }); @@ -293,11 +289,11 @@ describe('Prisma Encryption Extension', () => { mockResult: [ { id: '1', - data: { access_token: 'encrypted:secret1:iv:enckey' }, + data: { access_token: fakeCiphertext('secret1') }, }, { id: '2', - data: { access_token: 'encrypted:secret2:iv:enckey' }, + data: { access_token: fakeCiphertext('secret2') }, }, ], }; @@ -336,7 +332,7 @@ describe('Prisma Encryption Extension', () => { where: { id: '1' }, mockResult: { id: '1', - data: { access_token: 'encrypted:secret:iv:enckey' }, + data: { access_token: fakeCiphertext('secret') }, }, }; @@ -411,8 +407,8 @@ describe('Prisma Encryption Extension', () => { mockResult: { id: '123', data: { - access_token: 'encrypted:secret:iv:enckey', - refresh_token: 'encrypted:refresh:iv:enckey', + access_token: fakeCiphertext('secret'), + refresh_token: fakeCiphertext('refresh'), other: 'public', }, }, diff --git a/packages/core/database/encryption/test-support/fake-ciphertext.js b/packages/core/database/encryption/test-support/fake-ciphertext.js new file mode 100644 index 000000000..485525869 --- /dev/null +++ b/packages/core/database/encryption/test-support/fake-ciphertext.js @@ -0,0 +1,15 @@ +/** + * Test double for Cryptor output. Same shape as the real thing + * (`keyId:iv:ciphertext:encryptedKey`: base64, 16-byte hex, hex, base64) so + * FieldEncryptionService._isEncrypted accepts it, with the plaintext + * recoverable for the fake decrypt. + */ +const fakeCiphertext = (plain) => + `a2V5SWQ=:${'0'.repeat(32)}:${Buffer.from(String(plain)).toString( + 'hex' + )}:ZW5jS2V5`; + +const fakePlaintext = (ciphertext) => + Buffer.from(String(ciphertext).split(':')[2], 'hex').toString(); + +module.exports = { fakeCiphertext, fakePlaintext }; diff --git a/packages/core/database/prisma.encryption.test.js b/packages/core/database/prisma.encryption.test.js new file mode 100644 index 000000000..231973c42 --- /dev/null +++ b/packages/core/database/prisma.encryption.test.js @@ -0,0 +1,125 @@ +/** + * Prisma client creation against the field-level encryption rule: a deployed + * runtime with no key refuses to start rather than writing plaintext. + */ + +jest.mock('./config', () => ({ DB_TYPE: 'postgresql' })); + +const mockExtends = jest.fn(); +jest.mock( + '../generated/prisma-postgresql', + () => ({ + PrismaClient: class { + $extends(extension) { + mockExtends(extension); + return this; + } + }, + }), + { virtual: true } +); + +jest.mock('./encryption/encryption-schema-registry', () => ({ + ...jest.requireActual('./encryption/encryption-schema-registry'), + loadCustomEncryptionSchema: jest.fn(), +})); + +const { + loadCustomEncryptionSchema, +} = require('./encryption/encryption-schema-registry'); +const { logger } = require('./encryption/logger'); +const { + EncryptionConfigurationError, +} = require('./encryption/encryption-config'); +const { prisma } = require('./prisma'); + +const ENV_KEYS = [ + 'AWS_LAMBDA_FUNCTION_NAME', + 'LAMBDA_TASK_ROOT', + 'AWS_EXECUTION_ENV', + 'IS_OFFLINE', + 'IS_LOCAL', + 'JEST_WORKER_ID', + 'STAGE', + 'NODE_ENV', + 'KMS_KEY_ARN', + 'AES_KEY_ID', + 'AES_KEY', + 'FRIGG_ENCRYPTION_DISABLED', +]; + +function createClient() { + // Any property read creates the singleton. + return prisma.$extends; +} + +describe('Prisma client creation and field-level encryption', () => { + let savedEnv; + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + for (const key of ENV_KEYS) delete process.env[key]; + delete global._prismaInstance; + mockExtends.mockReset(); + loadCustomEncryptionSchema.mockReset(); + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + jest.spyOn(logger, 'info').mockImplementation(() => {}); + jest.spyOn(logger, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + delete global._prismaInstance; + jest.restoreAllMocks(); + }); + + function deployed(extra = {}) { + Object.assign(process.env, { + AWS_LAMBDA_FUNCTION_NAME: 'my-app-dev-auth', + LAMBDA_TASK_ROOT: '/var/task', + STAGE: 'dev', + ...extra, + }); + } + + it('throws at startup when deployed with no key, even on STAGE=dev', () => { + deployed(); + expect(createClient).toThrow(EncryptionConfigurationError); + expect(createClient).toThrow(/No field-level encryption key/); + expect(global._prismaInstance).toBeUndefined(); + expect(mockExtends).not.toHaveBeenCalled(); + }); + + it('installs the encryption extension when deployed with a KMS key on STAGE=dev', () => { + deployed({ KMS_KEY_ARN: 'arn:aws:kms:us-east-1:123456789012:key/abc' }); + createClient(); + expect(mockExtends).toHaveBeenCalledTimes(1); + }); + + it('starts without encryption when deployed with the explicit opt-out', () => { + deployed({ FRIGG_ENCRYPTION_DISABLED: 'true' }); + createClient(); + expect(mockExtends).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/FRIGG_ENCRYPTION_DISABLED/) + ); + }); + + it('starts without encryption under frigg start (serverless-offline)', () => { + deployed({ IS_OFFLINE: 'true' }); + createClient(); + expect(mockExtends).not.toHaveBeenCalled(); + }); + + it('refuses to start when the encryption extension fails to initialise', () => { + deployed({ KMS_KEY_ARN: 'arn:aws:kms:us-east-1:123456789012:key/abc' }); + loadCustomEncryptionSchema.mockImplementation(() => { + throw new Error('bad custom schema'); + }); + expect(createClient).toThrow(/bad custom schema/); + expect(global._prismaInstance).toBeUndefined(); + }); +}); diff --git a/packages/core/database/prisma.js b/packages/core/database/prisma.js index 5042d1608..c035cb0e1 100644 --- a/packages/core/database/prisma.js +++ b/packages/core/database/prisma.js @@ -4,6 +4,7 @@ const { const { loadCustomEncryptionSchema } = require('./encryption/encryption-schema-registry'); const { logger } = require('./encryption/logger'); const { Cryptor } = require('../encrypt/Cryptor'); +const { getEncryptionConfig } = require('./encryption/encryption-config'); const config = require('./config'); /** @@ -32,33 +33,6 @@ function ensureMongoDbUrl() { ); } -function getEncryptionConfig() { - const STAGE = process.env.STAGE || process.env.NODE_ENV || 'development'; - const shouldBypassEncryption = ['dev', 'test', 'local'].includes(STAGE); - - if (shouldBypassEncryption) { - return { enabled: false }; - } - - const hasKMS = - process.env.KMS_KEY_ARN && process.env.KMS_KEY_ARN.trim() !== ''; - const hasAES = - process.env.AES_KEY_ID && process.env.AES_KEY_ID.trim() !== ''; - - if (!hasKMS && !hasAES) { - logger.warn( - 'No encryption keys configured (KMS_KEY_ARN or AES_KEY_ID). ' + - 'Field-level encryption disabled. Set STAGE=production and configure keys to enable.' - ); - return { enabled: false }; - } - - return { - enabled: true, - method: hasKMS ? 'kms' : 'aes', - }; -} - const prismaClientSingleton = () => { let PrismaClient; @@ -103,9 +77,13 @@ const prismaClientSingleton = () => { errorFormat: 'pretty', }); + // Throws when a deployed runtime has no encryption key: the app must not + // start and write sensitive fields in plaintext. const encryptionConfig = getEncryptionConfig(); if (encryptionConfig.enabled) { + // Fail closed: if the extension cannot be installed, do not hand out + // a client that would write plaintext. try { // Load custom encryption schema from appDefinition before creating extension loadCustomEncryptionSchema(); @@ -120,17 +98,14 @@ const prismaClientSingleton = () => { enabled: true, }) ); - - logger.info( - `Field-level encryption enabled using ${encryptionConfig.method.toUpperCase()}` - ); } catch (error) { - logger.error( - 'Failed to initialize encryption extension:', - error - ); - logger.warn('Continuing without encryption...'); + logger.error('Failed to initialize encryption extension:', error); + throw error; } + + logger.info( + `Field-level encryption enabled using ${encryptionConfig.method.toUpperCase()}` + ); } else { logger.info('Field-level encryption disabled'); } diff --git a/packages/core/database/use-cases/check-encryption-health-use-case.js b/packages/core/database/use-cases/check-encryption-health-use-case.js index 23b3d5b35..ca11e9b9e 100644 --- a/packages/core/database/use-cases/check-encryption-health-use-case.js +++ b/packages/core/database/use-cases/check-encryption-health-use-case.js @@ -1,3 +1,12 @@ +const { + resolveEncryptionConfig, + OPT_OUT_VAR, +} = require('../encryption/encryption-config'); + +/** + * Reports whether field-level encryption is on and working, using the same + * rule the Prisma client applies at startup (encryption/encryption-config.js). + */ class CheckEncryptionHealthUseCase { constructor({ testEncryptionUseCase }) { this.testEncryptionUseCase = testEncryptionUseCase; @@ -5,78 +14,60 @@ class CheckEncryptionHealthUseCase { async execute() { const config = this._getEncryptionConfiguration(); + const summary = { + mode: config.mode, + bypassed: config.bypassed, + optedOut: config.optedOut, + runtime: config.runtime, + stage: config.stage, + debug: { + hasKMS: config.hasKMS, + hasAES: config.hasAES, + }, + }; - if (config.isBypassed || config.mode === 'none') { - const testResult = config.isBypassed - ? 'Encryption bypassed for this stage' - : 'No encryption keys configured'; + if (config.error) { + return { + status: 'unhealthy', + ...summary, + testResult: config.error, + encryptionWorks: false, + }; + } + if (!config.enabled) { return { status: 'disabled', - mode: config.mode, - bypassed: config.isBypassed, - stage: config.stage, - testResult, + ...summary, + testResult: this._disabledReason(config), encryptionWorks: false, - debug: { - hasKMS: config.hasKMS, - hasAES: config.hasAES, - }, }; } try { const testResults = await this.testEncryptionUseCase.execute(); - - return { - ...testResults, - mode: config.mode, - bypassed: config.isBypassed, - stage: config.stage, - debug: { - hasKMS: config.hasKMS, - hasAES: config.hasAES, - }, - }; + return { ...testResults, ...summary }; } catch (error) { return { status: 'unhealthy', - mode: config.mode, - bypassed: config.isBypassed, - stage: config.stage, + ...summary, testResult: `Encryption test failed: ${error.message}`, encryptionWorks: false, - debug: { - hasKMS: config.hasKMS, - hasAES: config.hasAES, - }, }; } } - _getEncryptionConfiguration() { - const { STAGE, BYPASS_ENCRYPTION_STAGE, KMS_KEY_ARN, AES_KEY_ID } = - process.env; - - const defaultBypassStages = ['dev', 'test', 'local']; - const useEnv = BYPASS_ENCRYPTION_STAGE !== undefined; - const bypassStages = useEnv - ? BYPASS_ENCRYPTION_STAGE.split(',').map((s) => s.trim()) - : defaultBypassStages; - - const isBypassed = bypassStages.includes(STAGE); - const hasAES = AES_KEY_ID && AES_KEY_ID.trim() !== ''; - const hasKMS = KMS_KEY_ARN && KMS_KEY_ARN.trim() !== ''; - // Prefer KMS over AES when both are configured (KMS is more secure) - const mode = hasKMS ? 'kms' : hasAES ? 'aes' : 'none'; + _disabledReason(config) { + if (config.bypassed) return 'Encryption bypassed for this stage'; + if (config.optedOut) { + return `Encryption explicitly disabled (${OPT_OUT_VAR}=true); sensitive fields are stored in plaintext`; + } + return 'No encryption keys configured'; + } - return { - stage: STAGE || null, - isBypassed, - hasAES, - hasKMS, - mode, - }; + _getEncryptionConfiguration() { + const config = resolveEncryptionConfig(process.env); + return { ...config, stage: process.env.STAGE || null }; } } diff --git a/packages/core/database/use-cases/check-encryption-health-use-case.test.js b/packages/core/database/use-cases/check-encryption-health-use-case.test.js index ca6e08d3e..f4bd541ab 100644 --- a/packages/core/database/use-cases/check-encryption-health-use-case.test.js +++ b/packages/core/database/use-cases/check-encryption-health-use-case.test.js @@ -188,5 +188,105 @@ describe('CheckEncryptionHealthUseCase', () => { expect(result.testResult).toBe('No encryption keys configured'); }); }); -}); + describe('execute() - deployed runtime (in Lambda, not offline)', () => { + beforeEach(() => { + for (const key of [ + 'JEST_WORKER_ID', + 'IS_OFFLINE', + 'IS_LOCAL', + 'KMS_KEY_ARN', + 'AES_KEY_ID', + 'AES_KEY', + 'FRIGG_ENCRYPTION_DISABLED', + ]) { + delete process.env[key]; + } + process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-app-dev-health'; + }); + + it('runs the encryption test on STAGE=dev when a key is configured', async () => { + process.env.STAGE = 'dev'; + process.env.KMS_KEY_ARN = 'arn:aws:kms:us-east-1:123:key/abc'; + const testEncryption = { + execute: jest + .fn() + .mockResolvedValue({ status: 'enabled', encryptionWorks: true }), + }; + + const result = await new CheckEncryptionHealthUseCase({ + testEncryptionUseCase: testEncryption, + }).execute(); + + expect(testEncryption.execute).toHaveBeenCalled(); + expect(result.status).toBe('enabled'); + expect(result.bypassed).toBe(false); + expect(result.runtime).toBe('deployed'); + expect(result.mode).toBe('kms'); + }); + + it('reports unhealthy, with the fix, when no key is configured', async () => { + process.env.STAGE = 'dev'; + const testEncryption = { execute: jest.fn() }; + + const result = await new CheckEncryptionHealthUseCase({ + testEncryptionUseCase: testEncryption, + }).execute(); + + expect(testEncryption.execute).not.toHaveBeenCalled(); + expect(result.status).toBe('unhealthy'); + expect(result.mode).toBe('none'); + expect(result.bypassed).toBe(false); + expect(result.encryptionWorks).toBe(false); + expect(result.testResult).toMatch(/No field-level encryption key/); + expect(result.testResult).toMatch(/fieldLevelEncryptionMethod: 'kms'/); + }); + + it('reports disabled and opted out under FRIGG_ENCRYPTION_DISABLED=true', async () => { + process.env.STAGE = 'prod'; + process.env.FRIGG_ENCRYPTION_DISABLED = 'true'; + + const result = await new CheckEncryptionHealthUseCase({ + testEncryptionUseCase: { execute: jest.fn() }, + }).execute(); + + expect(result.status).toBe('disabled'); + expect(result.optedOut).toBe(true); + expect(result.testResult).toMatch(/FRIGG_ENCRYPTION_DISABLED/); + }); + + it('ignores BYPASS_ENCRYPTION_STAGE, which never switched encryption off', async () => { + process.env.STAGE = 'prod'; + process.env.BYPASS_ENCRYPTION_STAGE = 'prod'; + process.env.KMS_KEY_ARN = 'arn:aws:kms:us-east-1:123:key/abc'; + const testEncryption = { + execute: jest.fn().mockResolvedValue({ status: 'enabled' }), + }; + + const result = await new CheckEncryptionHealthUseCase({ + testEncryptionUseCase: testEncryption, + }).execute(); + + expect(result.bypassed).toBe(false); + expect(testEncryption.execute).toHaveBeenCalled(); + }); + }); + + describe('execute() - frigg start (serverless-offline)', () => { + it('reports the local bypass on STAGE=dev', async () => { + delete process.env.KMS_KEY_ARN; + delete process.env.AES_KEY_ID; + process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-app-dev-health'; + process.env.IS_OFFLINE = 'true'; + process.env.STAGE = 'dev'; + + const result = await new CheckEncryptionHealthUseCase({ + testEncryptionUseCase: { execute: jest.fn() }, + }).execute(); + + expect(result.status).toBe('disabled'); + expect(result.bypassed).toBe(true); + expect(result.runtime).toBe('local'); + }); + }); +}); diff --git a/packages/core/integrations/repositories/integration-mapping-repository-query-documentdb.test.js b/packages/core/integrations/repositories/integration-mapping-repository-query-documentdb.test.js index ff140d0ac..c5056ff49 100644 --- a/packages/core/integrations/repositories/integration-mapping-repository-query-documentdb.test.js +++ b/packages/core/integrations/repositories/integration-mapping-repository-query-documentdb.test.js @@ -242,7 +242,7 @@ describe('IntegrationMappingRepositoryDocumentDB.queryMappings', () => { }); describe('encryption guard', () => { - const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'KMS_KEY_ARN']; + const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'AES_KEY', 'KMS_KEY_ARN']; let savedEnv; beforeEach(() => { @@ -251,6 +251,7 @@ describe('IntegrationMappingRepositoryDocumentDB.queryMappings', () => { ); process.env.STAGE = 'production'; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; delete process.env.KMS_KEY_ARN; loadCustomEncryptionSchema.mockReset(); resetEncryptionOptOut(); diff --git a/packages/core/integrations/repositories/integration-mapping-repository-query-mongo.test.js b/packages/core/integrations/repositories/integration-mapping-repository-query-mongo.test.js index 1ad5e7e6c..f1fa2e350 100644 --- a/packages/core/integrations/repositories/integration-mapping-repository-query-mongo.test.js +++ b/packages/core/integrations/repositories/integration-mapping-repository-query-mongo.test.js @@ -501,7 +501,7 @@ describe('IntegrationMappingRepositoryMongo.queryMappings', () => { }); describe('encryption guard', () => { - const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'KMS_KEY_ARN']; + const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'AES_KEY', 'KMS_KEY_ARN']; let savedEnv; beforeEach(() => { @@ -510,6 +510,7 @@ describe('IntegrationMappingRepositoryMongo.queryMappings', () => { ); process.env.STAGE = 'production'; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; delete process.env.KMS_KEY_ARN; loadCustomEncryptionSchema.mockReset(); resetEncryptionOptOut(); diff --git a/packages/core/integrations/repositories/integration-mapping-repository-query.test.js b/packages/core/integrations/repositories/integration-mapping-repository-query.test.js index 9d63e21cf..e0ff69726 100644 --- a/packages/core/integrations/repositories/integration-mapping-repository-query.test.js +++ b/packages/core/integrations/repositories/integration-mapping-repository-query.test.js @@ -490,7 +490,7 @@ describe('IntegrationMappingRepositoryPostgres.queryMappings', () => { }); describe('encryption guard', () => { - const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'KMS_KEY_ARN']; + const ENV_KEYS = ['STAGE', 'NODE_ENV', 'AES_KEY_ID', 'AES_KEY', 'KMS_KEY_ARN']; let savedEnv; beforeEach(() => { @@ -516,6 +516,7 @@ describe('IntegrationMappingRepositoryPostgres.queryMappings', () => { const enableEncryption = () => { process.env.STAGE = 'production'; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; delete process.env.KMS_KEY_ARN; }; @@ -562,6 +563,7 @@ describe('IntegrationMappingRepositoryPostgres.queryMappings', () => { it('runs on STAGE=dev, where encryption is off and the opt-out is never registered', async () => { process.env.STAGE = 'dev'; process.env.AES_KEY_ID = 'test-key'; + process.env.AES_KEY = '0123456789abcdef0123456789abcdef'; const { repo } = makeRepo(); await expect( diff --git a/packages/devtools/infrastructure/README.md b/packages/devtools/infrastructure/README.md index cabce55a5..0bf227bde 100644 --- a/packages/devtools/infrastructure/README.md +++ b/packages/devtools/infrastructure/README.md @@ -163,9 +163,11 @@ const appDefinition = { enableVPCEndpoints: true // Optional: create VPC endpoints }, - // KMS encryption + // Field-level encryption. 'kms' wires KMS_KEY_ARN on every stage (dev too). + // A deployed stage with no key refuses to start; 'none' is the explicit + // plaintext opt-out (sets FRIGG_ENCRYPTION_DISABLED=true). encryption: { - fieldLevelEncryptionMethod: 'kms', + fieldLevelEncryptionMethod: 'kms', // 'kms' | 'aes' | 'none' createResourceIfNoneFound: true }, diff --git a/packages/devtools/infrastructure/domains/parameters/offload-utils.js b/packages/devtools/infrastructure/domains/parameters/offload-utils.js index 4ac55df52..ac8dbc693 100644 --- a/packages/devtools/infrastructure/domains/parameters/offload-utils.js +++ b/packages/devtools/infrastructure/domains/parameters/offload-utils.js @@ -40,6 +40,7 @@ const FRAMEWORK_ENV_BLOCKLIST = new Set([ 'KMS_KEY_ARN', 'AES_KEY', 'AES_KEY_ID', + 'FRIGG_ENCRYPTION_DISABLED', 'DATABASE_URL', 'DATABASE_HOST', 'DATABASE_PORT', diff --git a/packages/devtools/infrastructure/domains/shared/environment-builder.js b/packages/devtools/infrastructure/domains/shared/environment-builder.js index 94b2de0df..d54b12242 100644 --- a/packages/devtools/infrastructure/domains/shared/environment-builder.js +++ b/packages/devtools/infrastructure/domains/shared/environment-builder.js @@ -72,6 +72,16 @@ function addLoggingEnv(appDefinition, envVars) { envVars.FRIGG_LOG_LEVEL = normalized; } +// Field-level encryption fails closed in a deployed stage with no key (core's +// encryption-config.js). `fieldLevelEncryptionMethod: 'none'` is the explicit, +// documented way to run a stage in plaintext; core logs a warning on every +// cold start while it is set. +function addEncryptionEnv(appDefinition, envVars) { + if (appDefinition?.encryption?.fieldLevelEncryptionMethod === 'none') { + envVars.FRIGG_ENCRYPTION_DISABLED = 'true'; + } +} + /** * Get environment variables from AppDefinition * @@ -149,6 +159,7 @@ function getAppEnvironmentVars(appDefinition) { } addLoggingEnv(appDefinition, envVars); + addEncryptionEnv(appDefinition, envVars); if (envKeys.length > 0) { console.log( @@ -187,7 +198,7 @@ function getAppEnvironmentVars(appDefinition) { function buildEnvironment(appEnvironmentVars, discoveredResources) { const environment = { ...appEnvironmentVars, - STAGE: '${self:provider.stage}', // Used by encryption bypass logic + STAGE: '${self:provider.stage}', // Read by core: e.g. the local-only encryption bypass FRIGG_STACK: '${self:service}', FRIGG_STAGE: '${self:provider.stage}', FRIGG_REGION: '${self:provider.region}', diff --git a/packages/devtools/infrastructure/domains/shared/environment-builder.test.js b/packages/devtools/infrastructure/domains/shared/environment-builder.test.js index 1440bd65b..c9e77b209 100644 --- a/packages/devtools/infrastructure/domains/shared/environment-builder.test.js +++ b/packages/devtools/infrastructure/domains/shared/environment-builder.test.js @@ -468,6 +468,26 @@ describe('Environment Builder', () => { }); }); + describe('field-level encryption opt-out', () => { + it("emits FRIGG_ENCRYPTION_DISABLED=true for fieldLevelEncryptionMethod: 'none'", () => { + const result = getAppEnvironmentVars({ + encryption: { fieldLevelEncryptionMethod: 'none' }, + }); + + expect(result.FRIGG_ENCRYPTION_DISABLED).toBe('true'); + }); + + it.each([ + ['kms', { fieldLevelEncryptionMethod: 'kms' }], + ['aes', { fieldLevelEncryptionMethod: 'aes' }], + ['no encryption block', undefined], + ])('does not opt out for %s', (_label, encryption) => { + const result = getAppEnvironmentVars({ encryption }); + + expect(result.FRIGG_ENCRYPTION_DISABLED).toBeUndefined(); + }); + }); + describe('buildEnvironment()', () => { it('should combine app vars with standard Frigg variables', () => { const appEnvironmentVars = { diff --git a/packages/schemas/schemas/app-definition.schema.json b/packages/schemas/schemas/app-definition.schema.json index 897b977f9..9e7dd1d16 100644 --- a/packages/schemas/schemas/app-definition.schema.json +++ b/packages/schemas/schemas/app-definition.schema.json @@ -311,8 +311,8 @@ "properties": { "fieldLevelEncryptionMethod": { "type": "string", - "description": "Method for field-level encryption", - "enum": ["kms", "aes"], + "description": "Method for field-level encryption. 'kms' creates or discovers a KMS key and sets KMS_KEY_ARN; 'aes' expects AES_KEY_ID and AES_KEY in the Lambda environment; 'none' sets FRIGG_ENCRYPTION_DISABLED=true and stores sensitive fields in plaintext. A deployed stage with no key and no 'none' opt-out refuses to start.", + "enum": ["kms", "aes", "none"], "default": "aes" }, "createResourceIfNoneFound": { diff --git a/packages/schemas/tests/schemas.test.js b/packages/schemas/tests/schemas.test.js index 750c85e2a..ca91a414f 100644 --- a/packages/schemas/tests/schemas.test.js +++ b/packages/schemas/tests/schemas.test.js @@ -69,6 +69,25 @@ describe('@friggframework/schemas', () => { expect(result.valid).toBe(true); }); + test.each(['kms', 'aes', 'none'])( + "accepts encryption.fieldLevelEncryptionMethod '%s'", + (method) => { + const result = validateAppDefinition({ + integrations: [], + encryption: { fieldLevelEncryptionMethod: method }, + }); + expect(result.valid).toBe(true); + } + ); + + test('rejects an unknown encryption.fieldLevelEncryptionMethod', () => { + const result = validateAppDefinition({ + integrations: [], + encryption: { fieldLevelEncryptionMethod: 'plaintext' }, + }); + expect(result.valid).toBe(false); + }); + test('should reject invalid app definition', () => { const appDef = { // Missing required integrations field diff --git a/packages/test/mongodb.js b/packages/test/mongodb.js index 320b06955..16d5730fc 100644 --- a/packages/test/mongodb.js +++ b/packages/test/mongodb.js @@ -1,11 +1,21 @@ const { MongoMemoryServer } = require('mongodb-memory-server'); +// mongodb-memory-server 8 defaults to MongoDB 5.0.19, whose Linux build links +// libcrypto.so.1.1. Ubuntu 24.04 (GitHub's ubuntu-latest since Oct 2026) ships +// only OpenSSL 3, so that mongod cannot start. 8.0 publishes Ubuntu 24.04 +// builds. MONGOMS_VERSION still overrides it. +const MONGODB_VERSION = '8.0.4'; + class TestMongo { #mongoServer; // Start the in-memory mongo instance and set env variable for the app to use in its connection. async start() { - this.#mongoServer = await MongoMemoryServer.create(); + this.#mongoServer = await MongoMemoryServer.create({ + binary: { version: process.env.MONGOMS_VERSION || MONGODB_VERSION }, + // The library's default, ephemeralForTest, was removed in MongoDB 7. + instance: { storageEngine: 'wiredTiger' }, + }); process.env.MONGO_URI = this.#mongoServer.getUri(); console.log('Started in memory mongo server', process.env.MONGO_URI); } diff --git a/website/friggframework-api/assistant.mjs b/website/friggframework-api/assistant.mjs index 52d9a337e..fe8a3053a 100644 --- a/website/friggframework-api/assistant.mjs +++ b/website/friggframework-api/assistant.mjs @@ -99,7 +99,9 @@ developers and their agents: - Packaging/deploy: Serverless Framework fork (osls) + esbuild, generating serverless.yml and CloudFormation. - Database: PostgreSQL OR MongoDB, via Prisma (two schemas ship). -- Encryption: field-level, AWS KMS OR AES-256 (auto-bypassed in dev/test/local). +- Encryption: field-level, AWS KMS OR AES-256. Skipped only on local runs + (frigg start, tests) with STAGE dev/test/local; a deployed stage with no key + refuses to start unless FRIGG_ENCRYPTION_DISABLED=true opts out. - Async: AWS SQS queues; EventBridge Scheduler + cron for scheduled jobs. - Config/secrets: SSM Parameter Store + Secrets Manager. - HTTP: Express via serverless-http.