diff --git a/.evergreen/run-csfle-tests.sh b/.evergreen/run-csfle-tests.sh index e70e5d792..0fdc301af 100755 --- a/.evergreen/run-csfle-tests.sh +++ b/.evergreen/run-csfle-tests.sh @@ -10,7 +10,7 @@ set -o xtrace export CSFLE_TLS_CERT_DIR="${DRIVERS_TOOLS}/.evergreen/x509gen" -FEATURE_FLAGS+=("in-use-encryption" "azure-kms") +FEATURE_FLAGS+=("in-use-encryption" "azure-kms" "text-indexes-unstable") CARGO_OPTIONS+=("--ignore-default-filter") if [[ "$OPENSSL" = true ]]; then diff --git a/Cargo.lock b/Cargo.lock index 50526ff18..871c661dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2004,7 +2004,7 @@ dependencies = [ [[package]] name = "mongocrypt" version = "0.3.1" -source = "git+https://github.com/mongodb/libmongocrypt-rust.git?branch=main#132757496c04007b0482f9014dbc616553f1f916" +source = "git+https://github.com/mongodb/libmongocrypt-rust.git?branch=main#0f34015fcde37d805c0e7ead397965ade63bdb07" dependencies = [ "bson 2.15.0", "bson 3.0.0", @@ -2016,7 +2016,7 @@ dependencies = [ [[package]] name = "mongocrypt-sys" version = "0.1.4+1.12.0" -source = "git+https://github.com/mongodb/libmongocrypt-rust.git?branch=main#132757496c04007b0482f9014dbc616553f1f916" +source = "git+https://github.com/mongodb/libmongocrypt-rust.git?branch=main#0f34015fcde37d805c0e7ead397965ade63bdb07" [[package]] name = "mongodb" diff --git a/Cargo.toml b/Cargo.toml index d72b373b2..e8bcb4c70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,12 @@ in-use-encryption-unstable = ["in-use-encryption"] # TODO: pending https://github.com/tokio-rs/tracing/issues/2036 stop depending directly on log. tracing-unstable = ["dep:tracing", "dep:log", "bson3?/serde_json-1"] +# Enables support for text indexes in explicit encryption. This feature is in preview and should be +# used for experimental workloads only. This feature is unstable and its security is not guaranteed +# until released as Generally Available (GA). The GA version of this feature may not be backwards +# compatible with the preview version. +text-indexes-unstable = [] + [dependencies] base64 = "0.13.0" bitflags = "1.1.0" diff --git a/README.md b/README.md index 7c9d7a9d4..a3e580712 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ features = ["sync"] | `compat-3-0-0` | Required for future compatibility if default features are disabled. | | `azure-oidc` | Enable support for Azure OIDC environment authentication. | | `gcp-oidc` | Enable support for GCP OIDC environment authentication. | +| `text-indexes-unstable` | Enables support for text indexes in explicit encryption. This feature is in preview and should be used for experimental workloads only. This feature is unstable and its security is not guaranteed until released as Generally Available (GA). The GA version of this feature may not be backwards compatible with the preview version. | ## Web Framework Examples diff --git a/src/action/csfle/encrypt.rs b/src/action/csfle/encrypt.rs index 066427cd7..c3569b658 100644 --- a/src/action/csfle/encrypt.rs +++ b/src/action/csfle/encrypt.rs @@ -123,6 +123,14 @@ pub struct EncryptOptions { /// Set the range options. This should only be set when the algorithm is /// [`Algorithm::Range`]. pub range_options: Option, + + /// Set the text options. This should only be set when the algorithm is + /// [`Algorithm::TextPreview`]. + /// + /// NOTE: This option is unstable and subject to backwards-breaking changes. It should only be + /// used in experimental workloads. + #[cfg(feature = "text-indexes-unstable")] + pub text_options: Option, } /// The index options for a Queryable Encryption field supporting "range" queries. @@ -150,6 +158,94 @@ pub struct RangeOptions { pub precision: Option, } +/// Options for a queryable encryption field supporting text queries. +/// +/// NOTE: These options are unstable and subject to backwards-breaking changes. They should only be +/// used in experimental workloads. +#[skip_serializing_none] +#[derive(Clone, Default, Debug, Serialize, TypedBuilder)] +#[serde(rename_all = "camelCase")] +#[builder(field_defaults(default, setter(into)))] +#[non_exhaustive] +#[cfg(feature = "text-indexes-unstable")] +pub struct TextOptions { + /// Options for substring queries. + pub substring: Option, + + /// Options for prefix queries. + pub prefix: Option, + + /// Options for suffix queries. + pub suffix: Option, + + /// Whether text indexes for this field are case-sensitive. + pub case_sensitive: bool, + + /// Whether text indexes for this field are diacritic-sensitive. + pub diacritic_sensitive: bool, +} + +/// Options for substring queries. +/// +/// NOTE: These options are unstable and subject to backwards-breaking changes. They should only be +/// used in experimental workloads. +#[derive(Clone, Default, Debug, Serialize, TypedBuilder)] +#[serde(rename_all = "camelCase")] +#[builder(field_defaults(default, setter(into)))] +#[non_exhaustive] +#[cfg(feature = "text-indexes-unstable")] +pub struct SubstringOptions { + /// The maximum allowed string length. Inserting a longer string will result in an error. + #[serde(rename = "strMaxLength")] + pub max_string_length: i32, + + /// The minimum allowed query length. Querying with a shorter string will result in an error. + #[serde(rename = "strMinQueryLength")] + pub min_query_length: i32, + + /// The maximum allowed query length. Querying with a longer string will result in an error. + #[serde(rename = "strMaxQueryLength")] + pub max_query_length: i32, +} + +/// Options for prefix queries. +/// +/// NOTE: These options are unstable and subject to backwards-breaking changes. They should only be +/// used in experimental workloads. +#[derive(Clone, Default, Debug, Serialize, TypedBuilder)] +#[serde(rename_all = "camelCase")] +#[builder(field_defaults(default, setter(into)))] +#[non_exhaustive] +#[cfg(feature = "text-indexes-unstable")] +pub struct PrefixOptions { + /// The minimum allowed query length. Querying with a shorter string will result in an error. + #[serde(rename = "strMinQueryLength")] + pub min_query_length: i32, + + /// The maximum allowed query length. Querying with a longer string will result in an error. + #[serde(rename = "strMaxQueryLength")] + pub max_query_length: i32, +} + +/// Options for suffix queries. +/// +/// NOTE: These options are unstable and subject to backwards-breaking changes. They should only be +/// used in experimental workloads. +#[derive(Clone, Default, Debug, Serialize, TypedBuilder)] +#[serde(rename_all = "camelCase")] +#[builder(field_defaults(default, setter(into)))] +#[non_exhaustive] +#[cfg(feature = "text-indexes-unstable")] +pub struct SuffixOptions { + /// The minimum allowed query length. Querying with a shorter string will result in an error. + #[serde(rename = "strMinQueryLength")] + pub min_query_length: i32, + + /// The maximum allowed query length. Querying with a longer string will result in an error. + #[serde(rename = "strMaxQueryLength")] + pub max_query_length: i32, +} + #[option_setters(EncryptOptions, skip = [query_type])] #[export_doc(encrypt, extra = [query_type])] #[export_doc(encrypt_expr)] diff --git a/src/client/csfle/client_encryption.rs b/src/client/csfle/client_encryption.rs index 8b1e22da2..735bea28a 100644 --- a/src/client/csfle/client_encryption.rs +++ b/src/client/csfle/client_encryption.rs @@ -28,6 +28,13 @@ use super::{options::KmsProviders, state_machine::CryptExecutor}; pub use super::client_builder::EncryptedClientBuilder; pub use crate::action::csfle::encrypt::{EncryptKey, RangeOptions}; +#[cfg(feature = "text-indexes-unstable")] +pub use crate::action::csfle::encrypt::{ + PrefixOptions, + SubstringOptions, + SuffixOptions, + TextOptions, +}; /// A handle to the key vault. Used to create data encryption keys, and to explicitly encrypt and /// decrypt values when auto-encryption is not an option. diff --git a/src/client/csfle/client_encryption/encrypt.rs b/src/client/csfle/client_encryption/encrypt.rs index 6bcc62349..5325225c4 100644 --- a/src/client/csfle/client_encryption/encrypt.rs +++ b/src/client/csfle/client_encryption/encrypt.rs @@ -86,6 +86,11 @@ impl ClientEncryption { let options_doc = crate::bson_compat::serialize_to_document(range_options)?; builder = builder.algorithm_range(options_doc)?; } + #[cfg(feature = "text-indexes-unstable")] + if let Some(text_options) = &opts.text_options { + let options_doc = crate::bson_compat::serialize_to_document(text_options)?; + builder = builder.algorithm_text(options_doc)?; + } Ok(builder) } } diff --git a/src/test.rs b/src/test.rs index 9bfe66deb..2bbfb92be 100644 --- a/src/test.rs +++ b/src/test.rs @@ -259,6 +259,14 @@ pub(crate) async fn streaming_monitor_protocol_supported() -> bool { .is_some() } +#[cfg(feature = "in-use-encryption")] +pub(crate) fn mongocrypt_version_lt(version: &str) -> bool { + let mut actual_version = semver::Version::parse(mongocrypt::version()).unwrap(); + actual_version.pre = semver::Prerelease::EMPTY; + let requirement = semver::VersionReq::parse(&format!("<{version}")).unwrap(); + requirement.matches(&actual_version) +} + pub(crate) static DEFAULT_URI: LazyLock = LazyLock::new(get_default_uri); pub(crate) static SERVER_API: LazyLock> = LazyLock::new(|| match std::env::var("MONGODB_API_VERSION") { diff --git a/src/test/csfle/prose.rs b/src/test/csfle/prose.rs index f62af848e..db585eb41 100644 --- a/src/test/csfle/prose.rs +++ b/src/test/csfle/prose.rs @@ -2264,3 +2264,339 @@ async fn encrypt_expression_with_options() { .await .unwrap(); } + +// Prose test 27. Text explicit encryption +#[tokio::test] +#[cfg(feature = "text-indexes-unstable")] +async fn text_indexes_explicit_encryption() { + use crate::{ + client_encryption::{PrefixOptions, SubstringOptions, SuffixOptions, TextOptions}, + test::mongocrypt_version_lt, + }; + + if server_version_lt(8, 2).await + || topology_is_standalone().await + || mongocrypt_version_lt("1.15.1") + { + log_uncaptured( + "skipping text_explicit_encryption: requires non-standalone topology, 8.2+, \ + libmongocrypt 1.15.1+", + ); + return; + } + + // Prefix test utils + let prefix_query_type = "prefixPreview"; + let text_prefix_options = TextOptions::builder() + .case_sensitive(true) + .diacritic_sensitive(true) + .prefix( + PrefixOptions::builder() + .max_query_length(10) + .min_query_length(2) + .build(), + ) + .build(); + let prefix_filter = |prefix: Binary| { + doc! { "$expr": { "$encStrStartsWith": { "input": "$encryptedText", "prefix": prefix } } } + }; + + // Suffix test utils + let suffix_query_type = "suffixPreview"; + let text_suffix_options = TextOptions::builder() + .case_sensitive(true) + .diacritic_sensitive(true) + .suffix( + SuffixOptions::builder() + .max_query_length(10) + .min_query_length(2) + .build(), + ) + .build(); + let suffix_filter = |suffix: Binary| { + doc! { "$expr": { "$encStrEndsWith": { "input": "$encryptedText", "suffix": suffix } } } + }; + + // Substring test utils + let substring_query_type = "substringPreview"; + let text_substring_options = TextOptions::builder() + .case_sensitive(true) + .diacritic_sensitive(true) + .substring( + SubstringOptions::builder() + .max_string_length(10) + .max_query_length(10) + .min_query_length(2) + .build(), + ) + .build(); + let substring_filter = |substring: Binary| { + doc! { "$expr": { "$encStrContains": { "input": "$encryptedText", "substring": substring } } } + }; + let substring_coll = + |client: &Client| client.database("db").collection::("substring"); + + // General utils + let prefix_suffix_coll = |client: &Client| { + client + .database("db") + .collection::("prefix-suffix") + }; + let expected = doc! { "_id": 0, "encryptedText": "foobarbaz" }; + let projection = doc! { "__safeContent__": 0 }; + + // Case 1: can find a document by prefix + let (encrypted_client, client_encryption, key1_id) = + text_indexes_explicit_encryption_setup().await; + + let prefix = client_encryption + .encrypt("foo", key1_id, Algorithm::TextPreview) + .query_type(prefix_query_type) + .contention_factor(0) + .text_options(text_prefix_options.clone()) + .await + .unwrap(); + + let actual = prefix_suffix_coll(&encrypted_client) + .find_one(prefix_filter(prefix)) + .projection(projection.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(actual, expected); + + // Case 2: can find a document by suffix + let (encrypted_client, client_encryption, key1_id) = + text_indexes_explicit_encryption_setup().await; + + let suffix = client_encryption + .encrypt("baz", key1_id, Algorithm::TextPreview) + .query_type(suffix_query_type) + .contention_factor(0) + .text_options(text_suffix_options.clone()) + .await + .unwrap(); + + let actual = prefix_suffix_coll(&encrypted_client) + .find_one(suffix_filter(suffix)) + .projection(projection.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(actual, expected); + + // Case 3: assert no document found by prefix + let (encrypted_client, client_encryption, key1_id) = + text_indexes_explicit_encryption_setup().await; + + let prefix = client_encryption + .encrypt("baz", key1_id, Algorithm::TextPreview) + .query_type(prefix_query_type) + .contention_factor(0) + .text_options(text_prefix_options.clone()) + .await + .unwrap(); + + let actual = prefix_suffix_coll(&encrypted_client) + .find_one(prefix_filter(prefix)) + .projection(projection.clone()) + .await + .unwrap(); + assert!(actual.is_none(), "{actual:?}"); + + // Case 4: assert no document found by suffix + let (encrypted_client, client_encryption, key1_id) = + text_indexes_explicit_encryption_setup().await; + + let suffix = client_encryption + .encrypt("foo", key1_id, Algorithm::TextPreview) + .query_type(suffix_query_type) + .contention_factor(0) + .text_options(text_suffix_options) + .await + .unwrap(); + + let actual = prefix_suffix_coll(&encrypted_client) + .find_one(suffix_filter(suffix)) + .projection(projection.clone()) + .await + .unwrap(); + assert!(actual.is_none(), "{actual:?}"); + + // Case 5: can find a document by substring + let (encrypted_client, client_encryption, key1_id) = + text_indexes_explicit_encryption_setup().await; + + let substring = client_encryption + .encrypt("bar", key1_id, Algorithm::TextPreview) + .query_type(substring_query_type) + .contention_factor(0) + .text_options(text_substring_options.clone()) + .await + .unwrap(); + + let actual = substring_coll(&encrypted_client) + .find_one(substring_filter(substring)) + .projection(projection.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(actual, expected); + + // Case 6: assert no document found by substring + let (encrypted_client, client_encryption, key1_id) = + text_indexes_explicit_encryption_setup().await; + + let substring = client_encryption + .encrypt("qux", key1_id, Algorithm::TextPreview) + .query_type(substring_query_type) + .contention_factor(0) + .text_options(text_substring_options) + .await + .unwrap(); + + let actual = substring_coll(&encrypted_client) + .find_one(substring_filter(substring)) + .projection(projection) + .await + .unwrap(); + assert!(actual.is_none(), "{actual:?}"); + + // Case 7: assert contentionFactor is required + let (_, client_encryption, key1_id) = text_indexes_explicit_encryption_setup().await; + let error = client_encryption + .encrypt("foo", key1_id, Algorithm::TextPreview) + .query_type(prefix_query_type) + .text_options(text_prefix_options) + .await + .unwrap_err(); + let message = error.message().unwrap(); + assert!(message.contains("contention factor is required for textPreview algorithm")); +} + +#[cfg(feature = "text-indexes-unstable")] +async fn text_indexes_explicit_encryption_setup() -> (Client, ClientEncryption, Binary) { + use crate::client_encryption::{PrefixOptions, SubstringOptions, SuffixOptions, TextOptions}; + + let util_client = Client::for_test().await; + let db = util_client.database("db"); + + let ps_encrypted_fields = load_testdata("data/encryptedFields-prefix-suffix.json").unwrap(); + db.collection::("prefix-suffix") + .drop() + .encrypted_fields(ps_encrypted_fields.clone()) + .await + .unwrap(); + db.create_collection("prefix-suffix") + .encrypted_fields(ps_encrypted_fields) + .write_concern(WriteConcern::majority()) + .await + .unwrap(); + + let substring_encrypted_fields = load_testdata("data/encryptedFields-substring.json").unwrap(); + db.collection::("substring") + .drop() + .encrypted_fields(substring_encrypted_fields.clone()) + .await + .unwrap(); + db.create_collection("substring") + .encrypted_fields(substring_encrypted_fields) + .write_concern(WriteConcern::majority()) + .await + .unwrap(); + + let key1_doc = load_testdata("data/keys/key1-document.json").unwrap(); + let key1_id = match key1_doc.get("_id").unwrap() { + Bson::Binary(b) => b.clone(), + other => panic!("expected binary, got {other}"), + }; + + let keyvault = util_client.database("keyvault"); + let datakeys = keyvault.collection("datakeys"); + datakeys.drop().await.unwrap(); + keyvault.create_collection("datakeys").await.unwrap(); + datakeys + .insert_one(key1_doc) + .write_concern(WriteConcern::majority()) + .await + .unwrap(); + + let key_vault_client = Client::for_test().await; + let client_encryption = ClientEncryption::builder( + key_vault_client.into_client(), + datakeys.namespace(), + [LOCAL_KMS.clone()], + ) + .build() + .unwrap(); + + let encrypted_client = Client::encrypted_builder( + get_client_options().await.clone(), + datakeys.namespace(), + [LOCAL_KMS.clone()], + ) + .unwrap() + .bypass_query_analysis(true) + .build() + .await + .unwrap(); + + let text_options = TextOptions::builder() + .case_sensitive(true) + .diacritic_sensitive(true) + .prefix( + PrefixOptions::builder() + .max_query_length(10) + .min_query_length(2) + .build(), + ) + .suffix( + SuffixOptions::builder() + .max_query_length(10) + .min_query_length(2) + .build(), + ) + .build(); + let encrypted_foobarbaz = client_encryption + .encrypt("foobarbaz", key1_id.clone(), Algorithm::TextPreview) + .contention_factor(0) + .text_options(text_options) + .await + .unwrap(); + + encrypted_client + .database("db") + .collection("prefix-suffix") + .insert_one(doc! { "_id": 0, "encryptedText": encrypted_foobarbaz }) + .write_concern(WriteConcern::majority()) + .await + .unwrap(); + + let text_options = TextOptions::builder() + .case_sensitive(true) + .diacritic_sensitive(true) + .substring( + SubstringOptions::builder() + .max_string_length(10) + .max_query_length(10) + .min_query_length(2) + .build(), + ) + .build(); + let encrypted_foobarbaz = client_encryption + .encrypt("foobarbaz", key1_id.clone(), Algorithm::TextPreview) + .contention_factor(0) + .text_options(text_options) + .await + .unwrap(); + + encrypted_client + .database("db") + .collection("substring") + .insert_one(doc! { "_id": 0 , "encryptedText": encrypted_foobarbaz }) + .write_concern(WriteConcern::majority()) + .await + .unwrap(); + + (encrypted_client, client_encryption, key1_id) +} diff --git a/src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-prefix-suffix.json b/src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-prefix-suffix.json new file mode 100644 index 000000000..ec4489fa0 --- /dev/null +++ b/src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-prefix-suffix.json @@ -0,0 +1,38 @@ +{ + "fields": [ + { + "keyId": { + "$binary": { + "base64": "EjRWeBI0mHYSNBI0VniQEg==", + "subType": "04" + } + }, + "path": "encryptedText", + "bsonType": "string", + "queries": [ + { + "queryType": "prefixPreview", + "strMinQueryLength": { + "$numberInt": "2" + }, + "strMaxQueryLength": { + "$numberInt": "10" + }, + "caseSensitive": true, + "diacriticSensitive": true + }, + { + "queryType": "suffixPreview", + "strMinQueryLength": { + "$numberInt": "2" + }, + "strMaxQueryLength": { + "$numberInt": "10" + }, + "caseSensitive": true, + "diacriticSensitive": true + } + ] + } + ] +} diff --git a/src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-substring.json b/src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-substring.json new file mode 100644 index 000000000..ee22def77 --- /dev/null +++ b/src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-substring.json @@ -0,0 +1,30 @@ +{ + "fields": [ + { + "keyId": { + "$binary": { + "base64": "EjRWeBI0mHYSNBI0VniQEg==", + "subType": "04" + } + }, + "path": "encryptedText", + "bsonType": "string", + "queries": [ + { + "queryType": "substringPreview", + "strMaxLength": { + "$numberInt": "10" + }, + "strMinQueryLength": { + "$numberInt": "2" + }, + "strMaxQueryLength": { + "$numberInt": "10" + }, + "caseSensitive": true, + "diacriticSensitive": true + } + ] + } + ] +} diff --git a/src/test/spec/unified_runner/test_file.rs b/src/test/spec/unified_runner/test_file.rs index 3dd1de74a..56c4d7fe4 100644 --- a/src/test/spec/unified_runner/test_file.rs +++ b/src/test/spec/unified_runner/test_file.rs @@ -173,15 +173,11 @@ impl RunOnRequirement { Csfle::Version { min_libmongocrypt_version, } => { - let requirement = - semver::VersionReq::parse(&format!(">={min_libmongocrypt_version}")) - .unwrap(); - let mut version = semver::Version::parse(mongocrypt::version()).unwrap(); - version.pre = semver::Prerelease::EMPTY; - if !requirement.matches(&version) { + if crate::test::mongocrypt_version_lt(min_libmongocrypt_version) { return Err(format!( "requires at least libmongocrypt version {min_libmongocrypt_version} \ - but using version {version}" + but using version {},", + mongocrypt::version() )); } }