Skip to content

fix(core): encrypt module credential fields, and read plaintext safely - #680

Open
seanspeaks wants to merge 11 commits into
claude/internal-mcp-03-entity-registryfrom
claude/internal-mcp-03b-module-credential-encryption
Open

seanspeaks wants to merge 11 commits into
claude/internal-mcp-03-entity-registryfrom
claude/internal-mcp-03b-module-credential-encryption

Conversation

@seanspeaks

@seanspeaks seanspeaks commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Stacked on #679.

The bug. API-module credentialFields have never been encrypted. loadModuleEncryptionSchemas required ../integrations/utils/map-integration-dto from database/encryption/, which is a path that does not exist, and the error was swallowed. The schemas were never registered, so module credential fields were stored in plaintext.

Why it isn't a one-line fix. Fixing the path alone breaks reads of existing rows:

  • _isEncrypted treated any string with 3 or more colons as ciphertext.
  • A stored plaintext value such as https://u:p@host:443 or a:b:c:d, in a field that is now marked encrypted, therefore went to decrypt and was rejected.
  • That rejection fails the whole Credential read, including findMany across every row.

Changes

  • Path fix. Module credentialFields now register with the Prisma encryption extension.
  • Merge, don't replace. Module fields are added to the app's own encryption.schema (Credential and every other model) instead of replacing it. Fields core already encrypts are skipped.
  • Stricter ciphertext check. _isEncrypted requires exactly four parts shaped base64 keyId : 32-hex iv : hex ciphertext : base64 encryptedKey.
  • Plaintext-tolerant decrypt. A value that fails to decrypt is returned as stored, and a warning is logged, instead of failing the read. Rows become ciphertext on their next write.
  • Docs. A CHANGELOG "Unreleased" entry and a note in database/encryption/README.md.

Upgrade impact

  • Encryption. Module credential fields are encrypted from this release on.
  • Existing rows stay plaintext until they are rewritten, for example on the next token refresh. There is no backfill, and reads are safe because of the tolerant decrypt.
  • Who is affected. No module in this repo declares credentialFields, so only external modules that declare them see a change.

Not changed

  • The DocumentDB path (documentdb-encryption-service.js) has its own _isEncryptedValue, which requires 4 base64 parts of at least 50 chars, and it still rethrows on decrypt failure.
  • Its stricter shape check makes the colon false positive unlikely there, but it is not tolerant. That is worth a follow-up if DocumentDB adopters use module credential fields.

Test plan

  • Plaintext containing 3 or more colons is tested through both decryptFields and decryptFieldsInBulk. An app schema on a second model (Integration) is tested alongside entities.
  • Existing tests that used fake ciphertext now use test-support/fake-ciphertext.js. The old "throws on decryption error" test now asserts the tolerant behaviour.
  • Full packages/core run: 57 tests fail, against 65 on feat(core): app-level entity registry (ADR-059) #679. The only suite that changes is encryption-schema-registry, which goes from failing to passing.
  • ESLint: 0 errors.
  • Mutations: 15 of 15 killed. They covered the rethrow, blanking on failure, the missing warning, the old 3-colon check, each part of the shape check, the dropped ...customSchema spread, replace instead of merge, the core-field filter, and a swallowed registry error.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6


Generated by Claude Code

📦 Published PR as canary version: 2.0.0--canary.680.d670696.0

✨ Test out this PR locally via:

npm install @friggframework/admin-scripts@2.0.0--canary.680.d670696.0
npm install @friggframework/core@2.0.0--canary.680.d670696.0
npm install @friggframework/devtools@2.0.0--canary.680.d670696.0
npm install @friggframework/eslint-config@2.0.0--canary.680.d670696.0
npm install @friggframework/prettier-config@2.0.0--canary.680.d670696.0
npm install @friggframework/schemas@2.0.0--canary.680.d670696.0
npm install @friggframework/serverless-plugin@2.0.0--canary.680.d670696.0
npm install @friggframework/test@2.0.0--canary.680.d670696.0
npm install @friggframework/ui@2.0.0--canary.680.d670696.0
# or 
yarn add @friggframework/admin-scripts@2.0.0--canary.680.d670696.0
yarn add @friggframework/core@2.0.0--canary.680.d670696.0
yarn add @friggframework/devtools@2.0.0--canary.680.d670696.0
yarn add @friggframework/eslint-config@2.0.0--canary.680.d670696.0
yarn add @friggframework/prettier-config@2.0.0--canary.680.d670696.0
yarn add @friggframework/schemas@2.0.0--canary.680.d670696.0
yarn add @friggframework/serverless-plugin@2.0.0--canary.680.d670696.0
yarn add @friggframework/test@2.0.0--canary.680.d670696.0
yarn add @friggframework/ui@2.0.0--canary.680.d670696.0

