Repository navigation
Conversation
6247e7c to
a58b3ee
Compare
encrypt_text called the 3-arg extensions.encrypt(plaintext, key, 'aes'), which pgcrypto documents as using an all-zero IV, so encryption was a pure function of (plaintext, key) alone: two equal passwords produced byte-identical ciphertext. Because create_secret encrypts server-side and RLS lets a user read their own password_encrypted back, any signed-in user was an encryption oracle - grouping a dump by ciphertext names every set of users sharing a password, with no key required. The same defect applied to recovery_codes_encrypted and twofa_secret. encrypt_text now draws a fresh random 16-byte IV per call and stores 'v2:' || base64(iv || ciphertext); decrypt_text dispatches on that prefix and falls back to the original zero-IV path for anything not yet migrated, so this deploys without a flag day. A backfill converts existing rows in all three columns, guarded by NOT LIKE 'v2:%' so a partial or repeated run is a no-op. Deliberately scoped to this one defect (BossConsole#618 names three). Defect 2 (the encryption key truncated to 32 string characters instead of 32 bytes) is not touched here: ops/rotate_master_encryption_key.sql records that a deployed key may be hex or base64, and this migration cannot tell which is live in any given project's Vault - guessing wrong would make get_encryption_key()'s result unusable and break encryption for every row on deploy. Defect 3 (no MAC/AEAD) needs its own wire-format review. ops/rotate_master_encryption_key.sql itself will need a follow-up before it can run again, since it manipulates raw ciphertext bytes assuming the old no-IV, no-prefix layout - both gaps are documented in the migration's own header. This could not be run against a real Postgres/pgTAP instance in this environment (no supabase/docker CLI available here) - the added test was written and reviewed by hand, and hosted CI's Database (pgTAP) check is the actual verification gate.
CI's Database (pgTAP) check caught what local review couldn't (no Postgres available in this sandbox): pg_catalog.substring(x from y) is a syntax error. substring's SQL-standard FROM/FOR trailing syntax is recognized by the parser only on the bare function name - schema-qualifying it makes PostgreSQL treat it as an ordinary call, where FROM is not a valid argument separator. 20260909000000_encrypt_totp.sql's own decrypt_text already calls substring unqualified for exactly this reason; this migration's three call sites (envelope/iv/body extraction in decrypt_text, and the twofa_secret backfill) now match that convention. Both functions' search_path already resolves the unqualified name to pg_catalog's substring, so nothing else about name resolution changes.
CI's Database (pgTAP) check caught what local review couldn't (no Postgres available in this sandbox): the pre-existing master_key_rotation_test.sql runs ops/rotate_master_encryption_key.sql against real fixture data, and that script's re-encrypt loop calls extensions.decrypt/encrypt directly on raw ciphertext bytes, assuming the old zero-IV, no-prefix layout. The 'v2:' prefix the previous commit's encrypt_text now writes is not valid base64, so decoding it raised - exactly the gap this migration's own header comment already named as a follow-up, just sooner than expected. Added a pg_temp helper, rotate_ciphertext, that re-encrypts a mapped column's value under the new key while preserving whichever envelope it already carries: a 'v2:' row is decrypted with decrypt_iv under the old key and re-encrypted with encrypt_iv under the new key AND a fresh IV (a function rather than an inline SQL expression, specifically so gen_random_bytes(16) is evaluated once and reused for both the encrypt_iv call and the stored prefix - two calls would produce two different values and corrupt the row). A legacy row - every column before this pair of migrations, and still qbo_token_state / google_token_state going forward, since those are untouched by the IV migration - stays zero-IV, matching what it already was. Rotation's contract stays "same shape, new key"; upgrading a row's shape is the encryption migration's job, not rotation's. Updated the IV migration's own header comment, which had flagged this as a still-open follow-up, to point at the fix now landing alongside it instead.
a58b3ee to
d32a200
Compare
|
Claude finished @kshivang's task in 12m 3s —— View job Review of d32a200 against
|
|
Claude review resolution for d32a200, implemented in ba3d46f and 095ec0c:
Also corrected the substring comment to explain parser resolution rather than claiming search_path protection. The original backslash bug was independently reproduced and repaired. 826 assertions / 34 isolated PostgreSQL 17 suites PASS, including all new regressions and rotation tests; fixture generation/check and diff whitespace checks pass. Current-head CI still must pass; no additional Claude request is needed for these direct resolutions. |
* fix(security): randomize the IV for encrypt_text/decrypt_text encrypt_text called the 3-arg extensions.encrypt(plaintext, key, 'aes'), which pgcrypto documents as using an all-zero IV, so encryption was a pure function of (plaintext, key) alone: two equal passwords produced byte-identical ciphertext. Because create_secret encrypts server-side and RLS lets a user read their own password_encrypted back, any signed-in user was an encryption oracle - grouping a dump by ciphertext names every set of users sharing a password, with no key required. The same defect applied to recovery_codes_encrypted and twofa_secret. encrypt_text now draws a fresh random 16-byte IV per call and stores 'v2:' || base64(iv || ciphertext); decrypt_text dispatches on that prefix and falls back to the original zero-IV path for anything not yet migrated, so this deploys without a flag day. A backfill converts existing rows in all three columns, guarded by NOT LIKE 'v2:%' so a partial or repeated run is a no-op. Deliberately scoped to this one defect (BossConsole#618 names three). Defect 2 (the encryption key truncated to 32 string characters instead of 32 bytes) is not touched here: ops/rotate_master_encryption_key.sql records that a deployed key may be hex or base64, and this migration cannot tell which is live in any given project's Vault - guessing wrong would make get_encryption_key()'s result unusable and break encryption for every row on deploy. Defect 3 (no MAC/AEAD) needs its own wire-format review. ops/rotate_master_encryption_key.sql itself will need a follow-up before it can run again, since it manipulates raw ciphertext bytes assuming the old no-IV, no-prefix layout - both gaps are documented in the migration's own header. This could not be run against a real Postgres/pgTAP instance in this environment (no supabase/docker CLI available here) - the added test was written and reviewed by hand, and hosted CI's Database (pgTAP) check is the actual verification gate. * fix(security): un-qualify substring(... from ...) so it parses CI's Database (pgTAP) check caught what local review couldn't (no Postgres available in this sandbox): pg_catalog.substring(x from y) is a syntax error. substring's SQL-standard FROM/FOR trailing syntax is recognized by the parser only on the bare function name - schema-qualifying it makes PostgreSQL treat it as an ordinary call, where FROM is not a valid argument separator. 20260909000000_encrypt_totp.sql's own decrypt_text already calls substring unqualified for exactly this reason; this migration's three call sites (envelope/iv/body extraction in decrypt_text, and the twofa_secret backfill) now match that convention. Both functions' search_path already resolves the unqualified name to pg_catalog's substring, so nothing else about name resolution changes. * fix(security): make key rotation understand the v2 IV envelope CI's Database (pgTAP) check caught what local review couldn't (no Postgres available in this sandbox): the pre-existing master_key_rotation_test.sql runs ops/rotate_master_encryption_key.sql against real fixture data, and that script's re-encrypt loop calls extensions.decrypt/encrypt directly on raw ciphertext bytes, assuming the old zero-IV, no-prefix layout. The 'v2:' prefix the previous commit's encrypt_text now writes is not valid base64, so decoding it raised - exactly the gap this migration's own header comment already named as a follow-up, just sooner than expected. Added a pg_temp helper, rotate_ciphertext, that re-encrypts a mapped column's value under the new key while preserving whichever envelope it already carries: a 'v2:' row is decrypted with decrypt_iv under the old key and re-encrypted with encrypt_iv under the new key AND a fresh IV (a function rather than an inline SQL expression, specifically so gen_random_bytes(16) is evaluated once and reused for both the encrypt_iv call and the stored prefix - two calls would produce two different values and corrupt the row). A legacy row - every column before this pair of migrations, and still qbo_token_state / google_token_state going forward, since those are untouched by the IV migration - stays zero-IV, matching what it already was. Rotation's contract stays "same shape, new key"; upgrading a row's shape is the encryption migration's job, not rotation's. Updated the IV migration's own header comment, which had flagged this as a still-open follow-up, to point at the fix now landing alongside it instead. * fix(security): preserve plaintext bytes during IV upgrade * fix(security): make IV backfill atomic and diagnose unreadable rows --------- Co-authored-by: Shivang <shivang.iitk@gmail.com>
risa-labs-inc#647 merged supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql into dev, which collides with this PR's 20260914000000 version. Supabase keys migrations by that numeric prefix, so two migrations at the same version break migration ordering (and supabase db start) once this merges. Re-timestamp to 20260915000000 so it applies after risa-labs-inc#647, and update the header and test references to match.
Summary
encrypt_textcalled the 3-argextensions.encrypt(plaintext, key, 'aes'), which pgcrypto documents as using an all-zero IV. Encryption was therefore a pure function of(plaintext, key): two equal passwords produced byte-identical ciphertext. Becausecreate_secretencrypts server-side and RLS lets a user read their ownpassword_encryptedback, any signed-in user was an encryption oracle - grouping a dump ofsecretsby ciphertext names every set of users sharing a password, from a dump alone, with no key required. The same defect applied torecovery_codes_encryptedand (#417)twofa_secret.This closes BossConsole#618's first of three named defects.
What changed
encrypt_textnow draws a fresh random 16-byte IV per call (extensions.gen_random_bytes) and encrypts viaextensions.encrypt_iv, storing'v2:' || base64(iv || ciphertext)- the same versioned-envelope convention20260909000000_encrypt_totp.sqlestablished for TOTP secrets.decrypt_textdispatches on the'v2:'prefix, or falls back to the original zero-IV path unchanged for anything not yet migrated - no flag day, nothing else about either function's contract changes.secrets.password_encrypted,secret_metadata.recovery_codes_encrypted, andsecret_metadata.twofa_secretrows, guarded byNOT LIKE 'v2:%'so a partial or repeated run is a no-op. Thetwofa_secretbackfill briefly disablesencrypt_twofa_secret_trigger(mirroring whatops/rotate_master_encryption_key.sqlalready does), since that trigger rejects any write already carrying its own'v1:'prefix.Deliberately not in scope
BossConsole#618 names two more defects, neither touched here, and both explained in the migration's own header comment:
encryption_key::byteatruncates to the string's first 32 characters, i.e. 128 bits if hex-encoded, not the documented 256).ops/rotate_master_encryption_key.sql's own comment records that a deployed key may be hex or base64, and this migration has no way to tell which is live in any given project's Vault - guessing wrong would makeget_encryption_key()'s result unusable and break encryption/decryption for every row the moment this deploys. That needs an operator who knows the live encoding, not a guess baked into a migration.ops/rotate_master_encryption_key.sqlwill also need a follow-up before it is next run: it manipulates raw ciphertext bytes assuming the old no-IV, no-prefix layout, and after this backfill every mapped column is'v2:'-framed. Flagged in the migration header; not actioned here.Two follow-up commits fix problems hosted CI caught that local review couldn't, since this environment has no Postgres/Supabase CLI to test against directly:
pg_catalog.substring(x from y)is a syntax error - the SQL-standardFROM/FORtrailing syntax is only recognized on the bare function name, matching how20260909000000_encrypt_totp.sqlalready calls it. Fixed at all three call sites indecrypt_textand thetwofa_secretbackfill.ops/rotate_master_encryption_key.sql's re-encrypt loop calledextensions.decrypt/encryptdirectly on raw ciphertext bytes, assuming the old zero-IV, no-prefix layout - exactly the gap this migration's header already flagged as a follow-up, just needed sooner than expected, since the pre-existingmaster_key_rotation_test.sqlexercises that script directly. Added apg_temphelper that re-encrypts a mapped column's value under the new key while preserving whichever envelope it already carries ('v2:'gets a fresh IV; a legacy row, including the still-unmigratedqbo_token_state/google_token_statecolumns, stays zero-IV).Test plan
supabase/tests/secret_encryption_iv_randomization_test.sql(pgTAP): asserts two encryptions of the same plaintext now differ, av2:-enveloped value round-trips, a hand-constructed legacy zero-IV ciphertext still decrypts, and the backfillUPDATEboth converts a legacy row and is idempotent on a second run.supabase/dockerbinary available in this sandbox), so all three commits were written and reviewed by hand rather than executed locally, taking hosted CI's Database (pgTAP) check as the actual verification gate - which is how the two follow-up commits above got caught and fixed.Review repairs and contributor credit
Original IV randomization, legacy-read compatibility, backfill and rotation implementation: @Antriksh1984. Review repair
ba3d46fdfby @kshivang changes plaintext encoding toconvert_to(..., 'utf8')so the backfill preserves literal backslashes, and adds real populated migration replay plus mixed legacy/v2 rotation tests.Validation: isolated Supabase PostgreSQL 17, 826 assertions across 34 suites passed. Real migration replay covers all three fields, exact backslash/Unicode preservation, idempotency and restored TOTP trigger behavior. Replaying with the original encoding fails with
invalid input syntax for type bytea, confirming the regression. Fixture generation/check andgit diff --checkpass. Final-head hosted CI passed all ten checks: https://github.com/risa-labs-inc/BossConsole/actions/runs/34822090604. Merged into dev as7dfb90e5a76301f34d9bf98e8f370b67d3dd197eafter the locked refresh and reviewed-head check.Cross-group context: draft #619 proposes an authenticated envelope also called
v2:. Its wire format is incompatible with this narrower IV-only envelope; a future integration must use a distinct version and migrate explicitly. #619 is read-only context here, not a consolidation target. #621's resilient-listing change and merged #417/#423's TOTP/access-control work remain distinct. No live database deployment or key rotation was performed.Latest review repair
095ec0c723976da5eab1c8f61cecea60ce20a957makes the complete backfill atomic, diagnoses unreadable columns before mutation, and refuses an absent/disabled TOTP trigger with a restore-first message. All eight findings from the completed Claude review are addressed or explicitly assessed in the review-resolution comment.