diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt index 6e0d1400cf..f4909c9767 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt @@ -70,7 +70,7 @@ class SecretDecodingTest { // into a property's default - a required field without one still throws. Because these RPCs // return LISTS, that throw is all-or-nothing and takes the WHOLE page, which is exactly the // outage this PR removes. So try_decrypt_text's NULL is COALESCEd to '' in every listing RPC - // (migration 20260914010000) before it can ever reach this decoder. + // (migrations 20260914010000 and 20260915000000) before it can ever reach this decoder. val payload = buildJsonArray { add( diff --git a/supabase/audit/identity_disclosure_audit.sql b/supabase/audit/identity_disclosure_audit.sql index bce6f3878a..e04e661710 100644 --- a/supabase/audit/identity_disclosure_audit.sql +++ b/supabase/audit/identity_disclosure_audit.sql @@ -225,7 +225,7 @@ from unnest(array['public.get_encryption_key()', 'public.encrypt_text(text)', 'public.decrypt_text(text)', 'public.try_decrypt_text(text)', 'public.safe_decrypt_recovery_codes(text)', 'public.upsert_plugin_rating(uuid,uuid,integer,text)', 'public.record_plugin_download(uuid,uuid,uuid,text)', - 'public.custom_access_token_hook(jsonb)']) signature + 'public.custom_access_token_hook(jsonb)', 'public.try_decrypt_text(text)']) signature cross join unnest(array['anon', 'authenticated']) role_name where to_regprocedure(signature) is null or has_function_privilege(role_name, to_regprocedure(signature), 'EXECUTE') diff --git a/supabase/migrations/20260915000000_resilient_secret_listing.sql b/supabase/migrations/20260915000000_resilient_secret_listing.sql new file mode 100644 index 0000000000..ae7fa2e3e5 --- /dev/null +++ b/supabase/migrations/20260915000000_resilient_secret_listing.sql @@ -0,0 +1,306 @@ +-- ============================================================================ +-- BOSS Database Schema: Resilient Secret Listing (one bad row must not blank a page) +-- ============================================================================ +-- File: 20260915000000_resilient_secret_listing.sql +-- Description: get_user_secrets, search_user_secrets and get_user_secrets_with_shared +-- decrypted the password (and recovery codes) with a bare public.decrypt_text +-- inside a set-returning query. decrypt_text RAISES on an undecryptable value - +-- a wrong key after a partial rotation or storage corruption - and one raised row aborts the WHOLE query, so a single bad row +-- made every one of the caller's secrets fail to load, not just the bad one. +-- twofa_secret was already read through safe_decrypt_twofa_secret (NULL on +-- failure); recovery codes and passwords were not. +-- +-- This adds public.try_decrypt_text(text): decrypt_text wrapped so a data decoding failure +-- yields NULL for that one field, exactly as safe_decrypt_recovery_codes yields +-- [] for a bad recovery-codes cell. The three listing RPCs now read the +-- password through it and the recovery codes through the existing +-- safe_decrypt_recovery_codes, so a single corrupt row surfaces as one secret +-- with an empty password / empty codes while every other secret still loads. +-- RPC passwords remain non-null strings for installed desktop clients, whose +-- SecretEntry and SecretEntryWithSharing models cannot decode a null password. +-- +-- NOT named safe_decrypt_*: the key-rotation script +-- (supabase/ops/rotate_master_encryption_key.sql) treats every public +-- safe_decrypt_% function as a bespoke-format read path that MUST be mapped in +-- its column list, and refuses to run if one is not. try_decrypt_text is a +-- generic resilient reader for an ordinary column, not a new storage format, +-- so it deliberately avoids that prefix and needs no rotation adapter. +-- +-- The three functions are restated verbatim from their current definitions +-- (get_user_secrets / search_user_secrets from 20260907000000, +-- get_user_secrets_with_shared from 20260909000000) with ONLY the two decrypt +-- calls changed; every authorization source, the paging tiebreaker and the +-- RETURNS TABLE shape are unchanged. Read authority is unchanged - only what a +-- reader already entitled to a row sees when that row cannot be decrypted. The +-- failure emits a SQLSTATE-only server warning +-- ('Secret decryption failed (SQLSTATE %)'). The RPCs COALESCE the helper's +-- NULL to '', so an installed client still decodes: the client-visible signal +-- is therefore an EMPTY password on a non-null password_encrypted, not a NULL, +-- and the authoritative signal is that server warning. Rotation still fails +-- closed on the same rows because it reads through decrypt_text, not this +-- wrapper. +-- ============================================================================ + +-- Missing/malformed/empty keys and privilege/undefined-function/internal errors +-- propagate. A valid-but-wrong key is indistinguishable from damaged ciphertext +-- in the unauthenticated legacy/v2 formats from #647; this is not key validation +-- or authenticated encryption. Rotation retains its strict decoder. + +-- Function: try_decrypt_text - decrypt_text, but NULL instead of RAISE on failure. +CREATE OR REPLACE FUNCTION "public"."try_decrypt_text"("ciphertext" "text") RETURNS "text" + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $BODY$ +DECLARE + key_bytes bytea; +BEGIN + IF ciphertext IS NULL THEN + RETURN NULL; + END IF; + -- Validate key lookup/encoding outside the corrupt-row boundary. + key_bytes := public.get_encryption_key()::bytea; + IF key_bytes IS NULL OR octet_length(key_bytes) = 0 THEN + RAISE EXCEPTION USING ERRCODE = '22023', MESSAGE = 'Secret encryption key configuration is invalid'; + END IF; + BEGIN + RETURN public.decrypt_text(ciphertext); + EXCEPTION + WHEN data_exception OR external_routine_invocation_exception THEN + -- The VALUE is bad, not the system. These are condition names, i.e. + -- CLASS matches (not exact SQLSTATEs): data_exception is all of class + -- 22 (invalid base64 22023, invalid/untranslatable UTF-8 22021/22P05) + -- and external_routine_invocation_exception is all of class 39 (the + -- pgcrypto decryption failure). A systemic fault stays uncaught and so + -- still aborts the listing: a malformed key raises 22P02 on the + -- get_encryption_key()::bytea cast ABOVE this block, and a missing key + -- (P0001), a revoked EXECUTE (42501), a dropped decrypt_text (42883) + -- or an internal error (XX000) are outside both classes. + -- Blank this one field rather than abort the caller's whole listing. + -- Report only SQLSTATE: error messages may contain secret material. + RAISE WARNING 'Secret decryption failed (SQLSTATE %)', SQLSTATE; + RETURN NULL; + END; +END; +$BODY$; + +ALTER FUNCTION "public"."try_decrypt_text"("ciphertext" "text") OWNER TO "postgres"; +COMMENT ON FUNCTION "public"."try_decrypt_text"("ciphertext" "text") IS + 'decrypt_text that returns NULL instead of raising, so one undecryptable row does not abort a secret listing. Not a safe_decrypt_* rotation adapter.'; +-- Same client posture as decrypt_text (20260909120000): called only inside these +-- SECURITY DEFINER RPCs, never directly by a client. +REVOKE ALL ON FUNCTION "public"."try_decrypt_text"("ciphertext" "text") FROM PUBLIC, anon, authenticated, service_role; + + +-- (a) get_user_secrets - restated from 20260907000000; decrypt calls only. +CREATE OR REPLACE FUNCTION "public"."get_user_secrets"( + "p_limit" integer DEFAULT 50, + "p_offset" integer DEFAULT 0 +) RETURNS TABLE( + "id" "uuid", "website" "text", "username" "text", "password" "text", "notes" "text", + "expiration_date" timestamp with time zone, "tags" "jsonb", "metadata" "jsonb", + "created_at" timestamp with time zone, "updated_at" timestamp with time zone, + "org_id" "uuid", "org_slug" "text", "is_org_owned" boolean, "can_manage" boolean +) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + s.id, s.website, s.username, + COALESCE(public.try_decrypt_text(s.password_encrypted), '') AS password, + s.notes, s.expiration_date, + COALESCE((SELECT jsonb_agg(st.tag) FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb) AS tags, + COALESCE(( + SELECT jsonb_build_object( + 'twofa_enabled', sm.twofa_enabled, + 'twofa_type', sm.twofa_type, + 'recovery_codes', public.safe_decrypt_recovery_codes(sm.recovery_codes_encrypted) + ) + FROM public.secret_metadata sm WHERE sm.secret_id = s.id + ), '{}'::jsonb) AS metadata, + s.created_at, s.updated_at, + s.org_id, + o.slug AS org_slug, + (s.org_id IS NOT NULL) AS is_org_owned, + (s.user_id = auth.uid() + OR public.is_user_admin(auth.uid()) + OR (s.org_id IS NOT NULL AND public.is_org_admin(s.org_id))) AS can_manage + FROM public.secrets s + LEFT JOIN public.organisations o ON o.id = s.org_id + -- Widened from "own secrets" to "own secrets + organisation secrets I can see". + WHERE s.user_id = auth.uid() + OR (s.org_id IS NOT NULL AND public.is_org_member(s.org_id)) + ORDER BY s.created_at DESC, s.id DESC + LIMIT p_limit OFFSET p_offset; +END; +$$; + +ALTER FUNCTION "public"."get_user_secrets"(integer,integer) OWNER TO "postgres"; + + +-- (b) search_user_secrets - restated from 20260907000000; decrypt calls only. +CREATE OR REPLACE FUNCTION "public"."search_user_secrets"( + "p_query" "text", + "p_limit" integer DEFAULT 50, + "p_offset" integer DEFAULT 0 +) RETURNS TABLE( + "id" "uuid", "website" "text", "username" "text", "password" "text", "notes" "text", + "expiration_date" timestamp with time zone, "tags" "jsonb", "metadata" "jsonb", + "created_at" timestamp with time zone, "updated_at" timestamp with time zone, + "org_id" "uuid", "org_slug" "text", "is_org_owned" boolean, "can_manage" boolean +) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + s.id, s.website, s.username, + COALESCE(public.try_decrypt_text(s.password_encrypted), '') AS password, + s.notes, s.expiration_date, + COALESCE((SELECT jsonb_agg(st.tag) FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb) AS tags, + COALESCE(( + SELECT jsonb_build_object( + 'twofa_enabled', sm.twofa_enabled, + 'twofa_type', sm.twofa_type, + 'recovery_codes', public.safe_decrypt_recovery_codes(sm.recovery_codes_encrypted) + ) + FROM public.secret_metadata sm WHERE sm.secret_id = s.id + ), '{}'::jsonb) AS metadata, + s.created_at, s.updated_at, + s.org_id, + o.slug AS org_slug, + (s.org_id IS NOT NULL) AS is_org_owned, + (s.user_id = auth.uid() + OR public.is_user_admin(auth.uid()) + OR (s.org_id IS NOT NULL AND public.is_org_admin(s.org_id))) AS can_manage + FROM public.secrets s + LEFT JOIN public.organisations o ON o.id = s.org_id + WHERE (s.user_id = auth.uid() + OR (s.org_id IS NOT NULL AND public.is_org_member(s.org_id))) + AND (s.website ILIKE '%' || p_query || '%' OR s.username ILIKE '%' || p_query || '%') + ORDER BY s.created_at DESC, s.id DESC + LIMIT p_limit OFFSET p_offset; +END; +$$; + +ALTER FUNCTION "public"."search_user_secrets"("text",integer,integer) OWNER TO "postgres"; + + +-- (c) get_user_secrets_with_shared - restated from 20260909000000; decrypt calls only. +CREATE OR REPLACE FUNCTION "public"."get_user_secrets_with_shared"( + "p_limit" integer DEFAULT 50, + "p_offset" integer DEFAULT 0 +) RETURNS TABLE( + "id" "uuid", "website" "text", "username" "text", "password" "text", "notes" "text", + "expiration_date" timestamp with time zone, "tags" "jsonb", "metadata" "jsonb", + "created_at" timestamp with time zone, "updated_at" timestamp with time zone, + "is_owner" boolean, "shared_by_email" "text", "access_level" "text", + "org_id" "uuid", "org_slug" "text", "is_org_owned" boolean, + "shared_with_org_slug" "text", "can_manage" boolean +) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + WITH accessible_secrets AS ( + -- Source 1: the caller's own secrets. + SELECT s.id, TRUE AS is_owner, NULL::TEXT AS shared_by_email, + 'owner'::TEXT AS access_level, NULL::TEXT AS shared_with_org_slug, 1 AS priority + FROM public.secrets s + WHERE s.user_id = auth.uid() + + UNION ALL + + -- Source 4: secrets OWNED BY an organisation the caller belongs to. + SELECT s.id, (s.user_id = auth.uid()) AS is_owner, NULL::TEXT, + 'org'::TEXT, o.slug, 2 + FROM public.secrets s + JOIN public.organisations o ON o.id = s.org_id + WHERE s.org_id IS NOT NULL + AND public.is_org_member(s.org_id) + + UNION ALL + + -- Source 2: shared directly with the caller. + SELECT s.id, FALSE, u.email, ss.access_level, NULL::TEXT, 3 + FROM public.secrets s + JOIN public.secret_shares ss ON ss.secret_id = s.id + JOIN auth.users u ON u.id = ss.shared_by + WHERE ss.shared_with_user_id = auth.uid() + AND (ss.expires_at IS NULL OR ss.expires_at > now()) + + UNION ALL + + -- Source 3: shared with a role the caller holds, OR any DESCENDANT of one + -- (20260802010000). Previously matched assigned roles only, which + -- disagreed with how authorize() expands permissions. + SELECT s.id, FALSE, u.email, ss.access_level, NULL::TEXT, 4 + FROM public.secrets s + JOIN public.secret_shares ss ON ss.secret_id = s.id + JOIN auth.users u ON u.id = ss.shared_by + WHERE ss.shared_with_role_id IN (SELECT public.effective_share_role_ids(auth.uid())) + AND (ss.expires_at IS NULL OR ss.expires_at > now()) + + UNION ALL + + -- Source 5: shared with an organisation the caller belongs to. + SELECT s.id, FALSE, u.email, ss.access_level, o.slug, 5 + FROM public.secrets s + JOIN public.secret_shares ss ON ss.secret_id = s.id + JOIN auth.users u ON u.id = ss.shared_by + JOIN public.organisations o ON o.id = ss.shared_with_org_id + WHERE ss.shared_with_org_id IS NOT NULL + AND public.is_org_member(ss.shared_with_org_id) + AND (ss.expires_at IS NULL OR ss.expires_at > now()) + ), + unique_secrets AS ( + SELECT DISTINCT ON (a.id) + a.id, a.is_owner, a.shared_by_email, a.access_level, a.shared_with_org_slug + FROM accessible_secrets a + ORDER BY a.id, a.is_owner DESC, a.priority + ) + SELECT + s.id, s.website, s.username, + COALESCE(public.try_decrypt_text(s.password_encrypted), '') AS password, + s.notes, s.expiration_date, + COALESCE((SELECT jsonb_agg(st.tag) FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb) AS tags, + COALESCE(( + SELECT jsonb_build_object( + 'twofa_enabled', sm.twofa_enabled, + 'twofa_type', sm.twofa_type, + 'twofa_secret', public.safe_decrypt_twofa_secret(sm.twofa_secret), + 'recovery_codes', public.safe_decrypt_recovery_codes(sm.recovery_codes_encrypted) + ) + FROM public.secret_metadata sm WHERE sm.secret_id = s.id + ), '{}'::jsonb) AS metadata, + s.created_at, s.updated_at, + us.is_owner, us.shared_by_email, us.access_level, + s.org_id, + o.slug AS org_slug, + (s.org_id IS NOT NULL) AS is_org_owned, + us.shared_with_org_slug, + (s.user_id = auth.uid() + OR public.is_user_admin(auth.uid()) + OR (s.org_id IS NOT NULL AND public.is_org_admin(s.org_id))) AS can_manage + FROM unique_secrets us + JOIN public.secrets s ON s.id = us.id + LEFT JOIN public.organisations o ON o.id = s.org_id + ORDER BY s.created_at DESC, s.id DESC + LIMIT p_limit OFFSET p_offset; +END; +$$; + +ALTER FUNCTION "public"."get_user_secrets_with_shared"(integer,integer) OWNER TO "postgres"; + + +-- Restate the existing RPC grants, as the paging and TOTP migrations do. +GRANT EXECUTE ON FUNCTION "public"."get_user_secrets"(integer,integer) TO "authenticated", "service_role"; +GRANT EXECUTE ON FUNCTION "public"."search_user_secrets"("text",integer,integer) TO "authenticated", "service_role"; +GRANT EXECUTE ON FUNCTION "public"."get_user_secrets_with_shared"(integer,integer) TO "authenticated", "service_role"; + +-- ============================================================================ +-- End of File: 20260915000000_resilient_secret_listing.sql +-- ============================================================================ diff --git a/supabase/ops/rotate_master_encryption_key.sql b/supabase/ops/rotate_master_encryption_key.sql index 7fd08d048d..55d36eab96 100644 --- a/supabase/ops/rotate_master_encryption_key.sql +++ b/supabase/ops/rotate_master_encryption_key.sql @@ -50,6 +50,15 @@ declare -- are read through safe_decrypt_* wrappers that tolerate formats plain -- decrypt_text does not, so verifying them with decrypt_text would prove -- something nobody relies on. + -- + -- One deliberate carve-out to that rule: secrets.password_encrypted is mapped + -- to public.decrypt_text below, even though the application's LISTING path now + -- reads passwords through public.try_decrypt_text (20260915000000), which + -- returns NULL instead of raising. That resilient reader exists so one corrupt + -- row does not blank a whole page; rotation is the opposite job and must fail + -- CLOSED, so it verifies with the strict decrypt_text and refuses to advance + -- past a row it cannot decrypt. The map stays decrypt_text on purpose - do not + -- "align" it with try_decrypt_text. cols text[][] := array[ array['secrets','password_encrypted','id','','public.decrypt_text'], array['secret_metadata','recovery_codes_encrypted','id','','public.safe_decrypt_recovery_codes'], diff --git a/supabase/tests/explicit_anon_and_org_visibility_test.sql b/supabase/tests/explicit_anon_and_org_visibility_test.sql index 588b4ad885..310cbeceea 100644 --- a/supabase/tests/explicit_anon_and_org_visibility_test.sql +++ b/supabase/tests/explicit_anon_and_org_visibility_test.sql @@ -7,7 +7,7 @@ from unnest(array['anon', 'authenticated']) r cross join unnest(array['public.get_encryption_key()', 'public.encrypt_text(text)', 'public.decrypt_text(text)', 'public.try_decrypt_text(text)', 'public.safe_decrypt_recovery_codes(text)', 'public.user_display_name(uuid)', 'public.upsert_plugin_rating(uuid,uuid,integer,text)', - 'public.record_plugin_download(uuid,uuid,uuid,text)', 'public.custom_access_token_hook(jsonb)']) f; + 'public.record_plugin_download(uuid,uuid,uuid,text)', 'public.custom_access_token_hook(jsonb)', 'public.try_decrypt_text(text)']) f; -- Also exercise the actual database role, not only JWT claims on postgres. set local role authenticated; diff --git a/supabase/tests/resilient_secret_listing_test.sql b/supabase/tests/resilient_secret_listing_test.sql new file mode 100644 index 0000000000..adaa2381c0 --- /dev/null +++ b/supabase/tests/resilient_secret_listing_test.sql @@ -0,0 +1,131 @@ +-- pgTAP tests for resilient secret listing +-- (migration 20260915000000_resilient_secret_listing.sql). +-- +-- get_user_secrets / search_user_secrets / get_user_secrets_with_shared read the +-- password with a bare decrypt_text inside a set-returning query. decrypt_text +-- RAISES on an undecryptable value, and one raised row aborts the whole query - +-- so a single corrupt row (wrong key mid-rotation, storage corruption, a v2 MAC +-- mismatch) made every one of the caller's secrets fail to load. The migration +-- routes the password through try_decrypt_text (NULL on failure) and the +-- recovery codes through the existing safe_decrypt_recovery_codes ([] on +-- failure), so one bad row surfaces as one blanked field while the rest load. +-- +-- COMPANION SUITE - do not delete either as a "duplicate". This file and +-- supabase/tests/secret_listing_decryption_resilience_test.sql (from PR #652 by +-- Akash Jadon) overlap on the happy path but each holds cases the other does +-- not. Unique to THIS file: the synthetic systemic-failure cases below that pin +-- a missing/empty key, a revoked EXECUTE, a dropped decrypt_text and an internal +-- error still PROPAGATE (they must abort the listing, not blank every row). +-- Unique to that file: the oracle-denial throws_ok on a client calling +-- try_decrypt_text directly. Removing one loses half the coverage. + +begin; +select plan(23); + +select vault.create_secret( + 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90', + 'master_encryption_key', + 'pgTAP only' +); + +insert into auth.users (id, email) values + ('cafe0000-0000-4000-8000-000000000001', 'listing-owner@pgtap.test'), + ('cafe0000-0000-4000-8000-000000000002', 'listing-sharer@pgtap.test'); + +-- A readable secret, a secret whose password ciphertext is corrupt, and a secret +-- whose recovery codes are corrupt. The password rows are inserted directly (as +-- the test superuser, bypassing RLS) so we control the exact stored bytes. +insert into public.secrets (id, user_id, website, username, password_encrypted) values + ('cafe0000-0000-4000-8000-0000000000a1', 'cafe0000-0000-4000-8000-000000000001', 'good.example', 'u1', public.encrypt_text('GoodPassword1')), + ('cafe0000-0000-4000-8000-0000000000a2', 'cafe0000-0000-4000-8000-000000000001', 'broken.example','u2', '!!!not-valid-base64!!!'), + ('cafe0000-0000-4000-8000-0000000000a3', 'cafe0000-0000-4000-8000-000000000001', 'codes.example', 'u3', public.encrypt_text('GoodPassword3')); +insert into public.secret_metadata (secret_id, twofa_enabled, twofa_type, recovery_codes_encrypted) values + ('cafe0000-0000-4000-8000-0000000000a3', true, 'app', '###corrupt-codes###'); +insert into public.secrets (id, user_id, website, username, password_encrypted) values + ('cafe0000-0000-4000-8000-0000000000a4', 'cafe0000-0000-4000-8000-000000000002', 'shared.example', 'u4', '!!!not-valid-base64!!!'); +insert into public.secret_shares (secret_id, shared_with_user_id, shared_by, access_level) values + ('cafe0000-0000-4000-8000-0000000000a4', 'cafe0000-0000-4000-8000-000000000001', 'cafe0000-0000-4000-8000-000000000002', 'read'); + +-- 1-2. try_decrypt_text is decrypt_text with NULL instead of a raise. +select is(public.try_decrypt_text(public.encrypt_text('round-trip')), 'round-trip', + 'try_decrypt_text returns the plaintext for a good value'); +select is(public.try_decrypt_text('!!!not-valid-base64!!!'), NULL::text, + 'try_decrypt_text returns NULL instead of raising on a corrupt value'); + +-- 3. Baseline: a bare decrypt_text over the same set still aborts, which is the +-- behaviour the RPCs used to inherit. +select throws_ok( + $$ SELECT public.decrypt_text(password_encrypted) FROM public.secrets $$, + NULL::text, NULL::text, + 'bare decrypt_text over the set still aborts on the corrupt row'); + +set local role authenticated; +select set_config('request.jwt.claims', + '{"sub":"cafe0000-0000-4000-8000-000000000001","role":"authenticated"}', true); + +-- 4. get_user_secrets returns EVERY row rather than aborting on the bad one. +select is((select count(*) from public.get_user_secrets(50, 0)), 3::bigint, + 'get_user_secrets returns all rows despite a corrupt one'); +-- 5-6. Keep the RPC's string contract: installed clients cannot decode NULL. +select is((select password from public.get_user_secrets(50, 0) where website = 'good.example'), + 'GoodPassword1', 'the readable password still decrypts'); +select is((select password from public.get_user_secrets(50, 0) where website = 'broken.example'), + ''::text, 'the corrupt password is blanked, not fatal'); +-- 7. Corrupt recovery codes degrade to [] through safe_decrypt_recovery_codes. +select is((select metadata->'recovery_codes' from public.get_user_secrets(50, 0) where website = 'codes.example'), + '[]'::jsonb, 'corrupt recovery codes degrade to an empty JSON array'); + +-- 8-9. The same resilience holds for the other two listing RPCs. +select is((select count(*) from public.search_user_secrets('.example', 50, 0)), 3::bigint, + 'search_user_secrets returns all matching rows despite a corrupt one'); +select is((select count(*) from public.get_user_secrets_with_shared(50, 0)), 4::bigint, + 'get_user_secrets_with_shared returns all rows despite a corrupt one'); +select is((select password from public.search_user_secrets('.example', 50, 0) where website = 'broken.example'), + ''::text, 'search preserves the non-null password contract'); +select is((select password from public.get_user_secrets_with_shared(50, 0) where website = 'broken.example'), + ''::text, 'sharing list preserves the non-null password contract'); +select is((select password from public.get_user_secrets_with_shared(50, 0) where website = 'shared.example'), + ''::text, 'a corrupt direct share stays visible with a blank password'); +select is((select access_level from public.get_user_secrets_with_shared(50, 0) where website = 'shared.example'), + 'read', 'a corrupt direct share retains its read-only access level'); +select is((select shared_by_email from public.get_user_secrets_with_shared(50, 0) where website = 'shared.example'), + 'listing-sharer@pgtap.test', 'a corrupt direct share retains its source'); + +reset role; + +-- 10-11. try_decrypt_text stays server-side, like decrypt_text (20260909120000). +select ok(not has_function_privilege('anon', 'public.try_decrypt_text(text)', 'EXECUTE'), + 'anon cannot execute try_decrypt_text'); +select ok(not has_function_privilege('authenticated', 'public.try_decrypt_text(text)', 'EXECUTE'), + 'authenticated cannot execute try_decrypt_text'); +select ok(not has_function_privilege('service_role', 'public.try_decrypt_text(text)', 'EXECUTE'), + 'service_role cannot execute the internal resilient reader'); + +-- Inject systemic failures without touching Vault data; transaction rollback restores functions. +CREATE OR REPLACE FUNCTION public.get_encryption_key() RETURNS text +LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN + RAISE EXCEPTION USING ERRCODE = 'P0001', MESSAGE = 'synthetic unavailable key'; +END $$; +select throws_ok($$select public.try_decrypt_text('invalid')$$, 'P0001', NULL::text, + 'missing key propagates instead of blanking the page'); +CREATE OR REPLACE FUNCTION public.get_encryption_key() RETURNS text +LANGUAGE sql SECURITY DEFINER AS $$ SELECT ''::text $$; +select throws_ok($$select public.try_decrypt_text('invalid')$$, '22023', NULL::text, + 'empty key fails outside the row handler'); +CREATE OR REPLACE FUNCTION public.get_encryption_key() RETURNS text +LANGUAGE sql SECURITY DEFINER AS $$ SELECT 'synthetic-test-key'::text $$; +CREATE OR REPLACE FUNCTION public.decrypt_text(ciphertext text) RETURNS text +LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN + RAISE EXCEPTION USING ERRCODE = ciphertext, MESSAGE = 'synthetic systemic failure'; +END $$; +select throws_ok($$select public.try_decrypt_text('42501')$$, '42501', NULL::text, + 'privilege errors propagate'); +select throws_ok($$select public.try_decrypt_text('42883')$$, '42883', NULL::text, + 'missing decoder propagates'); +select throws_ok($$select public.try_decrypt_text('XX000')$$, 'XX000', NULL::text, + 'internal errors propagate'); +select is(public.try_decrypt_text('39000'), NULL::text, + 'pgcrypto corrupt-ciphertext errors retain field fallback'); + +select * from finish(); +rollback; diff --git a/supabase/tests/secret_listing_decryption_resilience_test.sql b/supabase/tests/secret_listing_decryption_resilience_test.sql new file mode 100644 index 0000000000..bbc4415237 --- /dev/null +++ b/supabase/tests/secret_listing_decryption_resilience_test.sql @@ -0,0 +1,157 @@ +-- pgTAP regression tests for fail-soft secret list decryption. +-- Contributed in PR #652 by Akash Jadon , consolidated +-- alongside the original resilient-listing work (migration 20260915000000). +-- +-- COMPANION SUITE - do not delete either as a "duplicate". This file and +-- supabase/tests/resilient_secret_listing_test.sql overlap on the happy path but +-- each holds cases the other does not. Unique to THIS file: the oracle-denial +-- throws_ok on a client calling try_decrypt_text directly. Unique to that file: +-- the synthetic systemic-failure cases pinning that a missing/empty key, a +-- revoked EXECUTE, a dropped decrypt_text and an internal error still PROPAGATE. +-- Removing one loses half the coverage. +begin; +select plan(17); + +select vault.create_secret( + 'cGd0YXAtdGVzdC1rZXktMzItYnl0ZXMtYWVzLW9r', + 'master_encryption_key', + 'pgTAP test key (transaction-local)'); + +insert into auth.users (id, email, email_confirmed_at) values + ('d1900000-0000-4000-8000-000000000001', 'resilience@pgtap.test', now()), + ('d1900000-0000-4000-8000-000000000002', 'sharer@pgtap.test', now()); + +select set_config( + 'request.jwt.claims', + '{"sub":"d1900000-0000-4000-8000-000000000001","role":"authenticated"}', + true); +set local role authenticated; + +create temporary table secret_fixture (label text primary key, id uuid not null); +insert into secret_fixture +select 'good', (public.create_secret('resilience.example', 'good', 'good-password') ->> 'secret_id')::uuid; +insert into secret_fixture +select 'bad', (public.create_secret('resilience.example', 'bad', 'bad-password') ->> 'secret_id')::uuid; + +reset role; +update public.secrets +set password_encrypted = 'not-valid-ciphertext' +where id = (select id from secret_fixture where label = 'bad'); +insert into public.secret_metadata (secret_id, twofa_enabled, recovery_codes_encrypted) +values ( + (select id from secret_fixture where label = 'bad'), + true, + 'not-valid-ciphertext' +); + +-- A corrupt direct share exercises the separate sharing access path and proves +-- that decryption failure cannot erase its access metadata. +insert into public.secrets (id, user_id, website, username, password_encrypted) +values ( + 'd1900000-0000-4000-8000-000000000003', + 'd1900000-0000-4000-8000-000000000002', + 'shared-resilience.example', + 'sharer', + 'not-valid-ciphertext' +); +insert into public.secret_shares (secret_id, shared_with_user_id, shared_by, access_level) +values ( + 'd1900000-0000-4000-8000-000000000003', + 'd1900000-0000-4000-8000-000000000001', + 'd1900000-0000-4000-8000-000000000002', + 'read' +); + +select is( + public.try_decrypt_text(public.encrypt_text('round-trip')), + 'round-trip', + 'the internal helper returns plaintext for valid ciphertext' +); +select is( + public.try_decrypt_text('not-valid-ciphertext'), + null::text, + 'the internal helper returns null instead of raising for corrupt ciphertext' +); + +set local role authenticated; + +select is( + (select password from public.get_user_secrets() where id = (select id from secret_fixture where label = 'good')), + 'good-password', + 'get_user_secrets keeps returning decryptable secrets' +); +select is( + (select password from public.get_user_secrets() where id = (select id from secret_fixture where label = 'bad')), + ''::text, + 'get_user_secrets preserves its non-null password contract for corrupt data' +); +select is( + (select metadata->'recovery_codes' from public.get_user_secrets() where id = (select id from secret_fixture where label = 'bad')), + '[]'::jsonb, + 'get_user_secrets returns empty recovery codes when they cannot decrypt' +); +select is( + (select password from public.search_user_secrets('resilience.example') where id = (select id from secret_fixture where label = 'good')), + 'good-password', + 'search_user_secrets keeps returning decryptable secrets' +); +select is( + (select password from public.search_user_secrets('resilience.example') where id = (select id from secret_fixture where label = 'bad')), + ''::text, + 'search_user_secrets preserves its non-null password contract for corrupt data' +); +select is( + (select metadata->'recovery_codes' from public.search_user_secrets('resilience.example') where id = (select id from secret_fixture where label = 'bad')), + '[]'::jsonb, + 'search_user_secrets returns empty recovery codes when they cannot decrypt' +); +select is( + (select password from public.get_user_secrets_with_shared() where id = (select id from secret_fixture where label = 'good')), + 'good-password', + 'get_user_secrets_with_shared keeps returning decryptable secrets' +); +select is( + (select password from public.get_user_secrets_with_shared() where id = (select id from secret_fixture where label = 'bad')), + ''::text, + 'get_user_secrets_with_shared preserves its non-null password contract for corrupt data' +); +select is( + (select metadata->'recovery_codes' from public.get_user_secrets_with_shared() where id = (select id from secret_fixture where label = 'bad')), + '[]'::jsonb, + 'get_user_secrets_with_shared returns empty recovery codes when they cannot decrypt' +); +select throws_ok( + $$ select public.try_decrypt_text('not-valid-ciphertext') $$, + '42501', + null, + 'authenticated users cannot use the helper as a decryption oracle' +); +select is( + (select count(*)::int from public.get_user_secrets() where website = 'resilience.example'), + 2, + 'a corrupt secret still appears as one row rather than aborting the list' +); +select is( + (select password from public.get_user_secrets_with_shared() where website = 'shared-resilience.example'), + ''::text, + 'a corrupt direct share remains visible with a blank password' +); +select is( + (select access_level from public.get_user_secrets_with_shared() where website = 'shared-resilience.example'), + 'read', + 'a corrupt direct share retains its access level' +); +select is( + (select shared_by_email from public.get_user_secrets_with_shared() where website = 'shared-resilience.example'), + 'sharer@pgtap.test', + 'a corrupt direct share retains sharing attribution' +); +select ok( + not has_function_privilege('anon', 'public.try_decrypt_text(text)', 'EXECUTE') + and not has_function_privilege('service_role', 'public.try_decrypt_text(text)', 'EXECUTE'), + 'the fail-soft helper remains inaccessible to anonymous and service roles' +); + +reset role; +select * from finish(); +rollback;