T4: AES-GCM crypto + encrypted token store #4

Merged
dzenda merged 107 commits from t4-crypto-token-store into t3-d1-drizzle 2026-11-14 17:51:00 +01:00
Owner

T4: crypto + token store

Stacked on #3 (t3-d1-drizzle) → #2 → #1. Merge those first.

src/lib/crypto.ts: AES-256-GCM

  • Envelope v1.<kid>.<iv>.<ct> (base64url). kid = first 8 bytes of SHA-256(key), so key IDs need no configuration.
  • A fresh 96-bit random IV for every encryption.
  • AAD binding: every ciphertext is bound to connections:<userId>:<provider>:<access|refresh>, so a ciphertext copied to another row or user fails to decrypt.
  • Rotation: TOKEN_ENCRYPTION_KEY (current) plus optional TOKEN_ENCRYPTION_KEYS_PREVIOUS (comma-separated, decrypt-only). The token store re-encrypts stale envelopes with the current key when it reads them.
  • Keys are validated: they must be base64 or base64url and exactly 32 bytes.

src/lib/token-store.ts

  • saveConnection (upsert, ciphertext only), getToken(user, provider) (auto-refresh), listConnections (UI-safe metadata, never tokens), deleteConnection.
  • Errors: NotConnectedError and ReauthRequiredError (src/lib/errors.ts, messages are safe to show to users and LLMs and include the connect URL), plus transient RefreshInProgressError and RefreshTimeoutError.
  • Refresh protocol, safe across isolates for single-use rotating refresh tokens:
    1. Refresh 60 s before expiry. Concurrent calls in the same isolate share one in-flight promise.
    2. Take a D1 lease (refresh_locked_until + random refresh_lease_id) with a conditional UPDATE before spending the refresh token. Other callers poll until the credential changes or the lease expires.
    3. The upstream exchange has a 15 s hard deadline (AbortSignal + race) against a 60 s lease. On timeout the lease is kept until it expires, so nobody replays the token while the aborted request could still be in flight.
    4. The new credential is written with a CAS on token_version. Connect and refresh bump the version; re-encryption never does, so key rotation can't discard a refresh.
    5. Change detection compares row id as well as version, which catches a delete followed by a reconnect.
  • Migration 0001_refresh_lease.sql adds token_version, refresh_locked_until and refresh_lease_id.

Verified

  • bun run lint && bun run typecheck && bun run test: 58 tests, 11 for crypto and 26 for the token store, covering tampering, AAD, rotation, refresh, dedupe, lease wait/takeover/timeout, stale-owner release, reconnect while polling, and rotation during refresh.
  • WebCrypto smoke-tested inside workerd (bun run dev, a temporary route that encrypts and decrypts with the .dev.vars key, removed before committing). bun run db:migrate applies 0000 and 0001.

Astra review (gpt-6-astra), 3 rounds

Fixed:

  1. (P1) Cross-isolate refreshes could spend the same single-use refresh token → D1 lease before the exchange.
  2. (P1) Key-rotation re-encryption broke the refresh CAS (which compared refresh_token_enc) → CAS on token_version.
  3. (P1) A slow exchange could outlive the lease and allow a takeover → 15 s deadline vs. 60 s lease, lease kept on timeout.
  4. (P2) A stale owner could release a newer owner's lease → refresh_lease_id owner check.
  5. (P2) A delete and reconnect during polling looked unchanged at version 0 → compare row id too.
  6. (P1) An abort-aware refresher (e.g. fetch → AbortError) made a timeout look like a normal failure, which released the lease → track the timeout separately. Test added and confirmed to fail on the old code.

Each fix has a regression test.

Known limitations (accepted): if an upstream request is processed after we abort it, we can't fully detect that. The 60 s quarantine only narrows the window. Same-isolate dedupe is per isolate, and D1 coordinates across isolates.

Also

  • Generated local-only TOKEN_ENCRYPTION_KEY and BETTER_AUTH_SECRET into the git-ignored .dev.vars. They are not committed and were never printed.

🤖 Generated with Claude Code

## T4: crypto + token store **Stacked on #3 (`t3-d1-drizzle`) → #2 → #1.** Merge those first. ### `src/lib/crypto.ts`: AES-256-GCM - Envelope `v1.<kid>.<iv>.<ct>` (base64url). `kid` = first 8 bytes of SHA-256(key), so key IDs need no configuration. - A fresh 96-bit random IV for every encryption. - **AAD binding**: every ciphertext is bound to `connections:<userId>:<provider>:<access|refresh>`, so a ciphertext copied to another row or user fails to decrypt. - **Rotation**: `TOKEN_ENCRYPTION_KEY` (current) plus optional `TOKEN_ENCRYPTION_KEYS_PREVIOUS` (comma-separated, decrypt-only). The token store re-encrypts stale envelopes with the current key when it reads them. - Keys are validated: they must be base64 or base64url and exactly 32 bytes. ### `src/lib/token-store.ts` - `saveConnection` (upsert, ciphertext only), `getToken(user, provider)` (auto-refresh), `listConnections` (UI-safe metadata, never tokens), `deleteConnection`. - Errors: `NotConnectedError` and `ReauthRequiredError` (`src/lib/errors.ts`, messages are safe to show to users and LLMs and include the connect URL), plus transient `RefreshInProgressError` and `RefreshTimeoutError`. - **Refresh protocol**, safe across isolates for single-use rotating refresh tokens: 1. Refresh 60 s before expiry. Concurrent calls in the same isolate share one in-flight promise. 2. Take a **D1 lease** (`refresh_locked_until` + random `refresh_lease_id`) with a conditional UPDATE before spending the refresh token. Other callers poll until the credential changes or the lease expires. 3. The upstream exchange has a **15 s hard deadline** (`AbortSignal` + race) against a **60 s lease**. On timeout the lease is **kept** until it expires, so nobody replays the token while the aborted request could still be in flight. 4. The new credential is written with a **CAS on `token_version`**. Connect and refresh bump the version; re-encryption never does, so key rotation can't discard a refresh. 5. Change detection compares row id as well as version, which catches a delete followed by a reconnect. - Migration `0001_refresh_lease.sql` adds `token_version`, `refresh_locked_until` and `refresh_lease_id`. ### Verified - `bun run lint && bun run typecheck && bun run test`: 58 tests, 11 for crypto and 26 for the token store, covering tampering, AAD, rotation, refresh, dedupe, lease wait/takeover/timeout, stale-owner release, reconnect while polling, and rotation during refresh. - WebCrypto smoke-tested inside workerd (`bun run dev`, a temporary route that encrypts and decrypts with the `.dev.vars` key, removed before committing). `bun run db:migrate` applies 0000 and 0001. ### Astra review (gpt-6-astra), 3 rounds **Fixed:** 1. *(P1)* Cross-isolate refreshes could spend the same single-use refresh token → D1 lease before the exchange. 2. *(P1)* Key-rotation re-encryption broke the refresh CAS (which compared `refresh_token_enc`) → CAS on `token_version`. 3. *(P1)* A slow exchange could outlive the lease and allow a takeover → 15 s deadline vs. 60 s lease, lease kept on timeout. 4. *(P2)* A stale owner could release a newer owner's lease → `refresh_lease_id` owner check. 5. *(P2)* A delete and reconnect during polling looked unchanged at version 0 → compare row id too. 6. *(P1)* An abort-aware refresher (e.g. `fetch` → `AbortError`) made a timeout look like a normal failure, which released the lease → track the timeout separately. Test added and confirmed to fail on the old code. Each fix has a regression test. **Known limitations (accepted):** if an upstream request is processed after we abort it, we can't fully detect that. The 60 s quarantine only narrows the window. Same-isolate dedupe is per isolate, and D1 coordinates across isolates. ### Also - Generated local-only `TOKEN_ENCRYPTION_KEY` and `BETTER_AUTH_SECRET` into the git-ignored `.dev.vars`. They are not committed and were never printed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refreshes take a short D1 lease before spending a single-use refresh token, and
writes compare-and-swap on token_version, which re-encryption never bumps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applies to Usermap calls, the FIT token/check_token requests and the login profile lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- fixtures:record: reads the encrypted token from local D1, GET-only, never refreshes,
  scrubs PII into git-ignored test/fixtures/live/, prints shapes only
- Marast: FIT token goes in Api-Key (Bearer is 401); only_open is 401 → filter locally
- Usermap: fields may be null (preferredEmail) → nullish schemas
- Grades: precise error for missing OAuth app resource; docs on scopes vs. resources

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also fixes a production bug found while smoke-testing: createFnContext stored the global
fetch on the context object, so ctx.fetch(...) threw "Illegal invocation" in workerd. That
broke every registry call in the Worker (including all MCP tool calls); Node tests could
not see it. Regression test emulates workerd's this-check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ALLOWED_USERS enforced for MCP too (shared policy); excluded users' grants revoked on use
- telemetry: streaming 512-byte cap before parsing
- POST /oauth/register rate-limited per IP and instance-wide (Workers rate-limit bindings)
- FIT API bodies capped at 2 MB (streaming); Grades tree walk iterative and bounded
- readBounded moved to src/lib/bounded.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Logos: FIT service marks vendored in public/services (see README there); assistant marks
from @lobehub/icons (MIT), simple-icons (CC0) and devicon (MIT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- service_settings table (migration 0002); MCP calls to a switched-off service fail with
  service_disabled, aggregates skip it with a warning, fit.status lists it
- setServiceEnabledFn: same-origin + session only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- fit.sirius.courseEvents: every parallel of a course (room, teachers, capacity/occupied),
  shared paging helper with fit.sirius.events; respects the Sirius MCP switch
- getCourseWeek server fn for the calendar overlay; upcoming deadlines (60 days) beside the week

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- layoutLanes/pragueMinutes in src/lib/calendar.ts (tested)
- shared EventDetails popover; agenda list stays on mobile
- agentSetupPrompt in src/mcp/setup.ts (tested)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Referrer-Policy: same-origin (no-referrer made browsers send Origin: null on our form POSTs,
  so every management form was rejected with 403); shared isSameOriginRequest accepts
  Origin: null only with Sec-Fetch-Site: same-origin
- No COOP on OAuth/consent/login routes (popup-based web MCP clients keep window.opener)
- fit.sirius.icalUrl is websiteOnly: excluded from the MCP registry (URL embeds a credential)
- docs: telemetry wording (operator-enabled, consent advice), CSRF/COOP/credential notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed-on: #25
Reviewed-on: #24
Reviewed-on: #23
Reviewed-on: #22
Reviewed-on: #21
Reviewed-on: #20
Reviewed-on: #19
Reviewed-on: #18
Reviewed-on: #17
Reviewed-on: #16
Reviewed-on: #15
Reviewed-on: #14
Reviewed-on: #13
Reviewed-on: #12
Reviewed-on: #11
Reviewed-on: #10
Reviewed-on: #9
Reviewed-on: #8
Reviewed-on: #7
Reviewed-on: #6
Reviewed-on: #5
dzenda merged commit 72e95563b9 into t3-d1-drizzle 2026-11-14 17:51:00 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dzenda/os!4
No description provided.