diff --git a/crates/api-sync/src/routes/e2ee_witness.rs b/crates/api-sync/src/routes/e2ee_witness.rs index d524797699..e07dc93b57 100644 --- a/crates/api-sync/src/routes/e2ee_witness.rs +++ b/crates/api-sync/src/routes/e2ee_witness.rs @@ -1102,6 +1102,36 @@ mod tests { server.verify().await; } + #[tokio::test] + async fn maps_invalid_witness_events_to_bad_request() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/rest/v1/rpc/publish_e2ee_freshness_events")) + .respond_with( + ResponseTemplate::new(500) + .set_body_json(json!({ "code": "22023", "message": "invalid event" })), + ) + .mount(&server) + .await; + let response = test_router(&server) + .oneshot(request( + Method::POST, + &format!("/e2ee/witness/{OWNER}"), + Some(json!({ + "initialize": false, + "events": [{ + "recordId": RECORD_ID, + "payloadHash": PAYLOAD_HASH, + "payload": "opaque" + }] + })), + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn maps_uninitialized_legacy_witnesses_to_conflict() { let server = MockServer::start().await; diff --git a/crates/e2ee/src/lib.rs b/crates/e2ee/src/lib.rs index e890df05f9..b749ff6b4c 100644 --- a/crates/e2ee/src/lib.rs +++ b/crates/e2ee/src/lib.rs @@ -567,6 +567,29 @@ mod tests { assert_ne!(first.key_id(), other.key_id()); } + #[test] + fn personal_payload_key_id_is_not_the_recovery_identity() { + let recovery = recovery_key(); + let workspace = recovery.workspace_key("workspace-a").unwrap(); + let sealed = workspace + .seal_field( + "workspace-a", + "sessions", + "session-1", + "title", + "00000000000000000000000000000001", + 1, + false, + json!("Planning"), + ) + .unwrap(); + let envelope: Value = serde_json::from_str(&sealed.payload).unwrap(); + + assert_ne!(workspace.key_id(), recovery.key_id()); + assert_eq!(envelope["key_id"], workspace.key_id()); + assert_ne!(envelope["key_id"], recovery.key_id()); + } + #[test] fn fields_round_trip_with_blinded_identifiers() { let key = recovery_key().workspace_key("workspace-a").unwrap(); diff --git a/supabase/migrations/20260820104000_personal_e2ee_freshness_payload_key.sql b/supabase/migrations/20260820104000_personal_e2ee_freshness_payload_key.sql new file mode 100644 index 0000000000..85c51533d1 --- /dev/null +++ b/supabase/migrations/20260820104000_personal_e2ee_freshness_payload_key.sql @@ -0,0 +1,112 @@ +CREATE OR REPLACE FUNCTION public.publish_e2ee_freshness_events( + p_actor_user_id uuid, + p_workspace_id uuid, + p_initialize boolean, + p_events jsonb +) +RETURNS TABLE ( + initialized_at timestamptz, + head_sequence bigint +) +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $$ +DECLARE + v_active_key_id text; + v_initialized_at timestamptz; + v_workspace_kind text; + v_events jsonb := COALESCE(p_events, '[]'::jsonb); +BEGIN + IF p_actor_user_id IS NULL OR p_workspace_id IS NULL OR p_initialize IS NULL THEN + RAISE EXCEPTION 'E2EE freshness request is invalid' USING ERRCODE = '22023'; + END IF; + + SELECT workspace.e2ee_freshness_initialized_at, workspace.kind::text + INTO v_initialized_at, v_workspace_kind + FROM public.workspaces AS workspace + WHERE workspace.id = p_workspace_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'E2EE freshness publication is not permitted' USING ERRCODE = '42501'; + END IF; + + SELECT private.active_e2ee_freshness_key_id(p_actor_user_id, p_workspace_id) + INTO v_active_key_id; + + IF v_active_key_id IS NULL THEN + RAISE EXCEPTION 'E2EE freshness publication is not permitted' USING ERRCODE = '42501'; + END IF; + + IF jsonb_typeof(v_events) <> 'array' OR jsonb_array_length(v_events) > 64 THEN + RAISE EXCEPTION 'E2EE freshness event batch is invalid' USING ERRCODE = '22023'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(v_events) AS event(value) + WHERE jsonb_typeof(event.value) <> 'object' + OR COALESCE(event.value->>'record_id', '') !~ '^[A-Za-z0-9_-]{43}$' + OR COALESCE(event.value->>'payload_hash', '') !~ '^[A-Za-z0-9_-]{43}$' + OR octet_length(COALESCE(event.value->>'payload', '')) NOT BETWEEN 1 AND 16777216 + OR private.e2ee_freshness_payload_key_id(event.value->>'payload') IS NULL + OR ( + v_workspace_kind = 'shared' + AND private.e2ee_freshness_payload_key_id(event.value->>'payload') + IS DISTINCT FROM v_active_key_id + ) + OR event.value->>'payload_hash' <> rtrim( + translate( + encode(extensions.digest(event.value->>'payload', 'sha256'), 'base64'), + '+/', + '-_' + ), + '=' + ) + ) THEN + RAISE EXCEPTION 'E2EE freshness event is invalid' USING ERRCODE = '22023'; + END IF; + + IF v_initialized_at IS NULL AND NOT p_initialize THEN + RAISE EXCEPTION 'E2EE freshness witness is not initialized' USING ERRCODE = '55000'; + END IF; + + IF v_initialized_at IS NULL AND jsonb_array_length(v_events) = 0 THEN + RAISE EXCEPTION 'E2EE freshness initialization requires established state' + USING ERRCODE = '55000'; + END IF; + + INSERT INTO public.e2ee_freshness_events ( + workspace_id, + record_id, + payload_hash, + payload, + created_by + ) + SELECT + p_workspace_id, + event.value->>'record_id', + event.value->>'payload_hash', + event.value->>'payload', + p_actor_user_id + FROM jsonb_array_elements(v_events) AS event(value) + ON CONFLICT (workspace_id, record_id, payload_hash) DO NOTHING; + + IF v_initialized_at IS NULL THEN + UPDATE public.workspaces AS workspace + SET e2ee_freshness_initialized_at = now(), + updated_at = now() + WHERE workspace.id = p_workspace_id + RETURNING workspace.e2ee_freshness_initialized_at + INTO v_initialized_at; + END IF; + + RETURN QUERY + SELECT + v_initialized_at, + COALESCE(MAX(event.sequence), 0)::bigint + FROM public.e2ee_freshness_events AS event + WHERE event.workspace_id = p_workspace_id; +END; +$$; diff --git a/supabase/tests/021-e2ee-freshness-witness.sql b/supabase/tests/021-e2ee-freshness-witness.sql index 6a39db85e0..94e086bf9a 100644 --- a/supabase/tests/021-e2ee-freshness-witness.sql +++ b/supabase/tests/021-e2ee-freshness-witness.sql @@ -1,5 +1,5 @@ begin; -select plan(18); +select plan(20); select tests.create_supabase_user('witness_owner', 'witness-owner@example.com'); select tests.create_supabase_user('witness_other', 'witness-other@example.com'); @@ -88,6 +88,15 @@ select is( 'Claiming a new recovery-key identity succeeds' ); +select is( + private.active_e2ee_freshness_key_id( + tests.get_supabase_uid('witness_owner'), + tests.get_supabase_uid('witness_owner') + ), + 'abcdefghijklmnopqrstuv', + 'Personal witness access remains bound to the recovery-key identity' +); + select isnt( ( select e2ee_freshness_initialized_at @@ -114,7 +123,7 @@ select results_eq( create temporary table witness_event as select repeat('r', 43)::text as record_id, - '{"version":1,"key_id":"abcdefghijklmnopqrstuv","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ciphertext":"opaque"}'::text as payload; + '{"version":1,"key_id":"ABCDEFGHIJKLMNOPQRSTUV","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ciphertext":"opaque"}'::text as payload; alter table witness_event add column payload_hash text; update witness_event @@ -123,6 +132,36 @@ set payload_hash = rtrim( '=' ); +select throws_ok( + format( + $$ + select * + from public.publish_e2ee_freshness_events( + %L, + %L, + false, + jsonb_build_array(jsonb_build_object( + 'record_id', repeat('m', 43), + 'payload_hash', rtrim( + translate( + encode(extensions.digest('not-an-envelope', 'sha256'), 'base64'), + '+/', + '-_' + ), + '=' + ), + 'payload', 'not-an-envelope' + )) + ) + $$, + tests.get_supabase_uid('witness_owner'), + tests.get_supabase_uid('witness_owner') + ), + '22023', + 'E2EE freshness event is invalid', + 'Personal witness events still require a valid envelope' +); + select isnt( ( select head_sequence @@ -207,7 +246,7 @@ select from ( select ordinal, format( - '{"version":1,"key_id":"abcdefghijklmnopqrstuv","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ciphertext":"opaque-%s"}', + '{"version":1,"key_id":"ABCDEFGHIJKLMNOPQRSTUV","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ciphertext":"opaque-%s"}', ordinal )::text as payload from generate_series(1, 64) as series(ordinal)