From 35227ecd61819b0efc836f78ee2827aa9f5e5356 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 8 May 2026 02:05:04 +0800 Subject: [PATCH] feat(sync): wire crypto session for encrypted-field sync (#4084) Adds the per-account PBKDF2 salt endpoint and an in-memory CryptoSession that derives keys lazily per saltId. Lets future kinds (OPDS catalogs in PR 4b) encrypt/decrypt fields without re-deriving on every operation. - Migration 008: replica_keys_{create,list} RPCs round-trip the bytea salt as base64; SECURITY INVOKER, RLS-gated by the existing replica_keys policies. - /api/sync/replica-keys GET/POST endpoint matches the dual app/pages shape used by /api/sync/replicas. - ReplicaSyncClient.{listReplicaKeys,createReplicaKey} wraps the endpoint. - CryptoSession.{unlock,setup,encryptField,decryptField,lock} caches derived keys per saltId; foreign envelopes trigger a lazy re-list + derive. Iterations injectable so tests run with PBKDF2 ITER=1000. Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/__tests__/libs/crypto/session.test.ts | 178 ++++++++++++++++++ apps/readest-app/src/libs/crypto/session.ts | 133 +++++++++++++ .../readest-app/src/libs/replicaSyncClient.ts | 63 +++++++ .../src/pages/api/sync/replica-keys.ts | 124 ++++++++++++ .../db/migrations/008_replica_keys_rpcs.sql | 44 +++++ 5 files changed, 542 insertions(+) create mode 100644 apps/readest-app/src/__tests__/libs/crypto/session.test.ts create mode 100644 apps/readest-app/src/libs/crypto/session.ts create mode 100644 apps/readest-app/src/pages/api/sync/replica-keys.ts create mode 100644 docker/volumes/db/migrations/008_replica_keys_rpcs.sql diff --git a/apps/readest-app/src/__tests__/libs/crypto/session.test.ts b/apps/readest-app/src/__tests__/libs/crypto/session.test.ts new file mode 100644 index 00000000..ae241f0e --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/crypto/session.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { CryptoSession } from '@/libs/crypto/session'; +import { CURRENT_ALG, encryptToEnvelope } from '@/libs/crypto/envelope'; +import { derivePbkdf2Key } from '@/libs/crypto/derive'; +import { isSyncError, SyncError } from '@/libs/errors'; +import type { ReplicaKeyRow } from '@/libs/replicaSyncClient'; + +const ITER = 1000; +const PBKDF2_ALG = 'pbkdf2-600k-sha256'; + +const bytesToBase64 = (bytes: Uint8Array): string => { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!); + return btoa(s); +}; + +const makeSaltRow = (saltId: string, createdAt: string): ReplicaKeyRow => { + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) bytes[i] = (i + saltId.length) & 0xff; + return { + saltId, + alg: PBKDF2_ALG, + salt: bytesToBase64(bytes), + createdAt, + }; +}; + +class FakeClient { + rows: ReplicaKeyRow[] = []; + listCalls = 0; + createCalls = 0; + + async listReplicaKeys(): Promise { + this.listCalls += 1; + return [...this.rows].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } + + async createReplicaKey(alg: string): Promise { + this.createCalls += 1; + const row = makeSaltRow(`salt-${this.rows.length + 1}`, new Date().toISOString()); + if (alg !== PBKDF2_ALG) throw new SyncError('UNSUPPORTED_ALG', `bad alg: ${alg}`); + this.rows.push(row); + return row; + } +} + +const makeSession = (client: FakeClient) => new CryptoSession({ client, iterations: ITER }); + +describe('CryptoSession', () => { + let client: FakeClient; + let session: CryptoSession; + + beforeEach(() => { + client = new FakeClient(); + session = makeSession(client); + }); + + test('starts locked', () => { + expect(session.isUnlocked()).toBe(false); + }); + + test('unlock() throws NO_PASSPHRASE when account has no salts', async () => { + await expect(session.unlock('pw')).rejects.toMatchObject({ + name: 'SyncError', + code: 'NO_PASSPHRASE', + }); + expect(session.isUnlocked()).toBe(false); + }); + + test('setup() creates a salt and unlocks the session', async () => { + await session.setup('pw'); + expect(session.isUnlocked()).toBe(true); + expect(client.createCalls).toBe(1); + expect(client.rows).toHaveLength(1); + }); + + test('encryptField → decryptField round-trip', async () => { + await session.setup('correct-horse'); + const env = await session.encryptField('hunter2'); + expect(env.alg).toBe(CURRENT_ALG); + const plain = await session.decryptField(env); + expect(plain).toBe('hunter2'); + }); + + test('unlock() picks the newest salt by createdAt', async () => { + client.rows.push( + makeSaltRow('salt-old', '2026-01-01T00:00:00Z'), + makeSaltRow('salt-new', '2026-05-01T00:00:00Z'), + ); + await session.unlock('pw'); + const env = await session.encryptField('x'); + expect(env.s).toBe('salt-new'); + }); + + test('encryptField throws NO_PASSPHRASE before unlock', async () => { + await expect(session.encryptField('x')).rejects.toMatchObject({ + name: 'SyncError', + code: 'NO_PASSPHRASE', + }); + }); + + test('decryptField for a foreign salt re-fetches and derives', async () => { + const otherSession = makeSession(client); + await otherSession.setup('pw'); + const env = await otherSession.encryptField('foreign-secret'); + + const fresh = makeSession(client); + await fresh.unlock('pw'); + const callsBefore = client.listCalls; + const plain = await fresh.decryptField(env); + expect(plain).toBe('foreign-secret'); + expect(client.listCalls).toBe(callsBefore); + }); + + test('decryptField throws SALT_NOT_FOUND for unknown saltId', async () => { + await session.setup('pw'); + const env = await session.encryptField('x'); + const tampered = { ...env, s: 'no-such-salt' }; + await expect(session.decryptField(tampered)).rejects.toMatchObject({ + name: 'SyncError', + code: 'SALT_NOT_FOUND', + }); + }); + + test('decryptField throws UNSUPPORTED_ALG for foreign alg envelope', async () => { + await session.setup('pw'); + const env = await session.encryptField('x'); + const foreign = { ...env, alg: 'rot13/none' }; + await expect(session.decryptField(foreign)).rejects.toMatchObject({ + name: 'SyncError', + code: 'UNSUPPORTED_ALG', + }); + }); + + test('wrong passphrase decrypt fails (DECRYPT or INTEGRITY)', async () => { + const right = makeSession(client); + await right.setup('correct'); + const env = await right.encryptField('secret'); + + const wrong = makeSession(client); + await wrong.unlock('wrong'); + let caught: unknown = null; + try { + await wrong.decryptField(env); + } catch (e) { + caught = e; + } + expect(caught).not.toBeNull(); + expect(isSyncError(caught)).toBe(true); + expect((caught as SyncError).code).toMatch(/DECRYPT|INTEGRITY/); + }); + + test('lock() clears state; encryptField throws after lock', async () => { + await session.setup('pw'); + expect(session.isUnlocked()).toBe(true); + session.lock(); + expect(session.isUnlocked()).toBe(false); + await expect(session.encryptField('x')).rejects.toMatchObject({ code: 'NO_PASSPHRASE' }); + }); + + test('keys are cached: derive runs once per saltId', async () => { + await session.setup('pw'); + const env1 = await session.encryptField('a'); + const env2 = await session.encryptField('b'); + expect(env1.s).toBe(env2.s); + // Sanity: deriving directly produces the same key bytes — proves we use + // the same salt+passphrase pair across calls. + const salt = client.rows[0]!; + const directKey = await derivePbkdf2Key( + 'pw', + Uint8Array.from(atob(salt.salt), (c) => c.charCodeAt(0)), + ITER, + ); + const direct = await encryptToEnvelope('a', directKey, salt.saltId); + const recovered = await session.decryptField(direct); + expect(recovered).toBe('a'); + }); +}); diff --git a/apps/readest-app/src/libs/crypto/session.ts b/apps/readest-app/src/libs/crypto/session.ts new file mode 100644 index 00000000..af2da4db --- /dev/null +++ b/apps/readest-app/src/libs/crypto/session.ts @@ -0,0 +1,133 @@ +import { SyncError } from '@/libs/errors'; +import type { CipherEnvelope } from '@/types/replica'; +import { replicaSyncClient } from '@/libs/replicaSyncClient'; +import type { ReplicaKeyRow, ReplicaSyncClient } from '@/libs/replicaSyncClient'; +import { derivePbkdf2Key } from './derive'; +import { CURRENT_ALG, decryptFromEnvelope, encryptToEnvelope } from './envelope'; + +const PBKDF2_ALG = 'pbkdf2-600k-sha256'; + +const base64ToBytes = (b64: string): Uint8Array => { + 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; +}; + +interface KnownSalt { + saltId: string; + alg: string; + bytes: Uint8Array; +} + +export interface CryptoSessionDeps { + client?: Pick; + /** Override PBKDF2 iterations. Tests pass a low value; production omits. */ + iterations?: number; +} + +export class CryptoSession { + private passphrase: string | null = null; + private salts = new Map(); + private keys = new Map(); + private activeSaltId: string | null = null; + private readonly client: Pick; + private readonly iterations: number | undefined; + + constructor(deps: CryptoSessionDeps = {}) { + this.client = deps.client ?? replicaSyncClient; + this.iterations = deps.iterations; + } + + isUnlocked(): boolean { + return this.passphrase !== null && this.activeSaltId !== null; + } + + /** Drop in-memory passphrase and derived keys. Idempotent. */ + lock(): void { + this.passphrase = null; + this.salts.clear(); + this.keys.clear(); + this.activeSaltId = null; + } + + /** + * Derive against the user's existing newest salt. Throws NO_PASSPHRASE if + * the account has no salt yet — callers must call setup() instead. + */ + async unlock(passphrase: string): Promise { + const rows = await this.client.listReplicaKeys(); + if (rows.length === 0) { + throw new SyncError( + 'NO_PASSPHRASE', + 'No replica_keys row exists for this account. Call setup() to create one.', + ); + } + this.ingestRows(rows); + this.passphrase = passphrase; + this.activeSaltId = rows[0]!.saltId; + await this.deriveKeyFor(this.activeSaltId); + } + + /** + * Create a fresh salt server-side, then derive against it. Used on first + * passphrase setup. If a salt already exists this still appends a new one, + * matching passphrase-rotation semantics. + */ + async setup(passphrase: string): Promise { + const row = await this.client.createReplicaKey(PBKDF2_ALG); + this.ingestRows([row]); + this.passphrase = passphrase; + this.activeSaltId = row.saltId; + await this.deriveKeyFor(row.saltId); + } + + async encryptField(plaintext: string): Promise { + if (!this.activeSaltId) { + throw new SyncError('NO_PASSPHRASE', 'CryptoSession is locked'); + } + const key = await this.deriveKeyFor(this.activeSaltId); + return encryptToEnvelope(plaintext, key, this.activeSaltId); + } + + async decryptField(envelope: CipherEnvelope): Promise { + if (envelope.alg !== CURRENT_ALG) { + throw new SyncError('UNSUPPORTED_ALG', `Unsupported envelope alg: ${envelope.alg}`); + } + const key = await this.deriveKeyFor(envelope.s); + return decryptFromEnvelope(envelope, key); + } + + private ingestRows(rows: ReplicaKeyRow[]): void { + for (const row of rows) { + if (row.alg !== PBKDF2_ALG) continue; + this.salts.set(row.saltId, { + saltId: row.saltId, + alg: row.alg, + bytes: base64ToBytes(row.salt), + }); + } + } + + private async deriveKeyFor(saltId: string): Promise { + const cached = this.keys.get(saltId); + if (cached) return cached; + if (!this.passphrase) { + throw new SyncError('NO_PASSPHRASE', 'CryptoSession is locked'); + } + let salt = this.salts.get(saltId); + if (!salt) { + const rows = await this.client.listReplicaKeys(); + this.ingestRows(rows); + salt = this.salts.get(saltId); + if (!salt) { + throw new SyncError('SALT_NOT_FOUND', `Unknown saltId: ${saltId}`); + } + } + const key = await derivePbkdf2Key(this.passphrase, salt.bytes, this.iterations); + this.keys.set(saltId, key); + return key; + } +} + +export const cryptoSession = new CryptoSession(); diff --git a/apps/readest-app/src/libs/replicaSyncClient.ts b/apps/readest-app/src/libs/replicaSyncClient.ts index 4e261caf..695e61ed 100644 --- a/apps/readest-app/src/libs/replicaSyncClient.ts +++ b/apps/readest-app/src/libs/replicaSyncClient.ts @@ -5,6 +5,14 @@ import type { Hlc, ReplicaRow } from '@/types/replica'; import type { SyncErrorCode } from '@/libs/errors'; const ENDPOINT = () => `${getAPIBaseUrl()}/sync/replicas`; +const KEYS_ENDPOINT = () => `${getAPIBaseUrl()}/sync/replica-keys`; + +export interface ReplicaKeyRow { + saltId: string; + alg: string; + salt: string; + createdAt: string; +} interface ErrorBody { error?: string; @@ -89,6 +97,61 @@ export class ReplicaSyncClient { const data = (await response.json()) as { rows: ReplicaRow[] }; return data.rows ?? []; } + + async listReplicaKeys(): Promise { + const token = await requireToken(); + let response: Response; + try { + response = await fetch(KEYS_ENDPOINT(), { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + }); + } catch (cause) { + throw new SyncError('SERVER', 'Network failure during replica-keys list', { cause }); + } + if (!response.ok) { + const body = await parseErrorBody(response); + const code = body.code ?? statusToDefaultCode(response.status); + throw new SyncError( + code, + body.error ?? `replica-keys list failed with status ${response.status}`, + { status: response.status }, + ); + } + const data = (await response.json()) as { rows: ReplicaKeyRow[] }; + return data.rows ?? []; + } + + async createReplicaKey(alg: string): Promise { + const token = await requireToken(); + let response: Response; + try { + response = await fetch(KEYS_ENDPOINT(), { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ alg }), + }); + } catch (cause) { + throw new SyncError('SERVER', 'Network failure during replica-keys create', { cause }); + } + if (!response.ok) { + const body = await parseErrorBody(response); + const code = body.code ?? statusToDefaultCode(response.status); + throw new SyncError( + code, + body.error ?? `replica-keys create failed with status ${response.status}`, + { status: response.status }, + ); + } + const data = (await response.json()) as { row: ReplicaKeyRow }; + if (!data.row) { + throw new SyncError('SERVER', 'replica-keys create returned no row'); + } + return data.row; + } } export const replicaSyncClient = new ReplicaSyncClient(); diff --git a/apps/readest-app/src/pages/api/sync/replica-keys.ts b/apps/readest-app/src/pages/api/sync/replica-keys.ts new file mode 100644 index 00000000..b4aa90ee --- /dev/null +++ b/apps/readest-app/src/pages/api/sync/replica-keys.ts @@ -0,0 +1,124 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { NextRequest, NextResponse } from 'next/server'; +import { createSupabaseClient } from '@/utils/supabase'; +import { validateUserAndToken } from '@/utils/access'; +import { runMiddleware, corsAllMethods } from '@/utils/cors'; + +const SUPPORTED_ALGS = new Set(['pbkdf2-600k-sha256']); + +interface ReplicaKeyRpcRow { + salt_id: string; + alg: string; + salt_b64: string; + created_at: string; +} + +interface ReplicaKeyResponseRow { + saltId: string; + alg: string; + salt: string; + createdAt: string; +} + +const errorResponse = (status: number, code: string, message: string) => + NextResponse.json({ error: message, code }, { status }); + +const toResponseRow = (row: ReplicaKeyRpcRow): ReplicaKeyResponseRow => ({ + saltId: row.salt_id, + alg: row.alg, + salt: row.salt_b64, + createdAt: row.created_at, +}); + +export async function GET(req: NextRequest) { + const { user, token } = await validateUserAndToken(req.headers.get('authorization')); + if (!user || !token) { + return errorResponse(401, 'AUTH', 'Not authenticated'); + } + const supabase = createSupabaseClient(token); + + const { data, error } = await supabase.rpc('replica_keys_list'); + if (error) { + console.error('replica_keys_list failed', { userId: user.id, error }); + return errorResponse(500, 'SERVER', error.message); + } + const rows = (data ?? []) as ReplicaKeyRpcRow[]; + return NextResponse.json({ rows: rows.map(toResponseRow) }, { status: 200 }); +} + +export async function POST(req: NextRequest) { + const { user, token } = await validateUserAndToken(req.headers.get('authorization')); + if (!user || !token) { + return errorResponse(401, 'AUTH', 'Not authenticated'); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return errorResponse(400, 'VALIDATION', 'Invalid JSON body'); + } + const alg = + typeof body === 'object' && body !== null && 'alg' in body + ? (body as { alg: unknown }).alg + : undefined; + if (typeof alg !== 'string' || !SUPPORTED_ALGS.has(alg)) { + return errorResponse(422, 'UNSUPPORTED_ALG', `Unsupported alg: ${String(alg)}`); + } + + const supabase = createSupabaseClient(token); + const { data, error } = await supabase + .rpc('replica_keys_create', { p_alg: alg }) + .single(); + if (error || !data) { + console.error('replica_keys_create failed', { userId: user.id, error }); + return errorResponse(500, 'SERVER', error?.message ?? 'replica_keys_create returned no row'); + } + return NextResponse.json({ row: toResponseRow(data) }, { status: 201 }); +} + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (!req.url) { + return res.status(400).json({ error: 'Invalid request URL' }); + } + const protocol = process.env['PROTOCOL'] || 'http'; + const host = process.env['HOST'] || 'localhost:3000'; + const url = new URL(req.url, `${protocol}://${host}`); + + await runMiddleware(req, res, corsAllMethods); + + try { + let response: Response; + if (req.method === 'GET') { + const nextReq = new NextRequest(url.toString(), { + headers: new Headers(req.headers as Record), + method: 'GET', + }); + response = await GET(nextReq); + } else if (req.method === 'POST') { + const nextReq = new NextRequest(url.toString(), { + headers: new Headers(req.headers as Record), + method: 'POST', + body: JSON.stringify(req.body), + }); + response = await POST(nextReq); + } else { + res.setHeader('Allow', ['GET', 'POST']); + return res.status(405).json({ error: 'Method Not Allowed' }); + } + + res.status(response.status); + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + res.send(buffer); + } catch (error) { + console.error('Error processing /api/sync/replica-keys request:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}; + +export default handler; diff --git a/docker/volumes/db/migrations/008_replica_keys_rpcs.sql b/docker/volumes/db/migrations/008_replica_keys_rpcs.sql new file mode 100644 index 00000000..10e96f62 --- /dev/null +++ b/docker/volumes/db/migrations/008_replica_keys_rpcs.sql @@ -0,0 +1,44 @@ +-- Migration 008: RPC helpers for the replica_keys table. +-- The bytea salt round-trips as base64 over PostgREST so the client never +-- has to deal with Postgres hex encoding. Both functions run SECURITY +-- INVOKER, so the table's RLS policies (created in migration 003) enforce +-- the auth.uid() = user_id guard. + +-- gen_random_bytes() lives in pgcrypto; Supabase enables it by default, +-- but we make the dependency explicit so a fresh self-hosted database +-- works on first apply. +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions; + +CREATE OR REPLACE FUNCTION public.replica_keys_create(p_alg text) +RETURNS TABLE(salt_id text, alg text, salt_b64 text, created_at timestamptz) +LANGUAGE plpgsql +SECURITY INVOKER +AS $$ +DECLARE + v_salt_id text := gen_random_uuid()::text; + v_salt bytea := extensions.gen_random_bytes(32); +BEGIN + IF p_alg <> 'pbkdf2-600k-sha256' THEN + RAISE EXCEPTION 'Unsupported alg: %', p_alg USING ERRCODE = '22023'; + END IF; + INSERT INTO public.replica_keys (user_id, salt_id, alg, salt) + VALUES (auth.uid(), v_salt_id, p_alg, v_salt); + RETURN QUERY + SELECT v_salt_id, p_alg, encode(v_salt, 'base64'), now(); +END; +$$; + +CREATE OR REPLACE FUNCTION public.replica_keys_list() +RETURNS TABLE(salt_id text, alg text, salt_b64 text, created_at timestamptz) +LANGUAGE sql +SECURITY INVOKER +STABLE +AS $$ + SELECT salt_id, alg, encode(salt, 'base64') AS salt_b64, created_at + FROM public.replica_keys + WHERE user_id = (SELECT auth.uid()) + ORDER BY created_at DESC; +$$; + +GRANT EXECUTE ON FUNCTION public.replica_keys_create(text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.replica_keys_list() TO authenticated;