Implement E2EE and message content moderation

This commit is contained in:
Jamie Brunton committed 2026-01-02 20:49:11 +00:00
1 parent e1ff87dc03
commit 198cd80dfd
74 files changed
+12939 -2178

No files matched your search

@@ -0,0 +1,29 @@
-- Add E2EE support to messages table
-- This migration adds columns for end-to-end encryption of chat messages
-- Version indicator: 1 = plaintext (legacy), 2 = E2EE
ALTER TABLE messages ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1;
-- Encrypted message content (base64 encoded ciphertext)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encrypted_content TEXT;
-- Initialization vector for AES-GCM (base64 encoded, 12 bytes)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encryption_iv TEXT;
-- Triple-wrapped encryption keys and metadata
ALTER TABLE messages ADD COLUMN IF NOT EXISTS keys_metadata JSONB;
-- Add comment for documentation
COMMENT ON COLUMN messages.version IS
'Encryption version: 1 = plaintext (legacy), 2 = E2EE with triple-wrapped keys';
-- Constraint: v2 messages must have encryption fields
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'e2ee_fields_required'
) THEN
ALTER TABLE messages ADD CONSTRAINT e2ee_fields_required
CHECK (version = 1 OR (version = 2 AND encryption_iv IS NOT NULL AND keys_metadata IS NOT NULL));
END IF;
END $$;
@@ -0,0 +1,12 @@
-- Add E2EE public key to profiles table
-- This migration adds a column to store user's RSA public key for E2EE
-- RSA public key in JWK format (JSON Web Key)
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS public_key_jwk JSONB;
-- Index for efficient lookups when encrypting for partner
CREATE INDEX IF NOT EXISTS idx_profiles_couple_id_public_key ON profiles(couple_id)
WHERE public_key_jwk IS NOT NULL;
COMMENT ON COLUMN profiles.public_key_jwk IS
'RSA-2048 public key in JWK format for E2EE. Private key stored on device only.';
@@ -0,0 +1,70 @@
-- Create master_keys table for admin E2EE access
-- This migration creates the table to store admin RSA public keys for moderation
CREATE TABLE IF NOT EXISTS master_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_name TEXT UNIQUE NOT NULL,
public_key_jwk JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
rotated_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true
);
-- Add table comment
COMMENT ON TABLE master_keys IS
'Stores admin RSA public keys for E2EE moderation access. Private key stored securely in environment variables.';
-- Enable RLS
ALTER TABLE master_keys ENABLE ROW LEVEL SECURITY;
-- Super admins can view master keys
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE policyname = 'Super admins can view master keys' AND tablename = 'master_keys'
) THEN
CREATE POLICY "Super admins can view master keys"
ON master_keys FOR SELECT
TO authenticated
USING (is_super_admin(auth.uid()));
END IF;
END $$;
-- Super admins can insert master keys
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE policyname = 'Super admins can insert master keys' AND tablename = 'master_keys'
) THEN
CREATE POLICY "Super admins can insert master keys"
ON master_keys FOR INSERT
TO authenticated
WITH CHECK (is_super_admin(auth.uid()));
END IF;
END $$;
-- Super admins can update master keys
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE policyname = 'Super admins can update master keys' AND tablename = 'master_keys'
) THEN
CREATE POLICY "Super admins can update master keys"
ON master_keys FOR UPDATE
TO authenticated
USING (is_super_admin(auth.uid()));
END IF;
END $$;
-- Allow all authenticated users to read active master key public keys (needed for encryption)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE policyname = 'Anyone can read active master key public keys' AND tablename = 'master_keys'
) THEN
CREATE POLICY "Anyone can read active master key public keys"
ON master_keys FOR SELECT
TO authenticated
USING (is_active = true);
END IF;
END $$;
@@ -0,0 +1,39 @@
-- Migration: Sanitize public keys in profiles table
-- Description: Replaces base64 url unsafe characters (+, /) with safe ones (-, _) and removes padding (=) in 'n' and 'e' fields of public_key_jwk
DO $$
DECLARE
r RECORD;
new_n TEXT;
new_e TEXT;
original_n TEXT;
original_e TEXT;
BEGIN
FOR r IN SELECT id, public_key_jwk FROM profiles WHERE public_key_jwk IS NOT NULL LOOP
original_n := r.public_key_jwk->>'n';
original_e := r.public_key_jwk->>'e';
IF original_n IS NOT NULL AND original_e IS NOT NULL THEN
-- Sanitize 'n'
new_n := REPLACE(original_n, '+', '-');
new_n := REPLACE(new_n, '/', '_');
new_n := REPLACE(new_n, '=', '');
-- Sanitize 'e'
new_e := REPLACE(original_e, '+', '-');
new_e := REPLACE(new_e, '/', '_');
new_e := REPLACE(new_e, '=', '');
-- Update if changed
IF new_n <> original_n OR new_e <> original_e THEN
UPDATE profiles
SET public_key_jwk = jsonb_set(
jsonb_set(public_key_jwk, '{n}', to_jsonb(new_n)),
'{e}',
to_jsonb(new_e)
)
WHERE id = r.id;
END IF;
END IF;
END LOOP;
END $$;
@@ -0,0 +1,15 @@
-- Update the content_or_media constraint to allow E2EE messages
-- E2EE messages have encrypted_content instead of content
ALTER TABLE public.messages DROP CONSTRAINT IF EXISTS content_or_media;
ALTER TABLE public.messages ADD CONSTRAINT content_or_media CHECK (
-- v1 (plaintext): must have content or media_path
(version = 1 AND (content IS NOT NULL OR media_path IS NOT NULL))
OR
-- v2 (E2EE): must have encrypted_content or media_path
(version = 2 AND (encrypted_content IS NOT NULL OR media_path IS NOT NULL))
OR
-- Legacy messages without version: require content or media_path
(version IS NULL AND (content IS NOT NULL OR media_path IS NOT NULL))
);
@@ -0,0 +1,66 @@
-- Add moderation columns to messages table
ALTER TABLE messages
ADD COLUMN moderation_status text DEFAULT 'unmoderated' CHECK (moderation_status IN ('unmoderated', 'safe', 'flagged')),
ADD COLUMN flag_reason text;
-- Add classifier configuration to ai_config table
ALTER TABLE ai_config
ADD COLUMN classifier_enabled boolean DEFAULT true,
ADD COLUMN classifier_model text DEFAULT 'openai/gpt-4o',
ADD COLUMN classifier_prompt text DEFAULT 'You are a content moderator for a couples app. Your task is to analyze the following message (text and/or image) and determine if it contains ILLEGAL or DANGEROUS content.
Sexual content (nudity, sexual acts, intimate conversations) is ALLOWED and should be marked as SAFE.
You must FLAG content ONLY if it falls into these categories:
1. Child Sexual Abuse Material (CSAM) or any depiction of minors in a sexual context.
2. Non-consensual sexual content (revenge porn, rape, assault).
3. Promotion of self-harm or suicide.
4. Human trafficking, terrorism, or extreme violence/gore.
5. Illegal drugs or weapons trafficking.
If the content is safe (including consensual adult sexual content), respond with {"status": "safe"}.
If the content is illegal/dangerous, respond with {"status": "flagged", "reason": "Brief explanation"}.
Response must be valid JSON.';
-- Function to trigger classification
CREATE OR REPLACE FUNCTION trigger_classify_message()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_classifier_enabled boolean;
BEGIN
-- Check if classifier is enabled
SELECT classifier_enabled INTO v_classifier_enabled
FROM public.ai_config
LIMIT 1;
-- If enabled (or null which defaults to true conceptually, but let's be strict), trigger function
IF v_classifier_enabled IS TRUE THEN
PERFORM net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/classify-message',
headers := jsonb_build_object(
'Content-Type', 'application/json'
),
body := jsonb_build_object(
'message_id', NEW.id
)
);
END IF;
RETURN NEW;
END;
$$;
-- Create trigger on messages table
CREATE TRIGGER on_message_created_classify
AFTER INSERT ON messages
FOR EACH ROW
EXECUTE FUNCTION trigger_classify_message();
-- Add comments
COMMENT ON COLUMN messages.moderation_status IS 'Content moderation status: unmoderated, safe, or flagged.';
COMMENT ON COLUMN messages.flag_reason IS 'Reason for flagging the content (if status is flagged).';
@@ -0,0 +1,32 @@
-- Add category column to messages table
ALTER TABLE messages
ADD COLUMN category text;
COMMENT ON COLUMN messages.category IS 'Content category: Neutral, Romantic, Playful, Explicit, etc.';
-- Update the default classifier prompt in ai_config
UPDATE ai_config
SET classifier_prompt = 'You are a content moderator for a couples app. Your task is to analyze the following message (text and/or image) and determine if it contains ILLEGAL or DANGEROUS content, and also categorize the tone/nature of the message.
1. SAFETY CHECK:
Sexual content (nudity, sexual acts, intimate conversations) is ALLOWED and should be marked as SAFE.
You must FLAG content ONLY if it falls into these categories:
- Child Sexual Abuse Material (CSAM) or any depiction of minors in a sexual context.
- Non-consensual sexual content (revenge porn, rape, assault).
- Promotion of self-harm or suicide.
- Human trafficking, terrorism, or extreme violence/gore.
- Illegal drugs or weapons trafficking.
2. CATEGORIZATION:
Classify the content into one of these categories:
- "Neutral": General conversation, logistics, day-to-day, non-sexual.
- "Romantic": Affectionate, emotional connection, loving, sweet.
- "Playful": Flirty, teasing, fun, joking.
- "Explicit": Sexual, intimate, NSFW, steamy.
Response Format (JSON):
{
"status": "safe" | "flagged",
"reason": "Explanation if flagged, or null if safe",
"category": "Neutral" | "Romantic" | "Playful" | "Explicit"
}';
@@ -0,0 +1,52 @@
-- Add deleted_at column to messages table for "delete for everyone"
ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMPTZ;
-- Create message_deletions table for "delete for self" (per-user deletions)
CREATE TABLE message_deletions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
deleted_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(message_id, user_id)
);
-- Index for efficient lookup when filtering messages
CREATE INDEX idx_message_deletions_user_message ON message_deletions(user_id, message_id);
CREATE INDEX idx_messages_deleted_at ON messages(deleted_at) WHERE deleted_at IS NOT NULL;
-- Enable RLS on message_deletions
ALTER TABLE message_deletions ENABLE ROW LEVEL SECURITY;
-- RLS Policies for message_deletions
-- Users can view their own deletions (needed for filtering)
CREATE POLICY "Users can view own deletions"
ON message_deletions FOR SELECT
USING (user_id = auth.uid());
-- Users can insert their own deletions (for messages in their matches)
CREATE POLICY "Users can delete messages for self"
ON message_deletions FOR INSERT
WITH CHECK (
user_id = auth.uid() AND
EXISTS (
SELECT 1 FROM messages m
JOIN matches ma ON ma.id = m.match_id
JOIN profiles p ON p.couple_id = ma.couple_id
WHERE m.id = message_deletions.message_id
AND p.id = auth.uid()
)
);
-- Update messages RLS policy to allow authors to set deleted_at
CREATE POLICY "Authors can soft delete messages for everyone"
ON messages FOR UPDATE
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- Add to realtime for live updates
ALTER PUBLICATION supabase_realtime ADD TABLE message_deletions;
-- Comments for documentation
COMMENT ON COLUMN messages.deleted_at IS 'Timestamp when message was deleted for everyone (author-only action). Shows "Message deleted" to all users.';
COMMENT ON TABLE message_deletions IS 'Per-user message deletions for "delete for self" functionality. Message still visible to other user.';