Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/web/src/app/api/keys/lock/route.ts
Original file line number Diff line number Diff line change
@@ -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 } });
}
21 changes: 21 additions & 0 deletions apps/web/src/app/api/keys/route.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});
}
77 changes: 77 additions & 0 deletions apps/web/src/app/api/keys/setup/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
26 changes: 26 additions & 0 deletions apps/web/src/app/api/keys/unlock/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
8 changes: 7 additions & 1 deletion apps/web/src/app/api/youtube/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/settings/youtube-connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +19,7 @@ export function YouTubeConnect() {
const t = useTranslations('settings.youtube');
const [status, setStatus] = useState<YtStatus | null>(null);
const [busy, setBusy] = useState(false);
const [keysState, setKeysState] = useState<string | null>(null);

const load = useCallback(async () => {
try {
Expand Down Expand Up @@ -80,7 +82,7 @@ export function YouTubeConnect() {
{t('disconnect')}
</button>
</div>
) : (
) : keysState === 'setup_required' ? null : (
<a
href="/api/youtube/auth"
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
Expand All @@ -89,6 +91,7 @@ export function YouTubeConnect() {
{t('connect')}
</a>
)}
{status?.configured && <YouTubeKeys onChange={setKeysState} />}
</div>
</div>
);
Expand Down
Loading
Loading