Skip to content

Seal the YouTube refresh token under the user's passphrase - #165

Merged
sh1pt-actions-fleet[bot] merged 2 commits into
masterfrom
fix/seal-youtube-refresh-token
Oct 28, 2026
Merged

sh1pt-actions-fleet[bot] merged 2 commits into
masterfrom
fix/seal-youtube-refresh-token

Conversation

@sh1pt-actions-fleet

Copy link
Copy Markdown
Contributor

youtube_credentials.refresh_token was stored in plaintext.

  • Settings > YouTube: the user sets a key passphrase in the browser. src/lib/sealed/crypto.ts (WebCrypto) runs PBKDF2-SHA256 600k + HKDF; the recovery key is shown once. The server stores only the seal (user_key_seals).
  • The OAuth callback seals the refresh token to the user's public key. Without a key it returns ?youtube=seal_required, so no new plaintext is ever written.
  • Unlock (POST /api/keys/unlock) sends the data key, never the passphrase. The server keeps the opened key in memory only, with a TTL (default 7d in the UI, max 7d) and Lock. A restart locks every user.
  • autoTransitionYouTube opens the token on use. While locked it skips with a log line and never fails. Plaintext tokens are never used.
  • Migration: setting the passphrase seals any legacy plaintext token in the same request. Prod has 0 rows.

Tests: youtubeAutoLive.test.ts covers the sealed token path, locked and restart, no plaintext fallback, and TTL clamp/expiry. 6/6 pass.

🤖 Generated with Claude Code

…ive only while unlocked

youtube_credentials.refresh_token was stored in PLAINTEXT (RLS-protected,
but readable by anything with the service role or the database).

Now (seal v2):
- Settings > YouTube: the user sets a key passphrase in the browser
  (PBKDF2-SHA256 600k + HKDF -> KEK wraps a random DEK; recovery key shown
  once; the DEK seals an ECDH P-256 private key whose public half is stored).
- The OAuth callback seals the refresh token to the public key and refuses
  to store it without one: no new plaintext.
- Unlock sends the DEK (never the passphrase); the server keeps the opened key
  in memory only (TTL up to 7 days, Lock, restart = locked). Auto go-live opens
  the token on use and skips with a log line while locked. Plaintext tokens
  are never used.
- Migration: setting the passphrase seals a legacy plaintext token in the same
  request (prod has none today).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
});

it('never uses a plaintext token (no server-side fallback)', async () => {
mockMaybeSingle.mockResolvedValue({ data: { refresh_token: 'plaintext-rt' } });
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

53 finding(s)

HIGH/CRITICAL: 4 | MEDIUM: 29 | LOW: 20

Severity Rule Location
HIGH sh-eval-expansion .githooks/pre-commit:33
HIGH js-electron-node-integration apps/desktop/src/main/window.ts:49
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:717
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:921
MEDIUM sh-remote-script-execution apps/installer/scripts/install.sh:693
MEDIUM sh-remote-script-execution apps/installer/scripts/install.sh:824
MEDIUM sh-remote-script-execution apps/installer/scripts/install.sh:826
MEDIUM sh-remote-script-execution apps/installer/scripts/install.sh:1084
MEDIUM sh-remote-script-execution apps/installer/scripts/install.sh:1086
MEDIUM sh-remote-script-execution apps/livekit/setup-livekit-server.sh:93
MEDIUM sh-remote-script-execution apps/turn/deploy-droplet.sh:62
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:52
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:79
MEDIUM js-unescaped-html-sink apps/web/src/app/c/[handle]/page.tsx:199
MEDIUM js-open-redirect apps/web/src/app/cli/authorize/consent.tsx:68
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:146
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:230
MEDIUM js-unescaped-html-sink apps/web/src/app/layout.tsx:150
MEDIUM js-unescaped-html-sink apps/web/src/app/live/page.tsx:154
MEDIUM js-unescaped-html-sink apps/web/src/app/page.tsx:98
MEDIUM js-unescaped-html-sink apps/web/src/app/pricing/page.tsx:262
MEDIUM js-open-redirect apps/web/src/app/settings/copilot/copilot-settings.tsx:105
MEDIUM js-unescaped-html-sink apps/web/src/app/u/[username]/page.tsx:285
MEDIUM js-open-redirect apps/web/src/components/billing/useCheckout.ts:81
MEDIUM js-unescaped-html-sink apps/web/src/components/pit-footer.tsx:24
MEDIUM js-open-redirect apps/web/src/hooks/useDesktopHandoff.ts:24
MEDIUM redos-nested-quantifier apps/web/src/lib/deliverable.ts:11
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:124
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:393
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:396
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:405
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:608
MEDIUM sql-template-interpolation packages/ai-core/src/prompts.ts:36
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:138
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:139
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:340
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:341
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:371
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:372
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:383
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:397
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:398
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:405
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:415
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:416
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:453
LOW secret-generic-credential apps/desktop/src/main/agent-cli/commands.test.ts:454
LOW secret-generic-credential apps/desktop/src/main/agent-cli/copilot.test.ts:92
LOW secret-generic-credential apps/livekit/fly.toml:12
LOW secret-generic-credential apps/turn/fly.toml:11

…and 3 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sh1pt-actions-fleet
sh1pt-actions-fleet Bot merged commit e4f9558 into master Oct 28, 2026
14 checks passed
sh1pt-actions-fleet Bot added a commit that referenced this pull request Oct 28, 2026
copilot_settings.anthropic_key / openai_key / elevenlabs_key were AES-GCM
under the server env key COPILOT_KEYS_SECRET, so anyone with the box could
open every user's BYOK keys. They now use the seal v2 pieces from #165 (same
key passphrase and user_key_seals row as the YouTube token):

- Saving seals each key to the owner's public key (v2:), 409 before a key
  passphrase is set; the settings page can set one (recovery key shown once).
- Keys open only for a meeting the owner unlocked: Start sends the data key
  derived in the browser/desktop (never the passphrase); the private key is
  held in memory under copilot:<user>:<session> with a TTL and dropped when
  the copilot stops, the meeting ends, on Lock, or on restart.
- Locked keys mean free models only, never our paid provider in their place;
  the panel shows an unlock box (also mid-meeting after a restart).
- Legacy v1 values are never used for provider calls; they are re-sealed once
  in the owner's own request (key setup/unlock, copilot settings, start) and
  overwritten in place. LEGACY_KEY_MIGRATION=off is the cutoff.
- Migration adds copilot_settings.seal_version.

Co-authored-by: Anthony Ettinger <anthony@profullstack.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants