From eac159687f66f68573d464703ef529fd63b2b55d Mon Sep 17 00:00:00 2001 From: Dmitry Rybakov Date: Thu, 30 Jul 2026 11:31:30 +0200 Subject: [PATCH] RUBY-3524 Add option to configure DEK cache lifetime Adds a key_expiration_ms option to both auto_encryption_options and ClientEncryption, wired to libmongocrypt's mongocrypt_setopt_key_expiration. A value of 0 means the data encryption key cache never expires; when the option is not given, libmongocrypt's 60000 ms default applies. libmongocrypt has exported this setopt since 1.12.0 and the driver already requires 1.20.0, so no version bump is needed. --- lib/mongo/client.rb | 4 + lib/mongo/client_encryption.rb | 7 +- lib/mongo/crypt/auto_encrypter.rb | 6 +- lib/mongo/crypt/binding.rb | 25 ++++ lib/mongo/crypt/explicit_encrypter.rb | 11 +- lib/mongo/crypt/handle.rb | 18 +++ spec/mongo/crypt/handle_spec.rb | 53 +++++++- spec/runners/unified/test.rb | 6 + .../unified/keyCache.yml | 117 ++++++++++++++++++ 9 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 spec/spec_tests/data/client_side_encryption/unified/keyCache.yml diff --git a/lib/mongo/client.rb b/lib/mongo/client.rb index 4fc3250a83..254ffe1ae3 100644 --- a/lib/mongo/client.rb +++ b/lib/mongo/client.rb @@ -523,6 +523,10 @@ def hash # - :crypt_shared_lib_required => [ Boolean | nil ] Whether # crypt shared library is required. If 'true', an error will be raised # if a crypt_shared library cannot be loaded by libmongocrypt. + # - :key_expiration_ms => Integer | nil, the lifetime of the data + # encryption key cache, in milliseconds. Must be a non-negative + # integer. A value of 0 means the cache never expires. Defaults to + # 60000. # # Notes on automatic encryption: # - Automatic encryption is an enterprise only feature that only applies diff --git a/lib/mongo/client_encryption.rb b/lib/mongo/client_encryption.rb index f9d4039b54..b333c21109 100644 --- a/lib/mongo/client_encryption.rb +++ b/lib/mongo/client_encryption.rb @@ -42,6 +42,10 @@ class ClientEncryption # @option options [ Integer ] :timeout_ms The operation timeout in milliseconds. # Must be a non-negative integer. An explicit value of 0 means infinite. # The default value is unset which means the feature is disabled. + # @option options [ Integer ] :key_expiration_ms The lifetime of the data + # encryption key cache, in milliseconds. Must be a non-negative integer. + # An explicit value of 0 means the cache never expires. The default is + # 60000. # # @raise [ ArgumentError ] If required options are missing or incorrectly # formatted. @@ -51,7 +55,8 @@ def initialize(key_vault_client, options = {}) options[:key_vault_namespace], Crypt::KMS::Credentials.new(options[:kms_providers]), Crypt::KMS::Validations.validate_tls_options(options[:kms_tls_options]), - options[:timeout_ms] + options[:timeout_ms], + options[:key_expiration_ms] ) end diff --git a/lib/mongo/crypt/auto_encrypter.rb b/lib/mongo/crypt/auto_encrypter.rb index 5f3673f892..16229b269c 100644 --- a/lib/mongo/crypt/auto_encrypter.rb +++ b/lib/mongo/crypt/auto_encrypter.rb @@ -81,6 +81,9 @@ class AutoEncrypter # @option options [ Boolean | nil ] :crypt_shared_lib_required Whether # crypt shared library is required. If 'true', an error will be raised # if a crypt_shared library cannot be loaded by libmongocrypt. + # @option options [ Integer | nil ] :key_expiration_ms The lifetime of the + # data encryption key cache, in milliseconds. A value of 0 means the + # cache never expires. Defaults to 60000. # # @raise [ ArgumentError ] If required options are missing or incorrectly # formatted. @@ -99,7 +102,8 @@ def initialize(options) bypass_query_analysis: @options[:bypass_query_analysis], crypt_shared_lib_path: @options[:extra_options][:crypt_shared_lib_path], crypt_shared_lib_required: @options[:extra_options][:crypt_shared_lib_required], - disable_crypt_shared_lib_search: @options[:extra_options][:disable_crypt_shared_lib_search] + disable_crypt_shared_lib_search: @options[:extra_options][:disable_crypt_shared_lib_search], + key_expiration_ms: @options[:key_expiration_ms] ) @mongocryptd_options = @options[:extra_options].slice( diff --git a/lib/mongo/crypt/binding.rb b/lib/mongo/crypt/binding.rb index 1e6b224f1f..98fe3e3a5c 100644 --- a/lib/mongo/crypt/binding.rb +++ b/lib/mongo/crypt/binding.rb @@ -1479,6 +1479,31 @@ def self.setopt_bypass_query_analysis(handle) mongocrypt_setopt_bypass_query_analysis(handle.ref) end + # @!method self.mongocrypt_setopt_key_expiration(crypt, cache_expiration_ms) + # @api private + # + # Set the expiration time for the data encryption key cache. + # + # @param [ FFI::Pointer ] crypt A pointer to a mongocrypt_t object. + # @param [ Integer ] cache_expiration_ms The cache expiration time in + # milliseconds. If zero, the cache never expires. + # @return [ Boolean ] Returns whether the option was set successfully. + attach_function(:mongocrypt_setopt_key_expiration, %i[pointer uint64], :bool) + + # Set the expiration time for the data encryption key cache on the + # Mongo::Crypt::Handle object. + # + # @param [ Mongo::Crypt::Handle ] handle + # @param [ Integer ] cache_expiration_ms The cache expiration time in + # milliseconds. If zero, the cache never expires. + # + # @raise [ Mongo::Error::CryptError ] If the option is not set successfully. + def self.setopt_key_expiration(handle, cache_expiration_ms) + check_status(handle) do + mongocrypt_setopt_key_expiration(handle.ref, cache_expiration_ms) + end + end + # @!method self.mongocrypt_setopt_aes_256_ctr(crypt, aes_256_ctr_encrypt, aes_256_ctr_decrypt, ctx) # @api private # diff --git a/lib/mongo/crypt/explicit_encrypter.rb b/lib/mongo/crypt/explicit_encrypter.rb index 60878a66b5..1536b9620a 100644 --- a/lib/mongo/crypt/explicit_encrypter.rb +++ b/lib/mongo/crypt/explicit_encrypter.rb @@ -37,12 +37,19 @@ class ExplicitEncrypter # to TLS connection options of Mongo::Client. # @param [ Integer | nil ] timeout_ms Timeout for every operation executed # on this object. - def initialize(key_vault_client, key_vault_namespace, kms_providers, kms_tls_options, timeout_ms = nil) + # @param [ Integer | nil ] key_expiration_ms The lifetime of the data + # encryption key cache, in milliseconds. A value of 0 means the cache + # never expires. When nil, libmongocrypt's default of 60000 is used. + def initialize( + key_vault_client, key_vault_namespace, kms_providers, kms_tls_options, + timeout_ms = nil, key_expiration_ms = nil + ) Crypt.validate_ffi! @crypt_handle = Handle.new( kms_providers, kms_tls_options, - explicit_encryption_only: true + explicit_encryption_only: true, + key_expiration_ms: key_expiration_ms ) @encryption_io = EncryptionIO.new( key_vault_client: key_vault_client, diff --git a/lib/mongo/crypt/handle.rb b/lib/mongo/crypt/handle.rb index 0ecff0b38d..84c1677610 100644 --- a/lib/mongo/crypt/handle.rb +++ b/lib/mongo/crypt/handle.rb @@ -66,6 +66,10 @@ class Handle # error that libmongocrypt raises on a subsequent "$SYSTEM" search. # @option options [ Logger ] :logger A Logger object to which libmongocrypt logs # will be sent + # @option options [ Integer | nil ] :key_expiration_ms The lifetime of the + # data encryption key cache, in milliseconds. A value of 0 means the + # cache never expires. When nil, libmongocrypt's default of 60000 is + # used. def initialize(kms_providers, kms_tls_options, options = {}) # FFI::AutoPointer uses a custom release strategy to automatically free # the pointer once this object goes out of scope @@ -87,6 +91,9 @@ def initialize(kms_providers, kms_tls_options, options = {}) @bypass_query_analysis = options[:bypass_query_analysis] set_bypass_query_analysis if @bypass_query_analysis + @key_expiration_ms = options[:key_expiration_ms] + set_key_expiration unless @key_expiration_ms.nil? + @crypt_shared_lib_path = options[:crypt_shared_lib_path] @explicit_encryption_only = options[:explicit_encryption_only] @disable_crypt_shared_lib_search = options[:disable_crypt_shared_lib_search] @@ -200,6 +207,17 @@ def set_bypass_query_analysis Binding.setopt_bypass_query_analysis(self) if @bypass_query_analysis end + def set_key_expiration + unless @key_expiration_ms.is_a?(Integer) && !@key_expiration_ms.negative? + raise ArgumentError.new( + "#{@key_expiration_ms} is an invalid key_expiration_ms value; " \ + 'must be a non-negative Integer or nil' + ) + end + + Binding.setopt_key_expiration(self, @key_expiration_ms) + end + # Send the logs from libmongocrypt to the Mongo::Logger def set_logger_callback @log_callback = proc do |level, msg| diff --git a/spec/mongo/crypt/handle_spec.rb b/spec/mongo/crypt/handle_spec.rb index 89d65c12aa..097b123028 100644 --- a/spec/mongo/crypt/handle_spec.rb +++ b/spec/mongo/crypt/handle_spec.rb @@ -20,7 +20,8 @@ bypass_query_analysis: bypass_query_analysis, crypt_shared_lib_path: crypt_shared_lib_path, crypt_shared_lib_required: crypt_shared_lib_required, - explicit_encryption_only: explicit_encryption_only + explicit_encryption_only: explicit_encryption_only, + key_expiration_ms: key_expiration_ms ) end @@ -48,6 +49,10 @@ nil end + let(:key_expiration_ms) do + nil + end + shared_examples 'a functioning Mongo::Crypt::Handle' do context 'with valid schema map' do it 'does not raise an exception' do @@ -219,6 +224,52 @@ end end + # The happy path (a short expiration causes the DEK to be re-fetched) is + # covered by the keyCache.yml unified spec test. These specs cover the + # validation and pass-through behavior that YAML cannot express. + context 'key_expiration_ms' do + include_context 'with local kms_providers' + + context 'when not given' do + it 'leaves the libmongocrypt default in place' do + expect(Mongo::Crypt::Binding).not_to receive(:setopt_key_expiration) + + handle + end + end + + context 'when zero' do + let(:key_expiration_ms) { 0 } + + it 'passes it through to mean "never expire"' do + expect(Mongo::Crypt::Binding).to receive(:setopt_key_expiration) + .with(anything, 0).and_call_original + + handle + end + end + + context 'when negative' do + let(:key_expiration_ms) { -1 } + + it 'raises an exception' do + expect { handle }.to raise_error( + ArgumentError, /invalid key_expiration_ms value; must be a non-negative Integer or nil/ + ) + end + end + + context 'when not an Integer' do + let(:key_expiration_ms) { '60000' } + + it 'raises an exception' do + expect { handle }.to raise_error( + ArgumentError, /invalid key_expiration_ms value; must be a non-negative Integer or nil/ + ) + end + end + end + context 'AWS' do context 'with valid AWS kms_providers' do include_context 'with AWS kms_providers' diff --git a/spec/runners/unified/test.rb b/spec/runners/unified/test.rb index 348496cd74..ecbde6fef5 100644 --- a/spec/runners/unified/test.rb +++ b/spec/runners/unified/test.rb @@ -330,6 +330,12 @@ def generate_entities(es) [ provider, converted_options ] end.to_h + # A keyExpirationMS of 0 is meaningful (never expire), so test + # for presence rather than truthiness. + if client_encryption_opts.key?('keyExpirationMS') + opts[:key_expiration_ms] = client_encryption_opts['keyExpirationMS'] + end + Mongo::ClientEncryption.new( key_vault_client, opts diff --git a/spec/spec_tests/data/client_side_encryption/unified/keyCache.yml b/spec/spec_tests/data/client_side_encryption/unified/keyCache.yml new file mode 100644 index 0000000000..4166e5e6a7 --- /dev/null +++ b/spec/spec_tests/data/client_side_encryption/unified/keyCache.yml @@ -0,0 +1,117 @@ +description: keyCache-explicit + +schemaVersion: "1.22" + +runOnRequirements: + - csfle: true + +createEntities: + - client: + id: &client0 client0 + observeEvents: + - commandStartedEvent + - clientEncryption: + id: &clientEncryption0 clientEncryption0 + clientEncryptionOpts: + keyVaultClient: *client0 + keyVaultNamespace: keyvault.datakeys + kmsProviders: + "local": + { + key: "OCTP9uKPPmvuqpHlqq83gPk4U6rUPxKVRRyVtrjFmVjdoa4Xzm1SzUbr7aIhNI42czkUBmrCtZKF31eaaJnxEBkqf0RFukA9Mo3NEHQWgAQ2cn9duOcRbaFUQo2z0/rB", + } + keyExpirationMS: 1 + - database: + id: &database0 database0 + client: *client0 + databaseName: &database0Name keyvault + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: &collection0Name datakeys + +initialData: + - databaseName: *database0Name + collectionName: *collection0Name + documents: + - { + "_id": { "$binary": { "base64": "a+YWzdygTAG62/cNUkqZiQ==", "subType": "04" } }, + "keyAltNames": [], + "keyMaterial": + { + "$binary": + { + "base64": "iocBkhO3YBokiJ+FtxDTS71/qKXQ7tSWhWbcnFTXBcMjarsepvALeJ5li+SdUd9ePuatjidxAdMo7vh1V2ZESLMkQWdpPJ9PaJjA67gKQKbbbB4Ik5F2uKjULvrMBnFNVRMup4JNUwWFQJpqbfMveXnUVcD06+pUpAkml/f+DSXrV3e5rxciiNVtz03dAG8wJrsKsFXWj6vTjFhsfknyBA==", + "subType": "00", + }, + }, + "creationDate": { "$date": { "$numberLong": "1552949630483" } }, + "updateDate": { "$date": { "$numberLong": "1552949630483" } }, + "status": { "$numberInt": "0" }, + "masterKey": { "provider": "local" }, + } + +tests: + - description: decrypt, wait, and decrypt again + operations: + - name: decrypt + object: *clientEncryption0 + arguments: + value: + { + "$binary": + { + "base64": "AWvmFs3coEwButv3DVJKmYkCJ6lUzRX9R28WNlw5uyndb+8gurA+p8q14s7GZ04K2ZvghieRlAr5UwZbow3PMq27u5EIhDDczwBFcbdP1amllw==", + "subType": "06", + }, + } + expectResult: "foobar" + - name: wait + object: testRunner + arguments: + ms: 50 # Wait long enough to account for coarse time resolution on Windows (CDRIVER-4526). + - name: decrypt + object: *clientEncryption0 + arguments: + value: + { + "$binary": + { + "base64": "AWvmFs3coEwButv3DVJKmYkCJ6lUzRX9R28WNlw5uyndb+8gurA+p8q14s7GZ04K2ZvghieRlAr5UwZbow3PMq27u5EIhDDczwBFcbdP1amllw==", + "subType": "06", + }, + } + expectResult: "foobar" + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + find: datakeys + filter: + { + "$or": + [ + { + "_id": { "$in": [{ "$binary": { "base64": "a+YWzdygTAG62/cNUkqZiQ==", "subType": "04" } }] }, + }, + { "keyAltNames": { "$in": [] } }, + ], + } + $db: keyvault + readConcern: { level: "majority" } + - commandStartedEvent: + command: + find: datakeys + filter: + { + "$or": + [ + { + "_id": { "$in": [{ "$binary": { "base64": "a+YWzdygTAG62/cNUkqZiQ==", "subType": "04" } }] }, + }, + { "keyAltNames": { "$in": [] } }, + ], + } + $db: keyvault + readConcern: { level: "majority" }