Summary
On a non-superuser install (Supabase, or any managed Postgres), the _ord_ore domains are created, but the btree operator class they depend on is not. The result is a surface that looks installed and usable, where ORDER BY eql_v3.ord_term_ore(col) silently returns rows in an order unrelated to the plaintext.
Without eql_v3_internal.ore_block_256_operator_class, ordering was never going to work. So the bug is not the ordering fallback — it is that we install the ORE domains at all in an environment where they cannot be correct.
The DO ... EXCEPTION WHEN insufficient_privilege skip introduced in #375 makes the installer succeed everywhere, but it leaves the ORE domains behind without their ordering machinery. #378 proposes testing that skip path; this issue argues the skip path's end state is itself wrong.
What actually happens
eql_v3.ord_term_ore() returns eql_v3_internal.ore_block_256, which is a composite type (pg_type.typtype = 'c'), not a domain over bytea.
- With the opclass present,
ORDER BY resolves it and comparison routes through ore_block_256_lt → the ORE comparator. Correct.
- With the opclass absent, Postgres does not raise
could not identify an ordering operator. It falls back to its built-in record comparison, which walks the composite down to the raw bytes field and compares bytewise.
Bytewise order over ORE ciphertext is a random permutation of plaintext order — fixed at encryption time, so stable across queries, and meaningless.
Blast radius
|
non-superuser |
superuser |
_ord_ore domains created |
✅ yes |
✅ yes |
| ORE comparison functions |
✅ yes (33) |
✅ yes |
ore_block_256_operator_class |
❌ no (pg_opclass count 0) |
✅ yes (count 1) |
eql_v3.gt / lt / gte / lte |
✅ correct |
✅ correct |
ORDER BY eql_v3.ord_term_ore(col) |
❌ silently wrong |
✅ correct |
| btree index on the column |
❌ cannot be created |
✅ yes |
Range filters are unaffected: </>/<=/>= are backed by ore_block_256_lt and operators need no opclass. Only ORDER BY, which resolves an opclass, takes the fallback. So the failure is confined to ordering and indexing — but it is silent.
Reproduction
Measured against supabase/postgres:17.4.1.048, EQL 3.0.0 installed as the non-superuser postgres role via stash eql install --eql-version 3 --supabase --direct.
1. The opclass is missing, the domains are not.
SELECT count(*) FROM pg_opclass o JOIN pg_namespace n ON n.oid = o.opcnamespace
WHERE n.nspname LIKE 'eql_v3%';
-- non-superuser: 0 superuser: 1
SELECT count(*) FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public' AND t.typname LIKE '%ord_ore%';
-- both: 9 (integer, smallint, bigint, real, double, numeric, date, timestamp, text)
2. ORDER BY diverges from the ORE comparator ~44% of the time.
WITH pairs AS (
SELECT
ROW(ARRAY[ROW(gen_random_bytes(65))::eql_v3_internal.ore_block_256_term])::eql_v3_internal.ore_block_256 AS a,
ROW(ARRAY[ROW(gen_random_bytes(65))::eql_v3_internal.ore_block_256_term])::eql_v3_internal.ore_block_256 AS b
FROM generate_series(1, 200)
), cmp AS (
SELECT ((a).terms[1].bytes < (b).terms[1].bytes) AS bytewise_lt, -- what ORDER BY does
(eql_v3_internal.compare_ore_block_256_terms(a, b) < 0) AS ore_lt -- what is correct
FROM pairs
)
SELECT count(*) FILTER (WHERE bytewise_lt = ore_lt) AS agree,
count(*) FILTER (WHERE bytewise_lt <> ore_lt) AS disagree
FROM cmp;
-- agree 113 | disagree 87
(65 bytes = the minimum well-formed term, 49*N + 16 with N = 1.)
3. The two installs demonstrably take different code paths. Order a deliberately malformed 1-byte term:
WITH v(label, t) AS (
VALUES ('A', ROW(ARRAY[ROW('\xff'::bytea)::eql_v3_internal.ore_block_256_term])::eql_v3_internal.ore_block_256),
('B', ROW(ARRAY[ROW('\x01'::bytea)::eql_v3_internal.ore_block_256_term])::eql_v3_internal.ore_block_256)
) SELECT label FROM v ORDER BY t;
- non-superuser: returns
B, A — record comparison never looks at ORE semantics.
- superuser:
ERROR: Malformed ORE term: 1 bytes, raised from ore_block_256_lt.
The superuser install validates its input because ordering goes through the ORE code. The non-superuser install cannot, because it never gets there.
Why this matters
A user on Supabase can declare an eql_v3_integer_ord_ore column, insert, filter with </> correctly, and then ORDER BY it and get a stable, plausible-looking, wrong answer with no error and no NOTICE at query time. Encrypted ordering that silently lies is worse than encrypted ordering that is unavailable.
Suggested fixes
Roughly in order of preference:
- Do not create the
_ord_ore domains when the opclass cannot be created. If ORE ordering is unavailable, the type that exists to provide it should not exist either. A user then gets a clear "type does not exist" at DDL time rather than wrong rows at query time.
- Fail the install loudly for the ORE portion on non-superuser, and document
_ord (OPE) as the managed-Postgres ordering type. This is the same outcome, stated earlier.
- If the domains must exist for compatibility, make ordering impossible rather than wrong — e.g. define the type so record comparison cannot apply, or ship an opclass whose support function raises unless the real one is present.
Whatever is chosen, _ord / OPE is unaffected: eql_v3_internal.ope_cllw is a domain over bytea, so it orders via the native bytea btree and is correct on every provider without superuser. That is the ordering type managed Postgres should be steered toward.
Related
Summary
On a non-superuser install (Supabase, or any managed Postgres), the
_ord_oredomains are created, but the btree operator class they depend on is not. The result is a surface that looks installed and usable, whereORDER BY eql_v3.ord_term_ore(col)silently returns rows in an order unrelated to the plaintext.Without
eql_v3_internal.ore_block_256_operator_class, ordering was never going to work. So the bug is not the ordering fallback — it is that we install the ORE domains at all in an environment where they cannot be correct.The
DO ... EXCEPTION WHEN insufficient_privilegeskip introduced in #375 makes the installer succeed everywhere, but it leaves the ORE domains behind without their ordering machinery. #378 proposes testing that skip path; this issue argues the skip path's end state is itself wrong.What actually happens
eql_v3.ord_term_ore()returnseql_v3_internal.ore_block_256, which is a composite type (pg_type.typtype = 'c'), not a domain overbytea.ORDER BYresolves it and comparison routes throughore_block_256_lt→ the ORE comparator. Correct.could not identify an ordering operator. It falls back to its built-in record comparison, which walks the composite down to the rawbytesfield and compares bytewise.Bytewise order over ORE ciphertext is a random permutation of plaintext order — fixed at encryption time, so stable across queries, and meaningless.
Blast radius
_ord_oredomains createdore_block_256_operator_classpg_opclasscount 0)eql_v3.gt/lt/gte/lteORDER BY eql_v3.ord_term_ore(col)Range filters are unaffected:
</>/<=/>=are backed byore_block_256_ltand operators need no opclass. OnlyORDER BY, which resolves an opclass, takes the fallback. So the failure is confined to ordering and indexing — but it is silent.Reproduction
Measured against
supabase/postgres:17.4.1.048, EQL3.0.0installed as the non-superuserpostgresrole viastash eql install --eql-version 3 --supabase --direct.1. The opclass is missing, the domains are not.
2.
ORDER BYdiverges from the ORE comparator ~44% of the time.(65 bytes = the minimum well-formed term,
49*N + 16withN = 1.)3. The two installs demonstrably take different code paths. Order a deliberately malformed 1-byte term:
B, A— record comparison never looks at ORE semantics.ERROR: Malformed ORE term: 1 bytes, raised fromore_block_256_lt.The superuser install validates its input because ordering goes through the ORE code. The non-superuser install cannot, because it never gets there.
Why this matters
A user on Supabase can declare an
eql_v3_integer_ord_orecolumn, insert, filter with</>correctly, and thenORDER BYit and get a stable, plausible-looking, wrong answer with no error and noNOTICEat query time. Encrypted ordering that silently lies is worse than encrypted ordering that is unavailable.Suggested fixes
Roughly in order of preference:
_ord_oredomains when the opclass cannot be created. If ORE ordering is unavailable, the type that exists to provide it should not exist either. A user then gets a clear "type does not exist" at DDL time rather than wrong rows at query time._ord(OPE) as the managed-Postgres ordering type. This is the same outcome, stated earlier.Whatever is chosen,
_ord/ OPE is unaffected:eql_v3_internal.ope_cllwis a domain overbytea, so it orders via the native bytea btree and is correct on every provider without superuser. That is the ordering type managed Postgres should be steered toward.Related
pg_opclasswith 0 custom classes and calls that success, which is exactly the end state described above.cipherstash/stack—docs/eql-v3-ord-term-ordering-defect.mddocuments the ordering symptom; the stack integration matrix defers the nine_ord_oredomains for this reason.