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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/skills/frigg-canary-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ replica set (`rs0`) because Prisma requires one; `mongo-init.sh` initialises it.
`node_modules/@friggframework/core/generated/prisma-<db>`. 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.<PR>.<short-sha>.0`; check
`npm view @friggframework/core dist-tags.canary` and use the hash matching the PR
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/frigg-canary-test/assets/harness/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 11 additions & 8 deletions .claude/skills/frigg/references/security-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 18 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand All @@ -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`
Expand Down
14 changes: 14 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
32 changes: 9 additions & 23 deletions packages/core/database/documentdb-encryption-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const {
getFieldsToEncryptOnWrite,
loadCustomEncryptionSchema,
} = require('./encryption/encryption-schema-registry');
const { getEncryptionConfig } = require('./encryption/encryption-config');

/**
* Encryption service specifically for DocumentDB repositories
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -264,7 +251,6 @@ class DocumentDBEncryptionService {
const errorContext = {
modelName,
fieldPath,
encryptedValuePrefix: encryptedValue.substring(0, 20),
errorMessage: error.message
};

Expand Down
Loading
Loading