Note

High Risk
Changes credential encryption registration and deployed startup/read behavior for sensitive fields; deployed dev/staging without keys will fail until KMS/AES or an explicit none opt-out is configured.

Overview
Fixes API module credentialFields never being registered (broken require path with swallowed errors) by loading schemas through the entity registry (integrations + appDefinition.entities), merging module fields into the app’s custom encryption.schema instead of replacing it.

Encryption behavior changes: shared encryption-config.js decides by runtime (deployed Lambda/Netlify vs local frigg start/Jest), not stage name alone. Deployed functions without KMS or complete AES keys refuse to start (EncryptionConfigurationError) unless FRIGG_ENCRYPTION_DISABLED=true (wired from fieldLevelEncryptionMethod: 'none' in the app definition). Prisma no longer continues without encryption if the extension fails to initialize.

Read/write safety: ciphertext detection requires the exact four-part envelope shape so colon-heavy plaintext is not decrypted. Deployed reads throw FieldDecryptionError when real ciphertext cannot be decrypted; local runs return the stored value with a warning. Existing credential rows stay plaintext until rewritten.

Docs, health checks, DocumentDB encryption, SSM blocklist, and test harness Mongo 8.0.4 (CI OpenSSL 3) are updated to match.

Reviewed by Cursor Bugbot for commit d670696. Bugbot is set up for automated code reviews on this repo. Configure here.

seanspeaks and others added 5 commits October 2, 2026 16:01
Field-level encryption used to be skipped whenever STAGE was dev, test
or local, and silently turned off (with a warning) when no key was
configured. `frigg deploy` defaults to --stage dev, so a plain deploy
stored credentials in plaintext.

The rule now keys off where the code runs, not the stage name, and
lives in one place (database/encryption/encryption-config.js) shared by
the Prisma client, DocumentDBEncryptionService, the integration-mapping
helpers and the encryption health check:

- Deployed (in Lambda, not serverless-offline / invoke local / Jest):
  KMS_KEY_ARN or AES_KEY_ID+AES_KEY encrypts on every stage, dev
  included. No key throws EncryptionConfigurationError at Prisma client
  creation, naming the fix, unless FRIGG_ENCRYPTION_DISABLED=true opts
  out explicitly (warned once per cold start). A configured key wins
  over the opt-out.
- Local (frigg start, tests, scripts): unchanged. dev/test/local skip
  encryption; other stages encrypt when a key is set.

The Prisma client also stops continuing without encryption when the
extension fails to initialise. /health/detailed reports a deployed stage
without a key as unhealthy and an opt-out as disabled; it no longer
reads BYPASS_ENCRYPTION_STAGE, which never switched encryption off.

BREAKING CHANGE: a deployed stage with no KMS_KEY_ARN and no
AES_KEY_ID/AES_KEY now refuses to start instead of writing plaintext,
including stages named dev, test or local. Set
encryption.fieldLevelEncryptionMethod: 'kms' (or provide AES keys), or
opt out with FRIGG_ENCRYPTION_DISABLED=true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`encryption: { fieldLevelEncryptionMethod: 'none' }` is the declarative
way to run a deployed stage without field-level encryption now that core
refuses to start without a key. The environment builder turns it into
FRIGG_ENCRYPTION_DISABLED=true on every function, and the SSM offload
blocklist keeps that variable in the Lambda environment, since Prisma
reads it at INIT before SSM values load.

The KMS wiring already ignores the stage name, so an app with
fieldLevelEncryptionMethod: 'kms' deployed with --stage dev gets
KMS_KEY_ARN and encrypts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the "dev, test and local stages bypass encryption" guidance in
CLAUDE.md, the core encryption README, the DocumentDB encryption design
note, the frigg and canary skills, the infrastructure README and the
website assistant prompt with the deployed/local rule, the explicit
opt-out, and the startup error and its fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
loadModuleEncryptionSchemas required a path that does not exist, so module
credentialFields were never registered for encryption (the error was
swallowed). Fix the path, consume the entity registry so registry-only
modules count, filter fields core already encrypts, and add module fields
to the app's own custom schema instead of replacing it (other models
included).

Because that makes more fields encrypted, make reads tolerate plaintext:
_isEncrypted now requires the exact Cryptor shape (keyId:iv:ciphertext:
encryptedKey, base64/32-hex/hex/base64) instead of any string with three
colons, and a field that fails to decrypt is returned as stored with a
warning instead of failing the whole read, findMany included.

Existing rows stay plaintext until rewritten. See CHANGELOG (Unreleased).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.

Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

@seanspeaks seanspeaks added release Create a release when this pr is merged prerelease This change is available in a prerelease. labels Oct 15, 2026 — with Claude
@seanspeaks

Copy link
Copy Markdown
Contributor Author

ADR reconciliation needed after Stack A/B — drafted with Claude Code at Sean's request

Severity: must-reconcile with #663 (fail-closed encryption). No ADR owns field-level encryption today. The rules live in database/encryption/README.md and CLAUDE.md. ADR-009 is the e2e test package, not encryption.

Conflict

  • feat(core)!: fail closed on field-level encryption in deployed stages #663 makes encryption fail closed in deployed runtimes: no key gives EncryptionConfigurationError, and a failed extension setup re-throws instead of "Continuing without encryption".
  • This PR makes reads fail open: "a value that fails to decrypt is returned as stored with a warning instead of failing the whole read". In a deployed runtime with a wrong or rotated key, callers then get ciphertext strings as access_token/api_key. They see provider 401s, refresh, and may overwrite a still-valid credential. The real error is a warning in the logs.
  • Tolerating legacy plaintext (values not in the strict keyId:iv:ciphertext:encryptedKey shape) is right and compatible with feat(core)!: fail closed on field-level encryption in deployed stages #663. The problem is only ciphertext-shaped values that fail.

