Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f74510e
test(db): gate the two client-grant shapes that produced #547 three t…
arjun28115 Sep 14, 2026
70b7209
fix(browser): actually warn before downloading an executable file (#484)
Antriksh1984 Sep 14, 2026
4680dbe
test: harden source convention scans (#508)
sgoel2be24-cyber Sep 14, 2026
d073acc
fix(plugins): scope PluginStorageFactory.createStorage to the calling…
Antriksh1984 Sep 14, 2026
4415a33
fix(browser): validate co-browse control payloads before splicing int…
Antriksh1984 Sep 14, 2026
4bc9ae7
fix(kernel): require verified caller identity for the Supabase proxy …
Antriksh1984 Sep 14, 2026
d3da351
fix(plugins): give a plugin's file panel an owner and its own directo…
arjun28115 Sep 14, 2026
13d154a
feat(mcp): trust a whole plugin's tools at once, not one at a time (#…
Antriksh1984 Sep 14, 2026
948b71e
fix(keymap): make an explicit Ctrl chord fire on the Control key off …
arjun28115 Sep 14, 2026
e36c47f
fix(chrome): restore a minimized window before focusing it (#560) (#561)
arjun28115 Sep 14, 2026
b39ced5
fix(cli): prevent readiness queue lost commands (#633)
Ayush04-C Sep 14, 2026
9a2e503
fix(cli): queue external command approval prompts
kshivang Sep 14, 2026
a4b91fb
fix(cli): guard queued approvals against double clicks and floods
kshivang Sep 14, 2026
8e05bf6
fix(cli): log command holding only after enqueue succeeds
kshivang Sep 14, 2026
3af8341
fix(overlays): honor RTL and fractional placement (#477)
sgoel2be24-cyber Sep 14, 2026
cf22064
fix(kernel): require verified caller identity for the Git bridge (#635)
Antriksh1984 Sep 14, 2026
c89611b
fix(kernel): require verified caller identity for the Log bridge (#645)
Antriksh1984 Sep 14, 2026
7dfb90e
fix(security): randomize the IV for encrypt_text/decrypt_text (#647)
Antriksh1984 Sep 14, 2026
91b9521
fix(security): don't let one undecryptable row abort the secrets listing
Rushikeshiitb Sep 12, 2026
085ca7f
fix(secrets): keep resilient listings compatible with installed clients
kshivang Sep 13, 2026
7e72f12
fix(db): move resilient listing migration past the merged IV migration
Rushikeshiitb Sep 14, 2026
21cc371
fix(overlays): honor RTL and fractional placement (#477)
sgoel2be24-cyber Sep 14, 2026
6b20b4c
fix(kernel): require verified caller identity for the Git bridge (#635)
Antriksh1984 Sep 14, 2026
5808033
fix(kernel): require verified caller identity for the Log bridge (#645)
Antriksh1984 Sep 14, 2026
a3f4b7f
fix(security): randomize the IV for encrypt_text/decrypt_text (#647)
Antriksh1984 Sep 14, 2026
3e09b57
fix: verify encryption backfill and address integration review
kshivang Sep 14, 2026
6b5461e
Merge current dev and retain reviewed integration migration repairs
kshivang Sep 14, 2026
fd3d903
fix(secrets): propagate systemic listing failures and consolidate reg…
kshivang Sep 14, 2026
6684f53
fix(secrets): address resilient-listing review findings A-F
Rushikeshiitb Sep 14, 2026
72b1fd4
Merge remote-tracking branch 'upstream/dev' into fix/secret-listing-r…
Rushikeshiitb Sep 14, 2026
7278e81
Merge remote-tracking branch 'upstream/dev' into fix/secret-listing-r…
Rushikeshiitb Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion supabase/audit/identity_disclosure_audit.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
306 changes: 306 additions & 0 deletions supabase/migrations/20260915000000_resilient_secret_listing.sql
Original file line number Diff line number Diff line change
@@ -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
-- ============================================================================
9 changes: 9 additions & 0 deletions supabase/ops/rotate_master_encryption_key.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
2 changes: 1 addition & 1 deletion supabase/tests/explicit_anon_and_org_visibility_test.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading