Skip to content

Commit f843f1f

Browse files
authored
Merge pull request #341 from cipherstash/encryption-sanity-checks
Encryption sanity checks
2 parents 88c5450 + 8f91e79 commit f843f1f

File tree

5 files changed

+320
-13
lines changed

5 files changed

+320
-13
lines changed

packages/cipherstash-proxy-integration/src/common.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,9 @@ pub async fn insert_jsonb() -> Value {
306306

307307
insert(&sql, &[&id, &encrypted_jsonb]).await;
308308

309+
// Verify encryption actually occurred
310+
assert_encrypted_jsonb(id, &encrypted_jsonb).await;
311+
309312
encrypted_jsonb
310313
}
311314

@@ -321,9 +324,72 @@ pub async fn insert_jsonb_for_search() {
321324

322325
let sql = "INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, $2)";
323326
insert(sql, &[&id, &encrypted_jsonb]).await;
327+
328+
// Verify encryption actually occurred for each row
329+
assert_encrypted_jsonb(id, &encrypted_jsonb).await;
330+
}
331+
}
332+
333+
/// Verifies that a text value was actually encrypted in the database.
334+
/// Queries directly (bypassing proxy) and asserts stored value differs from plaintext.
335+
pub async fn assert_encrypted_text(id: i64, column: &str, plaintext: &str) {
336+
let sql = format!("SELECT {}::text FROM encrypted WHERE id = $1", column);
337+
let stored: Vec<String> = query_direct_by(&sql, &id).await;
338+
339+
assert_eq!(stored.len(), 1, "Expected exactly one row");
340+
let stored_text = &stored[0];
341+
342+
assert_ne!(
343+
stored_text, plaintext,
344+
"ENCRYPTION FAILED for {}: Stored value matches plaintext! Data was not encrypted.",
345+
column
346+
);
347+
}
348+
349+
/// Verifies that a JSONB value was actually encrypted in the database.
350+
/// Queries directly (bypassing proxy) and asserts stored value differs from plaintext.
351+
pub async fn assert_encrypted_jsonb(id: i64, plaintext: &Value) {
352+
let sql = "SELECT encrypted_jsonb::text FROM encrypted WHERE id = $1";
353+
let stored: Vec<String> = query_direct_by(sql, &id).await;
354+
355+
assert_eq!(stored.len(), 1, "Expected exactly one row");
356+
let stored_text = &stored[0];
357+
358+
let plaintext_str = plaintext.to_string();
359+
assert_ne!(
360+
stored_text, &plaintext_str,
361+
"ENCRYPTION FAILED for encrypted_jsonb: Stored value matches plaintext! Data was not encrypted."
362+
);
363+
364+
// Additional verification: the encrypted format should be different structure
365+
if let Ok(stored_json) = serde_json::from_str::<Value>(stored_text) {
366+
assert_ne!(
367+
stored_json, *plaintext,
368+
"ENCRYPTION FAILED for encrypted_jsonb: Stored JSON structure matches plaintext!"
369+
);
324370
}
325371
}
326372

373+
/// Verifies that a numeric value was actually encrypted in the database.
374+
/// Queries directly (bypassing proxy) and asserts stored value differs from plaintext.
375+
pub async fn assert_encrypted_numeric<T>(id: i64, column: &str, plaintext: T)
376+
where
377+
T: std::fmt::Display + std::str::FromStr + PartialEq,
378+
{
379+
let sql = format!("SELECT {}::text FROM encrypted WHERE id = $1", column);
380+
let stored: Vec<String> = query_direct_by(&sql, &id).await;
381+
382+
assert_eq!(stored.len(), 1, "Expected exactly one row");
383+
let stored_text = &stored[0];
384+
385+
let plaintext_str = plaintext.to_string();
386+
assert_ne!(
387+
stored_text, &plaintext_str,
388+
"ENCRYPTION FAILED for {}: Stored value matches plaintext! Data was not encrypted.",
389+
column
390+
);
391+
}
392+
327393
///
328394
/// Configure the client TLS settings.
329395
/// These are the settings for connecting to the database with TLS.
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
//! Encryption sanity checks - verify data is actually encrypted.
2+
//!
3+
//! These tests insert data through the proxy, then query DIRECTLY from the database
4+
//! (bypassing the proxy) to verify the stored value is encrypted (differs from plaintext).
5+
//!
6+
//! This catches silent mapping failures where data passes through unencrypted.
7+
8+
#[cfg(test)]
9+
mod tests {
10+
use crate::common::{
11+
assert_encrypted_jsonb, assert_encrypted_numeric, assert_encrypted_text, clear,
12+
connect_with_tls, random_id, trace, PROXY,
13+
};
14+
use chrono::NaiveDate;
15+
16+
#[tokio::test]
17+
async fn text_encryption_sanity_check() {
18+
trace();
19+
clear().await;
20+
21+
let id = random_id();
22+
let plaintext = "hello world";
23+
24+
// Insert through proxy (should encrypt)
25+
let client = connect_with_tls(PROXY).await;
26+
let sql = "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)";
27+
client.query(sql, &[&id, &plaintext]).await.unwrap();
28+
29+
// Verify encryption occurred
30+
assert_encrypted_text(id, "encrypted_text", plaintext).await;
31+
32+
// Round-trip: query through proxy should decrypt back to original
33+
let sql = "SELECT encrypted_text FROM encrypted WHERE id = $1";
34+
let rows = client.query(sql, &[&id]).await.unwrap();
35+
assert_eq!(rows.len(), 1, "Expected exactly one row for round-trip");
36+
let decrypted: String = rows[0].get(0);
37+
assert_eq!(
38+
decrypted, plaintext,
39+
"DECRYPTION FAILED: Round-trip value doesn't match original!"
40+
);
41+
}
42+
43+
#[tokio::test]
44+
async fn jsonb_encryption_sanity_check() {
45+
trace();
46+
clear().await;
47+
48+
let id = random_id();
49+
let plaintext_json = serde_json::json!({"key": "value", "number": 42});
50+
51+
// Insert through proxy (should encrypt)
52+
let client = connect_with_tls(PROXY).await;
53+
let sql = "INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, $2)";
54+
client.query(sql, &[&id, &plaintext_json]).await.unwrap();
55+
56+
// Verify encryption occurred
57+
assert_encrypted_jsonb(id, &plaintext_json).await;
58+
59+
// Round-trip: query through proxy should decrypt back to original
60+
let sql = "SELECT encrypted_jsonb FROM encrypted WHERE id = $1";
61+
let rows = client.query(sql, &[&id]).await.unwrap();
62+
assert_eq!(rows.len(), 1, "Expected exactly one row for round-trip");
63+
let decrypted: serde_json::Value = rows[0].get(0);
64+
assert_eq!(
65+
decrypted, plaintext_json,
66+
"DECRYPTION FAILED: Round-trip value doesn't match original!"
67+
);
68+
}
69+
70+
#[tokio::test]
71+
async fn float8_encryption_sanity_check() {
72+
trace();
73+
clear().await;
74+
75+
let id = random_id();
76+
let plaintext: f64 = 123.456;
77+
78+
// Insert through proxy (should encrypt)
79+
let client = connect_with_tls(PROXY).await;
80+
let sql = "INSERT INTO encrypted (id, encrypted_float8) VALUES ($1, $2)";
81+
client.query(sql, &[&id, &plaintext]).await.unwrap();
82+
83+
// Verify encryption occurred
84+
assert_encrypted_numeric(id, "encrypted_float8", plaintext).await;
85+
86+
// Round-trip: query through proxy should decrypt back to original
87+
let sql = "SELECT encrypted_float8 FROM encrypted WHERE id = $1";
88+
let rows = client.query(sql, &[&id]).await.unwrap();
89+
assert_eq!(rows.len(), 1, "Expected exactly one row for round-trip");
90+
let decrypted: f64 = rows[0].get(0);
91+
assert!(
92+
(decrypted - plaintext).abs() < f64::EPSILON,
93+
"DECRYPTION FAILED: Round-trip value doesn't match original!"
94+
);
95+
}
96+
97+
#[tokio::test]
98+
async fn bool_encryption_sanity_check() {
99+
trace();
100+
clear().await;
101+
102+
let id = random_id();
103+
let plaintext: bool = true;
104+
105+
// Insert through proxy (should encrypt)
106+
let client = connect_with_tls(PROXY).await;
107+
let sql = "INSERT INTO encrypted (id, encrypted_bool) VALUES ($1, $2)";
108+
client.query(sql, &[&id, &plaintext]).await.unwrap();
109+
110+
// Verify encryption occurred
111+
assert_encrypted_text(id, "encrypted_bool", &plaintext.to_string()).await;
112+
113+
// Round-trip: query through proxy should decrypt back to original
114+
let sql = "SELECT encrypted_bool FROM encrypted WHERE id = $1";
115+
let rows = client.query(sql, &[&id]).await.unwrap();
116+
assert_eq!(rows.len(), 1, "Expected exactly one row for round-trip");
117+
let decrypted: bool = rows[0].get(0);
118+
assert_eq!(
119+
decrypted, plaintext,
120+
"DECRYPTION FAILED: Round-trip value doesn't match original!"
121+
);
122+
}
123+
124+
#[tokio::test]
125+
async fn date_encryption_sanity_check() {
126+
trace();
127+
clear().await;
128+
129+
let id = random_id();
130+
let plaintext = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
131+
132+
// Insert through proxy (should encrypt)
133+
let client = connect_with_tls(PROXY).await;
134+
let sql = "INSERT INTO encrypted (id, encrypted_date) VALUES ($1, $2)";
135+
client.query(sql, &[&id, &plaintext]).await.unwrap();
136+
137+
// Verify encryption occurred
138+
assert_encrypted_text(id, "encrypted_date", &plaintext.to_string()).await;
139+
140+
// Round-trip: query through proxy should decrypt back to original
141+
let sql = "SELECT encrypted_date FROM encrypted WHERE id = $1";
142+
let rows = client.query(sql, &[&id]).await.unwrap();
143+
assert_eq!(rows.len(), 1, "Expected exactly one row for round-trip");
144+
let decrypted: NaiveDate = rows[0].get(0);
145+
assert_eq!(
146+
decrypted, plaintext,
147+
"DECRYPTION FAILED: Round-trip value doesn't match original!"
148+
);
149+
}
150+
151+
#[tokio::test]
152+
async fn int2_encryption_sanity_check() {
153+
trace();
154+
clear().await;
155+
156+
let id = random_id();
157+
let plaintext: i16 = 42;
158+
159+
// Insert through proxy (should encrypt)
160+
let client = connect_with_tls(PROXY).await;
161+
let sql = "INSERT INTO encrypted (id, encrypted_int2) VALUES ($1, $2)";
162+
client.query(sql, &[&id, &plaintext]).await.unwrap();
163+
164+
// Verify encryption occurred
165+
assert_encrypted_numeric(id, "encrypted_int2", plaintext).await;
166+
167+
// Round-trip: query through proxy should decrypt back to original
168+
let sql = "SELECT encrypted_int2 FROM encrypted WHERE id = $1";
169+
let rows = client.query(sql, &[&id]).await.unwrap();
170+
assert_eq!(rows.len(), 1, "Expected exactly one row for round-trip");
171+
let decrypted: i16 = rows[0].get(0);
172+
assert_eq!(
173+
decrypted, plaintext,
174+
"DECRYPTION FAILED: Round-trip value doesn't match original!"
175+
);
176+
}
177+
178+
#[tokio::test]
179+
async fn int4_encryption_sanity_check() {
180+
trace();
181+
clear().await;
182+
183+
let id = random_id();
184+
let plaintext: i32 = 12345;
185+
186+
// Insert through proxy (should encrypt)
187+
let client = connect_with_tls(PROXY).await;
188+
let sql = "INSERT INTO encrypted (id, encrypted_int4) VALUES ($1, $2)";
189+
client.query(sql, &[&id, &plaintext]).await.unwrap();
190+
191+
// Verify encryption occurred
192+
assert_encrypted_numeric(id, "encrypted_int4", plaintext).await;
193+
194+
// Round-trip: query through proxy should decrypt back to original
195+
let sql = "SELECT encrypted_int4 FROM encrypted WHERE id = $1";
196+
let rows = client.query(sql, &[&id]).await.unwrap();
197+
assert_eq!(rows.len(), 1, "Expected exactly one row for round-trip");
198+
let decrypted: i32 = rows[0].get(0);
199+
assert_eq!(
200+
decrypted, plaintext,
201+
"DECRYPTION FAILED: Round-trip value doesn't match original!"
202+
);
203+
}
204+
205+
#[tokio::test]
206+
async fn int8_encryption_sanity_check() {
207+
trace();
208+
clear().await;
209+
210+
let id = random_id();
211+
let plaintext: i64 = 9876543210;
212+
213+
// Insert through proxy (should encrypt)
214+
let client = connect_with_tls(PROXY).await;
215+
let sql = "INSERT INTO encrypted (id, encrypted_int8) VALUES ($1, $2)";
216+
client.query(sql, &[&id, &plaintext]).await.unwrap();
217+
218+
// Verify encryption occurred
219+
assert_encrypted_numeric(id, "encrypted_int8", plaintext).await;
220+
221+
// Round-trip: query through proxy should decrypt back to original
222+
let sql = "SELECT encrypted_int8 FROM encrypted WHERE id = $1";
223+
let rows = client.query(sql, &[&id]).await.unwrap();
224+
assert_eq!(rows.len(), 1, "Expected exactly one row for round-trip");
225+
let decrypted: i64 = rows[0].get(0);
226+
assert_eq!(
227+
decrypted, plaintext,
228+
"DECRYPTION FAILED: Round-trip value doesn't match original!"
229+
);
230+
}
231+
}

packages/cipherstash-proxy-integration/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ mod common;
22
mod decrypt;
33
mod disable_mapping;
44
mod empty_result;
5+
mod encryption_sanity;
56
mod extended_protocol_error_messages;
67
mod insert;
78
mod map_concat;

packages/cipherstash-proxy-integration/src/multitenant/set_keyset_name.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ mod tests {
115115
let result = client.simple_query(&sql).await;
116116
assert!(result.is_ok());
117117

118+
// SET TENANT_1 WITHOUT QUOTES
119+
// VALID AS LONG AS NAME IS A VALID PG IDENTIFIER
120+
let sql = format!("SET CIPHERSTASH.KEYSET_NAME = {tenant_keyset_name_1}");
121+
let result = client.simple_query(&sql).await;
122+
assert!(result.is_ok());
123+
118124
// INSERT
119125
let tenant_1_id = random_id();
120126
let encrypted_text = "hello";
@@ -372,8 +378,8 @@ mod tests {
372378

373379
// Test cases that should potentially fail or be handled gracefully
374380
let invalid_cases = vec![
375-
format!("SET CIPHERSTASH.KEYSET_NAME = {tenant_keyset_name_1}"), // unquoted string
376-
format!("SET CIPHERSTASH.KEYSET_NAME = NULL"), // null value
381+
format!("SET CIPHERSTASH.KEYSET_NAME = test-1"), // unquoted string that is NOT a valid pg Identifier
382+
format!("SET CIPHERSTASH.KEYSET_NAME = NULL"), // null value
377383
];
378384

379385
for invalid_sql in invalid_cases {

packages/cipherstash-proxy/src/postgresql/context/mod.rs

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -446,17 +446,20 @@ where
446446
}) = statement
447447
{
448448
if variable == &*SQL_SETTING_NAME_KEYSET_NAME {
449-
if let Some(Expr::Value(ValueWithSpan { value, .. })) = values.first() {
450-
let keyset_name = match value {
451-
Value::SingleQuotedString(s) | Value::DoubleQuotedString(s) => s.clone(),
452-
Value::Number(n, _) => n.to_string(),
453-
_ => {
454-
let err = EncryptError::KeysetNameCouldNotBeSet;
455-
warn!(target: CONTEXT, client_id = self.client_id, msg = err.to_string());
456-
return Ok(None);
449+
// Try to extract keyset name from Value (quoted string/number) or Identifier (unquoted)
450+
let keyset_name = match values.first() {
451+
Some(Expr::Value(ValueWithSpan { value, .. })) => match value {
452+
Value::SingleQuotedString(s) | Value::DoubleQuotedString(s) => {
453+
Some(s.clone())
457454
}
458-
};
459-
455+
Value::Number(n, _) => Some(n.to_string()),
456+
_ => None,
457+
},
458+
Some(Expr::Identifier(ident)) => Some(ident.value.clone()),
459+
_ => None,
460+
};
461+
462+
if let Some(keyset_name) = keyset_name {
460463
debug!(target: CONTEXT, client_id = self.client_id, msg = "Set KeysetName", ?keyset_name);
461464

462465
let identifier = KeysetIdentifier(IdentifiedBy::Name(keyset_name.into()));
@@ -469,7 +472,7 @@ where
469472
} else {
470473
let err = EncryptError::KeysetNameCouldNotBeSet;
471474
warn!(target: CONTEXT, client_id = self.client_id, msg = err.to_string());
472-
// We let the database handle any syntax errors to avoid complexifying the fronted flow (more)
475+
// We let the database handle any syntax errors to avoid complexifying the frontend flow
473476
}
474477
}
475478
}

0 commit comments

Comments
 (0)