Skip to content

feat(security): encryption.ts failclosed on crypto failure (audit F3) - #549

Merged
KooshaPari merged 1 commit into
mainfrom
feat/encryption-failclosed-20260808
Aug 8, 2026
Merged

KooshaPari merged 1 commit into
mainfrom
feat/encryption-failclosed-20260808

Conversation

@KooshaPari

@KooshaPari KooshaPari commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

User description

Closes audit finding F3 from PR #507 (the highest-priority deferred item). When STORAGE_ENCRYPTION_KEY is set but the crypto pipeline fails, tokens silently store as plaintext — this is the State-B bug.

What this PR does

Implements Option C+A per plans/encryption-failclosed-spec.md:

  1. encrypt() now throws EncryptionRuntimeError instead of returning plaintext when the crypto pipeline fails while a key is configured. State A (no key, passthrough) is preserved exactly.
  2. encryptConnectionFields() return type: T → T | null — when any inner encrypt() throws, the function returns null and logs the failure. Callers must check for null and refuse to write plaintext.
  3. providers.ts:348,544 callers — short-circuit insert/update when encryptConnectionFields returns null; surface a clear error to the caller.
  4. commandCodeAuth.ts:142 — wraps encrypt() in try/catch; returns null on EncryptionRuntimeError so the session is not poisoned with plaintext apiKey.
  5. validateEncryptionAtStartup() — new canary function that runs a known-plaintext encrypt/decrypt round-trip. Throws StartupEncryptionError on failure.
  6. instrumentation-node.ts — calls runEncryptionStartupCheck() after initOtel/initOtelMeter and BEFORE ensureSecrets()/DB init. process.exit(1) on canary failure.
  7. src/lib/db/encryptionStartup.ts (new) — async wrapper runEncryptionStartupCanary() (no exit) + sync runEncryptionStartupCheck() (exits on failure).
  8. .env.example documents the new error names and remediation hint.

Tests (all passing)

  • tests/unit/db/encryption-failclosed.test.ts (NEW, vitest) — 8 tests covering AC-1, AC-2, AC-3, AC-4, AC-11
  • tests/unit/db/encryption-startup.test.ts (NEW, vitest) — 6 tests covering AC-6, AC-10, runEncryptionStartupCanary, runEncryptionStartupCheck exports
  • tests/unit/db/encryption-connection-fields-failclosed.test.mjs (NEW, node:test) — 4 tests covering AC-11 contract

Test status

  • node:test encryption suite: 23/23 pass (db-encryption, encryption-error-handling, encryption-strict, encryption-connection-fields-failclosed, db-command-code-auth)
  • vitest failclosed+startup suite: 14/14 pass
  • providers-batch-update regression check: 12/12 pass
  • TSC: 1 unrelated pre-existing error in apiKeys.ts (no new errors)

Breaking surface

The T | null return type of encryptConnectionFields is a TS-breaking change at compile time only — runtime behavior is unchanged for the happy path. External consumers pinned to the old signature will see a type error and must handle null. Container.ts re-export surface is also affected (TypeScript will surface this in the type checker).

Out of scope (per spec)

  • Migration of legacy ciphertext
  • Key rotation
  • Hardware-backed keys

Refs: PR #507 F3 follow-up. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com


CodeAnt-AI Description

Prevent plaintext credential storage when encryption fails

What Changed

  • Encryption failures now stop credential writes instead of silently saving plaintext values.
  • Provider connection creates and updates fail with a clear error when encryption is unavailable.
  • Command authorization sessions refuse to store API keys if encryption fails.
  • The server checks encryption during startup and refuses to start when a configured key cannot complete a valid encrypt/decrypt round trip.
  • Deployments without STORAGE_ENCRYPTION_KEY continue to use the existing passthrough behavior.

Impact

✅ No plaintext credentials written after encryption failures
✅ Faster detection of broken encryption keys at startup
✅ Clearer encryption failure logs and remediation guidance

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

Copilot AI lite review requested due to automatic review settings August 8, 2026 23:24
@codeant-ai

codeant-ai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

🤖 CodeAnt AI — Review Status

Status Commit Started (UTC) Finished (UTC)
✅ Reviewed your PR ac158c6 Aug 08, 2026 · 23:24 23:27

@codeant-ai

codeant-ai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

@KooshaPari, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 28 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 853100e8-8887-4640-a827-c12e216e2d3e

📥 Commits

Reviewing files that changed from the base of the PR and between 9ced409 and fb93243.

📒 Files selected for processing (9)
  • .env.example
  • src/instrumentation-node.ts
  • src/lib/db/commandCodeAuth.ts
  • src/lib/db/encryption.ts
  • src/lib/db/encryptionStartup.ts
  • src/lib/db/providers.ts
  • tests/unit/db/encryption-connection-fields-failclosed.test.mjs
  • tests/unit/db/encryption-failclosed.test.ts
  • tests/unit/db/encryption-startup.test.ts

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key: "review"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Implements Option C+A (startup canary + runtime throw) per
plans/encryption-failclosed-spec.md.

The encrypt() fallback to plaintext (line 144-148) is the highest-risk
governance-debt finding from the recent audit. When STORAGE_ENCRYPTION_KEY
is set but crypto fails (bad key material, OOM, native binding broken),
tokens silently store as plaintext. This is undetectable from operator
view.

Changes:

1. New EncryptionRuntimeError class — thrown when crypto pipeline
   fails after key was successfully derived
2. validateEncryptionAtStartup() — runs a known-plaintext round-trip
   to detect broken encryption config before serving traffic
3. src/instrumentation-node.ts — calls the startup canary before
   ensureSecrets() and any DB init; refuses to start if encryption
   is broken (process.exit(1))
4. encryptConnectionFields() return type: T -> T | null to signal
   caller when encryption failed; providers.ts:348,544 callers
   updated to throw a clear error and skip the DB write
5. commandCodeAuth.ts:142 wraps encrypt() in try/catch; returns null
   on EncryptionRuntimeError so the session is not poisoned with
   plaintext apiKey
6. src/lib/db/encryptionStartup.ts (new) — async wrapper
   runEncryptionStartupCanary() + process-exiting
   runEncryptionStartupCheck() helpers
7. .env.example documents the new behaviour (EncryptionRuntimeError
   and StartupEncryptionError error names, remediation hint)

State A preserved exactly: when STORAGE_ENCRYPTION_KEY is unset,
passthrough returns plaintext (no breaking change for dev/test setups).

Out of scope (per spec):
- Migration of legacy ciphertext
- Key rotation
- Hardware-backed keys

Refs: PR #507 F3 follow-up. Closes State B from audit.

Verification:
- node:test encryption suite: 23/23 pass (db-encryption, encryption-error-handling,
  encryption-strict, encryption-connection-fields-failclosed, db-command-code-auth)
- vitest failclosed+startup suite: 14/14 pass
- providers-batch-update: 12/12 pass (no regression)
- TSC: 1 unrelated pre-existing error in apiKeys.ts (no new errors)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

L17 Latency Budget Report

--- Latency Budget Summary ---
  Total endpoints checked: 0
  Passed: 0
  Warnings: 0
  Failures: 0

Checked against: budgets/rest-endpoints.yaml.

@KooshaPari
KooshaPari force-pushed the feat/encryption-failclosed-20260808 branch from ac158c6 to fb93243 Compare August 8, 2026 23:25
@codeant-ai codeant-ai Bot added the size:XL This PR changes 500-999 lines, ignoring generated files label Aug 8, 2026
@KooshaPari
KooshaPari merged commit f81fa4a into main Aug 8, 2026
13 of 17 checks passed
@KooshaPari
KooshaPari deleted the feat/encryption-failclosed-20260808 branch August 8, 2026 23:25
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

L17 Latency Regression Report

No trace file available — cannot compute regression

Threshold: 10% p99 regression.

Comment on lines +145 to +157
let encryptedApiKey: string | null;
try {
encryptedApiKey = encrypt(input.apiKey) ?? null;
} catch (err: unknown) {
if (err instanceof EncryptionRuntimeError) {
// FAIL-CLOSED: encryption layer is broken (key configured but crypto
// pipeline threw). Refuse to write apiKey plaintext; surface the
// failure to the caller as "session not found".
log.error(
{ err: err.message, op: "markCommandCodeAuthSessionReceived", stateHash: input.stateHash },
`[commandCodeAuth] Refusing to store apiKey — encryption layer failed.`
);
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The encryption failure is collapsed into null, but the callback route treats every null result as an invalid or expired state and returns HTTP 400. A real server-side crypto outage is therefore indistinguishable from bad client input, causing clients to retry authentication and preventing monitoring or callers from recognizing that the server cannot persist the API key. Preserve the fail-closed write behavior, but propagate a distinct error/result so the route can return an appropriate server-error response. [api mismatch]

Severity Level: Major ⚠️
- ❌ Command Code callback reports crypto outages as HTTP 400.
- ⚠️ Valid authentication attempts appear to have expired state.
- ⚠️ Clients may retry instead of surfacing server failure.
- ⚠️ Monitoring cannot distinguish bad state from encryption failure.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/lib/db/commandCodeAuth.ts
**Line:** 145:157
**Comment:**
	*Api Mismatch: The encryption failure is collapsed into `null`, but the callback route treats every `null` result as an invalid or expired state and returns HTTP 400. A real server-side crypto outage is therefore indistinguishable from bad client input, causing clients to retry authentication and preventing monitoring or callers from recognizing that the server cannot persist the API key. Preserve the fail-closed write behavior, but propagate a distinct error/result so the route can return an appropriate server-error response.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ac158c6e76

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/lib/db/encryption.ts
Comment on lines +488 to +491
if (conn.apiKey) conn.apiKey = encrypt(conn.apiKey) ?? conn.apiKey;
if (conn.accessToken) conn.accessToken = encrypt(conn.accessToken) ?? conn.accessToken;
if (conn.refreshToken) conn.refreshToken = encrypt(conn.refreshToken) ?? conn.refreshToken;
if (conn.idToken) conn.idToken = encrypt(conn.idToken) ?? conn.idToken;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refuse plaintext when key derivation fails

When STORAGE_ENCRYPTION_KEY is present but getStaticKey() returns null from its derivation-failure path, encrypt() still returns the original secret instead of throwing, and these assignments treat that plaintext as a successful encryption result. In any entry point that does not run the new startup canary before provider writes, createProviderConnection/updateProviderConnection will pass the new null check and store API keys or OAuth tokens in plaintext even though encryption was configured; distinguish “no key configured” from “configured key failed” or verify encrypted fields get the enc:v1: prefix before returning success.

AGENTS.md reference: src/lib/db/AGENTS.md:L45-L50

Useful? React with 👍 / 👎.

@sonarqubecloud

sonarqubecloud Bot commented Aug 8, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

Comment thread src/lib/db/encryption.ts
`[Encryption] STORAGE_ENCRYPTION_KEY is set but encrypt() failed. ` +
`Refusing to write plaintext. Regenerate with: openssl rand -base64 32`
);
throw new EncryptionRuntimeError(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING]: encrypt() now throws EncryptionRuntimeError, but 9+ external callers are not updated to handle it

encrypt() was changed to throw instead of returning plaintext on crypto failure. These callers still rely on the old fallback behavior and will throw unhandled exceptions at runtime:

  • src/lib/services/apiKey.ts:32 — encrypt(key) ?? key
  • src/lib/webhookDispatcher.ts:25 — encrypt(JSON.stringify(meta)) ?? JSON.stringify(meta)
  • src/lib/db/obsidian.ts:31 — encrypt(token) ?? token (outer try/catch swallows error, token not persisted)
  • src/lib/db/obsidian.ts:190 — encrypt(password) ?? password
  • src/lib/cloudAgent/credentials.ts:71 — const encrypted = encrypt(apiKey);
  • src/app/api/settings/proxy/cloudflare-deploy/route.ts:155 — const encryptedRelayAuth = encrypt(relayAuth);
  • src/app/api/settings/proxy/vercel-deploy/route.ts:236 — same pattern
  • src/app/api/settings/proxy/deno-deploy/route.ts:347 — same pattern
  • src/app/api/services/9router/rotate-key/route.ts:12 — encrypt(newKey) ?? newKey

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread src/lib/db/encryption.ts
if (conn.idToken) conn.idToken = encrypt(conn.idToken);
return conn;
try {
if (conn.apiKey) conn.apiKey = encrypt(conn.apiKey) ?? conn.apiKey;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION]: Dead ?? conn.apiKey fallback — encrypt() now throws on failure

Since encrypt() throws EncryptionRuntimeError instead of returning plaintext on failure, the ?? conn.apiKey fallback is unreachable and contradicts the fail-closed intent. Lines 489-491 have the same pattern.

Suggested change
if (conn.apiKey) conn.apiKey = encrypt(conn.apiKey) ?? conn.apiKey;
if (conn.apiKey) conn.apiKey = encrypt(conn.apiKey);

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread src/lib/db/encryption.ts

let encrypted: string;
try {
encrypted = encrypt(STARTUP_CANARY_PLAINTEXT) ?? "";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION]: Dead ?? "" fallback — encrypt() now throws on failure

Since encrypt() throws EncryptionRuntimeError instead of returning plaintext, the ?? "" fallback is unreachable.

Suggested change
encrypted = encrypt(STARTUP_CANARY_PLAINTEXT) ?? "";
encrypted = encrypt(STARTUP_CANARY_PLAINTEXT);

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

const encryptedApiKey = encrypt(input.apiKey);
let encryptedApiKey: string | null;
try {
encryptedApiKey = encrypt(input.apiKey) ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION]: Dead ?? null fallback — encrypt() now throws on failure

Since encrypt() throws EncryptionRuntimeError instead of returning plaintext, the ?? null fallback is unreachable.

Suggested change
encryptedApiKey = encrypt(input.apiKey) ?? null;
encryptedApiKey = encrypt(input.apiKey);

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

* Strategy: we test the contract via the exported error class shape (no
* fault injection needed) and via the real encrypt() with the real crypto
* module (no fault injection needed for State A / happy-path). We cover the
* catch-block behaviour of `encryptConnectionFields` and the canary through

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION]: Test comment claims catch-block coverage that doesn't exist in the PR diff

The comment says "We cover the catch-block behaviour of encryptConnectionFields and the canary through a parallel test file that uses vi.spyOn on the real crypto module", but no such fault-injection test exists in the PR diff. Either add the missing test or update the comment to match actual coverage.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: 5 Issues Found | Recommendation: Address before merge

Overview

Severity Count
WARNING 1
SUGGESTION 4
Issue Details (click to expand)

WARNING

File Line Issue
src/lib/db/encryption.ts 245 encrypt() now throws EncryptionRuntimeError, but 9+ external callers are not updated to handle it

SUGGESTION

File Line Issue
src/lib/db/encryption.ts 488 Dead ?? conn.apiKey fallback — encrypt() now throws on failure
src/lib/db/encryption.ts 610 Dead ?? "" fallback — encrypt() now throws on failure
src/lib/db/commandCodeAuth.ts 147 Dead ?? null fallback — encrypt() now throws on failure
tests/unit/db/encryption-failclosed.test.ts 9 Test comment claims catch-block coverage that doesn't exist in the PR diff
Files Reviewed (9 files)
  • .env.example
  • src/instrumentation-node.ts
  • src/lib/db/commandCodeAuth.ts
  • src/lib/db/encryption.ts
  • src/lib/db/encryptionStartup.ts
  • src/lib/db/providers.ts
  • tests/unit/db/encryption-connection-fields-failclosed.test.mjs
  • tests/unit/db/encryption-failclosed.test.ts
  • tests/unit/db/encryption-startup.test.ts

Fix these issues in Kilo Cloud


Reviewed by step-3.7-flash · Input: 173.1K · Output: 28K · Cached: 2.6M

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

Labels

size:XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants