All notable changes to CipherStash Proxy will be documented in this file.
The format is based on Keep a Changelog.
-
Application-generated EQL values in statements: SQL literals and bound parameters may now carry EQL v3 storage payloads or query-only SEM operands produced by an application. Proxy authenticates every stored ciphertext, requires its authenticated descriptor to name the inferred destination column, and independently re-derives every SEM term before forwarding it without double encryption. Query-only operands contain no ciphertext to authenticate, so Proxy instead validates their version, identifier, term shape, column capabilities, and syntactic query role; they are rejected in storage positions. This includes bare SteVec selector hashes matching
^[0-9a-f]{32}$: in JSON selector query positions a match is treated as already hashed, while a non-match remains plaintext and is encrypted normally. A matching plaintext selector is inherently ambiguous and is intentionally treated as already hashed. Invalid payloads fail closed with one generic, transaction-aborting error so validation details cannot be used as an oracle.Compatibility note: on every encrypted column type, Proxy reserves three JSON object shapes for application-generated EQL: storage payloads with top-level
vandiplus at least one ofc,h, orsv; scalar query payloads withvandiplus at least one ofhm,bf,ob, orop; and SteVec query payloads whose only top-level key issv. An object matching one of these shapes is validated as EQL rather than encrypted as plaintext, and there is no opt-out. Query-only shapes are rejected in storage positions. Before upgrading, audit plaintext application writes for these key combinations; Invalid encrypted value includes ajsonbscan predicate.
- Upstream TLS verification for client traffic: Connections with
with_tls_verificationenabled now use a cached snapshot of the system root certificates, loaded once when Proxy starts. Unlike Proxy's background database connections, pg-proto client-traffic connections do not apply operating-system revocation checks or enterprise verification policy. Restart Proxy after changing the system trust store.
-
Configured default keyset selection: when a connection has not selected a keyset explicitly, Proxy now scopes encryption and decryption to
CS_DEFAULT_KEYSET_ID. Previously it passed no keyset to ZeroKMS and could silently use the account default instead, deriving different searchable-encryption terms when the two defaults differed. Before upgrading, verify that the configured and account defaults are intentional; values written by an affected version under the unintended account default must be decrypted with that old keyset and re-encrypted under the configured default. -
PostgreSQL protocol error handling after the pg-proto migration: Proxy now rejects
require_tlsconfigurations that omit a certificate, preserves PostgreSQL transaction state when statement mapping fails, returns decryption failures as PostgreSQL errors without dropping the connection, and reloads changed schemas only after PostgreSQL confirms the transaction boundary. Prepared-statement replacement also preserves existing portals and overlapping statement metrics.
3.0.1 - 2026-08-05
-
Multi-step JSON paths on encrypted columns, everywhere:
col -> 'a' -> 'b'now works wherever a single field access does — in the select list, in an ordering comparison (<,<=,>,>=), and in the->,->>andjsonb_path_query_firstspellings mixed freely, to any depth, with each step written as a literal or a placeholder. Previously only exact equality accepted a multi-step path and everything else was rejected. A chain is a single path into a single document, so it is now rewritten to one field access keyed on the whole composed path ($.a.b) instead of the nested accessors that would search an already-extracted entry and return NULL. Exact equality continues to fold the path and the value into one needle, which remains the stronger match.Two shapes are still rejected rather than answered. A path split across a subquery, CTE or view (
SELECT a -> 'foo' FROM (SELECT col -> 'bar' AS a FROM t) s) cannot be composed at all — the extracted value does not carry the path that produced it, and the document it came from is not in scope — so write the whole path in one expression. A placeholder step in front of a literal final step (col -> $1 -> 'b') cannot be composed either, because a literal is encrypted before any parameter is bound; parameterise the final step too (col -> $1 -> $2), or write the whole path as literals. -
ANY/ALLover an array of encrypted values:col = ANY(ARRAY['a', 'b'])now works on an encrypted column whose domain supports the operator, rewritten elementwise to the same term form as the scalar comparison — each array element is encrypted like any other comparison operand.ARRAY[…]written in the statement is the supported spelling; an encrypted subquery projection (ANY(SELECT enc FROM …)) or a bare array parameter (ANY($1)) is rejected with an explanatory error, where previously the subquery form was forwarded unrewritten and silently matched nothing.
-
One placeholder used as the JSON selector of two different paths:
col -> 'a' -> $1 = $2alongsidecol -> 'b' -> $1 = $3silently kept only one of the two paths, so one of the predicates was matched against the wrong field. The path a selector placeholder keys is recorded against the parameter it arrives in — at Bind time the parameter number is all Proxy has — so two different paths for one parameter cannot both be honoured. This is now reported as an error naming the parameter, rather than answered from whichever path was recorded last. -
Literals and params that escape type checking now fail closed: a literal or parameter whose type was never worked out during type checking used to be silently assumed to be plaintext — so a value in a clause the type checker did not cover could skip encryption without any error. Proxy now only makes that assumption where it is provably safe: a value that only flows to the client through a
SELECTprojection (SELECT 'lit',SELECT $1), or comparison operands that relate to nothing but each other (WHERE 1 = 1,1 IN (1, 2), and friends — anything encrypted arriving in such a comparison types it concretely first). Anywhere else the statement is rejected with an error naming the value. As part of this,WHERE/HAVING/joinONconditions andORDER BY/GROUP BYordinals are now explicitly typed as plaintext where they appear, and an encrypted column used bare as a boolean condition (for exampleWHERE enc_col) is rejected instead of being forwarded to the database. -
UPDATE … SET … FROMwith same-named columns: anUPDATEwas rejected as ambiguous when a table in theFROMclause had a column with the same name as the column being assigned. The assignment now always refers to the table being updated, so these statements work and the assigned value gets the target column's type — encrypted or not. -
Encrypted values as row counts are rejected: an encrypted column used in
LIMIT,OFFSET, orFETCH(for exampleLIMIT enc_col) is now rejected with a type error instead of being forwarded to the database. -
Statements Proxy cannot type-check fail with a clear error: a statement Proxy admits for type checking but has no support for is now rejected immediately with an error naming the statement, instead of surfacing later as an opaque resolution error. No currently-supported statement is affected.
-
Chained JSON field accessors sent the intermediate field name to the database in plaintext:
WHERE col -> 'a' -> 'b' = $1on an encrypted JSON column emittedeql_v3.jsonb_contains(col -> 'a', …), so the field nameaappeared in the statement text PostgreSQL received (and in its logs), and nativejsonb ->was applied to the encrypted payload — which also made the predicate match nothing. A chain is now treated as the single path it is:$.a.bof the whole document, folded into the one encrypted needle and matched against the bare column. Chains of any depth are supported, in the->,->>andjsonb_path_query_firstspellings, with=and<>, and with each step written as a literal or a placeholder. -
A NULL JSON selector forwarded the compared value to the database in plaintext:
WHERE col -> $1 = $2with$1bound NULL builds no needle, so$2was never encrypted — and it was then sent to PostgreSQL exactly as the client bound it, putting the plaintext comparand on the wire and into the server log when the column's domain CHECK rejected it. An encrypted operand that produced no ciphertext is now bound NULL, which is also what the SQL means: a comparison against NULL is NULL, so the query returns no rows. -
A surviving
eql_v2_encryptedcolumn was served as plaintext: after migrating to EQL v3, a column still declared with EQL v2'seql_v2_encryptedtype had no v3 domain identity, so Proxy fell back to treating it as an ordinary plaintext column — no encryption on writes, no decryption on reads. An application writing to such a column stored plaintext in a database it believed was encrypted, and saw no error doing so; the only signal was a single warning at startup. This is the shape of a partly-completed migration, where most columns move to v3 domains and one is left behind.Proxy now refuses every statement referencing a table that has such a column, with an error naming the column, its type and the need to migrate it. Other tables are unaffected, so one unmigrated column no longer costs you a deployment. The refusal is not subject to the
mapping_errors_enabledpassthrough — it applies whateverCS_DEVELOPMENT__ENABLE_MAPPING_ERRORSis set to. See Unmappable encrypted column.
3.0.0 - 2026-08-05
- EQL v3 (searchable encryption): Proxy now targets EQL v3. Encrypted columns are declared with self-configuring, typed
jsonbdomains (for exampleeql_v3_text_search,eql_v3_integer_ord,eql_v3_json_search) that encode both the scalar type and the column's searchable capabilities in the column type itself, replacing EQL v2's opaqueeql_v2_encryptedcomposite type and its separateeql_v2_configurationtable. The bundledcipherstash-clientis upgraded to 0.42.0 and EQL to 3.0.4. Existing v2-encrypted data and schemas must be migrated to v3.
-
Encrypted full-text match with
@@: The@@operator is now supported on encrypted text columns whose domain carries a match (bloom-filter) term, rewritten to the EQL v3eql_v3.match_termform. -
SELECT DISTINCTon an encrypted column now deduplicates:DISTINCTused to compare whole encrypted payloads, whose ciphertext is randomised per row, so equal plaintexts never collapsed andDISTINCTsilently returned duplicates. It is now keyed on the column's equality term —SELECT DISTINCT ON (eql_v3.eq_term(col)) col …— so one row is returned per distinct plaintext. Deduplication is equality, so a column whose domain carries no equality term (eql_v3_boolean, for instance, which is storage-only) is now rejected with a capability error rather than silently returning every row. -
SELECT DISTINCTordered by an encrypted column:SELECT DISTINCT … ORDER BY <encrypted column>now works. Ordering an encrypted column requires its ordering term, which PostgreSQL will not accept underDISTINCTunless it also appears in the select list, so the query is rewritten to project the term from a subquery and order the outer query by it. The term is never returned to the client and column names are preserved. Two shapes remain unsupported and are reported as such:SELECT DISTINCT ON (…)andSELECT DISTINCT *, both when combined withORDER BYon an encrypted column — list the columns explicitly for the latter. -
Equality on encrypted JSON fields:
WHERE col -> 'field' = 'value'now works on encrypted JSON columns, in both the simple and extended query protocols, and in the->>andjsonb_path_query_first(col, path) = valuespellings.<>is supported as the negation. The field and the value are combined into a single encrypted value-selector needle and matched by containment, so a query never reveals the field and value separately. Matching is exact and case-sensitive; the value must be a JSON scalar (comparing a whole object or array to a field is rejected — use containment with@>instead).
-
Statement errors no longer desync the connection: when a statement failed inside the proxy (an unsupported operation on an encrypted column, for instance), the error was written straight to the client and could overtake responses still in flight from the server — with connection pools and prepared-statement caching, the client then saw a protocol error (
unexpected message from serverin tokio_postgres) instead of the proxy's message, typically right after an encrypted statement had run on the same connection. The proxy now delivers statement errors through the server, so clients always receive the proxy's actual error message, in order, and the connection remains usable. -
A param bound as both a stored value and a query operand:
UPDATE t SET enc = $1 WHERE enc = $1failed with a domain CHECK violation. The two occurrences need different payloads — the stored one carries the ciphertext, the query one only search terms — but the role was tracked per input param, so marking the param as a query operand stripped the ciphertext from the value being stored. The role is now taken from the rewritten statement, per occurrence. -
JSON selector params when the client declares its own types: a client that sends param OIDs in Parse (pgx in
cache_describemode, for example) gotfunction eql_v3.jsonb_path_exists(eql_v3_json_search, jsonb) does not exist. A JSON field selector is passed to the rewritten function as bare text, but was being declared asjsonblike every other encrypted operand. Affects->,->>,jsonb_path_exists,jsonb_path_queryandjsonb_path_query_first. -
Binary-format text operands on encrypted JSON fields: a TEXT/VARCHAR operand arriving in binary format was handed straight to the JSON decoder and rejected, even though the same value in text format was accepted. Textual types are now read as a string first and then given the text format's treatment, so
Alicebehaves like"Alice". -
Aggregates over a grouped encrypted column:
SELECT MIN(enc) FROM t GROUP BY encproducedgrouped_value(eql_v3.min(enc))— an aggregate inside an aggregate, which PostgreSQL rejects. An aggregate already returns one value per group, so it is no longer lifted; only a direct projection of the grouped column is. -
SELECT *withGROUP BYon an encrypted column is now rejected with an explanatory error instead of PostgreSQL's "column must appear in the GROUP BY clause". A wildcard hides the projected columns, so the grouped column cannot be projected througheql_v3.grouped_value— list the columns explicitly. This matches the existing treatment ofSELECT DISTINCT *. -
SELECT DISTINCT *skipped the encrypted-column protection: a wildcard hides the columnsDISTINCTdeduplicates on, so neither the equality-term keying nor the capability check applied and duplicates were returned silently. The wildcard is now expanded to its columns, which are keyed like any other; a wildcard hiding a column with no equality term is rejected. -
@@with the encrypted column on the right:'pattern' @@ colproducedmatch_term('pattern') @> match_term(col)— a backwards containment, with the pattern never encrypted, that silently matched nothing.@@is symmetric in PostgreSQL, so both spellings now produce the same query. -
Encrypt config could pick up a same-named table from another schema: the config is keyed on
(table, column)while the schema query scanned every schema, so a table of the same name elsewhere — another tenant's, a staging copy — could overwrite the served one and give a column the wrong domain config or drop its encryption. The scan is now limited to the connection's search path, in precedence order. -
A prepared statement name reused for an unmapped statement:
Parserebinds its name, but a statement Proxy does not map —BEGIN,COMMIT, or anything needing no type check — left the previous statement cached under that name. The nextBindfor the name was then rewritten against a statement the client never parsed, failing withRewritten statement binds parameter 1, but only 0 were provided. Affects any client that reuses the unnamed prepared statement across a transaction, which includes pgbench in extended mode and psycopg withprepare=False. -
LIKE/ILIKEcapability checking:LIKEandILIKEon an encrypted column are now gated by the column's token-match capability. Previously these predicates bypassed capability checking and were silently accepted on columns that do not support fuzzy match; they are now rejected with a capability error. -
Upserts with
ON CONFLICT DO UPDATEnow encrypt the update path:INSERT … ON CONFLICT (…) DO UPDATE SET enc = …previously left theDO UPDATEassignments untouched, so a plaintext value landed in the encrypted column unencrypted whenever the conflict path ran. Assignments are now typed and encrypted exactly like a plainUPDATE … SET,excluded.<col>references resolve to the column's encrypted type, and comparisons in theDO UPDATE … WHEREpredicate are rewritten to their search terms. A conflict target naming an encrypted column (ON CONFLICT (enc)) is rejected: uniqueness there would be judged on the randomised ciphertext, so the conflict would never fire. -
Window functions over an encrypted column: the window's
ORDER BYis now checked against the column's ordering capability (previously it was silently left ordering on raw ciphertext, whose order differs on every insert), and named window definitions (OVER wwithWINDOW w AS (PARTITION BY enc …)) get the same equality-term and ordering-term treatment as inlineOVER (…)clauses, which previously escaped both checking and rewriting.RANGEframes with an offset over an encrypted sort key are rejected, since no search term supports the arithmetic they need;ROWSandGROUPSframes work. -
count(DISTINCT enc)now counts distinct plaintexts: the deduplication previously ran on whole encrypted payloads, whose ciphertext is randomised per row, so every value looked distinct and the count silently equalled the row count. The argument is now rewritten to the column's equality term.DISTINCTwith an encrypted argument in any other aggregate is rejected (the substitution would change that aggregate's result), as is an aggregate-internalORDER BY(array_agg(x ORDER BY enc)) on a column with no ordering term — where the capability exists, the key is rewritten to its ordering term. -
WITHIN GROUP (ORDER BY enc)is now rejected: an ordered-set aggregate (percentile_disc,mode, …) computes its result from the sort key, so on an encrypted column it would hand the client an opaque search term. Previously the clause escaped type checking entirely. -
SELECT … INTOan encrypted column is now rejected: the statement copies data into a table the encryption schema has never seen, leaving unreachable ciphertext there. Native-only projections pass through as before.
2.2.4 - 2026-06-18
- ZeroKMS authentication failures ~15 minutes after startup (access keys): Fixed the root cause of access tokens never being renewed when authenticating with an access key. The token's lifetime was misread, so renewal never triggered and every encrypt/decrypt operation began failing (
ZeroKMS error: Request not authorized, "Could not decrypt data") roughly 15 minutes — the token lifetime — after connecting, recovering only on restart. Tokens now renew correctly ahead of expiry. This resolves the remaining cases not addressed by the 2.2.3 fix.
2.2.3 - 2026-06-17
- ZeroKMS authentication failures ~15 minutes after startup: Fixed an issue in the access-key authentication path where, after an in-flight request was interrupted at the wrong moment (for example, a client disconnecting mid-query), access-token renewal could stall. This caused
ZeroKMS error: Request not authorizedon all encrypt/decrypt operations roughly 15 minutes (the access-token lifetime) after connecting — connections worked on startup and then began failing in lockstep.
2.2.2 - 2026-06-01
- Passthrough mode memory leak: Fixed a per-statement memory leak that occurred in passthrough mode (empty encrypt config), where per-statement queues were not drained. Long-running connections could grow unbounded and eventually OOM. (#400)
2.2.1 - 2026-05-14
- OPE (Order-Preserving Encryption) index: New
opeindex type alongside the existingorefor range andORDER BYqueries on encrypted columns. Drop-in alternative toore— pick one per column. See the encrypted indexes documentation for configuration.
2.2.0-alpha.1 - 2026-03-25
- Log target renamed:
KEYSETlog target renamed toZEROKMS. The environment variableCS_LOG__KEYSET_LEVELis nowCS_LOG__ZEROKMS_LEVEL.
- Log target removed:
PROXYlog target andCS_LOG__PROXY_LEVELenvironment variable have been removed.
- Cipher cache miss metric: New Prometheus counter
cipherstash_proxy_keyset_cipher_cache_miss_totaltracks cache misses requiring cipher initialization. This complements thecipherstash_proxy_keyset_cipher_cache_hits_totalmetric, and can be used to calculate cache hit/miss ratio. - Cipher init duration metric: New Prometheus histogram
cipherstash_proxy_keyset_cipher_init_duration_secondstracks cipher initialization time including ZeroKMS network calls. - Encrypt/decrypt timing: Debug logs for
encrypt_eqlanddecrypt_eqlnow includeduration_ms. - Cache eviction logging: ScopedCipher cache eviction events are now logged under the
ZEROKMStarget. - Slow cipher init warning: Cipher initialization taking longer than 1 second triggers a warning log.
2.1.22 - 2026-02-05
- Configurable slow database response threshold: The "Slow database response" log threshold is now configurable via
CS_LOG__SLOW_DB_RESPONSE_MIN_DURATION_MS(default: 100ms). This controls per-message logging for individual slow reads from the PostgreSQL server.
2.1.21 - 2026-02-04
- Updated
cipherstash-clientto v0.33.0. Addsarray_index_modeconfiguration for STE-VEC indexes, which controls how arrays are indexed in JSONB data. Defaults toall(generating item, wildcard, and positional selectors), preserving backwards compatibility with existing configurations.
2.1.20 - 2026-01-29
- Slow statement logging: Enable with
CS_LOG__SLOW_STATEMENTS=trueto log detailed timing breakdowns when queries exceed a configurable threshold (default 2 seconds). Includes breakdown of parse, encrypt, server wait, and decrypt phases. - Prometheus slow statement counter: New
cipherstash_proxy_slow_statements_totalmetric increments when slow statements are detected. - Prometheus histogram labels: Duration histograms now include
statement_type,protocol,mapped, andmulti_statementlabels for granular performance analysis. - Term filters for STE-VEC indexes: Support for
term_filtersconfiguration ineql_v2.add_search_config(), enabling case-insensitive JSONB queries with thedowncasefilter.
- Updated
cipherstash-clientto v0.32.2. - GitHub Actions jobs now timeout after 30 minutes.
- ARM64 builds migrated to Blacksmith runners.
2.1.9 - 2026-01-10
- Encryption sanity checks for improved error detection.
- Developer documentation updates.
- Updated
cipherstash-clientto v0.31.1.
2.1.8 - 2025-12-15
- Refactored EQL encryption logic.
- JSONB containment operator transformation improvements.
- Testing across multiple PostgreSQL versions.
2.1.7 - 2025-11-27
- Security documentation.
- Improved ZeroKMS error handling.
- Database connection CLI arguments now optional.
2.1.6 - 2025-09-05
- Accurate cipher cache sizing.
- JSONB encrypted type protocol fixes.
- Module restructuring.
2.1.5 - 2025-08-21
SETcommand forkeyset_idconfiguration.- Configurable cipher caching using async Moka.
2.1.4 - 2025-08-08
- Updated EQL to v2.1.8.
2.1.3 - 2025-08-01
- Helm chart support.
- JSONB operator integration tests.
- Comprehensive proxy/EQL showcase crate.
2.1.2 - 2025-07-16
- Common Table Expression (CTE) table resolution in EQL mapper.
2.1.1 - 2025-07-15
- JSON indexing for EQL v2.
- Prometheus metrics collection.
- Multiple integration test frameworks.
2.0.10 - 2025-06-26
SETcommand to disable mapping.
2.0.9 - 2025-06-20
- Upgraded container base image to Ubuntu 25.10.
- Updated sqltk dependency to v0.10.0.
2.0.8 - 2025-06-18
- Version string sent to ZeroKMS/CTS requests.
- Type-related issues in sqlparser.
- Release workflow now triggers on release events.
2.0.7 - 2025-06-12
- Language-specific tests in integration suite.
- PostgreSQL custom and domain type identifier handling.
- Docker image build processes in GitHub Actions.
2.0.6 - 2025-06-09
- TLS and Docker configuration documentation.
- Expanded test coverage for order and group operations.
- URL encoding for usernames in Docker entrypoint.
- Preference for CRN over workspace_id and region.
- Order and group transformers.
2.0.5 - 2025-05-27
- Cache usage in release artifact building.
2.0.4 - 2025-05-26
- OIDC support.
- Special character handling in database configuration.
- "Insufficient data left in message" errors with null values.
2.0.3 - 2025-05-26
- Tests now ignore
CS_environment variables during configuration validation.
- Added environment debugging to AWS Marketplace release workflow.
2.0.2 - 2025-05-22
- Multi-platform Docker image builds.
- Updated EQL to v2.0.1.
2.0.1 - 2025-05-21
- Encryption configuration validation.
- pgbench performance testing integration.
- ZeroKMS and CTS host configuration options.
GROUP BYSQL transformations.- EQL v2 decryption support.
- Enhanced column configuration verification.
- Connection termination messaging.
- Upgraded to Rust 1.86.0 compatibility.
- Upgraded sqltk to v0.8.0.
2.0.0 - 2025-03-27
- Initial release of CipherStash Proxy.
- Transparent proxy for PostgreSQL with automatic encryption/decryption.
- Support for queries over encrypted values (equality, comparison, ordering).
- Docker container deployment.
- Integration with CipherStash ZeroKMS.
- Encrypt Query Language (EQL) for indexing and searching encrypted data.