From 5a6c38f259faa70f153e300d86352d64a8845f53 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 27 Oct 2026 12:14:48 +1100 Subject: [PATCH 01/11] fix(deps): backport security dependency updates to 2.2 Bring the security patches that main already carries to release/2.2: - rustls-webpki 0.103.14 - rand 0.9.3 and 0.10.2 - quinn-proto 0.11.15 - hickory-proto and hickory-resolver 0.26.3, via reqwest 0.13.4 - diesel 2.3.12 - postgres-protocol 0.6.12 (including an unbounded SCRAM iteration count from a malicious server), which adds md-5 0.11 and sha2 0.11 beside the existing versions Lockfile only; no code changes were needed. --- CHANGELOG.md | 4 + Cargo.lock | 491 +++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 366 insertions(+), 129 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8f443e4..75ddf492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Plaintext forwarded beside a statement Proxy cannot type check**: When encrypted columns are configured, Proxy now refuses a write that it cannot type check, such as an `INSERT` into a table it has not loaded, instead of forwarding it to PostgreSQL unchanged. Such a write is still forwarded when Proxy knows that every table it writes to has no encrypted columns. Proxy also refuses a simple query that has a statement it cannot type check together with a statement it needs to encrypt or decrypt. Previously, with mapping errors off (the default), Proxy forwarded the whole query as written, so an `INSERT` into an encrypted column in the same query reached PostgreSQL with its plaintext values. A read that Proxy cannot type check is still forwarded unchanged when it is sent in a query of its own or only with statements that Proxy does not need to map. A statement that Proxy forwards unchanged is sent as written, so a plaintext value that it compares with an encrypted column still reaches the database. +### Security + +- **Dependency updates**: Updated `rustls-webpki` to 0.103.14, `rand` to 0.9.3 and 0.10.2, `quinn-proto` to 0.11.15, `hickory-proto` and `hickory-resolver` to 0.26.3 (with `reqwest` 0.13.4), `diesel` to 2.3.12, and `postgres-protocol` to 0.6.12, picking up the fixes for published security advisories in those crates that Proxy 3.x already ships. No configuration change is required. + ## [2.2.4] - 2026-06-18 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index f095eb2a..9686c2ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "generic-array", ] @@ -321,9 +321,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -332,14 +332,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -544,6 +545,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-modes" version = "0.9.1" @@ -664,7 +674,7 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.117", @@ -737,7 +747,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "inout", ] @@ -765,7 +775,7 @@ dependencies = [ "dirs", "futures", "hex", - "hmac", + "hmac 0.12.1", "itertools 0.12.1", "lazy_static", "log", @@ -789,7 +799,7 @@ dependencies = [ "serde_cbor", "serde_json", "serdect", - "sha2", + "sha2 0.10.8", "stack-auth", "stack-profile", "static_assertions", @@ -824,12 +834,12 @@ version = "0.34.1-alpha.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5bb7181053c3fc35569e0800fa7510c85ee2bffee21abbd2a7aeb498f5f0972" dependencies = [ - "hmac", + "hmac 0.12.1", "lazy_static", "num-bigint", "rand 0.8.6", "regex", - "sha2", + "sha2 0.10.8", "thiserror 1.0.69", ] @@ -852,7 +862,7 @@ dependencies = [ "eql-mapper", "exitcode", "hex", - "md-5", + "md-5 0.10.6", "metrics", "metrics-exporter-prometheus", "moka", @@ -860,7 +870,7 @@ dependencies = [ "pg_escape", "postgres-protocol", "postgres-types", - "rand 0.9.2", + "rand 0.9.3", "recipher 0.1.3", "regex", "rust_decimal", @@ -898,7 +908,7 @@ dependencies = [ "futures-util", "hex", "postgres-types", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "rustls", "serde", @@ -978,7 +988,7 @@ checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" dependencies = [ "cipher", "dbl", - "digest", + "digest 0.10.7", ] [[package]] @@ -990,6 +1000,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.3" @@ -1049,6 +1065,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -1158,6 +1180,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1200,14 +1231,33 @@ dependencies = [ "vitaminc", ] +[[package]] +name = "ctutils" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -1224,13 +1274,38 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn 2.0.117", ] @@ -1256,7 +1331,7 @@ version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der_derive", "flagset", "zeroize", @@ -1346,20 +1421,21 @@ checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d" [[package]] name = "diesel" -version = "2.2.10" +version = "2.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff3e1edb1f37b4953dd5176916347289ed43d7119cc2e6c7c3f7849ff44ea506" +checksum = "715377c6e464cb44bb89bd8487584240516c8d5052bc645d6babc50bb8be46c3" dependencies = [ "chrono", "diesel_derives", + "downcast-rs", "uuid", ] [[package]] name = "diesel_derives" -version = "2.2.5" +version = "2.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d4216021b3ea446fd2047f5c8f8fe6e98af34508a254a01e4d6bc1e844f84d" +checksum = "ecbd51fb6c020672543641167efa4e6417ff7ad76849ed556ace3595e72de03a" dependencies = [ "diesel_table_macro_syntax", "dsl_auto_type", @@ -1370,9 +1446,9 @@ dependencies = [ [[package]] name = "diesel_table_macro_syntax" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" dependencies = [ "syn 2.0.117", ] @@ -1389,11 +1465,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "4.0.0" @@ -1425,13 +1513,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dsl_auto_type" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" dependencies = [ - "darling", + "darling 0.21.3", "either", "heck", "proc-macro2", @@ -1445,7 +1539,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.117", @@ -1457,7 +1551,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abcba80bdf851db5616e27ff869399468e2d339d7c6480f5887681e6bdfc2186" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.117", @@ -1469,7 +1563,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bbcf21279103a67372982cb1156a2154a452451dff2b884cf897ccecce389e0" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.117", @@ -1499,18 +1593,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "eql-mapper" version = "1.0.0" @@ -1617,7 +1699,7 @@ dependencies = [ "chrono", "deunicode", "dummy 0.11.0", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] @@ -1926,46 +2008,70 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" [[package]] -name = "hickory-proto" -version = "0.25.2" +name = "hickory-net" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" dependencies = [ "async-trait", "cfg-if", "data-encoding", - "enum-as-inner", "futures-channel", "futures-io", "futures-util", + "hickory-proto", "idna", "ipnet", + "jni 0.22.4", + "rand 0.10.2", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", "once_cell", - "rand 0.9.2", + "prefix-trie", + "rand 0.10.2", "ring", "thiserror 2.0.18", "tinyvec", - "tokio", "tracing", "url", ] [[package]] name = "hickory-resolver" -version = "0.25.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" dependencies = [ "cfg-if", "futures-util", + "hickory-net", "hickory-proto", "ipconfig", + "ipnet", + "jni 0.22.4", "moka", + "ndk-context", "once_cell", "parking_lot", - "rand 0.9.2", + "rand 0.10.2", "resolv-conf", "smallvec", + "system-configuration 0.7.0", "thiserror 2.0.18", "tokio", "tracing", @@ -1977,7 +2083,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -2026,6 +2141,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2086,7 +2210,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.1", - "system-configuration", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", @@ -2335,6 +2459,9 @@ name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] [[package]] name = "iri-string" @@ -2410,19 +2537,68 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.0", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.32" @@ -2434,11 +2610,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -2566,7 +2743,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", ] [[package]] @@ -2707,6 +2894,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nom" version = "7.1.3" @@ -3093,19 +3286,19 @@ dependencies = [ [[package]] name = "postgres-protocol" -version = "0.6.8" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ff0abab4a9b844b93ef7b81f1efc0a366062aaef2cd702c76256b5dc075c54" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ "base64", "byteorder", "bytes", "fallible-iterator", - "hmac", - "md-5", + "hmac 0.13.0", + "md-5 0.11.0", "memchr", - "rand 0.9.2", - "sha2", + "rand 0.10.2", + "sha2 0.11.1", "stringprep", ] @@ -3163,6 +3356,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -3232,7 +3436,7 @@ dependencies = [ "bitflags", "lazy_static", "num-traits", - "rand 0.9.2", + "rand 0.9.3", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -3305,15 +3509,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.3.2", "lru-slab", - "rand 0.9.2", + "rand 0.9.3", "ring", "rustc-hash", "rustls", @@ -3379,9 +3583,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -3389,9 +3593,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -3485,7 +3689,7 @@ dependencies = [ "rand_chacha 0.3.1", "serde", "serde_cbor", - "sha2", + "sha2 0.10.8", "thiserror 1.0.69", "zeroize", ] @@ -3506,7 +3710,7 @@ dependencies = [ "rand_chacha 0.3.1", "serde", "serde_cbor", - "sha2", + "sha2 0.10.8", "thiserror 1.0.69", "zeroize", ] @@ -3591,9 +3795,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -3699,7 +3903,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a4bd6027df676bcb752d3724db0ea3c0c5fc1dd0376fec51ac7dcaf9cc69be" dependencies = [ - "rand 0.9.2", + "rand 0.9.3", ] [[package]] @@ -3878,11 +4082,12 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.11.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", + "zeroize", ] [[package]] @@ -3893,7 +4098,7 @@ checksum = "4a5467026f437b4cb2a533865eaa73eb840019a0916f4b9ec563c6e617e086c9" dependencies = [ "core-foundation 0.10.0", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -3914,7 +4119,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation 0.10.0", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -3935,9 +4140,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.3" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "aws-lc-rs", "ring", @@ -4193,7 +4398,18 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -4215,7 +4431,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" name = "showcase" version = "2.2.4" dependencies = [ - "rand 0.9.2", + "rand 0.9.3", "rustls", "serde", "serde_json", @@ -4236,6 +4452,16 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -4467,6 +4693,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4498,6 +4735,17 @@ dependencies = [ "system-configuration-sys", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -4723,7 +4971,7 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.9.2", + "rand 0.9.3", "socket2 0.5.8", "tokio", "tokio-util", @@ -4736,7 +4984,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" dependencies = [ - "const-oid", + "const-oid 0.9.6", "ring", "rustls", "tokio", @@ -5017,7 +5265,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "subtle", ] @@ -5096,7 +5344,7 @@ checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "atomic", "getrandom 0.3.2", - "md-5", + "md-5 0.10.6", "serde", "sha1_smol", ] @@ -5123,7 +5371,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" dependencies = [ - "darling", + "darling 0.20.10", "once_cell", "proc-macro-error2", "proc-macro2", @@ -5195,7 +5443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af693c39d3cd1c818ef6267539433c6ceca87840b12d24124adbc9c8ecba1709" dependencies = [ "bitvec", - "digest", + "digest 0.10.7", "serde", "serde_bytes", "subtle", @@ -5220,7 +5468,7 @@ version = "0.1.0-pre4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea9de431cb93359d293ec7e70d05d87117a57f34bfc5bc94f040b81d4dd1afd6" dependencies = [ - "rand 0.10.0", + "rand 0.10.2", "thiserror 2.0.18", "vitaminc-protected", "vitaminc-random-derives", @@ -5314,48 +5562,33 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ - "cfg-if", "js-sys", - "once_cell", + "tokio", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5363,22 +5596,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", - "wasm-bindgen-backend", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -5407,9 +5640,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -5446,9 +5679,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -6140,7 +6373,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der", "spki", "tls_codec", From d91dcd991c83f5fbdac3f5969bd4ae768322ea39 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Tue, 27 Oct 2026 12:04:35 +1100 Subject: [PATCH 02/11] fix(deps): backport rustls 0.23.45 to 2.2 rustls 0.23.28 has RUSTSEC-2026-0285 (TLS 1.3 handshake messages accepted across encryption levels). main already ships 0.23.45. rustls is Proxy's TLS server for clients and its TLS client to PostgreSQL. The CHANGELOG entry now names rustls and aws-lc-rs/aws-lc-sys, and no longer says that 2.2.5 has every fix that 3.x ships. --- CHANGELOG.md | 2 +- Cargo.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ddf492..64850a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Security -- **Dependency updates**: Updated `rustls-webpki` to 0.103.14, `rand` to 0.9.3 and 0.10.2, `quinn-proto` to 0.11.15, `hickory-proto` and `hickory-resolver` to 0.26.3 (with `reqwest` 0.13.4), `diesel` to 2.3.12, and `postgres-protocol` to 0.6.12, picking up the fixes for published security advisories in those crates that Proxy 3.x already ships. No configuration change is required. +- **Dependency updates**: Updated `rustls` to 0.23.45, `rustls-webpki` to 0.103.14, `aws-lc-rs` to 1.18.2 (with `aws-lc-sys` 0.46.0), `rand` to 0.9.3 and 0.10.2, `quinn-proto` to 0.11.15, `hickory-proto` and `hickory-resolver` to 0.26.3 (with `reqwest` 0.13.4), and `diesel` to 2.3.12, and `postgres-protocol` to 0.6.12, to pick up the fixes for published security advisories in those crates. No configuration change is required. ## [2.2.4] - 2026-06-18 diff --git a/Cargo.lock b/Cargo.lock index 9686c2ed..d6ecb238 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4055,9 +4055,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.28" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", From b1711d64e932b691cdb3c54f9d1a9a44bed36402 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Tue, 27 Oct 2026 12:04:35 +1100 Subject: [PATCH 03/11] ci: remove the Multitudes deployment notification (2.2) The Multitudes endpoint has returned 500 on every release run since 2026-08-17, so the v2.2.5 release run would end red after the images are published. main removed this step in #477. --- .github/workflows/release.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dcce7a0..6760ad77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -198,13 +198,3 @@ jobs: if: ${{ fromJSON(env.publish) }} run: | docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} - - - name: Notify Multitudes - if: ${{ fromJSON(env.publish) }} - run: | - curl --request POST \ - --fail-with-body \ - --url "https://api.developer.multitudes.co/deployments" \ - --header "Content-Type: application/json" \ - --header "Authorization: ${{ env.MULTITUDES_ACCESS_TOKEN }}" \ - --data '{"commitSha": "${{ github.sha }}", "environmentName":"dockerhub"}' From 1d6bafb5a90863fe6a87b1726013379229fae928 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Thu, 20 Aug 2026 14:55:05 +1000 Subject: [PATCH 04/11] fix(proxy): honor configured default keyset Scope the ZeroKMS cipher to CS_DEFAULT_KEYSET_ID whenever a connection has not selected an override. Previously Proxy only checked that the setting existed, then passed no identifier to ScopedCipher and could silently use the client's account default instead. Application-encrypted payloads use the configured keyset explicitly. When the account and configured defaults differ, Proxy derived searchable-encryption metadata with another index key and rejected valid inbound ciphertext during authentication. Preserve connection-level keyset precedence while making the configured fallback effective. Signed-off-by: James Sadler --- .../cipherstash-proxy/src/proxy/zerokms/zerokms.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs b/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs index 15e120d3..b2f43abf 100644 --- a/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs +++ b/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs @@ -16,6 +16,7 @@ use cipherstash_client::{ PreparedPlaintext, }, schema::column::IndexType, + IdentifiedBy, }; use eql_mapper::EqlTermVariant; use metrics::{counter, histogram}; @@ -93,7 +94,15 @@ impl ZeroKms { info!(target: ZEROKMS, msg = "Initializing ZeroKMS ScopedCipher (cache miss)", ?keyset_id); counter!(KEYSET_CIPHER_CACHE_MISS_TOTAL).increment(1); - let identified_by = keyset_id.as_ref().map(|id| id.0.clone()); + // A connection-level keyset takes precedence. Otherwise, scope the + // cipher to Proxy's configured default instead of passing `None` and + // silently falling back to the ZeroKMS client's account default. The + // two defaults are not required to be the same, and using the account + // default would derive different searchable-encryption terms. + let identified_by = keyset_id + .as_ref() + .map(|id| id.0.clone()) + .or_else(|| self.default_keyset_id.map(IdentifiedBy::Uuid)); let start = Instant::now(); let result = ScopedCipher::init(zerokms_client, identified_by).await; From 91c5c78a50867eaf665f25dfea86e4d0c542ff44 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 27 Oct 2026 16:34:33 +1100 Subject: [PATCH 05/11] fix(proxy): refuse an encryption result with fewer values than requested Bind rewrites pair encrypted values with parameters by position. ZeroKms::encrypt padded a short result from encrypt_eql with None, and Bind skips a None value, so the parameter's original plaintext was forwarded to PostgreSQL. ZeroKms::encrypt and Context::encrypt now fail with EncryptionResultCountMismatch when they receive fewer ciphertexts than values sent for encryption. --- CHANGELOG.md | 2 + docs/errors.md | 25 ++++++++ packages/cipherstash-proxy/src/error.rs | 7 +++ .../src/postgresql/context/mod.rs | 39 ++++++++++++- .../src/proxy/zerokms/zerokms.rs | 57 ++++++++++++++++--- 5 files changed, 120 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64850a21..a8267d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Plaintext forwarded beside a statement Proxy cannot type check**: When encrypted columns are configured, Proxy now refuses a write that it cannot type check, such as an `INSERT` into a table it has not loaded, instead of forwarding it to PostgreSQL unchanged. Such a write is still forwarded when Proxy knows that every table it writes to has no encrypted columns. Proxy also refuses a simple query that has a statement it cannot type check together with a statement it needs to encrypt or decrypt. Previously, with mapping errors off (the default), Proxy forwarded the whole query as written, so an `INSERT` into an encrypted column in the same query reached PostgreSQL with its plaintext values. A read that Proxy cannot type check is still forwarded unchanged when it is sent in a query of its own or only with statements that Proxy does not need to map. A statement that Proxy forwards unchanged is sent as written, so a plaintext value that it compares with an encrypted column still reaches the database. +- **Encryption results checked before use**: Proxy now refuses a statement when the encryption service returns fewer encrypted values than the values it was asked to encrypt. Previously the parameters left without an encrypted value were sent to PostgreSQL as written, so a misbehaving encryption service could cause plaintext to reach the database. + ### Security - **Dependency updates**: Updated `rustls` to 0.23.45, `rustls-webpki` to 0.103.14, `aws-lc-rs` to 1.18.2 (with `aws-lc-sys` 0.46.0), `rand` to 0.9.3 and 0.10.2, `quinn-proto` to 0.11.15, `hickory-proto` and `hickory-resolver` to 0.26.3 (with `reqwest` 0.13.4), and `diesel` to 2.3.12, and `postgres-protocol` to 0.6.12, to pick up the fixes for published security advisories in those crates. No configuration change is required. diff --git a/docs/errors.md b/docs/errors.md index 4777ff8c..b52aed77 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -34,6 +34,7 @@ - [Unknown table](#encrypt-unknown-table) - [Unknown index term](#encrypt-unknown-index-term) - [Column configuration mismatch](#encrypt-column-config-mismatch) + - [Encryption result count mismatch](#encrypt-result-count-mismatch) - Decrypt errors: - [Column could not be deserialised](#encrypt-column-could-not-be-deserialised) @@ -808,6 +809,30 @@ If the error persists, please contact CipherStash [support](https://cipherstash. + + + +## Encryption result count mismatch + +The encryption service returned a different number of encrypted values than the number of values it was asked to encrypt. + +### Error message + +``` +Expected 2 encrypted values but received 0. +``` + +### Notes + +Proxy matches encrypted values to statement parameters by position. If any value were missing, the parameters after it could be sent to the database unencrypted, so Proxy refuses the statement instead. + +### How to Fix + +This indicates an internal error in the encryption pipeline. Retry the statement, and if the error persists, contact CipherStash support with the Proxy logs. + + + + diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs index f9b6a1f5..faf76a90 100644 --- a/packages/cipherstash-proxy/src/error.rs +++ b/packages/cipherstash-proxy/src/error.rs @@ -269,6 +269,13 @@ pub enum EncryptError { #[error("InvalidIndexTerm")] InvalidIndexTerm, + /// The encryption service returned a different number of values than it was given. + /// + /// Bind rewrites pair encrypted values with parameters by position, so a short result + /// would forward the remaining parameters unencrypted. The statement is refused instead. + #[error("Expected {expected} encrypted values but received {actual}. For help visit {}#encrypt-result-count-mismatch", ERROR_DOC_BASE_URL)] + EncryptionResultCountMismatch { expected: usize, actual: usize }, + #[error( "KeysetId `{id}` could not be parsed using `SET CIPHERSTASH.KEYSET_ID`. KeysetId should be a valid UUID. For help visit {}#encrypt-keyset-id-could-not-be-parsed", ERROR_DOC_BASE_URL diff --git a/packages/cipherstash-proxy/src/postgresql/context/mod.rs b/packages/cipherstash-proxy/src/postgresql/context/mod.rs index e6590994..9bdfae82 100644 --- a/packages/cipherstash-proxy/src/postgresql/context/mod.rs +++ b/packages/cipherstash-proxy/src/postgresql/context/mod.rs @@ -17,7 +17,7 @@ use super::{ }; use crate::{ config::TandemConfig, - error::Error, + error::{EncryptError, Error}, log::{CONTEXT, SLOW_STATEMENTS}, prometheus::{ SLOW_STATEMENTS_TOTAL, STATEMENTS_EXECUTION_DURATION_SECONDS, @@ -1518,9 +1518,22 @@ where plaintexts: Vec>, columns: &[Option], ) -> Result>, Error> { - self.encryption + let expected = plaintexts.len(); + + let encrypted = self + .encryption .encrypt(keyset_id, plaintexts, columns) - .await + .await?; + + // Values are paired with parameters by position, so a short result must never be used + if encrypted.len() != expected { + return Err(EncryptError::EncryptionResultCountMismatch { + expected, + actual: encrypted.len(), + } + .into()); + } + Ok(encrypted) } pub async fn decrypt( @@ -2709,4 +2722,24 @@ mod tests { assert!(context.get_statement(&name).is_some()); } + + #[tokio::test] + async fn encryption_service_returning_fewer_values_fails_closed() { + let context = create_context(); + // TestService returns no outputs at all + let plaintext = cipherstash_client::encryption::Plaintext::new("secret".to_owned()); + let result = context + .encrypt(None, vec![Some(plaintext), None], &[None, None]) + .await; + + assert!(matches!( + result, + Err(Error::Encrypt( + crate::error::EncryptError::EncryptionResultCountMismatch { + expected: 2, + actual: 0 + } + )) + )); + } } diff --git a/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs b/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs index b2f43abf..7ed350e5 100644 --- a/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs +++ b/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs @@ -239,13 +239,7 @@ impl EncryptionService for ZeroKms { let encrypt_duration = encrypt_start.elapsed(); debug!(target: ENCRYPT, msg="encrypt_eql completed", count = encrypted.len(), duration_ms = encrypt_duration.as_millis()); - // Reconstruct the result vector with None values in the right places - let mut result: Vec> = vec![None; plaintexts.len()]; - for (idx, ciphertext) in indices.into_iter().zip(encrypted.into_iter()) { - result[idx] = Some(ciphertext); - } - - Ok(result) + place_ciphertexts(plaintexts.len(), indices, encrypted) } /// @@ -303,3 +297,52 @@ impl EncryptionService for ZeroKms { Ok(result) } } + +/// Put each ciphertext back at the position of the plaintext it encrypts, with `None` everywhere +/// else. +/// +/// Fails when there are fewer ciphertexts than encrypted positions: padding the gap with `None` +/// would forward the original value of that parameter unencrypted. +fn place_ciphertexts( + len: usize, + indices: Vec, + ciphertexts: Vec, +) -> Result>, Error> { + if ciphertexts.len() != indices.len() { + return Err(EncryptError::EncryptionResultCountMismatch { + expected: indices.len(), + actual: ciphertexts.len(), + } + .into()); + } + let mut result: Vec> = (0..len).map(|_| None).collect(); + for (idx, ciphertext) in indices.into_iter().zip(ciphertexts) { + result[idx] = Some(ciphertext); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ciphertexts_are_placed_at_the_positions_that_were_encrypted() { + let result = place_ciphertexts(3, vec![0, 2], vec!["a", "c"]).unwrap(); + assert_eq!(result, vec![Some("a"), None, Some("c")]); + } + + #[test] + fn fewer_ciphertexts_than_encrypted_positions_fails_closed() { + let result = place_ciphertexts(3, vec![0, 2], vec!["a"]); + assert!(matches!( + result, + Err(Error::Encrypt( + EncryptError::EncryptionResultCountMismatch { + expected: 2, + actual: 1 + } + )) + )); + } +} From 1ed52dd9f212adf3da8c00d77152873ad07de2e3 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 27 Oct 2026 16:34:33 +1100 Subject: [PATCH 06/11] fix(proxy): refuse a Bind to a statement Proxy did not see prepared Proxy keeps a Statement only for statements that touch encrypted columns. A Bind naming a statement Proxy had no record of was treated as plaintext and its values sent as they were, so any path that lost or dropped a statement too early would send plaintext for an encrypted column. Every Parse already records the statement's session, so while mapping is enabled a Bind to a name with no recorded Parse now fails closed with SQLSTATE 26000, PostgreSQL's own code for a missing prepared statement. The refusal goes through PostgreSQL as other refusals do, and the connection stays usable. --- CHANGELOG.md | 2 ++ docs/errors.md | 24 +++++++++++++++ packages/cipherstash-proxy/src/error.rs | 5 ++++ .../src/postgresql/context/mod.rs | 10 +++++++ .../src/postgresql/error_handler.rs | 7 +++-- .../src/postgresql/frontend.rs | 11 +++++++ .../src/postgresql/messages/error_response.rs | 2 +- .../cipherstash-proxy/src/postgresql/tests.rs | 29 +++++++++++++++++++ 8 files changed, 86 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8267d9f..7f7c749a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Encryption results checked before use**: Proxy now refuses a statement when the encryption service returns fewer encrypted values than the values it was asked to encrypt. Previously the parameters left without an encrypted value were sent to PostgreSQL as written, so a misbehaving encryption service could cause plaintext to reach the database. +- **Bind to a statement Proxy did not see prepared**: Proxy now refuses a `Bind` that names a prepared statement it did not see prepared on the connection, with SQLSTATE `26000`, PostgreSQL's own code for a missing prepared statement. Previously Proxy sent the bound values as written, so if it had lost track of a statement that writes an encrypted column, the values reached PostgreSQL unencrypted. A statement prepared with SQL `PREPARE` can no longer be bound with a `Bind` message through Proxy. + ### Security - **Dependency updates**: Updated `rustls` to 0.23.45, `rustls-webpki` to 0.103.14, `aws-lc-rs` to 1.18.2 (with `aws-lc-sys` 0.46.0), `rand` to 0.9.3 and 0.10.2, `quinn-proto` to 0.11.15, `hickory-proto` and `hickory-resolver` to 0.26.3 (with `reqwest` 0.13.4), and `diesel` to 2.3.12, and `postgres-protocol` to 0.6.12, to pick up the fixes for published security advisories in those crates. No configuration change is required. diff --git a/docs/errors.md b/docs/errors.md index b52aed77..b84d180f 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -17,6 +17,7 @@ - [COPY with a statement that uses an encrypted column](#mapping-copy-with-encrypted-statement) - [Internal Error](#mapping-internal-error) - [Prepared settings changed](#mapping-prepared-settings-changed) + - [Unknown prepared statement](#mapping-unknown-prepared-statement) - Encrypt errors: - [Column could not be encrypted](#encrypt-column-could-not-be-encrypted) @@ -408,6 +409,29 @@ Prefer bound parameters over encrypted literals when a prepared statement must b + + + +## Unknown prepared statement + +A Bind names a prepared statement that Proxy did not see prepared on this connection. +Proxy only knows whether a statement's values must be encrypted from the Parse that prepared it, so it refuses the Bind rather than send the values to the database as they are. + +### Error message + +``` +Prepared statement 'my_statement' does not exist on this connection. +``` + +### How to fix + +The error uses SQLSTATE `26000`, PostgreSQL's own code for a missing prepared statement, so drivers that cache statements can prepare it again. + +Prepare the statement with a Parse message on the same connection before binding it. +A statement prepared with SQL `PREPARE` cannot be bound through Proxy. + + + diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs index faf76a90..e592e383 100644 --- a/packages/cipherstash-proxy/src/error.rs +++ b/packages/cipherstash-proxy/src/error.rs @@ -122,6 +122,11 @@ pub enum MappingError { #[error("The prepared statement or bound values no longer match the connection's CipherStash settings. Close the statement, then prepare and bind it again. For help visit {}#mapping-prepared-settings-changed", ERROR_DOC_BASE_URL)] PreparedSettingsChanged, + /// A Bind names a statement Proxy has no Parse for, so Proxy cannot tell whether its + /// values must be encrypted. + #[error("Prepared statement '{name}' does not exist on this connection. For help visit {}#mapping-unknown-prepared-statement", ERROR_DOC_BASE_URL)] + UnknownPreparedStatement { name: String }, + #[error(transparent)] EqlMapper(#[from] EqlMapperError), } diff --git a/packages/cipherstash-proxy/src/postgresql/context/mod.rs b/packages/cipherstash-proxy/src/postgresql/context/mod.rs index 9bdfae82..830ca957 100644 --- a/packages/cipherstash-proxy/src/postgresql/context/mod.rs +++ b/packages/cipherstash-proxy/src/postgresql/context/mod.rs @@ -953,6 +953,16 @@ where .map(|mut sessions| sessions.insert(name, session_id)); } + /// Whether a Parse on this connection prepared `name`. + /// + /// Every Parse records its session, including one Proxy does not map, and closing the + /// statement removes it. + pub fn is_prepared(&self, name: &Name) -> bool { + self.statement_sessions + .read() + .is_ok_and(|sessions| sessions.contains_key(name)) + } + pub fn get_statement_session(&self, name: &Name) -> Option { let sessions = self.statement_sessions.read().ok()?; sessions.get(name).copied() diff --git a/packages/cipherstash-proxy/src/postgresql/error_handler.rs b/packages/cipherstash-proxy/src/postgresql/error_handler.rs index a440b955..15b268ec 100644 --- a/packages/cipherstash-proxy/src/postgresql/error_handler.rs +++ b/packages/cipherstash-proxy/src/postgresql/error_handler.rs @@ -42,9 +42,10 @@ pub trait PostgreSqlErrorHandler { &column.column_name(), ) } - Error::Mapping(MappingError::PreparedSettingsChanged) => { - ErrorResponse::prepared_statement_invalidated(err.to_string()) - } + Error::Mapping( + MappingError::PreparedSettingsChanged + | MappingError::UnknownPreparedStatement { .. }, + ) => ErrorResponse::prepared_statement_invalidated(err.to_string()), Error::Mapping(err) => ErrorResponse::invalid_sql_statement(err.to_string()), Error::Encrypt(EncryptError::UnknownColumn { ref table, diff --git a/packages/cipherstash-proxy/src/postgresql/frontend.rs b/packages/cipherstash-proxy/src/postgresql/frontend.rs index 122a4a88..a71a44ef 100644 --- a/packages/cipherstash-proxy/src/postgresql/frontend.rs +++ b/packages/cipherstash-proxy/src/postgresql/frontend.rs @@ -1353,6 +1353,17 @@ where } let mut bind = Bind::try_from(bytes)?; + + // Proxy stores a Statement only for statements that touch encrypted columns, so a name it + // has no Parse for could be one whose values must be encrypted. Refuse it rather than + // send the values as they are. + if !self.context.is_prepared(&bind.prepared_statement) { + return Err(MappingError::UnknownPreparedStatement { + name: bind.prepared_statement.as_str().to_owned(), + } + .into()); + } + let statement = self.context.get_statement(&bind.prepared_statement); let encrypted_values = statement .as_ref() diff --git a/packages/cipherstash-proxy/src/postgresql/messages/error_response.rs b/packages/cipherstash-proxy/src/postgresql/messages/error_response.rs index 52e8ddb9..c862c9ed 100644 --- a/packages/cipherstash-proxy/src/postgresql/messages/error_response.rs +++ b/packages/cipherstash-proxy/src/postgresql/messages/error_response.rs @@ -160,7 +160,7 @@ impl ErrorResponse { } /// - /// A prepared statement Proxy no longer runs as PostgreSQL error + /// A prepared statement Proxy no longer runs, or never saw prepared, as PostgreSQL error /// Code: 26000 invalid_sql_statement_name /// /// Drivers such as PostgreSQL JDBC invalidate their cached statements on this code. diff --git a/packages/cipherstash-proxy/src/postgresql/tests.rs b/packages/cipherstash-proxy/src/postgresql/tests.rs index 1261427b..dbf6d48a 100644 --- a/packages/cipherstash-proxy/src/postgresql/tests.rs +++ b/packages/cipherstash-proxy/src/postgresql/tests.rs @@ -2073,6 +2073,35 @@ mod refusals { assert_eq!(connection.context.state_sizes().pending_settings, 0); } + #[tokio::test] + async fn a_bind_to_a_statement_proxy_did_not_see_prepared_is_refused() { + let mut connection = Connection::started().await; + connection.client(bind_text("", "missing", "secret")).await; + connection.client(execute("")).await; + connection.client(sync()).await; + + // Proxy cannot tell whether the statement's values must be encrypted, so they never + // reach PostgreSQL under that name + let forwarded = connection.forwarded().await; + assert_eq!(forwarded[0].0, b'B'); + assert!(target(&forwarded[0].1, true).starts_with("__cipherstash_")); + assert!(!forwarded + .iter() + .any(|(_, body)| body.windows(6).any(|w| w == b"secret"))); + + connection.server(rejection(&forwarded)).await; + connection.server(ready(b'I')).await; + assert_eq!(connection.error_codes_sent_to_client(), vec!["26000"]); + + // The connection stays usable + connection.prepare("s", "SELECT 1").await; + connection.forwarded().await; + connection.client(bind("", "s")).await; + let forwarded = connection.forwarded().await; + assert_eq!(forwarded[0].0, b'B'); + assert_eq!(target(&forwarded[0].1, true), "s"); + } + #[tokio::test] async fn a_refused_execute_is_forwarded_as_an_execute_postgresql_rejects() { let mut connection = Connection::started().await; From c57e25111179849dabcf0fec066260421de96052 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 27 Oct 2026 16:34:33 +1100 Subject: [PATCH 07/11] fix(mapper): type check ON CONFLICT DO UPDATE assignments The mapper never inferred the DO UPDATE SET assignments of an INSERT ... ON CONFLICT, so a literal or param assigned to an encrypted column on the conflict path was left untyped and sent to PostgreSQL as plaintext. Because the statement still type-checked, the refusal of writes Proxy cannot type check did not catch it. Each assignment value is now unified with the type stored in its target column, as in a plain UPDATE, so it is encrypted. The `excluded` pseudo-table is brought into scope so `excluded.` carries the column's EQL type, and the insert target is named so qualified references resolve. A conflict target on an encrypted column, MySQL INSERT ... SET and ON DUPLICATE KEY UPDATE are rejected. This is the ON CONFLICT part of the mapper fix on main. Its window, aggregate and SELECT ... INTO changes are left out: those gaps act on ciphertext rather than forwarding plaintext. --- CHANGELOG.md | 2 + packages/eql-mapper/src/importer.rs | 36 +++- .../infer_type_impls/insert_statement.rs | 161 ++++++++++++++-- packages/eql-mapper/src/lib.rs | 178 ++++++++++++++++++ 4 files changed, 359 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f7c749a..81996890 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Bind to a statement Proxy did not see prepared**: Proxy now refuses a `Bind` that names a prepared statement it did not see prepared on the connection, with SQLSTATE `26000`, PostgreSQL's own code for a missing prepared statement. Previously Proxy sent the bound values as written, so if it had lost track of a statement that writes an encrypted column, the values reached PostgreSQL unencrypted. A statement prepared with SQL `PREPARE` can no longer be bound with a `Bind` message through Proxy. +- **Plaintext in `INSERT ... ON CONFLICT DO UPDATE`**: Proxy now encrypts a value assigned to an encrypted column in the `DO UPDATE SET` clause of an upsert. Previously the clause was not type checked, so a literal or parameter assigned to an encrypted column there reached PostgreSQL as plaintext. `excluded.` now carries the column's encrypted type, and an `ON CONFLICT` target on an encrypted column is refused, as a conflict judged on randomised ciphertext would never fire. + ### Security - **Dependency updates**: Updated `rustls` to 0.23.45, `rustls-webpki` to 0.103.14, `aws-lc-rs` to 1.18.2 (with `aws-lc-sys` 0.46.0), `rand` to 0.9.3 and 0.10.2, `quinn-proto` to 0.11.15, `hickory-proto` and `hickory-resolver` to 0.26.3 (with `reqwest` 0.13.4), and `diesel` to 2.3.12, and `postgres-protocol` to 0.6.12, to pick up the fixes for published security advisories in those crates. No configuration change is required. diff --git a/packages/eql-mapper/src/importer.rs b/packages/eql-mapper/src/importer.rs index 9d744ee6..a0b8002e 100644 --- a/packages/eql-mapper/src/importer.rs +++ b/packages/eql-mapper/src/importer.rs @@ -5,7 +5,8 @@ use crate::{ Relation, ScopeError, ScopeTracker, }; use sqltk::parser::ast::{ - Cte, Ident, Insert, ObjectNamePart, TableAlias, TableFactor, TableObject, + Cte, Ident, Insert, ObjectNamePart, OnConflict, OnConflictAction, OnInsert, TableAlias, + TableFactor, TableObject, }; use sqltk::{Break, Visitable, Visitor}; use std::{cell::RefCell, fmt::Debug, marker::PhantomData, ops::ControlFlow, rc::Rc, sync::Arc}; @@ -38,6 +39,7 @@ impl<'ast> Importer<'ast> { if let Insert { table: TableObject::TableName(table_name), table_alias, + on, .. } = insert { @@ -45,11 +47,39 @@ impl<'ast> Importer<'ast> { let projection = Projection::new_from_schema_table(table.clone()); + // The relation is named — by its alias when one is written, by the + // table name otherwise — so that qualified references (`t.col` in + // `RETURNING` or in `ON CONFLICT DO UPDATE`) can resolve. + let name = table_alias.clone().or_else(|| { + let ObjectNamePart::Identifier(ident) = table_name.0.last().unwrap(); + Some(ident.clone()) + }); + self.scope_tracker.borrow_mut().add_relation(Relation { - name: table_alias.clone(), - projection_type: Type::Value(Value::Projection(projection)).into(), + name, + projection_type: Type::Value(Value::Projection(projection.clone())).into(), })?; + // `ON CONFLICT DO UPDATE` can read the row proposed for insertion + // through the `excluded` pseudo-table, which projects exactly the + // target table's columns. Bringing it into scope is what gives + // `excluded.` a type — including the column's EQL type, so an + // upsert like `SET enc = excluded.enc` is fully constrained. + // + // An unqualified column reference in the `DO UPDATE` expressions is + // now ambiguous (both relations project it), which mirrors + // PostgreSQL's own `column reference is ambiguous` error there. + if let Some(OnInsert::OnConflict(OnConflict { + action: OnConflictAction::DoUpdate(_), + .. + })) = on + { + self.scope_tracker.borrow_mut().add_relation(Relation { + name: Some(Ident::new("excluded")), + projection_type: Type::Value(Value::Projection(projection)).into(), + })?; + } + Ok(()) } else { Err(ImportError::Unsupported( diff --git a/packages/eql-mapper/src/inference/infer_type_impls/insert_statement.rs b/packages/eql-mapper/src/inference/infer_type_impls/insert_statement.rs index 81562849..32cc688f 100644 --- a/packages/eql-mapper/src/inference/infer_type_impls/insert_statement.rs +++ b/packages/eql-mapper/src/inference/infer_type_impls/insert_statement.rs @@ -3,10 +3,29 @@ use std::sync::Arc; use crate::{ inference::{type_error::TypeError, unifier::Type, InferType}, unifier::{EqlTerm, EqlValue, NativeValue, Value}, - ColumnKind, TableColumn, TypeInferencer, + ColumnKind, SchemaTableColumn, TableColumn, TypeInferencer, }; use eql_mapper_macros::trace_infer; -use sqltk::parser::ast::{Insert, TableObject}; +use sqltk::parser::ast::{ + AssignmentTarget, ConflictTarget, Insert, ObjectName, ObjectNamePart, OnConflict, + OnConflictAction, OnInsert, TableObject, +}; + +/// The type of the value stored in a schema column: the column's EQL type when +/// it is encrypted, its native identity otherwise. +fn stored_value_type(stc: &SchemaTableColumn) -> (Value, TableColumn) { + let tc = TableColumn { + table: stc.table.clone(), + column: stc.column.clone(), + }; + + let value_ty = match &stc.kind { + ColumnKind::Native => Value::Native(NativeValue(Some(tc.clone()))), + ColumnKind::Eql(features) => Value::Eql(EqlTerm::Full(EqlValue(tc.clone(), *features))), + }; + + (value_ty, tc) +} #[trace_infer] impl<'ast> InferType<'ast, Insert> for TypeInferencer<'ast> { @@ -16,6 +35,8 @@ impl<'ast> InferType<'ast, Insert> for TypeInferencer<'ast> { table_alias, columns, source, + assignments, + on, .. } = insert { @@ -23,6 +44,12 @@ impl<'ast> InferType<'ast, Insert> for TypeInferencer<'ast> { return Err(TypeError::UnsupportedSqlFeature("INSERT with ALIAS".into())); } + if !assignments.is_empty() { + return Err(TypeError::UnsupportedSqlFeature( + "MySQL INSERT ... SET".into(), + )); + } + let table_columns = if columns.is_empty() { // When no columns are specified, the source must unify with a projection of ALL table columns. self.table_resolver.resolve_table_columns(table_name)? @@ -37,19 +64,8 @@ impl<'ast> InferType<'ast, Insert> for TypeInferencer<'ast> { &table_columns .into_iter() .map(|stc| { - let tc = TableColumn { - table: stc.table.clone(), - column: stc.column.clone(), - }; - - let value_ty = match &stc.kind { - ColumnKind::Native => Value::Native(NativeValue(Some(tc.clone()))), - ColumnKind::Eql(features) => { - Value::Eql(EqlTerm::Full(EqlValue(tc.clone(), *features))) - } - }; - - (Arc::new(Type::Value(value_ty)), Some(tc.column.clone())) + let (value_ty, tc) = stored_value_type(&stc); + (Arc::new(Type::Value(value_ty)), Some(tc.column)) }) .collect::>(), ); @@ -58,6 +74,29 @@ impl<'ast> InferType<'ast, Insert> for TypeInferencer<'ast> { self.unify_node_with_type(&**source, target_columns)?; } + match on { + Some(OnInsert::OnConflict(on_conflict)) => { + self.infer_on_conflict(table_name, on_conflict)?; + } + + Some(OnInsert::DuplicateKeyUpdate(_)) => { + return Err(TypeError::UnsupportedSqlFeature( + "MySQL ON DUPLICATE KEY UPDATE".into(), + )); + } + + // `OnInsert` is non-exhaustive: reject any variant this crate + // does not know rather than let its assignments escape + // inference. + Some(_) => { + return Err(TypeError::UnsupportedSqlFeature( + "unrecognised ON clause in INSERT".into(), + )); + } + + None => {} + } + Ok(()) } else { Err(TypeError::UnsupportedSqlFeature("table functions".into())) @@ -80,3 +119,95 @@ impl<'ast> InferType<'ast, Insert> for TypeInferencer<'ast> { Ok(()) } } + +impl<'ast> TypeInferencer<'ast> { + /// Constrains an `ON CONFLICT` clause of an `INSERT` against the target + /// table. + /// + /// The `DO UPDATE SET` assignments are the upsert path of the statement: + /// each assignment value is unified with the type stored in its target + /// column, exactly as a plain `UPDATE ... SET` is — so a plaintext literal + /// assigned to an encrypted column becomes an EQL literal to encrypt, and + /// never lands in the column unencrypted. + /// + /// Assignment targets are resolved against the target table directly (not + /// the lexical scope): PostgreSQL forbids qualifying them, and the scope + /// also contains the `excluded` pseudo-table, which projects the same + /// column names. Value expressions and the `DO UPDATE ... WHERE` predicate + /// are ordinary expressions resolved against the scope — which is how + /// `excluded.` references get their types (see + /// [`crate::importer::Importer`]). + /// + /// A conflict target on an encrypted column is rejected: a conflict only + /// ever fires off a unique index, and uniqueness of an encrypted column + /// would be judged on the whole jsonb payload — whose ciphertext is + /// randomised per row — so the conflict would never fire and every upsert + /// would silently insert a duplicate. + /// + /// `ON CONFLICT ON CONSTRAINT ` is let through as written: the + /// mapper's schema carries no constraint catalog to resolve the name + /// against, and a constraint over an encrypted column is a schema-design + /// error that exists independently of any statement. + fn infer_on_conflict( + &mut self, + table_name: &ObjectName, + on_conflict: &'ast OnConflict, + ) -> Result<(), TypeError> { + let OnConflict { + conflict_target, + action, + } = on_conflict; + + if let Some(ConflictTarget::Columns(columns)) = conflict_target { + for column in columns { + let stc = self + .table_resolver + .resolve_table_column(table_name, column)?; + if matches!(stc.kind, ColumnKind::Eql(..)) { + return Err(TypeError::UnsupportedSqlFeature(format!( + "ON CONFLICT on encrypted column {}.{}", + stc.table, stc.column + ))); + } + } + } + + match action { + OnConflictAction::DoNothing => {} + + OnConflictAction::DoUpdate(do_update) => { + for assignment in &do_update.assignments { + match &assignment.target { + AssignmentTarget::ColumnName(ObjectName(parts)) if parts.len() == 1 => { + let ObjectNamePart::Identifier(ident) = parts.last().unwrap(); + let stc = self + .table_resolver + .resolve_table_column(table_name, ident)?; + let (value_ty, _) = stored_value_type(&stc); + self.unify_node_with_type(&assignment.value, Type::Value(value_ty))?; + } + + AssignmentTarget::ColumnName(ObjectName(_)) => { + return Err(TypeError::UnsupportedSqlFeature( + "qualified column names in ON CONFLICT DO UPDATE".into(), + )); + } + + AssignmentTarget::Tuple(_) => { + return Err(TypeError::UnsupportedSqlFeature( + "tuple assignment target in ON CONFLICT DO UPDATE".into(), + )); + } + } + } + + // `do_update.selection` needs no handling here: it is an + // ordinary predicate whose identifiers resolve against the + // scope (the target table and `excluded`), and whose operators + // are constrained by the expression rules. + } + } + + Ok(()) + } +} diff --git a/packages/eql-mapper/src/lib.rs b/packages/eql-mapper/src/lib.rs index 1efeafef..59cfa0a0 100644 --- a/packages/eql-mapper/src/lib.rs +++ b/packages/eql-mapper/src/lib.rs @@ -2232,4 +2232,182 @@ mod test { type_check(schema, &statement).unwrap(); } + + /// `ON CONFLICT DO UPDATE SET enc = ` is the upsert path: the + /// assignment value must be typed as the column's EQL type so the literal + /// is encrypted, exactly as in a plain `UPDATE ... SET`. + #[test] + fn insert_on_conflict_do_update_encrypts_assignment_literal() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary (EQL), + } + } + }); + + let statement = parse( + "INSERT INTO employees (id, salary) VALUES (1, 20000) \ + ON CONFLICT (id) DO UPDATE SET salary = 30000", + ); + + let typed = match type_check(schema, &statement) { + Ok(typed) => typed, + Err(err) => panic!("type check failed: {err:#?}"), + }; + + // Both the inserted literal and the conflict-path literal are EQL + // literals to encrypt. + assert_eq!(typed.literals.len(), 2); + + let encrypted = typed + .literals + .iter() + .map(|(_, node)| { + ( + node.as_node_key(), + ast::Value::SingleQuotedString(format!("ENCRYPTED_{node}")), + ) + }) + .collect::>(); + + match typed.transform(encrypted) { + Ok(transformed_statement) => assert_eq!( + transformed_statement.to_string(), + "INSERT INTO employees (id, salary) VALUES (1, 'ENCRYPTED_20000'::JSONB::eql_v2_encrypted) \ + ON CONFLICT(id) DO UPDATE SET salary = 'ENCRYPTED_30000'::JSONB::eql_v2_encrypted" + ), + Err(err) => panic!("statement transformation failed: {err}"), + } + } + + /// A param assigned to an encrypted column on the conflict path is + /// encrypted too. + #[test] + fn insert_on_conflict_do_update_encrypts_assignment_param() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary (EQL), + } + } + }); + + let statement = parse( + "INSERT INTO employees (id, salary) VALUES ($1, $2) \ + ON CONFLICT (id) DO UPDATE SET salary = $3", + ); + + let typed = match type_check(schema, &statement) { + Ok(typed) => typed, + Err(err) => panic!("type check failed: {err:#?}"), + }; + + assert!( + matches!( + &typed.params[..], + [ + (_, Value::Native(_)), + (_, Value::Eql(EqlTerm::Full(_))), + (_, Value::Eql(EqlTerm::Full(_))) + ] + ), + "expected $1 native and $2, $3 EQL full payloads, got: {:?}", + typed.params + ); + } + + /// The `excluded` pseudo-table projects the target table's columns, so + /// `SET enc = excluded.enc` resolves and the param feeding the insert + /// value gets the column's EQL type. + #[test] + fn insert_on_conflict_do_update_with_excluded_reference() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary (EQL), + } + } + }); + + let statement = parse( + "INSERT INTO employees (id, salary) VALUES ($1, $2) \ + ON CONFLICT (id) DO UPDATE SET salary = excluded.salary", + ); + + let typed = match type_check(schema, &statement) { + Ok(typed) => typed, + Err(err) => panic!("type check failed: {err:#?}"), + }; + + assert!( + matches!( + &typed.params[..], + [(_, Value::Native(_)), (_, Value::Eql(EqlTerm::Full(_)))] + ), + "expected $1 native and $2 EQL full payload, got: {:?}", + typed.params + ); + + match typed.transform(HashMap::new()) { + Ok(transformed_statement) => assert_eq!( + transformed_statement.to_string(), + "INSERT INTO employees (id, salary) VALUES ($1, $2::JSONB::eql_v2_encrypted) \ + ON CONFLICT(id) DO UPDATE SET salary = excluded.salary" + ), + Err(err) => panic!("statement transformation failed: {err}"), + } + } + + /// The `DO UPDATE ... WHERE` predicate is an ordinary predicate over the + /// existing row and `excluded`. + #[test] + fn insert_on_conflict_do_update_where_type_checks() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary (EQL: Ord), + } + } + }); + + let statement = parse( + "INSERT INTO employees (id, salary) VALUES ($1, $2) \ + ON CONFLICT (id) DO UPDATE SET salary = excluded.salary \ + WHERE excluded.salary > employees.salary", + ); + + if let Err(err) = type_check(schema, &statement) { + panic!("type check failed: {err:#?}") + } + } + + /// A conflict only fires off a unique index, and uniqueness of an + /// encrypted column would be judged on the randomised ciphertext, so the + /// conflict would never fire. Rejected explicitly. + #[test] + fn insert_on_conflict_target_on_encrypted_column_is_rejected() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary (EQL: Eq), + } + } + }); + + let statement = parse( + "INSERT INTO employees (id, salary) VALUES (1, 2) \ + ON CONFLICT (salary) DO NOTHING", + ); + + assert!( + type_check(schema, &statement).is_err(), + "an encrypted conflict-target column should fail type checking" + ); + } } From c1ba6b6b51205744fc248be8f4e581257984bd01 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 27 Oct 2026 16:34:33 +1100 Subject: [PATCH 08/11] fix(proxy): log the full error source chain tokio-postgres 0.7.18 follows the convention that an error's Display describes only that error, with the cause reached through source(), so err.to_string() logs a bare "db error" and drops the server's message. Add ErrorChain, a Display wrapper that walks source(), and use it at the log sites that can carry a database error. Recording the error as a dyn Error field is not enough: the Structured (JSON) format, the default off a terminal, renders only Display. ErrorChain skips a cause the message already ends with, so variants that embed their cause in Display are not printed twice. --- .../cipherstash-proxy/src/cli/migrate/mod.rs | 8 +- packages/cipherstash-proxy/src/connect/mod.rs | 21 +++-- packages/cipherstash-proxy/src/error.rs | 88 ++++++++++++++++++- packages/cipherstash-proxy/src/main.rs | 18 ++-- .../src/proxy/encrypt_config/manager.rs | 10 +-- packages/cipherstash-proxy/src/proxy/mod.rs | 4 +- .../src/proxy/schema/manager.rs | 6 +- 7 files changed, 123 insertions(+), 32 deletions(-) diff --git a/packages/cipherstash-proxy/src/cli/migrate/mod.rs b/packages/cipherstash-proxy/src/cli/migrate/mod.rs index 4a694330..f82b50e2 100644 --- a/packages/cipherstash-proxy/src/cli/migrate/mod.rs +++ b/packages/cipherstash-proxy/src/cli/migrate/mod.rs @@ -1,4 +1,4 @@ -use crate::error::Error; +use crate::error::{Error, ErrorChain}; use crate::log::MIGRATE; use crate::tls::NoCertificateVerification; use crate::TandemConfig; @@ -136,7 +136,7 @@ impl Migrate { let rows = match client.simple_query(&sql).await { Ok(rows) => rows, Err(err) => { - error!(target: MIGRATE, msg = "Error fetching records", table = self.table, error = err.to_string()); + error!(target: MIGRATE, msg = "Error fetching records", table = self.table, error = %ErrorChain(&err)); std::process::exit(exitcode::SOFTWARE); } }; @@ -270,7 +270,7 @@ pub async fn connect_with_tls( tokio::spawn(async move { if let Err(err) = connection.await { - error!("Connection error: {}", err); + error!("Connection error: {}", ErrorChain(&err)); } }); Ok(client) @@ -285,7 +285,7 @@ pub async fn connect_with_no_tls(connection_string: &str) -> Result Result { tokio::spawn(async move { if let Err(err) = connection.await { - error!(msg = "Connection error", error = err.to_string()); + error!(msg = "Connection error", error = %ErrorChain(&err)); } }); Ok(client) @@ -66,7 +71,7 @@ pub async fn bind_with_retry(server: &ServerConfig) -> TcpListener { error!( msg = "Error binding connection", retries = MAX_RETRY_COUNT, - error = err.to_string() + error = %ErrorChain(&err) ); std::process::exit(exitcode::CONFIG); } @@ -91,7 +96,7 @@ pub async fn connect_with_retry(addr: &str) -> Result { } Err(err) => { if retry_count > MAX_RETRY_COUNT { - error!(msg = "Could not connect to database", retries = ?retry_count, error = err.to_string()); + error!(msg = "Could not connect to database", retries = ?retry_count, error = %ErrorChain(&err)); return Err(Error::DatabaseConnection); } } @@ -117,7 +122,7 @@ pub fn configure(stream: &TcpStream) { stream.set_nodelay(true).unwrap_or_else(|err| { warn!( msg = "Error configuring nodelay for connection", - error = err.to_string() + error = %ErrorChain(&err) ); }); @@ -127,7 +132,7 @@ pub fn configure(stream: &TcpStream) { Err(err) => { warn!( msg = "Error configuring tcp_user_timeout for connection", - error = err.to_string() + error = %ErrorChain(&err) ); } } @@ -144,7 +149,7 @@ pub fn configure(stream: &TcpStream) { Err(err) => { warn!( msg = "Error configuring keepalive for connection", - error = err.to_string() + error = %ErrorChain(&err) ); } } @@ -152,7 +157,7 @@ pub fn configure(stream: &TcpStream) { Err(err) => { warn!( msg = "Error configuring connection", - error = err.to_string() + error = %ErrorChain(&err) ); } } diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs index e592e383..c7d12036 100644 --- a/packages/cipherstash-proxy/src/error.rs +++ b/packages/cipherstash-proxy/src/error.rs @@ -3,7 +3,7 @@ use bytes::BytesMut; use cipherstash_client::{encryption, schema::ColumnType}; use eql_mapper::{EqlMapperError, EqlTermVariant}; use metrics_exporter_prometheus::BuildError; -use std::{io, time::Duration}; +use std::{fmt, io, time::Duration}; use thiserror::Error; const ERROR_DOC_BASE_URL: &str = "https://github.com/cipherstash/proxy/blob/main/docs/errors.md"; @@ -559,6 +559,39 @@ impl From for Error { } } +/// Renders an error and its full `source()` chain on one line: `a: b: c`. +/// +/// Use this wherever an error is logged. `Display` on its own is not enough: +/// by convention an error's `Display` describes only that error, and the cause +/// is reached through `source()`. `tokio_postgres::Error` follows that +/// convention from 0.7.18, so `err.to_string()` logs a bare `db error` and +/// drops the server's message. +/// +/// Recording the error as a `dyn Error` field does not help either: the +/// `Structured` (JSON) log format renders only `Display`, and it is the default +/// whenever stdout is not a terminal. +/// +/// A cause whose text the rendered message already ends with is skipped, so an +/// error that embeds its cause in `Display` (`"...: {0}"` with `#[from]`) is not +/// printed twice. +pub struct ErrorChain<'a>(pub &'a (dyn std::error::Error + 'static)); + +impl fmt::Display for ErrorChain<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut rendered = self.0.to_string(); + let mut source = self.0.source(); + while let Some(cause) = source { + let cause_text = cause.to_string(); + if !rendered.ends_with(&cause_text) { + rendered.push_str(": "); + rendered.push_str(&cause_text); + } + source = cause.source(); + } + f.write_str(&rendered) + } +} + #[cfg(test)] mod tests { use super::*; @@ -578,4 +611,57 @@ mod tests { }; assert_eq!(error.to_string(), "Connection timed out after 5000 ms"); } + + #[derive(Debug, Error)] + #[error("{message}")] + struct Leaf { + message: &'static str, + } + + #[derive(Debug, Error)] + #[error("{message}")] + struct Wrapper { + message: &'static str, + #[source] + source: Leaf, + } + + #[test] + fn error_chain_renders_an_error_without_a_source() { + let error = Leaf { + message: "db error", + }; + + assert_eq!(ErrorChain(&error).to_string(), "db error"); + } + + #[test] + fn error_chain_appends_each_cause() { + let error = Wrapper { + message: "db error", + source: Leaf { + message: "ERROR: permission denied for table encrypted", + }, + }; + + assert_eq!( + ErrorChain(&error).to_string(), + "db error: ERROR: permission denied for table encrypted" + ); + } + + #[test] + fn error_chain_skips_a_cause_already_in_the_message() { + let error = Wrapper { + message: "Invalid encryption configuration: bad column", + source: Leaf { + message: "bad column", + }, + }; + + assert_eq!( + ErrorChain(&error).to_string(), + "Invalid encryption configuration: bad column" + ); + } } diff --git a/packages/cipherstash-proxy/src/main.rs b/packages/cipherstash-proxy/src/main.rs index a11da143..7ceb1c25 100644 --- a/packages/cipherstash-proxy/src/main.rs +++ b/packages/cipherstash-proxy/src/main.rs @@ -1,6 +1,6 @@ use cipherstash_proxy::config::TandemConfig; use cipherstash_proxy::connect::{self, AsyncStream}; -use cipherstash_proxy::error::{ConfigError, Error}; +use cipherstash_proxy::error::{ConfigError, Error, ErrorChain}; use cipherstash_proxy::prometheus::CLIENTS_ACTIVE_CONNECTIONS; use cipherstash_proxy::proxy::Proxy; use cipherstash_proxy::{cli, log, postgresql as pg, prometheus, tls, Args}; @@ -43,7 +43,7 @@ fn main() -> Result<(), Box> { } Err(err) => { - error!(msg = "Error running command", error = err.to_string()); + error!(msg = "Error running command", error = %ErrorChain(&err)); std::process::exit(exitcode::USAGE); } } @@ -66,7 +66,7 @@ fn main() -> Result<(), Box> { Err(err) => { error!( msg = "Could not start CipherStash proxy", - error = err.to_string() + error = %ErrorChain(&err) ); std::process::exit(exitcode::CONFIG); } @@ -111,10 +111,10 @@ fn main() -> Result<(), Box> { info!(msg = "Database connection closed after cancel request"); } Error::ConnectionTimeout{..} => { - warn!(msg = "Database connection timeout", error = err.to_string()); + warn!(msg = "Database connection timeout", error = %ErrorChain(&err)); } _ => { - error!(msg = "Database connection error", error = err.to_string()); + error!(msg = "Database connection error", error = %ErrorChain(&err)); } } }, @@ -155,7 +155,7 @@ async fn init(mut config: TandemConfig) -> Proxy { Err(err) => { error!( msg = "Could not start CipherStash proxy", - error = err.to_string() + error = %ErrorChain(&err) ); std::process::exit(exitcode::CONFIG); } @@ -201,7 +201,7 @@ async fn init(mut config: TandemConfig) -> Proxy { Err(err) => { error!( msg = "Server Transport Layer Security (TLS) configuration error", - error = err.to_string() + error = %ErrorChain(&err) ); std::process::exit(exitcode::CONFIG); } @@ -236,7 +236,7 @@ async fn init(mut config: TandemConfig) -> Proxy { Err(err) => { error!( msg = "Could not start CipherStash proxy", - error = err.to_string() + error = %ErrorChain(&err) ); std::process::exit(exitcode::UNAVAILABLE); } @@ -272,7 +272,7 @@ async fn reload_application_config(config: &TandemConfig, args: &Args) -> Result Err(err) => { warn!( msg = "Configuration could not be reloaded: {}", - error = err.to_string() + error = %ErrorChain(&err) ); return Err(err); } diff --git a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs index d121ab11..508c007f 100644 --- a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs +++ b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs @@ -1,7 +1,7 @@ use crate::{ config::DatabaseConfig, connect, - error::{ConfigError, Error}, + error::{ConfigError, Error, ErrorChain}, log::ENCRYPT_CONFIG, proxy::ENCRYPT_CONFIG_QUERY, }; @@ -88,7 +88,7 @@ impl EncryptConfigManager { Err(err) => { warn!( msg = "Error reloading encrypt configuration", - error = err.to_string() + error = %ErrorChain(&err) ); } }; @@ -118,7 +118,7 @@ async fn init_reloader(config: DatabaseConfig) -> Result { error!( msg = "Error loading Encrypt configuration", - error = err.to_string() + error = %ErrorChain(&err) ); return Err(err); } @@ -161,7 +161,7 @@ async fn init_reloader(config: DatabaseConfig) -> Result { warn!( msg = "Error reloading Encrypt configuration", - error = err.to_string() + error = %ErrorChain(&err) ); } } @@ -197,7 +197,7 @@ async fn load_encrypt_config_with_retry(config: &DatabaseConfig) -> Result { warn!( msg = "Could not query EQL version from database", - error = err.to_string() + error = %ErrorChain(&err) ); None } diff --git a/packages/cipherstash-proxy/src/proxy/schema/manager.rs b/packages/cipherstash-proxy/src/proxy/schema/manager.rs index 5fa2710d..dc81a2e4 100644 --- a/packages/cipherstash-proxy/src/proxy/schema/manager.rs +++ b/packages/cipherstash-proxy/src/proxy/schema/manager.rs @@ -1,5 +1,5 @@ use crate::config::DatabaseConfig; -use crate::error::Error; +use crate::error::{Error, ErrorChain}; use crate::proxy::{AGGREGATE_QUERY, SCHEMA_QUERY}; use crate::{connect, log::SCHEMA}; use arc_swap::ArcSwap; @@ -37,7 +37,7 @@ impl SchemaManager { Err(err) => { warn!( msg = "Error reloading database schema", - error = err.to_string() + error = %ErrorChain(&err) ); } }; @@ -72,7 +72,7 @@ async fn init_reloader(config: DatabaseConfig) -> Result { Err(err) => { warn!( msg = "Error loading database schema", - error = err.to_string() + error = %ErrorChain(&err) ); } } From fc0c0d4107e0e382975e159493e25adc60fcdd03 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 27 Oct 2026 16:34:34 +1100 Subject: [PATCH 09/11] test(integration): assert structured database errors tokio-postgres 0.7.18 no longer appends the cause to Error's Display, so assertions on err.to_string() see a bare "db error". Assert on the structured error instead: assert_db_error reads the severity and message off as_db_error(), and assert_client_error reads the kind off Display and the detail off source(). The exact customer-visible message text, including the docs/errors.md links, stays pinned. --- .../src/common.rs | 40 ++ .../src/copy_from_stdin.rs | 8 +- .../src/extended_protocol_error_messages.rs | 55 +- .../src/generate_ore_fixtures.rs | 381 +++++++++++ .../src/large_json_insert.rs | 601 ++++++++++++++++++ .../src/multitenant/set_keyset_id.rs | 16 +- .../src/multitenant/set_keyset_name.rs | 16 +- .../src/set_keyset_error.rs | 13 +- 8 files changed, 1073 insertions(+), 57 deletions(-) create mode 100644 packages/cipherstash-proxy-integration/src/generate_ore_fixtures.rs create mode 100644 packages/cipherstash-proxy-integration/src/large_json_insert.rs diff --git a/packages/cipherstash-proxy-integration/src/common.rs b/packages/cipherstash-proxy-integration/src/common.rs index 4e4157b1..d99ed8e7 100644 --- a/packages/cipherstash-proxy-integration/src/common.rs +++ b/packages/cipherstash-proxy-integration/src/common.rs @@ -567,6 +567,46 @@ pub fn interleaved_indices(len: usize) -> Vec { indices } +/// Asserts that `result` failed with a PostgreSQL `ErrorResponse` carrying +/// exactly this severity and this message. +/// +/// Read the message off the `DbError` rather than off `err.to_string()`. +/// `tokio_postgres::Error`'s `Display` renders only the error *kind*: since +/// 0.7.18 it no longer appends its cause, so `to_string()` yields a bare +/// `"db error"` and pins nothing. The server's text is still reachable through +/// `as_db_error()`, so asserting there keeps the exact customer-visible wording +/// pinned. +pub fn assert_db_error(result: Result, severity: &str, message: &str) { + let Err(err) = result else { + panic!("expected a database error, got a successful result"); + }; + + let db_error = err + .as_db_error() + .unwrap_or_else(|| panic!("expected a database error, got: {err:?}")); + + assert_eq!(db_error.severity(), severity); + assert_eq!(db_error.message(), message); +} + +/// Asserts that `result` failed in the client, before the statement reached the +/// server, with exactly this error kind and this underlying cause. +/// +/// The counterpart to [`assert_db_error`] for errors that never become a +/// PostgreSQL `ErrorResponse`, such as a `ToSql` conversion failure. +pub fn assert_client_error(result: Result, kind: &str, cause: &str) { + let Err(err) = result else { + panic!("expected a client error, got a successful result"); + }; + + assert_eq!(err.to_string(), kind); + + let source = std::error::Error::source(&err) + .unwrap_or_else(|| panic!("expected the error to carry a cause, got: {err:?}")); + + assert_eq!(source.to_string(), cause); +} + /// /// Configure the client TLS settings. /// These are the settings for connecting to the database with TLS. diff --git a/packages/cipherstash-proxy-integration/src/copy_from_stdin.rs b/packages/cipherstash-proxy-integration/src/copy_from_stdin.rs index c1f56089..8ff30428 100644 --- a/packages/cipherstash-proxy-integration/src/copy_from_stdin.rs +++ b/packages/cipherstash-proxy-integration/src/copy_from_stdin.rs @@ -47,7 +47,7 @@ mod tests { .await .err() .unwrap(); - assert!(err.to_string().contains( + assert!(err.as_db_error().unwrap().message().contains( "COPY into encrypted column 'encrypted_text' of table 'encrypted' is not supported" )); @@ -57,7 +57,11 @@ mod tests { .await .err() .unwrap(); - assert!(err.to_string().contains("COPY into encrypted column")); + assert!(err + .as_db_error() + .unwrap() + .message() + .contains("COPY into encrypted column")); // The connection is still usable let rows = client.simple_query("SELECT 1").await.unwrap(); diff --git a/packages/cipherstash-proxy-integration/src/extended_protocol_error_messages.rs b/packages/cipherstash-proxy-integration/src/extended_protocol_error_messages.rs index 9148e2fa..70ebf6f8 100644 --- a/packages/cipherstash-proxy-integration/src/extended_protocol_error_messages.rs +++ b/packages/cipherstash-proxy-integration/src/extended_protocol_error_messages.rs @@ -1,8 +1,11 @@ #[cfg(test)] mod tests { - use tracing::{debug, info}; + use tracing::debug; - use crate::common::{clear, connect_with_tls, random_id, reset_schema, trace, PROXY}; + use crate::common::{ + assert_client_error, assert_db_error, clear, connect_with_tls, random_id, reset_schema, + trace, PROXY, + }; struct Reset; @@ -35,9 +38,12 @@ mod tests { // EQL Mapper cannot type check a write to a column it does not know, so Proxy refuses it // rather than forward the values as written - let msg = result.unwrap_err().to_string(); + let err = result.unwrap_err(); + let db_error = err.as_db_error().unwrap(); + assert_eq!(db_error.severity(), "ERROR"); + let msg = db_error.message(); assert!( - msg.starts_with("db error: ERROR: Statement could not be type checked:"), + msg.starts_with("Statement could not be type checked:"), "{msg}" ); assert!(msg.contains("encrypted_unconfigured"), "{msg}"); @@ -61,15 +67,11 @@ mod tests { let sql = "INSERT INTO unconfigured (id, encrypted_unconfigured) VALUES ($1, $2)"; let result = client.query(sql, &[&id, &encrypted_text]).await; - assert!(result.is_err()); - - if let Err(err) = result { - let msg = err.to_string(); - - assert_eq!(msg, "db error: ERROR: Column 'encrypted_unconfigured' in table 'unconfigured' has no Encrypt configuration. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unknown-column"); - } else { - unreachable!(); - } + assert_db_error( + result, + "ERROR", + "Column 'encrypted_unconfigured' in table 'unconfigured' has no Encrypt configuration. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unknown-column", + ); } /// The error here is in the Tokio/Postgres layer @@ -89,14 +91,11 @@ mod tests { let sql = "INSERT INTO encrypted (id, encrypted_date) VALUES ($1, $2)"; let result = client.query(sql, &[&id, &encrypted_date]).await; - assert!(result.is_err()); - - if let Err(err) = result { - let msg = err.to_string(); - assert_eq!(msg, "error serializing parameter 1: cannot convert between the Rust type `i32` and the Postgres type `date`"); - } else { - unreachable!(); - } + assert_client_error( + result, + "error serializing parameter 1", + "cannot convert between the Rust type `i32` and the Postgres type `date`", + ); } #[tokio::test] @@ -117,14 +116,10 @@ mod tests { let sql = "INSERT INTO encrypted id, encrypted_text VALUES ($1, $2)"; let result = client.query(sql, &[&id, &encrypted_text]).await; - assert!(result.is_err()); - - if let Err(err) = result { - let msg = err.to_string(); - info!("{}", msg); - assert_eq!(msg, "db error: ERROR: sql parser error: Expected: SELECT, VALUES, or a subquery in the query body, found: id at Line: 1, Column: 23. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#mapping-invalid-sql-statement"); - } else { - unreachable!(); - } + assert_db_error( + result, + "ERROR", + "sql parser error: Expected: SELECT, VALUES, or a subquery in the query body, found: id at Line: 1, Column: 23. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#mapping-invalid-sql-statement", + ); } } diff --git a/packages/cipherstash-proxy-integration/src/generate_ore_fixtures.rs b/packages/cipherstash-proxy-integration/src/generate_ore_fixtures.rs new file mode 100644 index 00000000..dacf2bcc --- /dev/null +++ b/packages/cipherstash-proxy-integration/src/generate_ore_fixtures.rs @@ -0,0 +1,381 @@ +//! ORE Test Fixture Generators +//! +//! These tests generate encrypted ORE fixture data for the EQL project by inserting +//! plaintext values through the CipherStash Proxy (which encrypts them), reading back +//! the raw encrypted JSON directly from PostgreSQL (bypassing the proxy), extracting +//! the ORE block (`ob`) field, and writing SQL fixture files. +//! +//! ## Usage +//! +//! Prerequisites: proxy and PostgreSQL must be running. +//! +//! ```bash +//! # Generate both numeric and text fixtures +//! cargo test -p cipherstash-proxy-integration generate_ore -- --ignored --nocapture +//! +//! # Generate only numeric fixtures +//! cargo test -p cipherstash-proxy-integration generate_ore_numeric -- --ignored --nocapture +//! +//! # Generate only text fixtures +//! cargo test -p cipherstash-proxy-integration generate_ore_text -- --ignored --nocapture +//! ``` +//! +//! Output defaults to `/Users/tobyhede/src/encrypt-query-language/tests/` but can be +//! overridden with the `ORE_FIXTURE_OUTPUT_DIR` environment variable. + +#[cfg(test)] +mod tests { + use crate::common::{clear, connect_with_tls, get_database_port, PROXY}; + use serde_json::Value; + use std::fmt::Write as FmtWrite; + use std::fs; + use std::path::PathBuf; + + const DEFAULT_OUTPUT_DIR: &str = "/Users/tobyhede/src/encrypt-query-language/tests"; + + fn output_dir() -> PathBuf { + std::env::var("ORE_FIXTURE_OUTPUT_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_OUTPUT_DIR)) + } + + /// Determine the ORE field name by inspecting a sample encrypted value. + /// Returns "ob" or "o" depending on what the proxy produces. + fn detect_ore_field(json: &Value) -> &'static str { + if json.get("ob").is_some() { + "ob" + } else if json.get("o").is_some() { + "o" + } else { + panic!( + "Could not detect ORE field in encrypted JSON. \ + Expected 'ob' or 'o' field. Got: {}", + json + ); + } + } + + /// Unwrap PostgreSQL composite type text representation. + /// + /// When casting `eql_v2_encrypted::text`, PostgreSQL returns the value wrapped + /// in a composite type format like: `("{"c": "...", "ob": [...]}")`. + /// The inner double-quotes are escaped as `""`. + fn unwrap_composite_text(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.starts_with("(\"") && trimmed.ends_with("\")") { + // Strip outer ("...") and unescape doubled double-quotes + trimmed[2..trimmed.len() - 2].replace("\"\"", "\"") + } else { + // Already plain JSON + trimmed.to_string() + } + } + + /// Extract just the ORE block from the encrypted JSON, returning a minimal JSON object. + fn extract_ore_json(ciphertext: &str) -> String { + let json_str = unwrap_composite_text(ciphertext); + let json: Value = serde_json::from_str(&json_str) + .unwrap_or_else(|e| panic!("Failed to parse encrypted JSON: {e}\nJSON: {json_str}")); + + let field = detect_ore_field(&json); + let ore_value = &json[field]; + + // Return compact JSON with just the ORE field + format!("{{\"{field}\": {}}}", serde_json::to_string(ore_value).unwrap()) + } + + /// Generate the numeric ORE fixture file with 1000 values (ids 1-1000). + /// + /// Inserts integers 1..=1000 as `encrypted_int4` through the proxy, reads back + /// the encrypted ORE data directly from PostgreSQL, and writes a SQL fixture file. + #[tokio::test] + #[ignore] + async fn generate_ore_numeric_fixture() { + let proxy_client = connect_with_tls(PROXY).await; + let db_port = get_database_port(); + let db_client = connect_with_tls(db_port).await; + + // Truncate the encrypted table + clear().await; + + println!("Inserting 1000 numeric values through proxy..."); + + // Insert values 1..=1000 through the proxy + for i in 1..=1000_i32 { + let id = i as i64; + proxy_client + .execute( + "INSERT INTO encrypted (id, encrypted_int4) VALUES ($1, $2)", + &[&id, &i], + ) + .await + .unwrap_or_else(|e| panic!("Failed to insert id={id}: {e}")); + + if i % 100 == 0 { + println!(" Inserted {i}/1000"); + } + } + + println!("Reading back encrypted values directly from PostgreSQL..."); + + // Read all encrypted values directly (bypassing proxy) + let rows = db_client + .query( + "SELECT id, encrypted_int4::text FROM encrypted WHERE id BETWEEN 1 AND 1000 ORDER BY id", + &[], + ) + .await + .unwrap(); + + assert_eq!( + rows.len(), + 1000, + "Expected 1000 rows, got {}", + rows.len() + ); + + // Build the SQL fixture file + let mut sql = String::new(); + writeln!(sql, "DROP TABLE IF EXISTS ore;").unwrap(); + writeln!(sql, "CREATE TABLE ore").unwrap(); + writeln!(sql, "(").unwrap(); + writeln!(sql, " id bigint,").unwrap(); + writeln!(sql, " e eql_v2_encrypted,").unwrap(); + writeln!(sql, " PRIMARY KEY(id)").unwrap(); + writeln!(sql, ");").unwrap(); + writeln!(sql).unwrap(); + + for row in &rows { + let id: i64 = row.get(0); + let ciphertext: String = row.get(1); + let ore_json = extract_ore_json(&ciphertext); + + writeln!( + sql, + "INSERT INTO ore(id, e) VALUES ({id}, '{ore_json}'::jsonb::eql_v2_encrypted);" + ) + .unwrap(); + } + + // Write the file + let output_path = output_dir().join("ore.sql"); + fs::write(&output_path, &sql) + .unwrap_or_else(|e| panic!("Failed to write {}: {e}", output_path.display())); + + println!("Wrote numeric ORE fixture: {}", output_path.display()); + println!(" {} INSERT statements", rows.len()); + println!(" {} total lines", sql.lines().count()); + } + + /// Generate the text ORE fixture file with 100 lexicographically diverse strings. + /// + /// Inserts sorted lowercase strings through the proxy, reads back the encrypted + /// ORE data directly from PostgreSQL, and writes a SQL fixture file that includes + /// both the plaintext and encrypted values. + #[tokio::test] + #[ignore] + async fn generate_ore_text_fixture() { + let proxy_client = connect_with_tls(PROXY).await; + let db_port = get_database_port(); + let db_client = connect_with_tls(db_port).await; + + // Truncate the encrypted table + clear().await; + + let words = text_fixture_words(); + assert_eq!(words.len(), 100, "Expected exactly 100 words"); + + // Verify words are sorted + let mut sorted = words.clone(); + sorted.sort(); + assert_eq!(words, sorted, "Words must be pre-sorted lexicographically"); + + println!("Inserting {} text values through proxy...", words.len()); + + // Use IDs starting at 1001 to avoid collisions if run alongside numeric + let id_offset = 1001_i64; + + for (i, word) in words.iter().enumerate() { + let id = id_offset + i as i64; + let text = word.to_string(); + proxy_client + .execute( + "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)", + &[&id, &text], + ) + .await + .unwrap_or_else(|e| panic!("Failed to insert id={id} word='{word}': {e}")); + } + + println!("Reading back encrypted values directly from PostgreSQL..."); + + let max_id = id_offset + words.len() as i64 - 1; + let rows = db_client + .query( + "SELECT id, encrypted_text::text FROM encrypted WHERE id BETWEEN $1 AND $2 ORDER BY id", + &[&id_offset, &max_id], + ) + .await + .unwrap(); + + assert_eq!( + rows.len(), + words.len(), + "Expected {} rows, got {}", + words.len(), + rows.len() + ); + + // Build the SQL fixture file + let mut sql = String::new(); + writeln!(sql, "DROP TABLE IF EXISTS ore_text;").unwrap(); + writeln!(sql, "CREATE TABLE ore_text").unwrap(); + writeln!(sql, "(").unwrap(); + writeln!(sql, " id bigint,").unwrap(); + writeln!(sql, " plaintext text,").unwrap(); + writeln!(sql, " e eql_v2_encrypted,").unwrap(); + writeln!(sql, " PRIMARY KEY(id)").unwrap(); + writeln!(sql, ");").unwrap(); + writeln!(sql).unwrap(); + + for (i, row) in rows.iter().enumerate() { + let _id: i64 = row.get(0); + let ciphertext: String = row.get(1); + let ore_json = extract_ore_json(&ciphertext); + let word = &words[i]; + + // Use sequential IDs starting from 1 for the fixture file + let fixture_id = i + 1; + + // Escape single quotes in the word for SQL safety + let escaped_word = word.replace('\'', "''"); + + writeln!( + sql, + "INSERT INTO ore_text(id, plaintext, e) VALUES ({fixture_id}, '{escaped_word}', '{ore_json}'::jsonb::eql_v2_encrypted);" + ) + .unwrap(); + } + + // Write the file + let output_path = output_dir().join("ore_text.sql"); + fs::write(&output_path, &sql) + .unwrap_or_else(|e| panic!("Failed to write {}: {e}", output_path.display())); + + println!("Wrote text ORE fixture: {}", output_path.display()); + println!(" {} INSERT statements", rows.len()); + println!(" {} total lines", sql.lines().count()); + } + + /// 100 lexicographically sorted, diverse lowercase English words. + /// + /// Includes: + /// - Coverage across A-Z + /// - Prefix overlaps: "app", "apple", "application" + /// - Similar starts: "car", "card", "care" + /// - Varied lengths from 3 to 11 characters + fn text_fixture_words() -> Vec<&'static str> { + vec![ + "aardvark", + "able", + "abstract", + "ace", + "anchor", + "app", + "apple", + "application", + "arctic", + "badge", + "balance", + "barn", + "basket", + "beacon", + "blaze", + "bond", + "brave", + "breeze", + "bronze", + "cabin", + "canal", + "car", + "card", + "care", + "castle", + "cedar", + "chief", + "cloud", + "coral", + "crane", + "dagger", + "dawn", + "delta", + "desert", + "diamond", + "dome", + "dragon", + "drift", + "eagle", + "earth", + "eclipse", + "ember", + "epic", + "falcon", + "fern", + "flame", + "forest", + "frost", + "garden", + "glacier", + "globe", + "grain", + "grove", + "harbor", + "hawk", + "horizon", + "iron", + "island", + "ivory", + "jade", + "jasmine", + "jungle", + "kernel", + "kite", + "knot", + "lake", + "lemon", + "light", + "lunar", + "maple", + "marsh", + "meadow", + "mist", + "noble", + "north", + "oak", + "ocean", + "olive", + "orbit", + "palm", + "pearl", + "pine", + "plaza", + "quartz", + "quest", + "raven", + "reef", + "ridge", + "river", + "sage", + "shadow", + "silver", + "stone", + "summit", + "thunder", + "valley", + "willow", + "wolf", + "zero", + "zinc", + ] + } +} diff --git a/packages/cipherstash-proxy-integration/src/large_json_insert.rs b/packages/cipherstash-proxy-integration/src/large_json_insert.rs new file mode 100644 index 00000000..b62983ab --- /dev/null +++ b/packages/cipherstash-proxy-integration/src/large_json_insert.rs @@ -0,0 +1,601 @@ +//! Integration tests for large JSON payload INSERT performance. +//! +//! These tests investigate customer-reported timeout issues when inserting +//! large JSON documents into two encrypted JSONB columns. +//! +//! Customer issue (Bloom Credit): +//! - Two encrypted JSON blob columns causing 25s+ timeouts on INSERTs +//! - Blanking either column alleviates timeouts +//! - Blanking both makes insertion times negligible (milliseconds) +//! +//! Test configuration: +//! - Uses `bloom_credit` table with encrypted columns configured WITHOUT ste_vec indexes +//! +//! Test categories: +//! - **Synthetic tests** - Generated data matching customer report structure (reproducible) +//! - **Customer data tests** - Actual Bloom Credit data from `bloom_credit/` directory + +#[cfg(test)] +mod tests { + use crate::common::{clear, connect_with_tls, random_id, trace, PROXY}; + use serde_json::{json, Value}; + use std::time::Instant; + + // ========================================================================= + // Configuration + // ========================================================================= + + /// Number of records to insert in each test. + /// Adjust this to test performance at different volumes. + const NUM_RECORDS: usize = 10; + + // ========================================================================= + // Data Generators + // ========================================================================= + + /// Generate a synthetic payment snapshot matching customer data structure. + fn generate_payment_snapshot(month_offset: u32) -> Value { + let statuses = ["CURRENT", "LATE_30", "LATE_60", "LATE_90"]; + let status = statuses[(month_offset as usize) % statuses.len()]; + json!({ + "occurrence_date": format!("2025-{:02}-01", (month_offset % 12) + 1), + "payment_status": status + }) + } + + /// Generate a synthetic tradeline matching customer data structure. + fn generate_tradeline(index: u32) -> Value { + let creditors = [ + "THE BANK OF MISSOURI", + "CAPITAL ONE", + "CITIBANK NA", + "CHASE BANK", + "AMERICAN EXPRESS", + "DISCOVER BANK", + "WELLS FARGO", + "US BANK", + ]; + let types = ["REVOLVING", "INSTALLMENT", "MORTGAGE", "OPEN"]; + let type_descriptions = ["Credit Card", "Auto Loan", "Home Mortgage", "Line of Credit"]; + + // Generate 18 payment snapshots per tradeline + let payment_snapshots: Vec = (0..18) + .map(|m| generate_payment_snapshot(m + index)) + .collect(); + + let creditor_name = creditors[(index as usize) % creditors.len()]; + let account_type = types[(index as usize) % types.len()]; + let account_type_desc = type_descriptions[(index as usize) % type_descriptions.len()]; + let year = (index % 5) + 1; + let month = (index % 12) + 1; + let day = (index % 28) + 1; + let delinquency_date: Value = if index % 3 == 0 { + Value::String("01092026".to_string()) + } else { + Value::Null + }; + let past_due = if index % 2 == 0 { (index + 1) * 10 } else { 0 }; + let terms_duration = if index % 2 == 0 { 36 } else { 60 }; + + json!({ + "account": { + "account_number": "", + "date_closed": null, + "date_opened": format!("202{}-{:02}-{:02}", year, month, day), + "name": "", + "type": account_type, + "type_description": account_type_desc, + "creditor": { + "address": null, + "email": null, + "industry": null, + "name": creditor_name, + "phone": null, + "website": null + } + }, + "account_rating": format!("{}", (index % 5) + 1), + "credit_limit": (index + 1) * 1000, + "current_balance": (index + 1) * 500, + "date_effective": "2026-01-09", + "delinquency_date": delinquency_date, + "high_credit": (index + 1) * 800, + "late_30_days_total": index % 3, + "late_60_days_total": index % 4, + "late_90_days_total": index % 5, + "months_reviewed_count": 18, + "most_recent_payment_amount": (index + 1) * 50, + "most_recent_payment_date": "2025-09-01", + "past_due": past_due, + "payment_pattern_start_date": "2024-07-09", + "scheduled_monthly_payment": (index + 1) * 25, + "payment_snapshots": payment_snapshots, + "remarks": [ + {"code": "CLO", "type": "COMPLIANCE"}, + {"code": format!("RMK{}", index), "type": "ACCOUNT"} + ], + "terms_duration": terms_duration, + "terms_frequency": "MONTHLY" + }) + } + + /// Generate a synthetic inquiry matching customer data structure. + fn generate_inquiry(index: u32) -> Value { + let inquirers = [ + "TD BANK USA/TARGET C", + "CAPITAL ONE", + "CITIBANK NA., BEST B", + "CHASE BANK USA", + "AMERICAN EXPRESS", + ]; + let categories = [ + "DEPARTMENT_AND_VARIETY", + "BANKS", + "HOME_FURNISHINGS", + "FINANCE_COMPANIES", + "CREDIT_UNIONS", + ]; + let names = [ + "Complete Dept. Stores", + "All Banks", + "Television & Radio", + "Personal Finance Companies", + "Credit Unions", + ]; + + json!({ + "date": format!("2025-{:02}-{:02}", (index % 12) + 1, (index % 28) + 1), + "inquirer_name": inquirers[(index as usize) % inquirers.len()], + "industry": { + "category": categories[(index as usize) % categories.len()], + "name": names[(index as usize) % names.len()] + } + }) + } + + /// Generate a synthetic credit score matching customer data structure. + fn generate_credit_score() -> Value { + json!({ + "model": "VANTAGE4", + "score_reasons": [ + {"narrative": "Lack of real estate secured loan information"}, + {"narrative": "Too many bankcards with high balance compared to credit limit"}, + {"narrative": "Balances on bankcards are too high compared with credit limits"}, + {"narrative": "Length of time accounts have been established"} + ], + "value": 650 + }) + } + + /// Generate synthetic "full report" JSON (~250KB) matching customer structure. + /// + /// Structure matches Bloom Credit's full-report.json with: + /// - 140 tradelines (each with 18 payment snapshots) + /// - 50 inquiries + /// - Credit scores, alerts, collections, etc. + /// + /// The `seed` parameter ensures each generated report is unique. + fn generate_synthetic_full_report(seed: u32) -> Value { + let tradelines: Vec = (0..140).map(|i| generate_tradeline(i + seed * 200)).collect(); + let inquiries: Vec = (0..50).map(|i| generate_inquiry(i + seed * 200)).collect(); + + json!({ + "fullReport": { + "credit_data_order": { + "order_date": "2026-01-12", + "order_id": "14d69ece-6e96-4bce-a8f7-ca452ff74072", + "alerts": [], + "bankruptcies": [], + "collections": [], + "consumer_id": "dc0b6c07-4f44-4b5f-9942-aeba35177a22", + "credit_scores": [generate_credit_score()], + "inquiries": inquiries, + "mla_statuses": [], + "ofac_statuses": [], + "tradelines": tradelines + } + } + }) + } + + /// Generate a synthetic consumer record for raw report. + fn generate_consumer_record(index: u32) -> Value { + let cities = ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"]; + let states = ["NY", "CA", "IL", "TX", "AZ"]; + let positions = ["Manager", "Engineer", "Analyst", "Director"]; + + let addresses: Vec = (0..3u32) + .map(|a| { + let city = cities[(a as usize) % 5]; + let state = states[(a as usize) % 5]; + let addr_type = if a == 0 { "CURRENT" } else { "PREVIOUS" }; + json!({ + "street": format!("{} Main Street", (index * 100) + a), + "city": city, + "state": state, + "zip": format!("{:05}", 10000 + (index * 100) + a), + "type": addr_type, + "date_reported": format!("2025-{:02}-01", (a % 12) + 1) + }) + }) + .collect(); + + let employers: Vec = (0..2u32) + .map(|e| { + let position = positions[(e as usize) % 4]; + json!({ + "name": format!("Employer {} Inc.", (index * 10) + e), + "address": format!("{} Business Park", (index * 10) + e), + "date_hired": format!("20{:02}-{:02}-01", 15 + (e % 10), (e % 12) + 1), + "position": position + }) + }) + .collect(); + + let phone_numbers: Vec = (0..2u32) + .map(|p| { + let phone_type = if p == 0 { "HOME" } else { "MOBILE" }; + json!({ + "number": format!("555-{:03}-{:04}", 100 + (index % 900), (p * 1000) + index), + "type": phone_type + }) + }) + .collect(); + + let middle_name = if index % 2 == 0 { + Value::String(format!("M{}", index)) + } else { + Value::Null + }; + + json!({ + "consumer_id": format!("consumer-{:08}", index), + "personal_info": { + "first_name": format!("FirstName{}", index), + "last_name": format!("LastName{}", index), + "middle_name": middle_name, + "suffix": null, + "date_of_birth": format!("19{:02}-{:02}-{:02}", 70 + (index % 30), (index % 12) + 1, (index % 28) + 1), + "ssn_last_four": format!("{:04}", (index * 1234) % 10000) + }, + "addresses": addresses, + "employers": employers, + "phone_numbers": phone_numbers + }) + } + + /// Generate synthetic "raw report" JSON (~500KB) matching customer structure. + /// + /// Structure matches Bloom Credit's raw-report.json with: + /// - Multiple consumer records with detailed personal info + /// - Addresses, employers, phone numbers + /// - Additional metadata fields + /// + /// The `seed` parameter ensures each generated report is unique. + fn generate_synthetic_raw_report(seed: u32) -> Value { + let consumers: Vec = (0..270).map(|i| generate_consumer_record(i + seed * 1000)).collect(); + let consumer_count = consumers.len(); + + let data_sources = ["EQUIFAX", "EXPERIAN", "TRANSUNION"]; + let statuses = ["VERIFIED", "PENDING", "FAILED"]; + + // Add extra bulk data to reach ~500KB target + let extra_data: Vec = (0..550u32) + .map(|i| { + let idx = i + seed * 1000; + let source = data_sources[(idx as usize) % 3]; + let status = statuses[(idx as usize) % 3]; + let flags: Vec = (0..5).map(|f| format!("FLAG_{}", f + idx)).collect(); + let day = (idx % 28) + 1; + let hour = idx % 24; + let minute = idx % 60; + let response_code = 200 + (idx % 100); + let processing_time = 50 + (idx % 500); + let cache_hit = idx % 2 == 0; + let retry_count = idx % 3; + json!({ + "record_id": format!("rec-{:08}", idx), + "timestamp": format!("2026-01-{:02}T{:02}:{:02}:00Z", day, hour, minute), + "data_source": source, + "processing_notes": format!("Processing note {} with additional context and details for record tracking and audit purposes. This field contains extended text to simulate realistic data volumes.", idx), + "verification_status": status, + "metadata": { + "request_id": format!("req-{:012}", idx * 12345), + "response_code": response_code, + "processing_time_ms": processing_time, + "cache_hit": cache_hit, + "retry_count": retry_count, + "flags": flags + } + }) + }) + .collect(); + + json!({ + "rawReport": { + "consumers": consumers, + "status": "COMPLETE", + "processing_records": extra_data, + "summary": { + "total_consumers": consumer_count, + "processing_date": "2026-01-12", + "report_version": "2.0", + "data_sources": ["EQUIFAX", "EXPERIAN", "TRANSUNION"] + } + } + }) + } + + /// Load customer JSON data from bloom_credit directory. + fn load_customer_json(filename: &str) -> Value { + // Find the project root by looking for the bloom_credit directory + let possible_paths = [ + format!("bloom_credit/{}", filename), + format!("../../bloom_credit/{}", filename), + format!("../../../bloom_credit/{}", filename), + ]; + + for path in &possible_paths { + if let Ok(content) = std::fs::read_to_string(path) { + return serde_json::from_str(&content) + .unwrap_or_else(|_| panic!("Failed to parse {} as JSON", path)); + } + } + + // If we can't find it, give a helpful error message + let cwd = std::env::current_dir().unwrap_or_default(); + panic!( + "Failed to find bloom_credit/{}. Current directory: {}. Tried: {:?}", + filename, + cwd.display(), + possible_paths + ) + } + + // ========================================================================= + // Synthetic Tests (generated data - reproducible, no external dependencies) + // ========================================================================= + + /// Synthetic test - INSERT with only first encrypted JSONB column populated. + /// + /// Uses generated data matching customer structure (~250KB). + /// Each record is uniquely generated. + #[tokio::test] + async fn synthetic_insert_first_column() { + trace(); + clear().await; + + let client = connect_with_tls(PROXY).await; + + // Generate first record to show size + let sample = generate_synthetic_full_report(0); + let full_size = serde_json::to_string(&sample).unwrap().len(); + + println!("Synthetic full report size: {}KB", full_size / 1024); + println!("Inserting {} unique records...", NUM_RECORDS); + + let sql = "INSERT INTO bloom_credit (id, encrypted_jsonb_full) VALUES ($1, $2)"; + + let start = Instant::now(); + for i in 0..NUM_RECORDS { + let id = random_id(); + let full_report = generate_synthetic_full_report(i as u32); + client.query(sql, &[&id, &full_report]).await.unwrap(); + if (i + 1) % 10 == 0 || i + 1 == NUM_RECORDS { + println!(" Inserted {}/{} records", i + 1, NUM_RECORDS); + } + } + let duration = start.elapsed(); + + let avg_ms = duration.as_millis() as f64 / NUM_RECORDS as f64; + println!( + "SYNTHETIC INSERT first column only ({}KB): {} records in {:?} (avg: {:.1}ms/record)", + full_size / 1024, + NUM_RECORDS, + duration, + avg_ms + ); + + if avg_ms > 10000.0 { + println!("WARNING: Average insert took >10s - in customer timeout territory!"); + } + } + + /// Synthetic test - INSERT with BOTH encrypted JSONB columns populated. + /// + /// Uses generated data matching customer structure: + /// - encrypted_jsonb_full: ~250KB + /// - encrypted_jsonb_raw: ~500KB + /// Each record is uniquely generated. + #[tokio::test] + async fn synthetic_insert_both_columns() { + trace(); + clear().await; + + let client = connect_with_tls(PROXY).await; + + // Generate first record to show sizes + let sample_full = generate_synthetic_full_report(0); + let sample_raw = generate_synthetic_raw_report(0); + + let full_size = serde_json::to_string(&sample_full).unwrap().len(); + let raw_size = serde_json::to_string(&sample_raw).unwrap().len(); + + println!("Synthetic full report size: {}KB", full_size / 1024); + println!("Synthetic raw report size: {}KB", raw_size / 1024); + println!("Inserting {} unique records...", NUM_RECORDS); + + let sql = "INSERT INTO bloom_credit (id, encrypted_jsonb_full, encrypted_jsonb_raw) VALUES ($1, $2, $3)"; + + let start = Instant::now(); + for i in 0..NUM_RECORDS { + let id = random_id(); + let full_report = generate_synthetic_full_report(i as u32); + let raw_report = generate_synthetic_raw_report(i as u32); + client + .query(sql, &[&id, &full_report, &raw_report]) + .await + .unwrap(); + if (i + 1) % 10 == 0 || i + 1 == NUM_RECORDS { + println!(" Inserted {}/{} records", i + 1, NUM_RECORDS); + } + } + let duration = start.elapsed(); + + let avg_ms = duration.as_millis() as f64 / NUM_RECORDS as f64; + println!( + "SYNTHETIC INSERT both columns (full: {}KB, raw: {}KB): {} records in {:?} (avg: {:.1}ms/record)", + full_size / 1024, + raw_size / 1024, + NUM_RECORDS, + duration, + avg_ms + ); + + if avg_ms > 10000.0 { + println!("WARNING: Average insert took >10s - in customer timeout territory!"); + } + } + + // ========================================================================= + // Customer Data Tests (actual Bloom Credit data from bloom_credit/) + // ========================================================================= + + /// Customer data test - INSERT with only first encrypted JSONB column populated. + /// + /// Uses actual customer data: full-report.json (~237KB) + #[tokio::test] + async fn customer_insert_first_column() { + trace(); + clear().await; + + let client = connect_with_tls(PROXY).await; + + let full_report = load_customer_json("full-report.json"); + let full_size = serde_json::to_string(&full_report).unwrap().len(); + + println!("Customer full-report.json size: {}KB", full_size / 1024); + println!("Inserting {} records...", NUM_RECORDS); + + let sql = "INSERT INTO bloom_credit (id, encrypted_jsonb_full) VALUES ($1, $2)"; + + let start = Instant::now(); + for i in 0..NUM_RECORDS { + let id = random_id(); + client.query(sql, &[&id, &full_report]).await.unwrap(); + if (i + 1) % 10 == 0 || i + 1 == NUM_RECORDS { + println!(" Inserted {}/{} records", i + 1, NUM_RECORDS); + } + } + let duration = start.elapsed(); + + let avg_ms = duration.as_millis() as f64 / NUM_RECORDS as f64; + println!( + "CUSTOMER INSERT first column only ({}KB): {} records in {:?} (avg: {:.1}ms/record)", + full_size / 1024, + NUM_RECORDS, + duration, + avg_ms + ); + + if avg_ms > 10000.0 { + println!("WARNING: Average insert took >10s - in customer timeout territory!"); + } + } + + /// Customer data test - INSERT with BOTH encrypted JSONB columns populated. + /// + /// Uses actual customer data: + /// - encrypted_jsonb_full: full-report.json (~237KB) + /// - encrypted_jsonb_raw: raw-report.json (~345KB) + /// + /// This should reproduce the customer's timeout issue where inserts take 25s+. + #[tokio::test] + async fn customer_insert_both_columns() { + trace(); + clear().await; + + let client = connect_with_tls(PROXY).await; + + let full_report = load_customer_json("full-report.json"); + let raw_report = load_customer_json("raw-report.json"); + + let full_size = serde_json::to_string(&full_report).unwrap().len(); + let raw_size = serde_json::to_string(&raw_report).unwrap().len(); + + println!("Customer full-report.json size: {}KB", full_size / 1024); + println!("Customer raw-report.json size: {}KB", raw_size / 1024); + println!("Inserting {} records...", NUM_RECORDS); + + let sql = "INSERT INTO bloom_credit (id, encrypted_jsonb_full, encrypted_jsonb_raw) VALUES ($1, $2, $3)"; + + let start = Instant::now(); + for i in 0..NUM_RECORDS { + let id = random_id(); + client + .query(sql, &[&id, &full_report, &raw_report]) + .await + .unwrap(); + if (i + 1) % 10 == 0 || i + 1 == NUM_RECORDS { + println!(" Inserted {}/{} records", i + 1, NUM_RECORDS); + } + } + let duration = start.elapsed(); + + let avg_ms = duration.as_millis() as f64 / NUM_RECORDS as f64; + println!( + "CUSTOMER INSERT both columns (full: {}KB, raw: {}KB): {} records in {:?} (avg: {:.1}ms/record)", + full_size / 1024, + raw_size / 1024, + NUM_RECORDS, + duration, + avg_ms + ); + + if avg_ms > 10000.0 { + println!("WARNING: Average insert took >10s - in customer timeout territory!"); + } + } + + // ========================================================================= + // Baseline Tests + // ========================================================================= + + /// Baseline test - INSERT with both encrypted JSONB columns as NULL. + /// + /// This establishes the baseline timing without encryption overhead. + #[tokio::test] + async fn baseline_insert_both_null() { + trace(); + clear().await; + + let client = connect_with_tls(PROXY).await; + + println!("Inserting {} records...", NUM_RECORDS); + + let sql = "INSERT INTO bloom_credit (id) VALUES ($1)"; + + let start = Instant::now(); + for i in 0..NUM_RECORDS { + let id = random_id(); + client.query(sql, &[&id]).await.unwrap(); + if (i + 1) % 10 == 0 || i + 1 == NUM_RECORDS { + println!(" Inserted {}/{} records", i + 1, NUM_RECORDS); + } + } + let duration = start.elapsed(); + + let avg_ms = duration.as_millis() as f64 / NUM_RECORDS as f64; + println!( + "BASELINE INSERT both columns NULL: {} records in {:?} (avg: {:.1}ms/record)", + NUM_RECORDS, duration, avg_ms + ); + + // Average should be fast (under 100ms per record for NULL inserts) + assert!( + avg_ms < 100.0, + "Baseline insert average should be <100ms, got {:.1}ms", + avg_ms + ); + } +} diff --git a/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_id.rs b/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_id.rs index f1f600ad..8789a90d 100644 --- a/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_id.rs +++ b/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_id.rs @@ -8,7 +8,8 @@ #[cfg(test)] mod tests { use crate::common::{ - clear, connect_with_tls, random_id, rows_to_vec, simple_query_with_client, trace, PROXY, + assert_db_error, clear, connect_with_tls, random_id, rows_to_vec, simple_query_with_client, + trace, PROXY, }; use uuid::Uuid; @@ -377,15 +378,12 @@ mod tests { let insert_sql = "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)"; let result = client.query(insert_sql, &[&id, &text]).await; - assert!(result.is_err()); - - if let Err(err) = result { - let msg = err.to_string(); - assert_eq!(msg, "db error: FATAL: Unknown keyset name or id '2cace9db-3a2a-4b46-a184-ba412b3e0730'. Check the configured credentials. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unknown-keyset"); - } else { - unreachable!(); - } + assert_db_error( + result, + "FATAL", + "Unknown keyset name or id '2cace9db-3a2a-4b46-a184-ba412b3e0730'. Check the configured credentials. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unknown-keyset", + ); // -------- // Switch back to TENANT_1 diff --git a/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_name.rs b/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_name.rs index 7919e059..95cfd71f 100644 --- a/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_name.rs +++ b/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_name.rs @@ -8,7 +8,8 @@ #[cfg(test)] mod tests { use crate::common::{ - clear, connect_with_tls, random_id, rows_to_vec, simple_query_with_client, trace, PROXY, + assert_db_error, clear, connect_with_tls, random_id, rows_to_vec, simple_query_with_client, + trace, PROXY, }; /// @@ -342,15 +343,12 @@ mod tests { let insert_sql = "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)"; let result = client.query(insert_sql, &[&id, &text]).await; - assert!(result.is_err()); - - if let Err(err) = result { - let msg = err.to_string(); - assert_eq!(msg, "db error: FATAL: Unknown keyset name or id 'BLAHVTHA'. Check the configured credentials. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unknown-keyset"); - } else { - unreachable!(); - } + assert_db_error( + result, + "FATAL", + "Unknown keyset name or id 'BLAHVTHA'. Check the configured credentials. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unknown-keyset", + ); // -------- // Switch back to TENANT_1 diff --git a/packages/cipherstash-proxy-integration/src/set_keyset_error.rs b/packages/cipherstash-proxy-integration/src/set_keyset_error.rs index e4b75f9b..e2025feb 100644 --- a/packages/cipherstash-proxy-integration/src/set_keyset_error.rs +++ b/packages/cipherstash-proxy-integration/src/set_keyset_error.rs @@ -11,16 +11,15 @@ mod tests { use tracing::info; - use crate::common::{connect_with_tls, trace, PROXY}; + use crate::common::{assert_db_error, connect_with_tls, trace, PROXY}; /// Helper function to assert that a result contains the expected "Cannot SET CIPHERSTASH.KEYSET" error fn assert_keyset_error(result: Result) { - if let Err(err) = result { - let msg = err.to_string(); - assert_eq!(msg, "db error: FATAL: Cannot SET CIPHERSTASH.KEYSET if a default keyset has been configured. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unexpected-set-keyset"); - } else { - unreachable!(); - } + assert_db_error( + result, + "FATAL", + "Cannot SET CIPHERSTASH.KEYSET if a default keyset has been configured. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unexpected-set-keyset", + ); } /// Tests error handling of unknown keyset id From e8af8659337e6283aa693c4ef91750eb97af8228 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 27 Oct 2026 16:34:34 +1100 Subject: [PATCH 10/11] fix(deps): update rustls, tokio-postgres and other dependencies with advisories - rustls 0.23.46 (RUSTSEC-2026-0346, 0347, 0348 and 0349) - rustls-webpki 0.103.16 (RUSTSEC-2026-0382) - webpki-root-certs 1.0.11 (RUSTSEC-2026-0383) - tokio-postgres 0.7.18 (RUSTSEC-2026-0178, a panic on a malformed DataRow), which also updates libredox, postgres-types and whoami - h2 0.4.20 (RUSTSEC-2026-0258) - crossbeam-epoch 0.9.21 (RUSTSEC-2026-0204) - tokio 1.51.6 (RUSTSEC-2026-0386) - chacha20 0.10.2, replacing the yanked 0.10.0 tokio-postgres 0.7.18 no longer enables socket2 0.5's "all" feature, which Proxy relied on for TcpKeepalive::with_retries, so Proxy now enables it itself. --- CHANGELOG.md | 2 +- Cargo.lock | 173 ++++++++++++++++---------- packages/cipherstash-proxy/Cargo.toml | 2 +- 3 files changed, 108 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81996890..6e479d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Security -- **Dependency updates**: Updated `rustls` to 0.23.45, `rustls-webpki` to 0.103.14, `aws-lc-rs` to 1.18.2 (with `aws-lc-sys` 0.46.0), `rand` to 0.9.3 and 0.10.2, `quinn-proto` to 0.11.15, `hickory-proto` and `hickory-resolver` to 0.26.3 (with `reqwest` 0.13.4), and `diesel` to 2.3.12, and `postgres-protocol` to 0.6.12, to pick up the fixes for published security advisories in those crates. No configuration change is required. +- **Dependency updates**: Updated `rustls` to 0.23.46, `rustls-webpki` to 0.103.16, `webpki-root-certs` to 1.0.11, `aws-lc-rs` to 1.18.2 (with `aws-lc-sys` 0.46.0), `tokio-postgres` to 0.7.18, `postgres-protocol` to 0.6.12, `tokio` to 1.51.6, `h2` to 0.4.20, `crossbeam-epoch` to 0.9.21, `rand` to 0.9.3 and 0.10.2, `quinn-proto` to 0.11.15, `hickory-proto` and `hickory-resolver` to 0.26.3 (with `reqwest` 0.13.4), and `diesel` to 2.3.12, to pick up the fixes for published security advisories in those crates. No configuration change is required. ## [2.2.4] - 2026-06-18 diff --git a/Cargo.lock b/Cargo.lock index d6ecb238..a591e21f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -718,9 +718,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1156,9 +1156,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] @@ -1935,9 +1935,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "h2" -version = "0.4.8" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -2209,7 +2209,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.5", "system-configuration 0.6.1", "tokio", "tower-service", @@ -2648,9 +2648,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libm" @@ -2660,11 +2660,10 @@ checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ - "bitflags", "libc", ] @@ -2863,13 +2862,13 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3007,6 +3006,24 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.36.7" @@ -3191,7 +3208,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c7bc82ccbe2c7ef7ceed38dcac90d7ff46681e061e9d7310cbcd409113e303" dependencies = [ - "phf", + "phf 0.11.3", ] [[package]] @@ -3201,7 +3218,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", ] [[package]] @@ -3210,7 +3237,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.6", ] @@ -3221,7 +3248,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.117", @@ -3236,6 +3263,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3274,9 +3310,9 @@ checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" [[package]] name = "postgres-derive" -version = "0.4.6" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69700ea4603c5ef32d447708e6a19cd3e8ac197a000842e97f527daea5e4175f" +checksum = "4d9d9089bb0ce62f4b5d52a0be0f4acfb35738b979380670d3dea85fe38d6ddd" dependencies = [ "heck", "proc-macro2", @@ -3304,9 +3340,9 @@ dependencies = [ [[package]] name = "postgres-types" -version = "0.2.9" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" dependencies = [ "array-init", "bytes", @@ -3314,7 +3350,7 @@ dependencies = [ "fallible-iterator", "postgres-derive", "postgres-protocol", - "serde", + "serde_core", "serde_json", "uuid", ] @@ -4055,9 +4091,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -4107,7 +4143,7 @@ dependencies = [ "rustls-webpki", "security-framework", "security-framework-sys", - "webpki-root-certs 0.26.8", + "webpki-root-certs 0.26.11", "windows-sys 0.59.0", ] @@ -4128,8 +4164,8 @@ dependencies = [ "rustls-webpki", "security-framework", "security-framework-sys", - "webpki-root-certs 1.0.5", - "windows-sys 0.61.2", + "webpki-root-certs 1.0.11", + "windows-sys 0.59.0", ] [[package]] @@ -4140,9 +4176,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -4306,14 +4342,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -4519,12 +4556,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4926,9 +4963,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.48.0" +version = "1.51.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "a13e32eacd0222f9658be2ccbd83b718bfd9bfb0d54b5921913c184cb86124a5" dependencies = [ "bytes", "libc", @@ -4936,27 +4973,27 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "tokio-postgres" -version = "0.7.13" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c95d533c83082bb6490e0189acaa0bbeef9084e60471b696ca6988cd0541fb0" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" dependencies = [ "async-trait", "byteorder", @@ -4967,12 +5004,12 @@ dependencies = [ "log", "parking_lot", "percent-encoding", - "phf", + "phf 0.13.1", "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.9.3", - "socket2 0.5.8", + "rand 0.10.2", + "socket2 0.6.5", "tokio", "tokio-util", "whoami", @@ -5556,9 +5593,12 @@ dependencies = [ [[package]] name = "wasite" -version = "0.1.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.2+wasi-0.2.4", +] [[package]] name = "wasm-bindgen" @@ -5699,29 +5739,31 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "0.26.8" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09aed61f5e8d2c18344b3faa33a4c837855fe56642757754775548fee21386c4" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" dependencies = [ - "rustls-pki-types", + "webpki-root-certs 1.0.11", ] [[package]] name = "webpki-root-certs" -version = "1.0.5" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] [[package]] name = "whoami" -version = "1.6.0" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" dependencies = [ - "redox_syscall", + "libc", + "libredox", + "objc2-system-configuration", "wasite", "web-sys", ] @@ -5951,15 +5993,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -6550,3 +6583,9 @@ dependencies = [ "quote", "syn 2.0.117", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/cipherstash-proxy/Cargo.toml b/packages/cipherstash-proxy/Cargo.toml index 790cea6f..0992d2a6 100644 --- a/packages/cipherstash-proxy/Cargo.toml +++ b/packages/cipherstash-proxy/Cargo.toml @@ -42,7 +42,7 @@ rustls-platform-verifier = "0.5.0" rustls-pki-types = "1.10.0" serde = "1.0" serde_json = "1.0" -socket2 = "0.5.7" +socket2 = { version = "0.5.7", features = ["all"] } sqltk = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } From 49c7057d93bfd65af5a44c8c3906c27e577672b2 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 27 Oct 2026 16:34:34 +1100 Subject: [PATCH 11/11] chore(release): prepare 2.2.5 Bump the workspace version to 2.2.5, move the unreleased changes into a 2.2.5 CHANGELOG section and add its compare links. Set `latest=false` on the Docker metadata steps so that publishing a 2.2.x release does not move the `cipherstash/proxy:latest` tag off 3.x. Document how 2.2.x maintenance releases are cut from release/2.2: the manual tag steps, `--latest=false`, and `--notes-start-tag` so the generated notes start from the previous 2.2.x tag. --- .github/workflows/release.yml | 6 ++++++ CHANGELOG.md | 5 ++++- Cargo.lock | 6 +++--- Cargo.toml | 2 +- DEVELOPMENT.md | 16 ++++++++++++++++ 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6760ad77..f8b91ad3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,6 +101,9 @@ jobs: uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY_IMAGE }} + # 2.2.x releases are maintenance releases: never move `latest` off 3.x. + flavor: | + latest=false - name: Login to Docker Hub uses: docker/login-action@v3 @@ -180,6 +183,9 @@ jobs: uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY_IMAGE }} + # 2.2.x releases are maintenance releases: never move `latest` off 3.x. + flavor: | + latest=false tags: | type=ref,event=branch type=ref,event=pr diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e479d0f..cb59b801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [2.2.5] - 2026-10-28 + ### Fixed - **Memory growth on long-lived connections**: Proxy now releases the prepared-statement, portal and statement-metrics state it keeps for each client connection once PostgreSQL has finished with it. Previously some common client patterns left state behind until the connection closed, so with a connection pool that keeps connections open, memory grew steadily until Proxy was restarted. The affected patterns were statements prepared and described but never executed, prepared statements removed with SQL `DEALLOCATE`, `DEALLOCATE ALL` or `DISCARD ALL` (used by psycopg 3, Rails and connection-pool reset queries, whether sent as a simple query or through the extended protocol), uniquely named portals, pipelined batches that fail part-way, statements rejected by Proxy, and connections that use `COPY ... FROM STDIN`. No configuration change is required. @@ -295,7 +297,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Integration with CipherStash ZeroKMS. - Encrypt Query Language (EQL) for indexing and searching encrypted data. -[Unreleased]: https://github.com/cipherstash/proxy/compare/v2.2.4...HEAD +[Unreleased]: https://github.com/cipherstash/proxy/compare/v2.2.5...HEAD +[2.2.5]: https://github.com/cipherstash/proxy/compare/v2.2.4...v2.2.5 [2.2.4]: https://github.com/cipherstash/proxy/compare/v2.2.3...v2.2.4 [2.2.3]: https://github.com/cipherstash/proxy/compare/v2.2.2...v2.2.3 [2.2.2]: https://github.com/cipherstash/proxy/compare/v2.2.1...v2.2.2 diff --git a/Cargo.lock b/Cargo.lock index a591e21f..b01c06f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -845,7 +845,7 @@ dependencies = [ [[package]] name = "cipherstash-proxy" -version = "2.2.4" +version = "2.2.5" dependencies = [ "arc-swap", "async-trait", @@ -1610,7 +1610,7 @@ dependencies = [ [[package]] name = "eql-mapper-macros" -version = "2.2.4" +version = "2.2.5" dependencies = [ "pretty_assertions", "proc-macro2", @@ -4466,7 +4466,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "showcase" -version = "2.2.4" +version = "2.2.5" dependencies = [ "rand 0.9.3", "rustls", diff --git a/Cargo.toml b/Cargo.toml index 5cbfa718..3b67fcc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["packages/*"] exclude = ["vendor/stack-auth"] [workspace.package] -version = "2.2.4" +version = "2.2.5" edition = "2021" [profile.dev] diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index fded1e84..7672babb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -782,6 +782,22 @@ git push origin vX.Y.Z gh release create vX.Y.Z --generate-notes ``` +### Maintenance releases (2.2.x) + +2.2.x patch releases are cut from the `release/2.2` branch, not `main`, which carries 3.x. +`mise run release` only runs on `main`, so open the prepare-release PR against `release/2.2` and use the manual steps. +`release.yml` on `release/2.2` sets `latest=false` on its Docker metadata steps, so a 2.2.x release publishes `cipherstash/proxy:X.Y.Z` and `:2.2` but never moves `:latest` off 3.x. +Keep the GitHub "Latest" release on 3.x too, and start the generated notes from the previous 2.2.x tag so they don't diff against a 3.x tag: + +```bash +# From the merged prepare-release commit on release/2.2 +git tag v2.2.Z +git push origin v2.2.Z + +gh release create v2.2.Z --target release/2.2 --latest=false \ + --generate-notes --notes-start-tag v2.2.Y +``` + ### Re-releasing a version If a release fails and you need to re-release the same version: