diff --git a/README.md b/README.md index 8f87715..69b76a2 100644 --- a/README.md +++ b/README.md @@ -506,7 +506,7 @@ Response parameters: | `k` | `string` | Always | Key type identifier (always `sv` for structured vector) | | `c` | `string` | Always | Base85-encoded ciphertext containing the encrypted data | | `dt` | `string` | Always | Data type for casting (from `cast_as` configuration parameter) | -| `sv` | `array` | `ste_vec` | Structured text encryption vector for JSONB containment queries | +| `sv` | `array\|null` | `ste_vec` | Structured text encryption vector for JSONB containment queries | | `sv[].s` | `string` | `ste_vec` | Tokenized selector representing the encrypted JSON path to the value | | `sv[].t` | `string` | `ste_vec` | Encrypted term value for equality and order-preserving queries | | `sv[].r` | `string` | `ste_vec` | Base85-encoded ciphertext containing the encrypted record data | @@ -973,7 +973,7 @@ Response parameters: | Parameter | Type | Source | Description | |-----------|------|--------|-------------| -| `sv` | `array` | `ste_vec` | Structured text encryption vector for JSONB containment queries | +| `sv` | `array\|null` | `ste_vec` | Structured text encryption vector for JSONB containment queries | | `sv[].s` | `string` | `ste_vec` | Tokenized selector representing the encrypted JSON path to the value | | `sv[].t` | `string` | `ste_vec` | Encrypted term value for equality and order-preserving queries | | `sv[].r` | `string` | `ste_vec` | Base85-encoded ciphertext containing the encrypted record data | diff --git a/crates/protect-ffi/src/lib.rs b/crates/protect-ffi/src/lib.rs index 7a36e6c..6ffbffc 100644 --- a/crates/protect-ffi/src/lib.rs +++ b/crates/protect-ffi/src/lib.rs @@ -108,7 +108,7 @@ pub enum Encrypted { data_type: String, /// Structured text encryption vector for JSONB containment queries. #[serde(rename = "sv")] - ste_vec_index: Vec, + ste_vec_index: Option>, /// Table and column identifier for this encrypted value. #[serde(rename = "i")] identifier: Identifier, @@ -438,97 +438,113 @@ fn to_eql_encrypted( identifier: &Identifier, cast_as: &CastAs, ) -> Result { - match encrypted { - encryption::Encrypted::Record(ciphertext, terms) => { - // Collect encryption indexes from encryption terms - struct Indexes { - unique_index: Option, - ore_index: Option>, - match_index: Option>, - } + match (cast_as, encrypted) { + // JSONB always uses SteVec format + (CastAs::JsonB, encrypted) => { + let (ciphertext, ste_vec_index) = match encrypted { + encryption::Encrypted::SteVec(ste_vec_index) => { + let root_ciphertext = ste_vec_index.root_ciphertext().map_err(|e| { + Error::InvariantViolation(format!("failed to get root ciphertext: {}", e)) + })?; + + let ciphertext = root_ciphertext + .to_mp_base85() + // The error type from `to_mp_base85` isn't public, so we don't derive an error for this one. + // Instead, we use `map_err`. + .map_err(|err| Error::Base85(err.to_string()))?; - let mut indexes = Indexes { - unique_index: None, - ore_index: None, - match_index: None, + let ste_vec_entries: Result, Error> = ste_vec_index + .into_iter() + .map(|entry| { + let record = entry + .record + .to_mp_base85() + // The error type from `to_mp_base85` isn't public, so we don't derive an error for this one. + // Instead, we use `map_err`. + .map_err(|err| Error::Base85(err.to_string()))?; + + Ok(SteVecEntry { + tokenized_selector: hex::encode( + entry.tokenized_selector.as_bytes(), + ), + term: hex::encode( + &serde_json::to_vec(&entry.term).map_err(Error::Parse)?, + ), + record, + parent_is_array: entry.parent_is_array, + }) + }) + .collect(); + + (ciphertext, Some(ste_vec_entries?)) + } + encryption::Encrypted::Record(ciphertext, _terms) => { + let ciphertext = ciphertext + .to_mp_base85() + // The error type from `to_mp_base85` isn't public, so we don't derive an error for this one. + // Instead, we use `map_err`. + .map_err(|err| Error::Base85(err.to_string()))?; + + (ciphertext, None) + } }; + Ok(Encrypted::SteVec { + ciphertext, + data_type: cast_as.to_string(), + ste_vec_index, + identifier: identifier.to_owned(), + version: 2, + }) + } + + // Non-JSONB types with indexes + (_, encryption::Encrypted::Record(ciphertext, terms)) => { + let ciphertext = ciphertext + .to_mp_base85() + // The error type from `to_mp_base85` isn't public, so we don't derive an error for this one. + // Instead, we use `map_err`. + .map_err(|err| Error::Base85(err.to_string()))?; + + let mut unique_index = None; + let mut ore_index = None; + let mut match_index = None; + for index_term in terms { match index_term { IndexTerm::Binary(bytes) => { - indexes.unique_index = Some(format_index_term_binary(&bytes)) + unique_index = Some(format_index_term_binary(&bytes)) } - IndexTerm::BitMap(inner) => indexes.match_index = Some(inner), + IndexTerm::BitMap(inner) => match_index = Some(inner), IndexTerm::OreArray(vec_of_bytes) => { - indexes.ore_index = Some(format_index_term_ore_array(&vec_of_bytes)); + ore_index = Some(format_index_term_ore_array(&vec_of_bytes)); } IndexTerm::OreFull(bytes) => { - indexes.ore_index = Some(format_index_term_ore(&bytes)); + ore_index = Some(format_index_term_ore(&bytes)); } IndexTerm::OreLeft(bytes) => { - indexes.ore_index = Some(format_index_term_ore(&bytes)); + ore_index = Some(format_index_term_ore(&bytes)); } IndexTerm::Null => {} term => return Err(Error::Unimplemented(format!("index term `{term:?}`"))), }; } - let ciphertext = ciphertext - .to_mp_base85() - // The error type from `to_mp_base85` isn't public, so we don't derive an error for this one. - // Instead, we use `map_err`. - .map_err(|err| Error::Base85(err.to_string()))?; - Ok(Encrypted::Ciphertext { ciphertext, data_type: cast_as.to_string(), - unique_index: indexes.unique_index, - ore_index: indexes.ore_index, - match_index: indexes.match_index, + unique_index, + ore_index, + match_index, identifier: identifier.to_owned(), version: 2, }) } - encryption::Encrypted::SteVec(ste_vec_index) => { - let root_ciphertext = ste_vec_index.root_ciphertext().map_err(|e| { - Error::InvariantViolation(format!("failed to get root ciphertext: {}", e)) - })?; - let ciphertext = root_ciphertext - .to_mp_base85() - // The error type from `to_mp_base85` isn't public, so we don't derive an error for this one. - // Instead, we use `map_err`. - .map_err(|err| Error::Base85(err.to_string()))?; - - let ste_vec_entries: Result, Error> = ste_vec_index - .into_iter() - .map(|entry| { - let record = entry - .record - .to_mp_base85() - // The error type from `to_mp_base85` isn't public, so we don't derive an error for this one. - // Instead, we use `map_err`. - .map_err(|err| Error::Base85(err.to_string()))?; - - Ok(SteVecEntry { - tokenized_selector: hex::encode(entry.tokenized_selector.as_bytes()), - term: hex::encode(&serde_json::to_vec(&entry.term).map_err(Error::Parse)?), - record, - parent_is_array: entry.parent_is_array, - }) - }) - .collect(); - - let ste_vec_entries = ste_vec_entries?; - - Ok(Encrypted::SteVec { - ciphertext, - data_type: cast_as.to_string(), - ste_vec_index: ste_vec_entries, - identifier: identifier.to_owned(), - version: 2, - }) - } + // Non-JSONB types should never return SteVec + (_, encryption::Encrypted::SteVec(_)) => Err(Error::InvariantViolation( + "non-JSONB type returned SteVec from encryption library".to_string(), + )), } } @@ -924,6 +940,26 @@ mod lib { } } + /// Create a sample SteVec `Encrypted` variant for testing. + fn create_encrypted_ste_vec( + table: &str, + column: &str, + ciphertext: &str, + data_type: &str, + ste_vec_entries: Option>, + ) -> Encrypted { + Encrypted::SteVec { + ciphertext: ciphertext.to_string(), + data_type: data_type.to_string(), + ste_vec_index: ste_vec_entries, + identifier: Identifier { + table: table.to_string(), + column: column.to_string(), + }, + version: TEST_SCHEMA_VERSION, + } + } + /// Assert that a null pointer error is returned as a valid C string. fn assert_null_pointer_error(error_ptr: *mut c_char) { assert!(!error_ptr.is_null()); @@ -971,6 +1007,18 @@ mod lib { assert_eq!(identifier_json["c"], TEST_COLUMN); } + #[test] + fn test_encrypted_ste_vec_json_format_with_null_entries() { + let sample_encrypted = + create_encrypted_ste_vec(TEST_TABLE, TEST_COLUMN, TEST_CIPHERTEXT, "jsonb", None); + + let json_string = serde_json::to_string(&sample_encrypted).unwrap(); + let parsed_json: serde_json::Value = serde_json::from_str(&json_string).unwrap(); + + assert_eq!(parsed_json["k"], "sv"); + assert_eq!(parsed_json["sv"], serde_json::Value::Null); + } + #[test] fn test_new_client_null_config() { let mut error_ptr: *mut c_char = ptr::null_mut(); diff --git a/platforms/darwin-arm64/libprotect_ffi.dylib b/platforms/darwin-arm64/libprotect_ffi.dylib index d72ae2c..a2c6afe 100644 Binary files a/platforms/darwin-arm64/libprotect_ffi.dylib and b/platforms/darwin-arm64/libprotect_ffi.dylib differ diff --git a/platforms/darwin-x64/libprotect_ffi.dylib b/platforms/darwin-x64/libprotect_ffi.dylib index 9e8189d..9259f24 100644 Binary files a/platforms/darwin-x64/libprotect_ffi.dylib and b/platforms/darwin-x64/libprotect_ffi.dylib differ diff --git a/platforms/linux-arm64-gnu/libprotect_ffi.so b/platforms/linux-arm64-gnu/libprotect_ffi.so index b6d42df..cb7dddd 100644 Binary files a/platforms/linux-arm64-gnu/libprotect_ffi.so and b/platforms/linux-arm64-gnu/libprotect_ffi.so differ diff --git a/platforms/linux-x64-gnu/libprotect_ffi.so b/platforms/linux-x64-gnu/libprotect_ffi.so index f8ee8ed..715cd43 100644 Binary files a/platforms/linux-x64-gnu/libprotect_ffi.so and b/platforms/linux-x64-gnu/libprotect_ffi.so differ diff --git a/platforms/win32-x64-msvc/protect_ffi.dll b/platforms/win32-x64-msvc/protect_ffi.dll index 1c3b8c0..03927b3 100644 Binary files a/platforms/win32-x64-msvc/protect_ffi.dll and b/platforms/win32-x64-msvc/protect_ffi.dll differ diff --git a/tests/Integration/ClientTest.php b/tests/Integration/ClientTest.php index 4540923..f47820b 100644 --- a/tests/Integration/ClientTest.php +++ b/tests/Integration/ClientTest.php @@ -45,6 +45,10 @@ public static function setUpBeforeClass(): void ], ], ], + 'session' => [ + 'cast_as' => 'jsonb', + 'indexes' => (object) [], + ], ], ], ], JSON_THROW_ON_ERROR); @@ -430,7 +434,7 @@ public function test_encrypt_decrypt_roundtrip_with_context(): void } } - public function test_decrypt_fails_with_wrong_tag_context(): void + public function test_decrypt_throws_exception_with_wrong_tag_context(): void { $client = new Client; $clientPtr = $client->newClient(self::$config); @@ -465,7 +469,7 @@ public function test_decrypt_fails_with_wrong_tag_context(): void } } - public function test_decrypt_fails_with_wrong_value_context(): void + public function test_decrypt_throws_exception_with_wrong_value_context(): void { $client = new Client; $clientPtr = $client->newClient(self::$config); @@ -548,6 +552,26 @@ public function test_decrypt_throws_exception_with_invalid_context(): void } } + public function test_encrypt_jsonb_returns_null_sv_on_non_ste_vec_column(): void + { + $client = new Client; + $clientPtr = $client->newClient(self::$config); + + try { + $sessionData = '{"browser": "Safari 17.4", "ip": "123.456.7.8", "last_active": "2020-01-21T10:30:00Z"}'; + + $encryptResultJson = $client->encrypt($clientPtr, $sessionData, 'session', 'users'); + $encryptResult = json_decode(json: $encryptResultJson, associative: true, flags: JSON_THROW_ON_ERROR); + + $this->assertIsArray($encryptResult); + $this->assertSame('sv', $encryptResult['k']); + $this->assertSame('jsonb', $encryptResult['dt']); + $this->assertNull($encryptResult['sv']); + } finally { + $client->freeClient($clientPtr); + } + } + public function test_decrypt_throws_exception_with_context_on_ste_vec_column(): void { $client = new Client;