Skip to content

perf(database): reuse the derived key when decrypting a stored value again - #3095

Merged
richiemcilroy merged 3 commits into
mainfrom
perf/database-cache-decrypt-keys
Oct 24, 2026
Merged

richiemcilroy merged 3 commits into
mainfrom
perf/database-cache-decrypt-keys

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

decrypt derives its AES key with PBKDF2-SHA256 at 100,000 iterations on every call. That's about 10 ms on an M-series Mac and more on a serverless CPU; 64 concurrent derivations take about 175 ms locally.

The salt is stored with each ciphertext, so decrypting the same stored value again always derives exactly the same key. Google Drive does that constantly: every Drive storage access decrypts the integration's config, including each source-commit page and each /api/storage/object request the media server makes for a segment. A 120 s Drive-backed recording repeats the same derivation ~130 times.

What changes

  • decrypt gets its key from a small cache keyed by encryption key + salt. It's least-recently-used, holds 256 entries, and stores the non-extractable CryptoKey promise, so concurrent decrypts of the same value share one derivation.
  • A derivation that fails is evicted, so the next call tries again.
  • encrypt is unchanged. It still makes a fresh random salt and derives a new key every time, so nothing about stored ciphertexts changes.
  • Because the database encryption key is part of the cache key, a rotated key never reuses an old derived key.
  • AES-GCM still authenticates every ciphertext, so a tampered value still fails to decrypt with a cached key.

Tests (new database-crypto.test.ts)

  • Decrypting the same stored value four times, two of them at once, calls crypto.subtle.deriveKey once. On main it's called four times, and the test fails there.
  • Two different ciphertexts with different salts derive two keys.
  • A tampered ciphertext still fails after its key is cached.
  • After the encryption key changes, the cached key isn't used and decryption fails.

The full web unit suite has 4,255 passing tests. The 15 failures are in add-folder-videos and share-stage-identity, which fail the same way on main and don't touch encryption.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge, with the previous test gap fixed and no new blocking issues found.

Summary

Reuses derived decryption keys in a bounded cache without changing stored ciphertexts.

  • Repeated decrypts reuse the derived key for stored values.

Reviews (3) · Last reviewed commit: "test(database): check that concurrent de..." · Reviewed by Greptile

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
cap-status Skipped Skipped Oct 24, 2026 12:30pm UTC

@vercel
vercel Bot temporarily deployed to Preview – cap-status October 24, 2026 12:25 Inactive

@capy-ai capy-ai 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.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

Comment on lines +19 to +23
await expect(decrypt(stored)).resolves.toBe("drive-config");
await expect(decrypt(stored)).resolves.toBe("drive-config");
await expect(
Promise.all([decrypt(stored), decrypt(stored)]),
).resolves.toEqual(["drive-config", "drive-config"]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Concurrent test skips pending keys

The concurrent calls start only after two completed calls have filled the cache. This checks reuse of a finished key, not sharing a pending derivation. A change that caches only finished keys would still pass, even if concurrent requests repeat the expensive work.

Move Promise.all([decrypt(stored), decrypt(stored)]) before the sequential calls and keep the one-call assertion on deriveKey.

Suggested change
await expect(decrypt(stored)).resolves.toBe("drive-config");
await expect(decrypt(stored)).resolves.toBe("drive-config");
await expect(
Promise.all([decrypt(stored), decrypt(stored)]),
).resolves.toEqual(["drive-config", "drive-config"]);
await expect(
Promise.all([decrypt(stored), decrypt(stored)]),
).resolves.toEqual(["drive-config", "drive-config"]);
await expect(decrypt(stored)).resolves.toBe("drive-config");
await expect(decrypt(stored)).resolves.toBe("drive-config");
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/__tests__/unit/database-crypto.test.ts
Line: 19-23

Comment:
**Concurrent test skips pending keys**

The concurrent calls start only after two completed calls have filled the cache. This checks reuse of a finished key, not sharing a pending derivation. A change that caches only finished keys would still pass, even if concurrent requests repeat the expensive work.

Move `Promise.all([decrypt(stored), decrypt(stored)])` before the sequential calls and keep the one-call assertion on `deriveKey`.

```suggestion
		await expect(
			Promise.all([decrypt(stored), decrypt(stored)]),
		).resolves.toEqual(["drive-config", "drive-config"]);
		await expect(decrypt(stored)).resolves.toBe("drive-config");
		await expect(decrypt(stored)).resolves.toBe("drive-config");
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied in 180b8c8. The concurrent decrypts now run first, against a cold cache, and deriveKey still has to be called exactly once. I checked it against a variant that only caches keys once they've finished deriving: that variant now fails this test.

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

@vercel
vercel Bot temporarily deployed to Preview – cap-status October 24, 2026 12:28 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

@vercel
vercel Bot temporarily deployed to Preview – cap-status October 24, 2026 12:30 Inactive
@richiemcilroy
richiemcilroy merged commit 75f1803 into main Oct 24, 2026
40 checks passed

@capy-ai capy-ai 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.

Capy found no issues.

View 2 other findings in Capy.

Open in Capy

This branch was successfully deployed

1 active and 1 inactive deployments
Preview – cap-web — 180b8c87 Deployed Oct 24, 2026 by vercel[bot]
Preview – cap-status — 180b8c87 Deployed Oct 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant