Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- **OPE (Order-Preserving Encryption) index**: New `ope` index type alongside the existing `ore` for range and ordering queries. OPE ciphertexts compare under standard lexicographic byte ordering, so they're a drop-in alternative to ORE for range and `ORDER BY`. Configure with `SELECT eql_v2.add_search_config('table', 'column', 'ope', 'int')` (any cast type that `ore` supports). Requires EQL with OPE support and a CipherStash client/config build that includes `IndexType::Ope`.
Comment on lines +9 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make the changelog entry release-facing.

The first sentence is changelog material; the SQL example and IndexType::Ope note read like implementation details. I'd move those details into docs or the PR description so [Unreleased] stays user-facing.

As per coding guidelines, "Write changelog entries from the user's perspective, not implementation details."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 9 - 11, The changelog entry includes
implementation-level details; revise the "Added" OPE entry to be release-facing
by keeping only the user-visible summary (e.g., "Added OPE (Order-Preserving
Encryption) index for range and ORDER BY queries") and remove the SQL example
and internal note about IndexType::Ope and CipherStash build requirements; move
the SQL usage example (SELECT eql_v2.add_search_config...) and the
IndexType::Ope/CipherStash build details into the docs or PR description instead
so the CHANGELOG remains high-level and user-oriented.


## [2.2.0-alpha.1] - 2026-03-25

### Changed
Expand Down
12 changes: 10 additions & 2 deletions docs/how-to/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ This statement adds a `unique` index for the `email` column in the `users` table

`unique` indexes are used to find records with columns with unique values, like with the `=` operator.

There are two other types of encrypted indexes you can use on `text` data:
There are other types of encrypted indexes you can use on `text` data:

```sql
SELECT eql_v2.add_search_config(
Expand All @@ -217,10 +217,18 @@ SELECT eql_v2.add_search_config(
'ore',
'text'
);

SELECT eql_v2.add_search_config(
'users',
'email',
'ope',
'text'
);
```

The first SQL statement adds a `match` index, which is used for partial matches with `LIKE`.
The second SQL statement adds an `ore` index, which is used for ordering with `ORDER BY`.
The second SQL statement adds an `ore` index, which is used for ordering with `ORDER BY` and range comparisons (`<`, `<=`, `>`, `>=`).
The third SQL statement adds an `ope` index, which supports the same range and ordering operators as `ore` and is a drop-in alternative — pick `ope` or `ore` per column, not both.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add selection criteria for choosing between ope and ore.

The documentation states that users should "pick ope or ore per column, not both" but doesn't explain when or why to choose one over the other. Users need guidance on the tradeoffs between these two encryption schemes to make an informed architectural decision.

Consider adding a brief explanation of the key differences or a link to more detailed documentation that covers:

  • Performance characteristics
  • Security properties
  • Use case recommendations
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/how-to/index.md` at line 231, The doc sentence advising "pick `ope` or
`ore` per column, not both" lacks guidance on how to choose; update the
`ope`/`ore` section to add a short selection guide that contrasts `ope` and
`ore` by name (mention `ope` and `ore`), summarizing performance differences
(e.g., typical query/scan speed and index size), high-level security tradeoffs
(what plaintext order leakage each reveals), and recommended use cases (when to
prefer `ope` for performance vs `ore` for stronger ordering/precision), and
include a link to deeper documentation for metrics/benchmarks and threat model
details so readers can make an informed choice.



> ![IMPORTANT]
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/searchable-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Encrypted literals cannot be passed as arguments to SQL functions. Encrypted col

Examples:
- `AVG()` cannot be used on encrypted numeric values ❌
- `MIN()` and `MAX()` can be used on encrypted values with ORE index ✅
- `MIN()` and `MAX()` can be used on encrypted values with an `ore` or `ope` index ✅
- `LOWER()` cannot be used on encrypted text (operates only on plaintext) ❌

⚠️ **CAST Operations**: CAST operations cannot work on encrypted data because casting would require decryption within the database, which is impossible. EQL's `ste_vec` configuration enables direct comparison and ordering operations on encrypted values without requiring CAST.
Expand Down
2 changes: 2 additions & 0 deletions docs/sql/schema-example.sql
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ SELECT eql_v2.add_search_config(
'text'
);

-- 'ore' supports ordering and range comparisons. 'ope' is a drop-in
-- alternative with the same operator support — choose one per column.
SELECT eql_v2.add_search_config(
'users',
'encrypted_email',
Expand Down
12 changes: 12 additions & 0 deletions packages/cipherstash-proxy-integration/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ pub async fn clear_with_client(client: &Client) {
client.simple_query(sql).await.unwrap();
}

/// Truncate a single table by name. Useful for tests that own a dedicated
/// fixture table (e.g. the per-test ORE/OPE tables) and don't need to wipe
/// the shared `encrypted`/`plaintext` tables.
pub async fn clear_table_with_client(client: &Client, table: &str) {
let sql = format!("TRUNCATE {}", table);
client.simple_query(&sql).await.unwrap();
}
Comment on lines +94 to +97
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard table names before building TRUNCATE SQL.

Line 95 interpolates table directly into SQL. Even in tests, this helper is pub and can execute unintended SQL if passed unexpected input. Constrain fixture table names before formatting.

Suggested hardening
 pub async fn clear_table_with_client(client: &Client, table: &str) {
+    assert!(
+        table
+            .chars()
+            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
+        "invalid fixture table name: {table}"
+    );
     let sql = format!("TRUNCATE {}", table);
     client.simple_query(&sql).await.unwrap();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub async fn clear_table_with_client(client: &Client, table: &str) {
let sql = format!("TRUNCATE {}", table);
client.simple_query(&sql).await.unwrap();
}
pub async fn clear_table_with_client(client: &Client, table: &str) {
assert!(
table
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
"invalid fixture table name: {table}"
);
let sql = format!("TRUNCATE {}", table);
client.simple_query(&sql).await.unwrap();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cipherstash-proxy-integration/src/common.rs` around lines 94 - 97,
The helper clear_table_with_client is interpolating the table name directly into
a TRUNCATE SQL string; instead validate/whitelist the table identifier before
formatting to prevent injection or accidental SQL execution. Update
clear_table_with_client to first check the table argument (e.g. match against a
whitelist of allowed fixture names or validate with a strict regex like only
ASCII letters/numbers/underscores) and return/raise an error if it fails
validation, and avoid using unwrap() on client.simple_query (propagate or handle
the Result); only format and execute the TRUNCATE when the table name passes the
guard.


pub async fn clear_table(table: &str) {
clear_table_with_client(&connect_with_tls(PROXY).await, table).await;
}

pub async fn reset_schema() {
let port = std::env::var("CS_DATABASE__PORT")
.map(|s| s.parse().unwrap())
Expand Down
2 changes: 2 additions & 0 deletions packages/cipherstash-proxy-integration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ mod map_concat;
mod map_literals;
mod map_match_index;
mod map_nulls;
mod map_ope_index_order;
mod map_ope_index_where;
mod map_ore_index_order;
mod map_ore_index_where;
mod map_params;
Expand Down
162 changes: 162 additions & 0 deletions packages/cipherstash-proxy-integration/src/map_ope_index_order.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#[cfg(test)]
mod tests {
use crate::common::{
clear_table, connect_with_tls, interleaved_indices, random_id, trace, PROXY,
};

#[tokio::test]
async fn map_ope_order_text_asc() {
trace();
let table = "encrypted_ope_order_text_asc";
clear_table(table).await;
let client = connect_with_tls(PROXY).await;

let values = ["aardvark", "aplomb", "chimera", "chrysalis", "zephyr"];

let insert = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2)");
for idx in interleaved_indices(values.len()) {
client
.query(&insert, &[&random_id(), &values[idx]])
.await
.unwrap();
}

let select = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text");
let rows = client.query(&select, &[]).await.unwrap();

let actual: Vec<String> = rows.iter().map(|r| r.get(0)).collect();
let expected: Vec<String> = values.iter().map(|s| s.to_string()).collect();

assert_eq!(actual, expected);
}

#[tokio::test]
async fn map_ope_order_text_desc() {
trace();
let table = "encrypted_ope_order_text_desc";
clear_table(table).await;
let client = connect_with_tls(PROXY).await;

let values = ["aardvark", "aplomb", "chimera", "chrysalis", "zephyr"];

let insert = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2)");
for idx in interleaved_indices(values.len()) {
client
.query(&insert, &[&random_id(), &values[idx]])
.await
.unwrap();
}

let select = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text DESC");
let rows = client.query(&select, &[]).await.unwrap();

let actual: Vec<String> = rows.iter().map(|r| r.get(0)).collect();
let expected: Vec<String> = values.iter().rev().map(|s| s.to_string()).collect();

assert_eq!(actual, expected);
}

#[tokio::test]
async fn map_ope_order_int4_asc() {
trace();
let table = "encrypted_ope_order_int4_asc";
clear_table(table).await;
let client = connect_with_tls(PROXY).await;

let values: Vec<i32> = vec![-100, -1, 0, 1, 42, 1000, i32::MAX];

let insert = format!("INSERT INTO {table} (id, encrypted_int4) VALUES ($1, $2)");
for idx in interleaved_indices(values.len()) {
client
.query(&insert, &[&random_id(), &values[idx]])
.await
.unwrap();
}

let select = format!("SELECT encrypted_int4 FROM {table} ORDER BY encrypted_int4");
let rows = client.query(&select, &[]).await.unwrap();

let actual: Vec<i32> = rows.iter().map(|r| r.get(0)).collect();
assert_eq!(actual, values);
}

#[tokio::test]
async fn map_ope_order_int4_desc() {
trace();
let table = "encrypted_ope_order_int4_desc";
clear_table(table).await;
let client = connect_with_tls(PROXY).await;

let values: Vec<i32> = vec![-100, -1, 0, 1, 42, 1000, i32::MAX];

let insert = format!("INSERT INTO {table} (id, encrypted_int4) VALUES ($1, $2)");
for idx in interleaved_indices(values.len()) {
client
.query(&insert, &[&random_id(), &values[idx]])
.await
.unwrap();
}

let select = format!("SELECT encrypted_int4 FROM {table} ORDER BY encrypted_int4 DESC");
let rows = client.query(&select, &[]).await.unwrap();

let actual: Vec<i32> = rows.iter().map(|r| r.get(0)).collect();
let expected: Vec<i32> = values.into_iter().rev().collect();
assert_eq!(actual, expected);
}

#[tokio::test]
async fn map_ope_order_nulls_last_by_default() {
trace();
let table = "encrypted_ope_order_nulls_last";
clear_table(table).await;
let client = connect_with_tls(PROXY).await;

let null_insert = format!("INSERT INTO {table} (id) VALUES ($1)");
client.query(&null_insert, &[&random_id()]).await.unwrap();

let insert = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2), ($3, $4)");
client
.query(&insert, &[&random_id(), &"a", &random_id(), &"b"])
.await
.unwrap();

let select = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text");
let rows = client.query(&select, &[]).await.unwrap();

let actual: Vec<Option<String>> = rows.iter().map(|r| r.get(0)).collect();
assert_eq!(
actual,
vec![Some("a".into()), Some("b".into()), None],
"NULLs should sort last by default"
);
}

#[tokio::test]
async fn map_ope_order_nulls_first() {
trace();
let table = "encrypted_ope_order_nulls_first";
clear_table(table).await;
let client = connect_with_tls(PROXY).await;

let insert = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2), ($3, $4)");
client
.query(&insert, &[&random_id(), &"a", &random_id(), &"b"])
.await
.unwrap();

let null_insert = format!("INSERT INTO {table} (id) VALUES ($1)");
client.query(&null_insert, &[&random_id()]).await.unwrap();

let select =
format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text NULLS FIRST");
let rows = client.query(&select, &[]).await.unwrap();

let actual: Vec<Option<String>> = rows.iter().map(|r| r.get(0)).collect();
assert_eq!(
actual,
vec![None, Some("a".into()), Some("b".into())],
"NULLS FIRST should explicitly sort NULLs first"
);
}
}
Loading
Loading