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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-08 02:05:04 +08:00
committed by GitHub
parent cb30716830
commit 35227ecd61
5 changed files with 542 additions and 0 deletions
@@ -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;