Proposed change

  1. A value that matches the ciphertext shape and fails to decrypt throws when feat(core)!: fail closed on field-level encryption in deployed stages #663's encryption-config.js says the runtime is deployed. It is tolerated, with a warning, only locally or with the explicit opt-out.
  2. Rebase onto feat(core)!: fail closed on field-level encryption in deployed stages #663 so both use one getEncryptionConfig(). This PR's loadCustomEncryptionSchema keeps swallowing errors except EntityRegistryError, so a broken module schema still silently means "no module fields". Under fail-closed, consider throwing in deployed runtimes there too.
  3. feat: Netlify provider on the v2 stack (port of #545) (F7) #686 adds a third source of encrypted fields (appDefinition.extensions[].encryption, merged in encryption-schema-registry.js). Make sure the merge order is the same there: core fields, app schema, module fields, extension fields, and never replace.
  4. Record these rules in an ADR: what is encrypted, the three sources, fail-closed on write, plaintext-tolerant/ciphertext-strict on read, and the deployed-runtime definition. A short new ADR is enough (061 is unclaimed; 060 is proposed for the stray marketplace ADR on docs(adr): reconcile the register — 13 unmerged ADRs into one contiguous sequence #644), and it would also give ADR-029's Cryptor keyProvider change (docs(adr): reconcile the register — 13 unmerged ADRs into one contiguous sequence #644) and feat: Netlify provider on the v2 stack (port of #545) (F7) #686's provider runtimes a rule to meet.

Merge order: #663 → this PR rebased. #686's encryption-registry changes come after both.

@seanspeaks

Copy link
Copy Markdown
Contributor Author

Decision (Sean, 22 Oct): decryption fails closed in deployed runtimes.

This replaces the review guide's decision 4 ("decrypt tolerates plaintext"):

  • In a deployed runtime (as feat(core)!: fail closed on field-level encryption in deployed stages #663 defines it, including Netlify), a value that looks like ciphertext but fails to decrypt must throw, not return the stored value with a warning.
  • Plaintext that was never encrypted, i.e. legacy rows written before this fix, may still be read as-is, so existing data keeps working until its next write.
  • Local runs may keep the warning.

Please use #663's shared getEncryptionConfig() / runtime detection rather than a separate rule. Merge after #663.

🤖 Generated with Claude Code

…vel encryption

isDeployedRuntime read only AWS Lambda's variables, so a Netlify function
with STAGE=dev counted as a local run and skipped encryption. A non-aws
FRIGG_PROVIDER (set by provider-netlify's generated entry points) now marks a
deployed function, and NETLIFY_DEV marks `netlify dev` as local. Netlify's
SITE_ID, CONTEXT and NETLIFY are build-time variables and are not used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
…pted in a deployed runtime

A value with Cryptor's keyId:iv:ciphertext:encryptedKey shape that fails to
decrypt (wrong, rotated or missing key) now throws FieldDecryptionError, with
the cause, when the process runs deployed (encryption-config's
isDeployedRuntime: AWS Lambda or a non-aws FRIGG_PROVIDER such as Netlify).
Returning it as stored would hand ciphertext to an API module as a token.
Legacy plaintext never takes this path, and local runs keep the warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.

Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

GitHub's ubuntu-latest is now Ubuntu 24.04, which has no libcrypto.so.1.1, so
mongodb-memory-server's default MongoDB 5.0.19 could not start and core's test
step failed before any test ran. Pin MongoDB 7.0.14 (published for Ubuntu
24.04; MONGOMS_VERSION still overrides it) and the wiredTiger storage engine,
since the library's default ephemeralForTest was removed in MongoDB 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
7.0.14 has no ubuntu2404 build (fastdl answers 403), so the in-memory server
still could not start on GitHub's runners. 8.0.4's ubuntu2404 build downloads
and starts with the wiredTiger engine; checked with an empty binary cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6

Copy link
Copy Markdown
Contributor Author

build (22.x) on 6bacd266: core's tests run in CI again. None of the remaining red is from this PR's changes.

Why core never ran: GitHub's ubuntu-latest is now Ubuntu 24.04, which has no libcrypto.so.1.1, so mongodb-memory-server's default MongoDB 5.0.19 could not start. The core test step failed in global setup on every branch.

What is still red:

  • Core: 55 tests in 14 suites. All of them fail locally on this branch before this round's changes (b3ca7f12), matched by name. That includes:

    • get-user-from-x-frigg-headers and get-user-from-adopter-jwt, which assert old error text ("Bad Request", "Not Implemented") the use cases no longer throw;
    • Cryptor, the encryption-schema registry, the decryption verifications, queuer-util, webhook-flow, check-database-state, integration-commands and find-integration-context.

    None of them covers what this PR changes. There is no fix yet; they belong in their own PR.

  • Devtools: 33 suites. The same pre-existing failures as on feat(core): managementApi.proxy.scopes and a tenant-isolation suite on real Postgres (F6) #685 and feat(core): scope credentials through their entities #684.

  • This PR's own tests pass: field-encryption-service (67, including the new deployed fail-closed cases), encryption-config and entity-registry-encryption.

A re-run would not change this result.


Generated by Claude Code

@cursor

cursor Bot commented Oct 24, 2026

Copy link
Copy Markdown

Frigg 2.0.1 review (Group 2) — COMMENT only

Checklist

  • Decrypt fails closed when deployed (throws; does not return ciphertext/plaintext silently): pass
    • FieldEncryptionService.decryptFields throws FieldDecryptionError when failClosedOnDecryptError() (defaults to isDeployedRuntime()).
    • Local path warns and leaves the stored value (intentional for legacy plaintext / false-positive shapes).
    • Stricter _isEncrypted (4-part envelope) prevents colon-y plaintext from entering decrypt.

Verdict (would): would approve

Findings

  1. should-fix — packages/core/database/documentdb-encryption-service.js _isEncryptedValue / _decryptFieldPath (unchanged on this PR; called out in the PR body)
    • DocumentDB still uses the looser “4 base64 parts, length ≥ 50” check and always rethrows on decrypt failure (no shared isDeployedRuntime / local tolerance).
    • Scenario: DocumentDB adopter with module credentialFields and a legacy plaintext value that happens to look like 4 base64 segments ≥ 50 chars → decrypt attempted → whole read throws even locally; or the inverse shape mismatch vs Prisma path. Worth aligning with FieldEncryptionService in a follow-up before advertising module credential encryption for DocumentDB.

No blocking issues found on the Prisma path for the 2.0.1 fail-closed decrypt requirement.

// 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()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checklist note (pass): deployed decrypt fail-closed is correctly implemented here — ciphertext-shaped values that fail cryptor.decrypt throw FieldDecryptionError when failClosedOnDecryptError() is true (default isDeployedRuntime()), so API modules never receive raw ciphertext as a “token”.

@cursor

cursor Bot commented Oct 24, 2026

Copy link
Copy Markdown

Frigg 2.0.1 review (Stack A) — COMMENT only

Verdict (would): would approve

Decrypt-fails-closed on the primary Prisma / FieldEncryptionService path is sound: deployed decrypt failures throw; legacy plaintext is shape-detected; deployed-without-key refuses start; extension install failure no longer falls through to a plaintext client; module credentialFields (including registry-only types) are registered.

Blocking

None on the primary Prisma decrypt path.

Should-fix

  1. encryption-schema-registry.js (~275–283) — Only EntityRegistryError is rethrown from module schema load; other registration failures are debug-logged and ignored.
    Failure: Unexpected throw during module field registration → app starts with only core Credential fields encrypted; module fields stay plaintext (same class of hole this PR claims to close). Treat module encryption schema load failure as fatal when encryption is enabled / deployed.
  2. documentdb-encryption-service.js (~250–261) — Decrypt error logs encryptedValuePrefix (first 20 chars of ciphertext). Drop it; mirror FieldEncryptionService (error name only).
  3. encryption-config.js — Local can enable AES on AES_KEY_ID alone; deployed requires both. Incomplete local cryptor can corrupt writes / return blobs as tokens under local fail-open. Align local with a complete AES pair before enabled: true.
  4. Deployed detection — Non-AWS hosts need FRIGG_PROVIDER; omitting it with production-like STAGE fail-opens writes. Document as hard requirement (or fail closed on known production stage names).

Nits

  • Explicit fieldLevelEncryptionMethod: 'none' / FRIGG_ENCRYPTION_DISABLED break-glass is clear if production policy forbids it outside documented use.
  • Local fail-open on bad ciphertext is intentional; keep IS_OFFLINE / NETLIFY_DEV out of real deploys.

No formal approve / request-changes on this review.

}
} catch (error) {
// A bad entity registry is a configuration error, not "no custom schema".
if (error instanceof EntityRegistryError) throw error;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix — module encryption registration can still fail open

Only EntityRegistryError is rethrown; other failures fall through to logger.debug and are ignored (“custom schema is optional”).

Scenario: Module credentialFields registration throws (unexpected shape / validation edge) → app starts with only core Credential fields encrypted; module fields stay plaintext — recreating the silent registration hole under a different exception.

When encryption is enabled / deployed, treat failure to load module encryption schemas as fatal.

…stered

From the Stack A review on #680:

- loadCustomEncryptionSchema swallowed every error, so a schema the app
  declares (encryption.schema, encryption.disable, module credentialFields)
  that failed to register left those fields in plaintext with only a debug
  line. Not finding an app definition is still quiet; a registration failure
  now throws in a deployed runtime and warns on a local run.
- Local runs enabled AES on AES_KEY_ID alone (deployed needs both). An
  incomplete pair now keeps a local run plaintext and warns that AES_KEY is
  missing. Tests that turned encryption on with the id alone set both.
- The DocumentDB decrypt error no longer logs a ciphertext prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6

Copy link
Copy Markdown
Contributor Author

On the Stack A review's should-fix items. Commit d670696 is not yet pushed.

  1. Schema load failure swallowed. Agreed; the outer try caught registration failures as well as "no app definition". Not finding an app definition is still quiet. A schema the app declares that fails to register now throws in a deployed runtime (isDeployedRuntime()) and warns on a local run; it used to be a debug line. This covers encryption.schema, encryption.disable and module credentialFields. There are tests for deployed (throws), local (warns) and no definition (quiet), and both mutations fail them.
  2. DocumentDB logs a ciphertext prefix. Removed.
  3. Local AES on AES_KEY_ID alone. Agreed. A local run now needs both values, like a deployed one. The id alone keeps it plaintext and warns that AES_KEY is missing. Four tests turned encryption on with the id alone and now set both. Reverting the rule fails 5 tests.
  4. FRIGG_PROVIDER on non-AWS hosts. Not changing it. Deployed detection is documented (core CLAUDE.md, encryption README), and provider-netlify's generated functions set FRIGG_PROVIDER themselves, so an app does not set it by hand. Failing closed on production-looking stage names would also refuse frigg start with STAGE=production, which local runs use.

The DocumentDB tolerance from the Group 2 review stays a follow-up, as the PR body says. The database, integrations/repositories and encrypt suites fail the same 32 tests on this commit as on 6bacd26.


Generated by Claude Code

@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prerelease This change is available in a prerelease. release Create a release when this pr is merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants