diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac6d4db..10a0fdba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Pre-encrypted EQL values in statements**: SQL literals and bound parameters may now carry EQL v3 storage payloads produced by an application. Proxy validates their wire shape, version, destination column and required SEM terms, authenticates their ciphertext with the connection's active keyset, and forwards them without encrypting them again. Invalid payloads fail closed with one generic error so validation details cannot be used as an oracle. + ## [3.0.1] - 2026-08-05 ### Added diff --git a/Cargo.lock b/Cargo.lock index 269cfe0d..88163a26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4308,6 +4308,8 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" name = "showcase" version = "3.0.1" dependencies = [ + "cipherstash-client", + "cipherstash-config", "rand 0.9.2", "rustls", "serde", diff --git a/packages/cipherstash-proxy-integration/src/inbound_ciphertext.rs b/packages/cipherstash-proxy-integration/src/inbound_ciphertext.rs new file mode 100644 index 00000000..ae42d0bc --- /dev/null +++ b/packages/cipherstash-proxy-integration/src/inbound_ciphertext.rs @@ -0,0 +1,140 @@ +//! End-to-end coverage for application-encrypted EQL payloads entering Proxy. + +#[cfg(test)] +mod tests { + use crate::common::{clear_with_client, connect_with_tls, random_id, PROXY}; + use cipherstash_client::{ + encryption::{Plaintext, ScopedCipher}, + eql::{ + encrypt_eql_v3, EqlEncryptOpts, EqlOperation, EqlOutputV3, Identifier, + PreparedPlaintext, + }, + schema::{column::Index, ColumnConfig, ColumnType}, + zerokms::{ClientKey, ZeroKMSBuilder}, + AutoStrategy, IdentifiedBy, + }; + use std::{borrow::Cow, sync::Arc}; + use uuid::Uuid; + + async fn cipher() -> Arc> { + let client_id = env("CS_CLIENT_ID", "CS_ENCRYPT__CLIENT_ID") + .parse() + .expect("CS_CLIENT_ID must be a UUID"); + let client_key = + ClientKey::from_hex_v1(client_id, &env("CS_CLIENT_KEY", "CS_ENCRYPT__CLIENT_KEY")) + .expect("CS_CLIENT_KEY must be valid"); + let zerokms = ZeroKMSBuilder::auto() + .expect("ZeroKMS credentials must be configured") + .with_client_key(client_key) + .build() + .expect("ZeroKMS client must initialize"); + let keyset_id: Uuid = env("CS_DEFAULT_KEYSET_ID", "CS_ENCRYPT__DEFAULT_KEYSET_ID") + .parse() + .expect("CS_DEFAULT_KEYSET_ID must be a UUID"); + Arc::new( + ScopedCipher::init(Arc::new(zerokms), Some(IdentifiedBy::Uuid(keyset_id))) + .await + .expect("scoped cipher must initialize"), + ) + } + + fn env(primary: &str, nested: &str) -> String { + std::env::var(primary) + .or_else(|_| std::env::var(nested)) + .unwrap_or_else(|_| panic!("{primary} must be configured")) + } + + fn text_search_config(column: &str) -> ColumnConfig { + ColumnConfig::build(column) + .casts_as(ColumnType::Text) + .add_index(Index::new_unique()) + .add_index(Index::new_ope()) + .add_index(Index::new_match()) + } + + async fn encrypt_text(table: &str, column: &str, plaintext: &str) -> String { + let prepared = PreparedPlaintext::new( + Cow::Owned(text_search_config(column)), + Identifier::new(table, column), + Plaintext::from(plaintext), + EqlOperation::Store, + ); + let mut outputs = + encrypt_eql_v3(cipher().await, vec![prepared], &EqlEncryptOpts::default()) + .await + .expect("application-side encryption must succeed"); + let EqlOutputV3::Store(ciphertext) = outputs.remove(0) else { + panic!("store encryption must return a stored payload"); + }; + serde_json::to_string(&ciphertext).unwrap() + } + + #[tokio::test] + async fn accepts_pre_encrypted_parameter_for_storage_and_search() { + let client = connect_with_tls(*PROXY).await; + clear_with_client(&client).await; + let id = random_id(); + let plaintext = "encrypted in the application"; + let payload = encrypt_text("encrypted", "encrypted_text", plaintext).await; + + client + .execute( + "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)", + &[&id, &payload], + ) + .await + .unwrap(); + + let rows = client + .query( + "SELECT encrypted_text FROM encrypted WHERE encrypted_text = $1", + &[&payload], + ) + .await + .unwrap(); + assert_eq!(rows[0].get::<_, String>(0), plaintext); + } + + #[tokio::test] + async fn accepts_pre_encrypted_literal_for_storage() { + let client = connect_with_tls(*PROXY).await; + clear_with_client(&client).await; + let id = random_id(); + let plaintext = "application encrypted literal"; + let payload = encrypt_text("encrypted", "encrypted_text", plaintext).await; + let payload = payload.replace('\'', "''"); + + client + .simple_query(&format!( + "INSERT INTO encrypted (id, encrypted_text) VALUES ({id}, '{payload}')" + )) + .await + .unwrap(); + + let row = client + .query_one("SELECT encrypted_text FROM encrypted WHERE id = $1", &[&id]) + .await + .unwrap(); + assert_eq!(row.get::<_, String>(0), plaintext); + } + + #[tokio::test] + async fn rejects_payload_for_a_different_destination_with_generic_error() { + let client = connect_with_tls(*PROXY).await; + clear_with_client(&client).await; + let id = random_id(); + let payload = encrypt_text("some_other_table", "encrypted_text", "secret").await; + + let error = client + .execute( + "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)", + &[&id, &payload], + ) + .await + .expect_err("destination mismatch must fail closed"); + assert_eq!( + error.as_db_error().unwrap().message(), + "Invalid encrypted value" + ); + } +} diff --git a/packages/cipherstash-proxy-integration/src/lib.rs b/packages/cipherstash-proxy-integration/src/lib.rs index 756ab8d2..d2a713d5 100644 --- a/packages/cipherstash-proxy-integration/src/lib.rs +++ b/packages/cipherstash-proxy-integration/src/lib.rs @@ -7,6 +7,7 @@ mod empty_result; mod encryption_sanity; mod eql_regression; mod extended_protocol_error_messages; +mod inbound_ciphertext; mod insert; mod legacy_v2_column; mod map_concat; diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs index dffc1493..45904faa 100644 --- a/packages/cipherstash-proxy/src/error.rs +++ b/packages/cipherstash-proxy/src/error.rs @@ -75,6 +75,7 @@ impl Error { // stores plaintext in a column its operator believes is encrypted // (CIP-3688). No configuration may turn that back on. Error::Mapping(MappingError::UnmappableEncryptedColumn { .. }) + | Error::Encrypt(EncryptError::InvalidInboundCiphertext) ) } } @@ -255,6 +256,12 @@ pub enum TlsConfigError { #[derive(Error, Debug)] pub enum EncryptError { + /// Deliberately contains no payload or validation detail: inbound + /// ciphertext failures are attacker-controlled and detailed responses can + /// become an oracle. + #[error("Invalid encrypted value")] + InvalidInboundCiphertext, + #[error(transparent)] CiphertextCouldNotBeSerialised(#[from] serde_json::Error), diff --git a/packages/cipherstash-proxy/src/postgresql/frontend.rs b/packages/cipherstash-proxy/src/postgresql/frontend.rs index 0e543642..f80fae23 100644 --- a/packages/cipherstash-proxy/src/postgresql/frontend.rs +++ b/packages/cipherstash-proxy/src/postgresql/frontend.rs @@ -21,6 +21,7 @@ use crate::postgresql::context::Portal; use crate::postgresql::data::{ compose_json_selector_path, json_value_selector_plaintext, literal_from_sql, literal_json_value, }; +use crate::postgresql::inbound_eql; use crate::postgresql::messages::close::Close; use crate::postgresql::messages::error_response::ErrorResponseCode; use crate::postgresql::messages::ready_for_query::ReadyForQuery; @@ -648,7 +649,18 @@ where return Ok(vec![]); } - let plaintexts = literals_to_plaintext(typed_statement, literal_columns)?; + let inbound = literal_values + .iter() + .zip(literal_columns) + .map(|((_, literal), column)| { + let (Some(column), Some(value)) = (column, (*literal).clone().into_string()) else { + return Ok(None); + }; + inbound_eql::parse(value.as_bytes(), column).map_err(Error::from) + }) + .collect::, Error>>()?; + let skip = inbound.iter().map(Option::is_some).collect::>(); + let plaintexts = literals_to_plaintext_skipping(typed_statement, literal_columns, &skip)?; let start = Instant::now(); @@ -660,6 +672,9 @@ where counter!(ENCRYPTION_ERROR_TOTAL).increment(1); })?; + self.authenticate_and_merge_inbound(&mut encrypted, inbound) + .await?; + for ((_, literal), encrypted) in literal_values.iter().zip(encrypted.iter_mut()) { project_query_operand( typed_statement.query_operands.contains_literal(literal), @@ -1156,8 +1171,13 @@ where bind: &Bind, statement: &Statement, ) -> Result>, Error> { - let plaintexts = - bind.to_plaintext(&statement.output_params, &statement.postgres_param_types)?; + let inbound = bind.inbound_ciphertexts(&statement.output_params)?; + let skip = inbound.iter().map(Option::is_some).collect::>(); + let plaintexts = bind.to_plaintext_skipping( + &statement.output_params, + &statement.postgres_param_types, + &skip, + )?; // Encryption is positional over the OUTPUT params — the values actually // sent — not over what the client bound. @@ -1179,6 +1199,9 @@ where counter!(ENCRYPTION_ERROR_TOTAL).increment(1); })?; + self.authenticate_and_merge_inbound(&mut encrypted, inbound) + .await?; + for (output, encrypted) in statement.output_params.iter().zip(encrypted.iter_mut()) { project_query_operand(output.query_operand, encrypted); } @@ -1205,6 +1228,37 @@ where Ok(encrypted) } + /// Authenticate inbound ciphertext with this connection's scoped cipher. + /// Any parse, metadata, key or AEAD failure is collapsed to one response. + async fn authenticate_and_merge_inbound( + &self, + encrypted: &mut [Option], + inbound: Vec>, + ) -> Result<(), Error> { + let positions = inbound + .iter() + .enumerate() + .filter_map(|(index, ciphertext)| ciphertext.as_ref().map(|ct| (index, ct.clone()))) + .collect::>(); + if positions.is_empty() { + return Ok(()); + } + + let ciphertexts = positions + .iter() + .map(|(_, ciphertext)| Some(ciphertext.clone())) + .collect(); + self.context + .decrypt(ciphertexts) + .await + .map_err(|_| EncryptError::InvalidInboundCiphertext)?; + + for (index, ciphertext) in positions { + encrypted[index] = Some(EqlOutput::Store(ciphertext)); + } + Ok(()) + } + fn type_check<'a>( &self, statement: &'a ast::Statement, @@ -1391,45 +1445,59 @@ fn project_query_operand(query_operand: bool, encrypted: &mut Option) fn literals_to_plaintext( typed_statement: &TypeCheckedStatement<'_>, literal_columns: &Vec>, +) -> Result>, Error> { + literals_to_plaintext_skipping(typed_statement, literal_columns, &[]) +} + +fn literals_to_plaintext_skipping( + typed_statement: &TypeCheckedStatement<'_>, + literal_columns: &Vec>, + skip: &[bool], ) -> Result>, Error> { let literals = typed_statement.literal_values(); let plaintexts = literals .iter() .zip(literal_columns) - .map(|((eql_term, val), col)| match col { - Some(col) => { - let plaintext = match eql_term.variant() { - EqlTermVariant::JsonValueSelector => { - json_value_selector_literal_plaintext(typed_statement, val) - } - // A selector that carries a collapsed chain keys the composed - // path, not the one segment it spells. Only a selector the - // mapper recorded a chain for: a single access has no record - // and takes the ordinary single-segment route below. - EqlTermVariant::JsonAccessor - if typed_statement - .json_accessor_paths - .for_literal(val) - .is_some() => - { - json_accessor_path_literal_plaintext(typed_statement, val) - } - _ => literal_from_sql(val, col.eql_term(), col.cast_type()), - }; + .enumerate() + .map(|(index, ((eql_term, val), col))| { + if skip.get(index).copied().unwrap_or(false) { + return Ok(None); + } + match col { + Some(col) => { + let plaintext = match eql_term.variant() { + EqlTermVariant::JsonValueSelector => { + json_value_selector_literal_plaintext(typed_statement, val) + } + // A selector that carries a collapsed chain keys the composed + // path, not the one segment it spells. Only a selector the + // mapper recorded a chain for: a single access has no record + // and takes the ordinary single-segment route below. + EqlTermVariant::JsonAccessor + if typed_statement + .json_accessor_paths + .for_literal(val) + .is_some() => + { + json_accessor_path_literal_plaintext(typed_statement, val) + } + _ => literal_from_sql(val, col.eql_term(), col.cast_type()), + }; - plaintext.map_err(|err| { - debug!( - target: MAPPER, - msg = "Could not convert literal value", - value = ?val, - cast_type = ?col.cast_type(), - error = err.to_string() - ); - MappingError::InvalidParameter(Box::new(col.to_owned())).into() - }) + plaintext.map_err(|err| { + debug!( + target: MAPPER, + msg = "Could not convert literal value", + value = ?val, + cast_type = ?col.cast_type(), + error = err.to_string() + ); + MappingError::InvalidParameter(Box::new(col.to_owned())).into() + }) + } + None => Ok(None), } - None => Ok(None), }) .collect::, Error>>()?; Ok(plaintexts) diff --git a/packages/cipherstash-proxy/src/postgresql/inbound_eql.rs b/packages/cipherstash-proxy/src/postgresql/inbound_eql.rs new file mode 100644 index 00000000..3301b189 --- /dev/null +++ b/packages/cipherstash-proxy/src/postgresql/inbound_eql.rs @@ -0,0 +1,159 @@ +use crate::{error::EncryptError, postgresql::Column, EqlCiphertext}; +use cipherstash_client::{ + eql::{EncryptedPayloadV3, EQL_SCHEMA_VERSION_V3}, + schema::column::IndexType, +}; +use serde_json::Value; + +/// Parse a value only when it advertises itself as an EQL storage payload. +/// Ordinary JSON remains plaintext; malformed payload-shaped JSON fails closed. +pub fn parse(bytes: &[u8], column: &Column) -> Result, EncryptError> { + let Ok(value) = serde_json::from_slice::(bytes) else { + return Ok(None); + }; + let Some(object) = value.as_object() else { + return Ok(None); + }; + + let payload_shaped = object.contains_key("c") + || object.contains_key("h") + || object.contains_key("sv") && object.contains_key("i"); + if !payload_shaped { + return Ok(None); + } + + let ciphertext: EqlCiphertext = + serde_json::from_value(value).map_err(|_| EncryptError::InvalidInboundCiphertext)?; + validate_metadata(&ciphertext, column)?; + Ok(Some(ciphertext)) +} + +fn validate_metadata(ciphertext: &EqlCiphertext, column: &Column) -> Result<(), EncryptError> { + if ciphertext.version() != EQL_SCHEMA_VERSION_V3 + || ciphertext.identifier() != &column.identifier + { + return Err(EncryptError::InvalidInboundCiphertext); + } + + match ciphertext { + EqlCiphertext::Encrypted(payload) => validate_scalar_terms(payload, column), + EqlCiphertext::SteVec(payload) => { + let configured = column + .config + .indexes + .iter() + .any(|index| matches!(index.index_type, IndexType::SteVec { .. })); + if !configured || payload.ste_vec.is_empty() { + return Err(EncryptError::InvalidInboundCiphertext); + } + Ok(()) + } + } +} + +fn validate_scalar_terms( + payload: &EncryptedPayloadV3, + column: &Column, +) -> Result<(), EncryptError> { + let mut hmac = false; + let mut bloom = false; + let mut ore = false; + let mut ope = false; + for index in &column.config.indexes { + match index.index_type { + IndexType::Unique { .. } => hmac = true, + IndexType::Match { .. } => bloom = true, + IndexType::Ore => ore = true, + IndexType::Ope => ope = true, + IndexType::SteVec { .. } => return Err(EncryptError::InvalidInboundCiphertext), + } + } + + if payload.hmac_256.is_some() != hmac + || payload.bloom_filter.is_some() != bloom + || payload.ore_block_u64_8_256.is_some() != ore + || payload.ope_cllw.is_some() != ope + { + return Err(EncryptError::InvalidInboundCiphertext); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cipherstash_client::schema::{ColumnConfig, ColumnMode, ColumnType}; + use cipherstash_client::zerokms::EncryptedRecord; + use eql_mapper::EqlTermVariant; + use uuid::Uuid; + + fn column() -> Column { + Column { + identifier: crate::Identifier::new("users", "email"), + config: ColumnConfig { + name: "email".into(), + in_place: true, + cast_type: ColumnType::Text, + indexes: vec![], + mode: ColumnMode::Encrypted, + }, + postgres_type: postgres_types::Type::TEXT, + eql_term: EqlTermVariant::Full, + } + } + + fn payload(identifier: crate::Identifier) -> EqlCiphertext { + EqlCiphertext::Encrypted(EncryptedPayloadV3 { + version: EQL_SCHEMA_VERSION_V3, + identifier, + ciphertext: EncryptedRecord { + iv: Default::default(), + ciphertext: vec![1; 16], + tag: vec![2; 16], + descriptor: "email".into(), + keyset_id: Some(Uuid::nil()), + decryption_policy: None, + }, + hmac_256: None, + bloom_filter: None, + ore_block_u64_8_256: None, + ope_cllw: None, + }) + } + + #[test] + fn ordinary_json_is_plaintext() { + assert!(parse(br#"{"name":"Ada"}"#, &column()).unwrap().is_none()); + } + + #[test] + fn malformed_payload_shape_fails_closed() { + assert!(matches!( + parse(br#"{"v":3,"i":"users.email","c":"bad"}"#, &column()), + Err(EncryptError::InvalidInboundCiphertext) + )); + } + + #[test] + fn destination_identifier_must_match() { + let ciphertext = payload(crate::Identifier::new("users", "phone")); + assert!(matches!( + validate_metadata(&ciphertext, &column()), + Err(EncryptError::InvalidInboundCiphertext) + )); + } + + #[test] + fn configured_sem_terms_must_be_present() { + let mut column = column(); + column + .config + .indexes + .push(cipherstash_client::schema::column::Index::new_unique()); + let ciphertext = payload(column.identifier.clone()); + assert!(matches!( + validate_metadata(&ciphertext, &column), + Err(EncryptError::InvalidInboundCiphertext) + )); + } +} diff --git a/packages/cipherstash-proxy/src/postgresql/messages/bind.rs b/packages/cipherstash-proxy/src/postgresql/messages/bind.rs index b3f382f5..e6211539 100644 --- a/packages/cipherstash-proxy/src/postgresql/messages/bind.rs +++ b/packages/cipherstash-proxy/src/postgresql/messages/bind.rs @@ -10,6 +10,7 @@ use crate::postgresql::data::{ json_value_selector_plaintext, }; use crate::postgresql::format_code::FormatCode; +use crate::postgresql::inbound_eql; use crate::postgresql::protocol::BytesMutReadString; use crate::{EqlOutput, EqlQueryPayload}; use crate::{SIZE_I16, SIZE_I32}; @@ -68,10 +69,23 @@ impl Bind { &self, output_params: &[OutputParam], param_types: &[i32], + ) -> Result>, Error> { + self.to_plaintext_skipping(output_params, param_types, &[]) + } + + pub fn to_plaintext_skipping( + &self, + output_params: &[OutputParam], + param_types: &[i32], + skip: &[bool], ) -> Result>, Error> { output_params .iter() - .map(|output| { + .enumerate() + .map(|(output_index, output)| { + if skip.get(output_index).copied().unwrap_or(false) { + return Ok(None); + } let Some(col) = &output.column else { // Native param: forwarded verbatim, nothing to encrypt. return Ok(None); @@ -114,6 +128,37 @@ impl Bind { .collect() } + /// Detect already-encrypted storage payloads before decoding parameters as + /// their configured plaintext PostgreSQL types. + pub fn inbound_ciphertexts( + &self, + output_params: &[OutputParam], + ) -> Result>, Error> { + output_params + .iter() + .map(|output| { + let Some(column) = &output.column else { + return Ok(None); + }; + let OutputParamSource::Input(input) = output.source else { + return Ok(None); + }; + let Some(param) = self.param_values.get(input) else { + return Ok(None); + }; + if param.is_null() { + return Ok(None); + } + let bytes = if param.is_binary() && param.bytes.first() == Some(&1) { + param.json_bytes() + } else { + ¶m.bytes + }; + inbound_eql::parse(bytes, column).map_err(Error::from) + }) + .collect() + } + /// Composes `{"path", "value"}` — the input to `SteVecValueSelector` — from /// the operands of a JSON field equality. /// diff --git a/packages/cipherstash-proxy/src/postgresql/mod.rs b/packages/cipherstash-proxy/src/postgresql/mod.rs index 71c8df24..c743832e 100644 --- a/packages/cipherstash-proxy/src/postgresql/mod.rs +++ b/packages/cipherstash-proxy/src/postgresql/mod.rs @@ -6,6 +6,7 @@ mod error_handler; mod format_code; mod frontend; mod handler; +mod inbound_eql; mod message_buffer; mod messages; mod parser; diff --git a/packages/showcase/Cargo.toml b/packages/showcase/Cargo.toml index 5881d1ad..d8a7f124 100644 --- a/packages/showcase/Cargo.toml +++ b/packages/showcase/Cargo.toml @@ -5,6 +5,8 @@ edition.workspace = true description = "Healthcare data model demonstrating EQL v3 searchable encryption with realistic encrypted application patterns" [dependencies] +cipherstash-client = { workspace = true, features = ["tokio"] } +cipherstash-config = { workspace = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" uuid = { version = "1.11.0", features = ["serde", "v4"] } diff --git a/packages/showcase/README.md b/packages/showcase/README.md index 6dae109d..7394425e 100644 --- a/packages/showcase/README.md +++ b/packages/showcase/README.md @@ -466,12 +466,24 @@ mise run test:integration:showcase The showcase will execute and display: -1. **Original Healthcare Query**: Aspirin prescription lookup -2. **Field Access Operations**: Testing `->` and `->>` -3. **Containment Operations**: Testing `@>` and `<@` -4. **JSONPath Functions**: Testing `jsonb_path_*` functions -5. **Comparison Operations**: Numeric, string, date, and float comparisons -6. **Complex Nested Queries**: JOINs, aggregations, and subqueries +1. **Application-side Encryption**: Insert pre-encrypted EQL payloads as a bound parameter and a SQL literal +2. **Original Healthcare Query**: Aspirin prescription lookup +3. **Field Access Operations**: Testing `->` and `->>` +4. **Containment Operations**: Testing `@>` and `<@` +5. **JSONPath Functions**: Testing `jsonb_path_*` functions +6. **Comparison Operations**: Numeric, string, date, and float comparisons +7. **Complex Nested Queries**: JOINs, aggregations, and subqueries + +### Application-side Encryption + +The `pre_encrypted` example constructs the same `eql_v3_json_search` column configuration declared by `patients.pii`, encrypts patient PII with `cipherstash-client`, and sends the resulting EQL payload through Proxy. It demonstrates both supported input forms: + +```sql +INSERT INTO patients (id, pii) VALUES ($1, $2); -- payload parameter +INSERT INTO patients (id, pii) VALUES ('...', '{...}'); -- payload literal +``` + +Proxy parses and authenticates each payload, checks that its identifier and SEM shape match `patients.pii`, and forwards it without double encryption. Selecting the rows through Proxy returns the original plaintext JSON. Each test section provides detailed output showing: - ✅ Successful query execution @@ -499,4 +511,4 @@ Examples: ⚠️ **Chained Operators**: The `->` operator cannot be chained on `ste_vec` encrypted columns. Use JSONPath functions like `jsonb_path_query_first()` for deep nested access instead. -This showcase proves that EQL v3 provides comprehensive JSONB support for encrypted data, enabling sophisticated healthcare applications while maintaining strong privacy protections. \ No newline at end of file +This showcase proves that EQL v3 provides comprehensive JSONB support for encrypted data, enabling sophisticated healthcare applications while maintaining strong privacy protections. diff --git a/packages/showcase/src/main.rs b/packages/showcase/src/main.rs index 9cc67097..f6c76226 100644 --- a/packages/showcase/src/main.rs +++ b/packages/showcase/src/main.rs @@ -53,6 +53,7 @@ mod common; mod data; mod model; +mod pre_encrypted; mod schema; use common::{connect_with_tls, trace, PROXY}; @@ -75,6 +76,7 @@ async fn main() -> Result<(), Box> { setup_schema().await; insert_test_data().await; create_enhanced_jsonb_test_data().await; + pre_encrypted::run_examples().await?; let client = connect_with_tls(*PROXY).await; @@ -156,6 +158,7 @@ async fn main() -> Result<(), Box> { println!(" • Healthcare-compliant database schema with proper foreign keys"); println!(" • Realistic medical data with nested objects, arrays, and mixed data types"); println!(" • Secure querying of encrypted data while maintaining privacy"); + println!(" • Application-side encryption passed through Proxy as parameters and literals"); println!(); println!("✨ EQL v3 provides comprehensive JSONB support for encrypted healthcare data!"); diff --git a/packages/showcase/src/pre_encrypted.rs b/packages/showcase/src/pre_encrypted.rs new file mode 100644 index 00000000..d4c65061 --- /dev/null +++ b/packages/showcase/src/pre_encrypted.rs @@ -0,0 +1,112 @@ +//! Application-side encryption examples for Stash-style ingestion. +//! +//! Proxy accepts the resulting EQL storage payload as either a bound parameter +//! or a SQL literal, authenticates it, and avoids encrypting it a second time. + +use crate::common::{connect_with_tls, PROXY}; +use cipherstash_client::{ + encryption::{Plaintext, ScopedCipher}, + eql::{ + encrypt_eql_v3, EqlEncryptOpts, EqlOperation, EqlOutputV3, Identifier, PreparedPlaintext, + }, + schema::{ColumnConfig, ColumnType}, + zerokms::{ClientKey, ZeroKMSBuilder}, + AutoStrategy, IdentifiedBy, +}; +use cipherstash_config::column::{ArrayIndexMode, Index, IndexType, SteVecMode}; +use serde_json::{json, Value}; +use std::{borrow::Cow, sync::Arc}; +use uuid::Uuid; + +pub async fn run_examples() -> Result<(), Box> { + println!("\n🔐 === Application-side EQL encryption ==="); + let client = connect_with_tls(*PROXY).await; + + // Example 1: bind an application-encrypted payload as a parameter. + let parameter_id = Uuid::parse_str("a1b2c3d4-e5f6-4a5b-8c9d-123456789021")?; + let parameter_pii = json!({ + "first_name": "Ada", + "last_name": "Lovelace", + "email": "ada@example.com", + "date_of_birth": "1815-12-10" + }); + let parameter_payload = encrypt_patient_pii(parameter_pii.clone()).await?; + client + .execute( + "INSERT INTO patients (id, pii) VALUES ($1, $2)", + &[¶meter_id, ¶meter_payload], + ) + .await?; + println!("✅ Inserted application-encrypted PII as a bound parameter"); + + // Example 2: the same wire payload can be supplied as a SQL literal. + let literal_id = Uuid::parse_str("a1b2c3d4-e5f6-4a5b-8c9d-123456789022")?; + let literal_pii = json!({ + "first_name": "Grace", + "last_name": "Hopper", + "email": "grace@example.com", + "date_of_birth": "1906-12-09" + }); + let literal_payload = encrypt_patient_pii(literal_pii.clone()).await?; + let literal_payload = literal_payload.to_string().replace('\'', "''"); + client + .simple_query(&format!( + "INSERT INTO patients (id, pii) VALUES ('{literal_id}', '{literal_payload}')" + )) + .await?; + println!("✅ Inserted application-encrypted PII as a SQL literal"); + + // Both rows still decrypt normally when selected through Proxy. + for (id, expected) in [(parameter_id, parameter_pii), (literal_id, literal_pii)] { + let row = client + .query_one("SELECT pii FROM patients WHERE id = $1", &[&id]) + .await?; + assert_eq!(row.get::<_, Value>(0), expected); + } + println!("✅ Proxy authenticated and decrypted both application-encrypted values"); + Ok(()) +} + +async fn encrypt_patient_pii(value: Value) -> Result> { + let config = ColumnConfig::build("pii") + .casts_as(ColumnType::Json) + .add_index(Index::new(IndexType::SteVec { + prefix: "patients/pii".into(), + term_filters: Vec::new(), + array_index_mode: ArrayIndexMode::ALL, + mode: SteVecMode::default(), + })); + let prepared = PreparedPlaintext::new( + Cow::Owned(config), + Identifier::new("patients", "pii"), + Plaintext::Json(Some(value)), + EqlOperation::Store, + ); + let mut outputs = encrypt_eql_v3( + scoped_cipher().await?, + vec![prepared], + &EqlEncryptOpts::default(), + ) + .await?; + let EqlOutputV3::Store(ciphertext) = outputs.remove(0) else { + return Err("store encryption returned a query payload".into()); + }; + Ok(serde_json::to_value(ciphertext)?) +} + +async fn scoped_cipher() -> Result>, Box> { + let client_id = env("CS_CLIENT_ID", "CS_ENCRYPT__CLIENT_ID")?.parse()?; + let client_key = + ClientKey::from_hex_v1(client_id, &env("CS_CLIENT_KEY", "CS_ENCRYPT__CLIENT_KEY")?)?; + let zerokms = ZeroKMSBuilder::auto()? + .with_client_key(client_key) + .build()?; + let keyset_id: Uuid = env("CS_DEFAULT_KEYSET_ID", "CS_ENCRYPT__DEFAULT_KEYSET_ID")?.parse()?; + Ok(Arc::new( + ScopedCipher::init(Arc::new(zerokms), Some(IdentifiedBy::Uuid(keyset_id))).await?, + )) +} + +fn env(primary: &str, nested: &str) -> Result { + std::env::var(primary).or_else(|_| std::env::var(nested)) +}