From 8a76ea2decc6aa89427b57a84b2ff417f1bb0209 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 21:17:51 +0000 Subject: [PATCH 1/2] Seal the YouTube refresh token under the user's passphrase; auto go-live 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 --- apps/web/src/app/api/keys/lock/route.ts | 11 + apps/web/src/app/api/keys/route.ts | 21 + apps/web/src/app/api/keys/setup/route.ts | 77 +++ apps/web/src/app/api/keys/unlock/route.ts | 26 + .../web/src/app/api/youtube/callback/route.ts | 8 +- apps/web/src/app/settings/youtube-connect.tsx | 5 +- apps/web/src/app/settings/youtube-keys.tsx | 244 ++++++++ apps/web/src/lib/sealed/crypto.ts | 529 ++++++++++++++++++ apps/web/src/lib/sealed/unlockStore.ts | 85 +++ apps/web/src/lib/sealed/vault.ts | 49 ++ apps/web/src/lib/youtubeAutoLive.test.ts | 53 +- apps/web/src/lib/youtubeAutoLive.ts | 13 +- ...28220000_user_key_seals_youtube_sealed.sql | 18 + 13 files changed, 1132 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/app/api/keys/lock/route.ts create mode 100644 apps/web/src/app/api/keys/route.ts create mode 100644 apps/web/src/app/api/keys/setup/route.ts create mode 100644 apps/web/src/app/api/keys/unlock/route.ts create mode 100644 apps/web/src/app/settings/youtube-keys.tsx create mode 100644 apps/web/src/lib/sealed/crypto.ts create mode 100644 apps/web/src/lib/sealed/unlockStore.ts create mode 100644 apps/web/src/lib/sealed/vault.ts create mode 100644 supabase/migrations/20261028220000_user_key_seals_youtube_sealed.sql diff --git a/apps/web/src/app/api/keys/lock/route.ts b/apps/web/src/app/api/keys/lock/route.ts new file mode 100644 index 00000000..e6a7c686 --- /dev/null +++ b/apps/web/src/app/api/keys/lock/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; +import { lockOwner } from '@/lib/sealed/unlockStore'; + +/** POST /api/keys/lock: drop the caller's key from memory. */ +export async function POST() { + const { user } = await getAuthenticatedUser(await createClient()); + if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + lockOwner(user.id); + return NextResponse.json({ data: { ok: true } }); +} diff --git a/apps/web/src/app/api/keys/route.ts b/apps/web/src/app/api/keys/route.ts new file mode 100644 index 00000000..7007bd89 --- /dev/null +++ b/apps/web/src/app/api/keys/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; +import { loadSeal } from '@/lib/sealed/vault'; +import { unlockStatus } from '@/lib/sealed/unlockStore'; + +/** GET /api/keys: the caller's seal record (ciphertext + public key) and lock state. */ +export async function GET() { + const { user } = await getAuthenticatedUser(await createClient()); + if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + const seal = await loadSeal(user.id); + const s = unlockStatus(user.id); + return NextResponse.json({ + data: { + userId: user.id, + seal, + unlocked: s.unlocked, + expiresAt: s.expiresAt, + state: !seal ? 'setup_required' : s.unlocked ? 'unlocked' : 'locked', + }, + }); +} diff --git a/apps/web/src/app/api/keys/setup/route.ts b/apps/web/src/app/api/keys/setup/route.ts new file mode 100644 index 00000000..c56bfe95 --- /dev/null +++ b/apps/web/src/app/api/keys/setup/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from 'next/server'; +import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; +import { serviceClient } from '@/lib/supabase/service'; +import { + base64ToBytes, + clampUnlockTtlSeconds, + isSealRecord, + isSealedValue, + openPrivateKey, + sealToPublicKey, + verifyDataKey, + wipe, +} from '@/lib/sealed/crypto'; +import { unlockOwner } from '@/lib/sealed/unlockStore'; +import { loadSeal } from '@/lib/sealed/vault'; + +/** + * POST /api/keys/setup { seal, dek, ttl_seconds?, reset? } + * Stores the seal the browser built from the passphrase, unlocks, and seals any + * legacy plaintext YouTube refresh token in the same request. + */ +export async function POST(request: Request) { + const { user } = await getAuthenticatedUser(await createClient()); + if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + const body = (await request.json().catch(() => ({}))) as { + seal?: unknown; + dek?: string; + ttl_seconds?: number; + reset?: boolean; + }; + if (!isSealRecord(body.seal) || typeof body.dek !== 'string') + return NextResponse.json({ error: 'invalid seal' }, { status: 400 }); + const dek = base64ToBytes(body.dek); + try { + if (!(await verifyDataKey(dek, body.seal, user.id))) + return NextResponse.json({ error: 'seal does not match the key' }, { status: 400 }); + if ((await loadSeal(user.id)) && body.reset !== true) { + return NextResponse.json({ error: 'a key passphrase is already set' }, { status: 409 }); + } + const sb = serviceClient(); + await sb + .from('user_key_seals' as never) + .upsert({ user_id: user.id, seal: body.seal, updated_at: new Date().toISOString() } as never); + + // One-shot migration: a plaintext refresh token is sealed now, or dropped + // after a reset (a token sealed to the old key can never open again). + const { data: row } = await sb + .from('youtube_credentials') + .select('refresh_token') + .eq('user_id', user.id) + .maybeSingle(); + const token = (row as { refresh_token?: string } | null)?.refresh_token; + if (token && !isSealedValue(token)) { + await sb + .from('youtube_credentials') + .update({ + refresh_token: await sealToPublicKey( + body.seal.public_key, + token, + user.id, + 'youtube_refresh_token' + ), + updated_at: new Date().toISOString(), + } as never) + .eq('user_id', user.id); + } + + unlockOwner( + user.id, + await openPrivateKey(dek, body.seal, user.id), + clampUnlockTtlSeconds(body.ttl_seconds) + ); + return NextResponse.json({ data: { ok: true } }); + } finally { + wipe(dek); + } +} diff --git a/apps/web/src/app/api/keys/unlock/route.ts b/apps/web/src/app/api/keys/unlock/route.ts new file mode 100644 index 00000000..8729a6be --- /dev/null +++ b/apps/web/src/app/api/keys/unlock/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; +import { base64ToBytes, clampUnlockTtlSeconds, openPrivateKey, wipe } from '@/lib/sealed/crypto'; +import { unlockOwner } from '@/lib/sealed/unlockStore'; +import { loadSeal } from '@/lib/sealed/vault'; + +/** POST /api/keys/unlock { dek, ttl_seconds? }: the data key, never the passphrase. Memory only. */ +export async function POST(request: Request) { + const { user } = await getAuthenticatedUser(await createClient()); + if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + const body = (await request.json().catch(() => ({}))) as { dek?: string; ttl_seconds?: number }; + const seal = await loadSeal(user.id); + if (!seal) return NextResponse.json({ error: 'no key passphrase set up' }, { status: 409 }); + if (typeof body.dek !== 'string') + return NextResponse.json({ error: 'dek required' }, { status: 400 }); + const dek = base64ToBytes(body.dek); + try { + const key = await openPrivateKey(dek, seal, user.id); + const { expiresAt } = unlockOwner(user.id, key, clampUnlockTtlSeconds(body.ttl_seconds)); + return NextResponse.json({ data: { ok: true, expiresAt: new Date(expiresAt).toISOString() } }); + } catch { + return NextResponse.json({ error: 'wrong key' }, { status: 403 }); + } finally { + wipe(dek); + } +} diff --git a/apps/web/src/app/api/youtube/callback/route.ts b/apps/web/src/app/api/youtube/callback/route.ts index 70bf4f50..10f42aeb 100644 --- a/apps/web/src/app/api/youtube/callback/route.ts +++ b/apps/web/src/app/api/youtube/callback/route.ts @@ -3,6 +3,7 @@ import { cookies } from 'next/headers'; import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; import { serviceClient } from '@/lib/supabase/service'; import { exchangeCodeForTokens } from '@/lib/youtube'; +import { sealForUser } from '@/lib/sealed/vault'; /** Google redirects here with ?code. Verify state, store the refresh token. */ export async function GET(request: Request) { @@ -31,13 +32,18 @@ export async function GET(request: Request) { return back('noref'); } + // Seal v2: the refresh token is sealed to the user's key and is never + // stored in plaintext. No key passphrase yet = no connect. + const sealed = await sealForUser(user.id, tokens.refresh_token, 'youtube_refresh_token'); + if (!sealed) return back('seal_required'); + // youtube_credentials isn't in the generated Database types yet (migration // ships in this PR), so the typed client infers `never` for the payload. await serviceClient() .from('youtube_credentials') .upsert({ user_id: user.id, - refresh_token: tokens.refresh_token, + refresh_token: sealed, scope: tokens.scope ?? null, updated_at: new Date().toISOString(), } as never); diff --git a/apps/web/src/app/settings/youtube-connect.tsx b/apps/web/src/app/settings/youtube-connect.tsx index 6c8c02af..68465b66 100644 --- a/apps/web/src/app/settings/youtube-connect.tsx +++ b/apps/web/src/app/settings/youtube-connect.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Radio, Check, Loader2 } from 'lucide-react'; import { useTranslations } from 'next-intl'; +import { YouTubeKeys } from './youtube-keys'; interface YtStatus { configured: boolean; @@ -18,6 +19,7 @@ export function YouTubeConnect() { const t = useTranslations('settings.youtube'); const [status, setStatus] = useState(null); const [busy, setBusy] = useState(false); + const [keysState, setKeysState] = useState(null); const load = useCallback(async () => { try { @@ -80,7 +82,7 @@ export function YouTubeConnect() { {t('disconnect')} - ) : ( + ) : keysState === 'setup_required' ? null : ( )} + {status?.configured && } ); diff --git a/apps/web/src/app/settings/youtube-keys.tsx b/apps/web/src/app/settings/youtube-keys.tsx new file mode 100644 index 00000000..898db4bb --- /dev/null +++ b/apps/web/src/app/settings/youtube-keys.tsx @@ -0,0 +1,244 @@ +'use client'; + +// The YouTube refresh token is sealed under a passphrase only you know. All +// passphrase work happens here in the browser; the server gets the seal +// (ciphertext + public key) and, to unlock, the derived data key — never the +// passphrase. Unlocked = PairUX can auto go-live for you until the unlock +// runs out (max 7 days) or you lock it. + +import { useCallback, useEffect, useState } from 'react'; +import { + bytesToBase64, + createSeal, + unlockSeal, + unlockSealWithRecovery, + wipe, + MIN_PASSPHRASE_LENGTH, + type SealRecord, +} from '@/lib/sealed/crypto'; + +interface KeysState { + userId: string; + seal: SealRecord | null; + unlocked: boolean; + expiresAt: string | null; + state: 'setup_required' | 'locked' | 'unlocked'; +} + +const TTLS: [string, number][] = [ + ['8 hours', 8 * 3600], + ['1 day', 86400], + ['7 days (max)', 7 * 86400], +]; + +function sealOf(k: KeysState): SealRecord { + if (!k.seal) throw new Error('No key passphrase set up yet.'); + return k.seal; +} + +export function YouTubeKeys({ onChange }: { onChange?: (state: KeysState['state']) => void }) { + const [keys, setKeys] = useState(null); + const [pass, setPass] = useState(''); + const [pass2, setPass2] = useState(''); + const [recovery, setRecovery] = useState(''); + const [ttl, setTtl] = useState(7 * 86400); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + const [shownRecovery, setShownRecovery] = useState(null); + + const load = useCallback(async () => { + const res = await fetch('/api/keys'); + if (res.ok) { + const json = (await res.json()) as { data: KeysState }; + setKeys(json.data); + onChange?.(json.data.state); + } + }, [onChange]); + + useEffect(() => { + void load(); + }, [load]); + + const post = async (path: string, body: unknown) => { + const res = await fetch(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const json = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) throw new Error(json.error ?? `HTTP ${String(res.status)}`); + }; + + const run = async (fn: () => Promise) => { + setBusy(true); + setMsg(null); + try { + await fn(); + await load(); + } catch (e) { + const err = e as Error; + setMsg(err.name === 'WrongPassphraseError' ? 'Wrong passphrase.' : err.message); + } finally { + setBusy(false); + } + }; + + if (!keys) return null; + const input = 'w-full rounded-lg border border-gray-200 px-3 py-2 text-sm'; + const button = + 'rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50'; + const ttlPicker = ( + + ); + + return ( +
+ {shownRecovery && ( +
+

Recovery key (shown once). Keep it somewhere safe:

+ {shownRecovery} + +
+ )} + {keys.state === 'setup_required' && ( +
+

+ First set a key passphrase. Your YouTube token is sealed under it and PairUX keeps no + key that can open it. If you lose both the passphrase and the recovery key, just + reconnect YouTube. +

+ { + setPass(e.target.value); + }} + /> + { + setPass2(e.target.value); + }} + /> +
+ {ttlPicker} + +
+
+ )} + {keys.state === 'locked' && ( +
+

+ Locked: auto go-live is paused until you unlock (after a server restart, too). +

+ { + setPass(e.target.value); + }} + /> + { + setRecovery(e.target.value); + }} + /> +
+ {ttlPicker} + +
+
+ )} + {keys.state === 'unlocked' && ( +
+ + Auto go-live unlocked until{' '} + {keys.expiresAt ? new Date(keys.expiresAt).toLocaleString() : ''}. + + +
+ )} + {msg &&

{msg}

} +
+ ); +} diff --git a/apps/web/src/lib/sealed/crypto.ts b/apps/web/src/lib/sealed/crypto.ts new file mode 100644 index 00000000..02d9e80d --- /dev/null +++ b/apps/web/src/lib/sealed/crypto.ts @@ -0,0 +1,529 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unnecessary-condition, @typescript-eslint/restrict-plus-operands, @typescript-eslint/prefer-for-of -- shared seal-v2 module, byte-compatible with the copies in crawlproof/outreachgraph */ +/** + * User-held sealing for every secret a user gives PairUX (seal v2). + * + * Isomorphic: WebCrypto only, so the same file runs in the browser (setup, + * unlock), the Next server, the worker and the CLI. Nothing stored on our + * servers can open a sealed secret; the opening key comes from the user. + * + * passphrase --PBKDF2-SHA256 600k, 16-byte salt--> ikm --HKDF--> KEK + * KEK wraps a random 32-byte data key (DEK) -> seal.wrapped_dek + * recovery key --HKDF--> RKEK wraps the same DEK -> seal.recovery_wrapped_dek + * DEK seals a fixed string -> seal.key_check + * DEK seals the private half of an ECDH P-256 key pair -> seal.private_sealed + * the public half is stored in the clear -> seal.public_key + * + * Every secret (OAuth token, app password, bot token, SMTP password, ...) is + * sealed to the PUBLIC key (ephemeral ECDH + HKDF + AES-256-GCM, AAD binds + * owner id + field). So the server can write a secret at any time (an OAuth + * callback, a token refresh in the worker) but can read one back only while + * the owner has unlocked: the browser/CLI derives the DEK from the passphrase + * and sends the DEK (never the passphrase); the server opens the private key + * with it and keeps that key in process memory with a TTL. + */ + +export const SEAL_VERSION = 2; +export const SEALED_PREFIX = 'v2:'; +export const PBKDF2_ITERATIONS = 600_000; +export const MIN_PBKDF2_ITERATIONS = 600_000; +export const MIN_PASSPHRASE_LENGTH = 10; + +const IV_BYTES = 12; +const TAG_BYTES = 16; +const KEY_CHECK_TEXT = 'pairux-key-check-v2'; + +export interface CipherBlob { + ciphertext: string; + iv: string; + tag: string; +} + +export interface SealKdf { + alg: 'PBKDF2-SHA256'; + iterations: number; + salt: string; +} + +/** Stored in user_key_seal.seal (jsonb). Nothing in here opens without the passphrase or recovery key. */ +export interface SealRecord { + v: 2; + kdf: SealKdf; + wrapped_dek: CipherBlob; + recovery_wrapped_dek: CipherBlob | null; + key_check: CipherBlob; + /** base64 raw uncompressed P-256 public key */ + public_key: string; + /** pkcs8 private key, sealed under the DEK */ + private_sealed: CipherBlob; +} + +const subtle = (): SubtleCrypto => { + const c = globalThis.crypto; + if (!c?.subtle) throw new Error('WebCrypto (crypto.subtle) is not available'); + return c.subtle; +}; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +type Bytes = Uint8Array; + +function buf(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +export function bytesToBase64(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!); + return btoa(s); +} + +export function base64ToBytes(b64: string): Bytes { + const s = atob(b64); + const out = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i); + return out; +} + +export function randomBytes(n: number): Bytes { + const out = new Uint8Array(n); + globalThis.crypto.getRandomValues(out); + return out; +} + +export function wipe(bytes: Uint8Array | null | undefined): void { + if (bytes) bytes.fill(0); +} + +function aad(ownerId: string, field: string): Bytes { + if (!ownerId) throw new Error('ownerId required for AAD'); + if (!field) throw new Error('field required for AAD'); + return enc.encode(`pairux:v2:${ownerId}:${field}`); +} + +async function gcmEncrypt( + key: CryptoKey, + plaintext: Uint8Array, + ad: Uint8Array +): Promise { + const iv = randomBytes(IV_BYTES); + const sealed = new Uint8Array( + await subtle().encrypt( + { name: 'AES-GCM', iv, additionalData: buf(ad), tagLength: TAG_BYTES * 8 }, + key, + buf(plaintext) + ) + ); + return { + ciphertext: bytesToBase64(sealed.slice(0, sealed.length - TAG_BYTES)), + iv: bytesToBase64(iv), + tag: bytesToBase64(sealed.slice(sealed.length - TAG_BYTES)), + }; +} + +async function gcmDecrypt(key: CryptoKey, blob: CipherBlob, ad: Uint8Array): Promise { + const ct = base64ToBytes(blob.ciphertext); + const tag = base64ToBytes(blob.tag); + const joined = new Uint8Array(ct.length + tag.length); + joined.set(ct, 0); + joined.set(tag, ct.length); + const out = await subtle().decrypt( + { + name: 'AES-GCM', + iv: base64ToBytes(blob.iv), + additionalData: buf(ad), + tagLength: TAG_BYTES * 8, + }, + key, + buf(joined) + ); + return new Uint8Array(out); +} + +async function aesKey(raw: Uint8Array): Promise { + if (raw.length !== 32) throw new Error(`data key must be 32 bytes (got ${raw.length})`); + return subtle().importKey('raw', buf(raw), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); +} + +async function hkdfAesKey(ikm: Uint8Array, salt: Uint8Array, info: string): Promise { + const base = await subtle().importKey('raw', buf(ikm), 'HKDF', false, ['deriveKey']); + return subtle().deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: buf(salt), info: buf(enc.encode(info)) }, + base, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** Passphrase -> KEK. PBKDF2-SHA256 (>= 600k) then HKDF, matching OpenCreds. */ +export async function deriveKek(passphrase: string, kdf: SealKdf): Promise { + if (kdf.alg !== 'PBKDF2-SHA256') throw new Error(`unsupported kdf ${kdf.alg}`); + if (kdf.iterations < MIN_PBKDF2_ITERATIONS) throw new Error('kdf iterations below policy'); + const salt = base64ToBytes(kdf.salt); + const pw = await subtle().importKey( + 'raw', + buf(enc.encode(passphrase.normalize('NFKC'))), + 'PBKDF2', + false, + ['deriveBits'] + ); + const bits = new Uint8Array( + await subtle().deriveBits( + { name: 'PBKDF2', hash: 'SHA-256', salt: buf(salt), iterations: kdf.iterations }, + pw, + 256 + ) + ); + try { + return await hkdfAesKey(bits, salt, 'pairux/kek/v2'); + } finally { + wipe(bits); + } +} + +async function deriveRecoveryKek(recoveryKey: string): Promise { + const raw = decodeRecoveryKey(recoveryKey); + try { + return await hkdfAesKey(raw, enc.encode('pairux/recovery'), 'pairux/recovery-kek/v2'); + } finally { + wipe(raw); + } +} + +const B32 = 'ABCDEFGHJKMNPQRSTVWXYZ0123456789'; + +export function encodeRecoveryKey(raw: Uint8Array): string { + let bits = 0; + let value = 0; + let out = ''; + for (const b of raw) { + value = (value << 8) | b; + bits += 8; + while (bits >= 5) { + out += B32[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += B32[(value << (5 - bits)) & 31]; + return out.match(/.{1,4}/g)!.join('-'); +} + +export function decodeRecoveryKey(text: string): Bytes { + const clean = text.toUpperCase().replace(/[^A-Z0-9]/g, ''); + let bits = 0; + let value = 0; + const out: number[] = []; + for (const ch of clean) { + const idx = B32.indexOf(ch); + if (idx < 0) throw new Error('invalid recovery key'); + value = ((value << 5) | idx) & 0xffff; + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + if (out.length < 32) throw new Error('invalid recovery key'); + return new Uint8Array(out.slice(0, 32)); +} + +export interface CreatedSeal { + dek: Bytes; + seal: SealRecord; + /** Show to the user exactly once; never stored. */ + recoveryKey: string; +} + +/** First-time setup (browser or CLI): new DEK + key pair, wrapped under the passphrase and a recovery key. */ +export async function createSeal( + passphrase: string, + ownerId: string, + iterations = PBKDF2_ITERATIONS +): Promise { + if (passphrase.length < MIN_PASSPHRASE_LENGTH) { + throw new Error(`passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`); + } + const dek = randomBytes(32); + const recoveryRaw = randomBytes(32); + const recoveryKey = encodeRecoveryKey(recoveryRaw); + wipe(recoveryRaw); + + const kdf: SealKdf = { alg: 'PBKDF2-SHA256', iterations, salt: bytesToBase64(randomBytes(16)) }; + const kek = await deriveKek(passphrase, kdf); + const wrapped_dek = await gcmEncrypt(kek, dek, aad(ownerId, 'dek:passphrase')); + const rkek = await deriveRecoveryKek(recoveryKey); + const recovery_wrapped_dek = await gcmEncrypt(rkek, dek, aad(ownerId, 'dek:recovery')); + const dk = await aesKey(dek); + const key_check = await gcmEncrypt(dk, enc.encode(KEY_CHECK_TEXT), aad(ownerId, 'key_check')); + const pair = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [ + 'deriveBits', + ]); + const pub = new Uint8Array(await subtle().exportKey('raw', pair.publicKey)); + const priv = new Uint8Array(await subtle().exportKey('pkcs8', pair.privateKey)); + const private_sealed = await gcmEncrypt(dk, priv, aad(ownerId, 'private_key')); + wipe(priv); + return { + dek, + recoveryKey, + seal: { + v: 2, + kdf, + wrapped_dek, + recovery_wrapped_dek, + key_check, + public_key: bytesToBase64(pub), + private_sealed, + }, + }; +} + +/** Change passphrase: same DEK and key pair, new salt and wrap. */ +export async function rewrapSeal( + dek: Uint8Array, + seal: SealRecord, + newPassphrase: string, + ownerId: string +): Promise { + if (newPassphrase.length < MIN_PASSPHRASE_LENGTH) { + throw new Error(`passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`); + } + if (!(await verifyDataKey(dek, seal, ownerId))) + throw new WrongPassphraseError('key check failed'); + const kdf: SealKdf = { + alg: 'PBKDF2-SHA256', + iterations: Math.max(seal.kdf.iterations, PBKDF2_ITERATIONS), + salt: bytesToBase64(randomBytes(16)), + }; + const kek = await deriveKek(newPassphrase, kdf); + const wrapped_dek = await gcmEncrypt(kek, dek, aad(ownerId, 'dek:passphrase')); + return { ...seal, kdf, wrapped_dek }; +} + +export class WrongPassphraseError extends Error { + constructor(msg = 'wrong passphrase') { + super(msg); + this.name = 'WrongPassphraseError'; + } +} + +/** Passphrase -> DEK (client side). */ +export async function unlockSeal( + seal: SealRecord, + passphrase: string, + ownerId: string +): Promise { + const kek = await deriveKek(passphrase, seal.kdf); + let dek: Bytes; + try { + dek = await gcmDecrypt(kek, seal.wrapped_dek, aad(ownerId, 'dek:passphrase')); + } catch { + throw new WrongPassphraseError(); + } + if (!(await verifyDataKey(dek, seal, ownerId))) + throw new WrongPassphraseError('key check failed'); + return dek; +} + +export async function unlockSealWithRecovery( + seal: SealRecord, + recoveryKey: string, + ownerId: string +): Promise { + if (!seal.recovery_wrapped_dek) throw new WrongPassphraseError('no recovery key on file'); + let dek: Bytes; + try { + const rkek = await deriveRecoveryKek(recoveryKey); + dek = await gcmDecrypt(rkek, seal.recovery_wrapped_dek, aad(ownerId, 'dek:recovery')); + } catch { + throw new WrongPassphraseError('wrong recovery key'); + } + if (!(await verifyDataKey(dek, seal, ownerId))) + throw new WrongPassphraseError('key check failed'); + return dek; +} + +export async function verifyDataKey( + dek: Uint8Array, + seal: Pick, + ownerId: string +): Promise { + if (dek.length !== 32) return false; + try { + const out = await gcmDecrypt(await aesKey(dek), seal.key_check, aad(ownerId, 'key_check')); + return dec.decode(out) === KEY_CHECK_TEXT; + } catch { + return false; + } +} + +/** Server side, at unlock: DEK -> non-extractable private key. Throws WrongPassphraseError on a bad DEK. */ +export async function openPrivateKey( + dek: Uint8Array, + seal: SealRecord, + ownerId: string +): Promise { + if (!(await verifyDataKey(dek, seal, ownerId))) + throw new WrongPassphraseError('key check failed'); + const pkcs8 = await gcmDecrypt( + await aesKey(dek), + seal.private_sealed, + aad(ownerId, 'private_key') + ); + try { + return await subtle().importKey( + 'pkcs8', + buf(pkcs8), + { name: 'ECDH', namedCurve: 'P-256' }, + false, + ['deriveBits'] + ); + } finally { + wipe(pkcs8); + } +} + +async function boxKey( + shared: Uint8Array, + epk: Uint8Array, + ownerId: string, + field: string +): Promise { + return hkdfAesKey(shared, epk, `pairux/box/v2/${ownerId}/${field}`); +} + +/** Seal a secret to the owner's public key. Output: "v2:" + base64(JSON {e, c, i, t}). */ +export async function sealToPublicKey( + publicKeyB64: string, + plaintext: string, + ownerId: string, + field: string +): Promise { + const recipient = await subtle().importKey( + 'raw', + buf(base64ToBytes(publicKeyB64)), + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + const eph = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [ + 'deriveBits', + ]); + const shared = new Uint8Array( + await subtle().deriveBits({ name: 'ECDH', public: recipient }, eph.privateKey, 256) + ); + const epk = new Uint8Array(await subtle().exportKey('raw', eph.publicKey)); + try { + const key = await boxKey(shared, epk, ownerId, field); + const blob = await gcmEncrypt(key, enc.encode(plaintext), aad(ownerId, field)); + const body = JSON.stringify({ + e: bytesToBase64(epk), + c: blob.ciphertext, + i: blob.iv, + t: blob.tag, + }); + return SEALED_PREFIX + btoa(body); + } finally { + wipe(shared); + } +} + +export function isSealedValue(v: unknown): v is string { + return typeof v === 'string' && v.startsWith(SEALED_PREFIX); +} + +/** Open a "v2:" value with the owner's private key (only present while unlocked). */ +export async function openWithPrivateKey( + privateKey: CryptoKey, + sealed: string, + ownerId: string, + field: string +): Promise { + if (!isSealedValue(sealed)) throw new Error('not a sealed v2 value'); + const body = JSON.parse(atob(sealed.slice(SEALED_PREFIX.length))) as { + e: string; + c: string; + i: string; + t: string; + }; + const epk = base64ToBytes(body.e); + const pub = await subtle().importKey( + 'raw', + buf(epk), + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + const shared = new Uint8Array( + await subtle().deriveBits({ name: 'ECDH', public: pub }, privateKey, 256) + ); + try { + const key = await boxKey(shared, epk, ownerId, field); + const out = await gcmDecrypt( + key, + { ciphertext: body.c, iv: body.i, tag: body.t }, + aad(ownerId, field) + ); + const text = dec.decode(out); + wipe(out); + return text; + } finally { + wipe(shared); + } +} + +export function isCipherBlob(v: unknown): v is CipherBlob { + if (!v || typeof v !== 'object') return false; + const b = v as Record; + return ( + typeof b.ciphertext === 'string' && + typeof b.iv === 'string' && + typeof b.tag === 'string' && + b.iv.length > 0 && + b.tag.length > 0 + ); +} + +export function isSealRecord(v: unknown): v is SealRecord { + if (!v || typeof v !== 'object') return false; + const s = v as Record; + const kdf = s.kdf as Record | undefined; + return ( + s.v === 2 && + !!kdf && + kdf.alg === 'PBKDF2-SHA256' && + typeof kdf.iterations === 'number' && + kdf.iterations >= MIN_PBKDF2_ITERATIONS && + typeof kdf.salt === 'string' && + isCipherBlob(s.wrapped_dek) && + isCipherBlob(s.key_check) && + isCipherBlob(s.private_sealed) && + (s.recovery_wrapped_dek === null || isCipherBlob(s.recovery_wrapped_dek)) && + typeof s.public_key === 'string' && + s.public_key.length > 0 + ); +} + +// ----------------------------------------------------- unlock TTL policy --- + +export const UNLOCK_DEFAULT_TTL_SECONDS = 8 * 3600; +export const OPERATOR_DEFAULT_TTL_SECONDS = 7 * 24 * 3600; +export const UNLOCK_MAX_TTL_SECONDS = 7 * 24 * 3600; +export const UNLOCK_MIN_TTL_SECONDS = 60; + +export function clampUnlockTtlSeconds(v: unknown, fallback = UNLOCK_DEFAULT_TTL_SECONDS): number { + const n = typeof v === 'number' ? v : typeof v === 'string' ? Number.parseInt(v, 10) : NaN; + if (!Number.isFinite(n)) return fallback; + return Math.min(UNLOCK_MAX_TTL_SECONDS, Math.max(UNLOCK_MIN_TTL_SECONDS, Math.floor(n))); +} + +/** "8h", "30m", "7d", "3600" -> seconds (clamped). */ +export function parseTtl(text: string | undefined, fallback = UNLOCK_DEFAULT_TTL_SECONDS): number { + if (!text) return fallback; + const m = /^(\d+)\s*([smhd]?)$/i.exec(text.trim()); + if (!m) throw new Error(`bad ttl "${text}" (use e.g. 30m, 8h, 7d)`); + const mult = { '': 1, s: 1, m: 60, h: 3600, d: 86400 }[ + (m[2] ?? '').toLowerCase() as '' | 's' | 'm' | 'h' | 'd' + ]; + return clampUnlockTtlSeconds(Number(m[1]) * mult, fallback); +} diff --git a/apps/web/src/lib/sealed/unlockStore.ts b/apps/web/src/lib/sealed/unlockStore.ts new file mode 100644 index 00000000..8b704b68 --- /dev/null +++ b/apps/web/src/lib/sealed/unlockStore.ts @@ -0,0 +1,85 @@ +/** + * In-process unlock store. Holds each unlocked owner's private key (a + * non-extractable CryptoKey) in memory only: never the DB, Redis, disk, logs + * or process.env. A restart empties it, so every owner is locked again and + * their jobs wait ("locked: unlock to resume") until they unlock. + * + * Kept on globalThis so the Next server's route bundles share one store. + */ + +import { clampUnlockTtlSeconds } from './crypto'; + +interface Entry { + privateKey: CryptoKey; + unlockedAt: number; + expiresAt: number; +} + +const KEY = Symbol.for('pairux.sealed.unlockStore'); +type G = typeof globalThis & { [KEY]?: Map }; + +function store(): Map { + const g = globalThis as G; + return (g[KEY] ??= new Map()); +} + +export function unlockOwner( + ownerId: string, + privateKey: CryptoKey, + ttlSeconds: number, + now = Date.now() +): { expiresAt: number } { + const ttl = clampUnlockTtlSeconds(ttlSeconds); + const expiresAt = now + ttl * 1000; + store().set(ownerId, { privateKey, unlockedAt: now, expiresAt }); + return { expiresAt }; +} + +export function lockOwner(ownerId: string): boolean { + return store().delete(ownerId); +} + +/** The owner's private key, or null when locked or expired (an expired entry is dropped). */ +export function privateKeyFor(ownerId: string, now = Date.now()): CryptoKey | null { + const e = store().get(ownerId); + if (!e) return null; + if (e.expiresAt <= now) { + store().delete(ownerId); + return null; + } + return e.privateKey; +} + +export function isUnlocked(ownerId: string, now = Date.now()): boolean { + return privateKeyFor(ownerId, now) !== null; +} + +export function unlockStatus( + ownerId: string, + now = Date.now() +): { unlocked: boolean; unlockedAt: string | null; expiresAt: string | null } { + const e = store().get(ownerId); + if (!e || e.expiresAt <= now) { + if (e) store().delete(ownerId); + return { unlocked: false, unlockedAt: null, expiresAt: null }; + } + return { + unlocked: true, + unlockedAt: new Date(e.unlockedAt).toISOString(), + expiresAt: new Date(e.expiresAt).toISOString(), + }; +} + +/** Test hook / simulated restart. */ +export function clearAllUnlocks(): void { + store().clear(); +} + +export function unlockedCount(now = Date.now()): number { + let n = 0; + for (const [id, e] of store()) { + if (e.expiresAt > now) n++; + else store().delete(id); + } + return n; +} diff --git a/apps/web/src/lib/sealed/vault.ts b/apps/web/src/lib/sealed/vault.ts new file mode 100644 index 00000000..29f195a0 --- /dev/null +++ b/apps/web/src/lib/sealed/vault.ts @@ -0,0 +1,49 @@ +/** + * Server side of seal v2 for PairUX (the YouTube refresh token). + * + * seal(): to the user's PUBLIC key, no unlock needed (OAuth callback). + * open(): needs the user's private key, held in this process's memory only + * while they are unlocked (Settings > YouTube > Unlock, TTL max 7 days). + * Locked = auto go-live is skipped, never crashes; plaintext is never used. + */ +import { serviceClient } from '@/lib/supabase/service'; +import { + isSealRecord, + isSealedValue, + openWithPrivateKey, + sealToPublicKey, + type SealRecord, +} from './crypto'; +import { privateKeyFor } from './unlockStore'; + +export async function loadSeal(userId: string): Promise { + const { data } = await serviceClient() + .from('user_key_seals' as never) + .select('seal') + .eq('user_id', userId) + .maybeSingle(); + const seal = (data as { seal?: unknown } | null)?.seal; + return isSealRecord(seal) ? seal : null; +} + +export async function sealForUser( + userId: string, + plaintext: string, + field: string +): Promise { + const seal = await loadSeal(userId); + if (!seal) return null; + return sealToPublicKey(seal.public_key, plaintext, userId, field); +} + +/** The opened secret, or null when the user is locked / the value is not sealed. */ +export async function openForUser( + userId: string, + stored: string, + field: string +): Promise { + if (!isSealedValue(stored)) return null; + const key = privateKeyFor(userId); + if (!key) return null; + return openWithPrivateKey(key, stored, userId, field); +} diff --git a/apps/web/src/lib/youtubeAutoLive.test.ts b/apps/web/src/lib/youtubeAutoLive.test.ts index e2044af2..69979525 100644 --- a/apps/web/src/lib/youtubeAutoLive.test.ts +++ b/apps/web/src/lib/youtubeAutoLive.test.ts @@ -18,14 +18,37 @@ vi.mock('@/lib/youtube', () => ({ })); import { autoTransitionYouTube } from './youtubeAutoLive'; +import { + createSeal, + openPrivateKey, + sealToPublicKey, + parseTtl, + UNLOCK_MAX_TTL_SECONDS, +} from './sealed/crypto'; +import { clearAllUnlocks, lockOwner, unlockOwner, privateKeyFor } from './sealed/unlockStore'; + +let sealedRt = ''; +let key: CryptoKey; describe('autoTransitionYouTube', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + clearAllUnlocks(); + if (!sealedRt) { + const made = await createSeal('correct horse battery', 'user-1'); + key = await openPrivateKey(made.dek, made.seal, 'user-1'); + sealedRt = await sealToPublicKey( + made.seal.public_key, + 'rt', + 'user-1', + 'youtube_refresh_token' + ); + } + unlockOwner('user-1', key, 3600); }); it('transitions the first ready broadcast to live', async () => { - mockMaybeSingle.mockResolvedValue({ data: { refresh_token: 'rt' } }); + mockMaybeSingle.mockResolvedValue({ data: { refresh_token: sealedRt } }); mockRefresh.mockResolvedValue('access-tok'); mockFind.mockResolvedValue([{ id: 'b1', lifeCycleStatus: 'ready', title: 'X' }]); mockTransition.mockResolvedValue(undefined); @@ -46,10 +69,34 @@ describe('autoTransitionYouTube', () => { }); it('never throws when the YouTube API fails', async () => { - mockMaybeSingle.mockResolvedValue({ data: { refresh_token: 'rt' } }); + mockMaybeSingle.mockResolvedValue({ data: { refresh_token: sealedRt } }); mockRefresh.mockRejectedValue(new Error('google down')); await expect(autoTransitionYouTube('user-1')).resolves.toBeUndefined(); expect(mockTransition).not.toHaveBeenCalled(); }); + + it('skips (never fails) while the user is locked, and after a restart', async () => { + mockMaybeSingle.mockResolvedValue({ data: { refresh_token: sealedRt } }); + lockOwner('user-1'); + await expect(autoTransitionYouTube('user-1')).resolves.toBeUndefined(); + unlockOwner('user-1', key, 3600); + clearAllUnlocks(); // restart + await autoTransitionYouTube('user-1'); + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + it('never uses a plaintext token (no server-side fallback)', async () => { + mockMaybeSingle.mockResolvedValue({ data: { refresh_token: 'plaintext-rt' } }); + await autoTransitionYouTube('user-1'); + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + it('unlock TTL is clamped to 7 days and expires', () => { + const now = 1_000; + const { expiresAt } = unlockOwner('user-2', key, 30 * 86400, now); + expect(expiresAt - now).toBe(UNLOCK_MAX_TTL_SECONDS * 1000); + expect(privateKeyFor('user-2', expiresAt + 1)).toBeNull(); + expect(parseTtl('8h')).toBe(8 * 3600); + }); }); diff --git a/apps/web/src/lib/youtubeAutoLive.ts b/apps/web/src/lib/youtubeAutoLive.ts index 832476c3..b0fb7bd0 100644 --- a/apps/web/src/lib/youtubeAutoLive.ts +++ b/apps/web/src/lib/youtubeAutoLive.ts @@ -5,6 +5,7 @@ import { transitionToLive, youtubeOAuthConfigured, } from '@/lib/youtube'; +import { openForUser } from '@/lib/sealed/vault'; /** How long/often to poll for a broadcast that's ready to go live. */ const POLL_INTERVAL_MS = 6_000; @@ -32,8 +33,16 @@ export async function autoTransitionYouTube(userId: string): Promise { try { if (!youtubeOAuthConfigured()) return; - const refreshToken = await getRefreshToken(userId); - if (!refreshToken) return; // user hasn't connected YouTube + const stored = await getRefreshToken(userId); + if (!stored) return; // user hasn't connected YouTube + // Seal v2: opens only while the user is unlocked; locked = skip, not fail. + const refreshToken = await openForUser(userId, stored, 'youtube_refresh_token'); + if (!refreshToken) { + console.warn( + `[youtube] auto go-live skipped for user ${userId}: keys locked (unlock in Settings)` + ); + return; + } const accessToken = await refreshAccessToken(refreshToken); diff --git a/supabase/migrations/20261028220000_user_key_seals_youtube_sealed.sql b/supabase/migrations/20261028220000_user_key_seals_youtube_sealed.sql new file mode 100644 index 00000000..ae7476fa --- /dev/null +++ b/supabase/migrations/20261028220000_user_key_seals_youtube_sealed.sql @@ -0,0 +1,18 @@ +-- Seal v2 for the YouTube refresh token (was PLAINTEXT). +-- +-- Each user sets a key passphrase in Settings. The browser derives a key from +-- it (PBKDF2-SHA256 600k + HKDF) and stores here only the seal: wrapped data +-- key, recovery wrap, key check and an ECDH public key. The OAuth callback seals +-- the refresh token to that public key ("v2:" values); only the user's unlock +-- (held in server memory with a TTL, max 7 days) opens it for auto go-live. +-- Service role only: RLS on, no policies. +CREATE TABLE IF NOT EXISTS public.user_key_seals ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + seal JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +ALTER TABLE public.user_key_seals ENABLE ROW LEVEL SECURITY; + +COMMENT ON COLUMN public.youtube_credentials.refresh_token IS + 'Sealed (v2:) to the user''s key; legacy plaintext rows are sealed when the user sets a key passphrase.'; From fc7dfa43f0f1ee8b8fd2a1719d252a3fe7c23150 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 21:22:00 +0000 Subject: [PATCH 2/2] lint: explicit global store init Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/sealed/unlockStore.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/sealed/unlockStore.ts b/apps/web/src/lib/sealed/unlockStore.ts index 8b704b68..6ee70412 100644 --- a/apps/web/src/lib/sealed/unlockStore.ts +++ b/apps/web/src/lib/sealed/unlockStore.ts @@ -20,7 +20,11 @@ type G = typeof globalThis & { [KEY]?: Map }; function store(): Map { const g = globalThis as G; - return (g[KEY] ??= new Map()); + const existing: Map | undefined = g[KEY]; + if (existing) return existing; + const created = new Map(); + g[KEY] = created; + return created; } export function unlockOwner(