diff --git a/lib/AuthenticationSDK/authentication/jwt/JwtToken.rb b/lib/AuthenticationSDK/authentication/jwt/JwtToken.rb index 57e45987..ed055966 100644 --- a/lib/AuthenticationSDK/authentication/jwt/JwtToken.rb +++ b/lib/AuthenticationSDK/authentication/jwt/JwtToken.rb @@ -8,6 +8,7 @@ require_relative '../../util/Constants.rb' require_relative '../../util/ExceptionHandler.rb' require_relative '../../util/Cache.rb' +require_relative '../../util/MLEUtility.rb' require_relative '../../authentication/payloadDigest/digest.rb' require_relative '../../logging/log_factory.rb' @@ -67,10 +68,11 @@ def getJwtBody(request_type, gmtDatetime, merchantconfig_obj, isResponseMLEForAp end if isResponseMLEForApi - jwtBody = jwtBody + ", \"v-c-response-mle-kid\":\"" + merchantconfig_obj.responseMleKID + "\"" + mleKid = MLEUtility.validate_and_auto_extract_response_mle_kid(merchantconfig_obj) + jwtBody = jwtBody + ", \"v-c-response-mle-kid\":\"" + mleKid + "\"" end jwtBody = jwtBody + "\n}" end implements TokenInterface - end \ No newline at end of file + end diff --git a/lib/AuthenticationSDK/core/MerchantConfig.rb b/lib/AuthenticationSDK/core/MerchantConfig.rb index aba572f9..40bc5963 100644 --- a/lib/AuthenticationSDK/core/MerchantConfig.rb +++ b/lib/AuthenticationSDK/core/MerchantConfig.rb @@ -428,10 +428,15 @@ def validateMLEConfiguration(cybsPropertyObj) raise err end + isP12 = false # If private key file path is provided, validate the file exists if !@responseMlePrivateKeyFilePath.nil? && !@responseMlePrivateKeyFilePath.to_s.strip.empty? begin CertificateUtility.validatePathAndFile(@responseMlePrivateKeyFilePath, "responseMlePrivateKeyFilePath", @log_config) + ext = File.extname(@responseMlePrivateKeyFilePath).downcase + if ext == '.p12' || ext == '.pfx' + isP12 = true + end rescue => err error = StandardError.new(Constants::ERROR_PREFIX + "Invalid responseMlePrivateKeyFilePath : #{err.message}") @log_obj.logger.error(ExceptionHandler.new.new_api_exception error) @@ -440,8 +445,8 @@ def validateMLEConfiguration(cybsPropertyObj) end # Validate responseMleKID is provided when response MLE is enabled - if @responseMleKID.nil? || @responseMleKID.to_s.strip.empty? - err = StandardError.new(Constants::ERROR_PREFIX + "Response MLE is enabled but responseMleKID is not provided.") + if !isP12 && (@responseMleKID.nil? || @responseMleKID.to_s.strip.empty?) + err = StandardError.new(Constants::ERROR_PREFIX + "responseMleKID is required when response MLE is enabled for non-P12/PFX files.") @log_obj.logger.error(ExceptionHandler.new.new_api_exception err) raise err end diff --git a/lib/AuthenticationSDK/util/Cache.rb b/lib/AuthenticationSDK/util/Cache.rb index dc7319c6..436d4e60 100644 --- a/lib/AuthenticationSDK/util/Cache.rb +++ b/lib/AuthenticationSDK/util/Cache.rb @@ -3,7 +3,9 @@ require 'active_support' require 'thread' require_relative 'CacheValue' +require_relative 'CachedMLEKId' require_relative 'CertificateUtility' +require_relative 'Utility' require_relative '../util/Constants.rb' require_relative '../logging/log_factory.rb' require_relative '../logging/log_configuration.rb' @@ -70,8 +72,8 @@ def setupCache(cacheKey, certificateFilePath, merchantConfig) end if (cacheKey.end_with?("_JWT")) - privateKey, certificateList = CertificateUtility.getCertificateCollectionAndPrivateKeyFromP12(certificateFilePath, merchantConfig) - jwtCertificate = CertificateUtility.getCertificateBasedOnKeyAlias(certificateList, merchantConfig.keyAlias) + privateKey, certificateList = Utility.getCertificateCollectionAndPrivateKeyFromP12(certificateFilePath, merchantConfig.keyPass) + jwtCertificate = Utility.getCertificateBasedOnKeyAlias(certificateList, merchantConfig.keyAlias) cacheValue = CacheValue.new(privateKey, jwtCertificate, fileModifiedTime) @@ -81,7 +83,7 @@ def setupCache(cacheKey, certificateFilePath, merchantConfig) if (cacheKey.end_with?(Constants::MLE_CACHE_IDENTIFIER_FOR_CONFIG_CERT)) certificateList = CertificateUtility.getCertificatesFromPemFile(certificateFilePath) - mleCertificate = CertificateUtility.getCertificateBasedOnKeyAlias(certificateList, merchantConfig.requestMleKeyAlias) + mleCertificate = Utility.getCertificateBasedOnKeyAlias(certificateList, merchantConfig.requestMleKeyAlias) if (!mleCertificate) fileName = File.basename(certificateFilePath) logger.warn("No certificate found for the specified mle_key_alias '#{merchantConfig.requestMleKeyAlias}'. Using the first certificate from file #{fileName} as the MLE request certificate.") @@ -95,8 +97,8 @@ def setupCache(cacheKey, certificateFilePath, merchantConfig) end if (cacheKey.end_with?(Constants::MLE_CACHE_IDENTIFIER_FOR_P12_CERT)) - privateKey, certificateList = CertificateUtility.getCertificateCollectionAndPrivateKeyFromP12(certificateFilePath, merchantConfig) - mleCertificate = CertificateUtility.getCertificateBasedOnKeyAlias(certificateList, merchantConfig.requestMleKeyAlias) + privateKey, certificateList = Utility.getCertificateCollectionAndPrivateKeyFromP12(certificateFilePath, merchantConfig.keyPass) + mleCertificate = Utility.getCertificateBasedOnKeyAlias(certificateList, merchantConfig.requestMleKeyAlias) if (!mleCertificate) fileName = File.basename(certificateFilePath) logger.error("No certificate found for the specified mle_key_alias '#{merchantConfig.requestMleKeyAlias}' in file #{fileName}.") @@ -176,6 +178,93 @@ def getMLEResponsePrivateKeyFromFilePath(merchantConfig) cachedCertificateInfo ? cachedCertificateInfo.private_key : nil end + def get_mle_kid_data_from_cache(merchant_config) + cache_key = merchant_config.responseMlePrivateKeyFilePath + Constants::RESPONSE_MLE_P12_PFX_CACHE_IDENTIFIER + file_path = merchant_config.responseMlePrivateKeyFilePath + + @@mutex.synchronize do + if !@@cache_obj.exist?(cache_key) + setup_mle_kid_cache(merchant_config) + else + cached_mle_kid = @@cache_obj.read(cache_key) + file_modified_time = File.mtime(file_path).to_i + + if cached_mle_kid.nil? || cached_mle_kid.last_modified_timestamp != file_modified_time + if !Cache.class_variable_defined?(:@@logger) || @@logger.nil? + @@logger = Log.new merchant_config.log_config, "Cache" + end + logger = @@logger.logger + logger.info("MLE KID cache outdated or file modified. Refreshing cache for: #{file_path}") + setup_mle_kid_cache(merchant_config) + end + end + + return @@cache_obj.read(cache_key) + end + end + + private + + def setup_mle_kid_cache(merchant_config) + file_path = nil + cache_key = nil + + begin + if !Cache.class_variable_defined?(:@@logger) || @@logger.nil? + @@logger = Log.new merchant_config.log_config, "Cache" + end + logger = @@logger.logger + + file_path = merchant_config.responseMlePrivateKeyFilePath + cache_key = merchant_config.responseMlePrivateKeyFilePath + Constants::RESPONSE_MLE_P12_PFX_CACHE_IDENTIFIER + + # Get certificate from P12 file + _, certificate_list = Utility.getCertificateCollectionAndPrivateKeyFromP12( + file_path, + merchant_config.responseMlePrivateKeyFilePassword + ) + + merchant_cert = Utility.getCertificateBasedOnKeyAlias(certificate_list, merchant_config.merchantId) + + if merchant_cert.nil? + raise StandardError.new("No certificate found for Response MLE Private Key file with merchant ID alias #{merchant_config.merchantId}") + end + + # Check if this is a CyberSource-generated P12 file + is_cybersource_p12 = Utility.isP12GeneratedByCyberSource( + file_path, + merchant_config.responseMlePrivateKeyFilePassword, + logger + ) + + cached_mle_kid = CachedMLEKId.new + cached_mle_kid.last_modified_timestamp = File.mtime(file_path).to_i + + if is_cybersource_p12 + begin + mle_kid = MLEUtility.extract_serial_number_from_certificate(merchant_cert) + cached_mle_kid.kid = mle_kid + rescue ArgumentError => e + raise StandardError.new("Failed to extract serial number from certificate for Response MLE: #{e.message}") + end + end + + @@cache_obj.write(cache_key, cached_mle_kid) + + rescue StandardError => e + logger.error("Failed to load MLE KID from Response MLE Private Key file: #{file_path}. Error: #{e.message}") + + if !cache_key.nil? + failure_marker = CachedMLEKId.new + failure_marker.kid = nil + failure_marker.last_modified_timestamp = 0 + @@cache_obj.write(cache_key, failure_marker) + end + end + end + + public + # DEPRECATED: This method has been marked as Deprecated and will be removed in coming releases. def fetchPEMFileForNetworkTokenization(filePath) warn("[DEPRECATED] 'fetchPEMFileForNetworkTokenization' method is deprecated and will be removed in coming releases.") diff --git a/lib/AuthenticationSDK/util/CachedMLEKId.rb b/lib/AuthenticationSDK/util/CachedMLEKId.rb new file mode 100644 index 00000000..f4d4b650 --- /dev/null +++ b/lib/AuthenticationSDK/util/CachedMLEKId.rb @@ -0,0 +1,17 @@ +# Cache value object to store MLE KID data +class CachedMLEKId + attr_accessor :kid, :last_modified_timestamp + + def initialize(kid = nil, last_modified_timestamp = nil) + @kid = kid + @last_modified_timestamp = last_modified_timestamp + end + + def to_s + "CachedMLEKId(kid: #{@kid ? 'present' : 'nil'}, last_modified_timestamp: #{@last_modified_timestamp})" + end + + def empty? + @kid.nil? && @last_modified_timestamp.nil? + end +end diff --git a/lib/AuthenticationSDK/util/CertificateUtility.rb b/lib/AuthenticationSDK/util/CertificateUtility.rb index 38159849..8da3f575 100644 --- a/lib/AuthenticationSDK/util/CertificateUtility.rb +++ b/lib/AuthenticationSDK/util/CertificateUtility.rb @@ -7,34 +7,6 @@ class CertificateUtility @@logger - def self.getCertificateCollectionAndPrivateKeyFromP12(certificateFilePath, merchantConfig) - if !CertificateUtility.class_variable_defined?(:@@logger) || @@logger.nil? - @@logger = Log.new merchantConfig.log_config, "CertificateUtility" - end - logger = @@logger.logger - - p12File = File.binread(certificateFilePath) - p12Object = OpenSSL::PKCS12.new(p12File, merchantConfig.keyPass) - - privateKey = OpenSSL::PKey::RSA.new(p12Object.key) - - primaryX5Certificate = p12Object.certificate - additionalX5Certificates = p12Object.ca_certs - - certificateList = [primaryX5Certificate] - certificateList.concat(additionalX5Certificates) if additionalX5Certificates - - return [privateKey, certificateList] - end - - def self.getCertificateBasedOnKeyAlias(certificateList, keyAlias) - return nil if certificateList.nil? - - certificateList.find do |cert| - cert.subject.to_a.any? { |_, value, _| value.include?(keyAlias) } - end - end - def self.getCertificatesFromPemFile(certificateFilePath) pem_data = File.read(certificateFilePath) certificateList = [] @@ -229,4 +201,4 @@ def self.convert_key_to_JWK(keyValue, password=nil) end end end - end \ No newline at end of file + end diff --git a/lib/AuthenticationSDK/util/Constants.rb b/lib/AuthenticationSDK/util/Constants.rb index cb9e8ca3..7a73e967 100644 --- a/lib/AuthenticationSDK/util/Constants.rb +++ b/lib/AuthenticationSDK/util/Constants.rb @@ -177,4 +177,6 @@ class Constants DEFAULT_KEY_FILE_PATH = File.join(Dir.pwd, "resources") MLE_CACHE_KEY_IDENTIFIER_FOR_RESPONSE_PRIVATE_KEY = "mleResponsePrivateKeyFromFile" + + RESPONSE_MLE_P12_PFX_CACHE_IDENTIFIER = "_responseMleP12Pfx" end diff --git a/lib/AuthenticationSDK/util/MLEUtility.rb b/lib/AuthenticationSDK/util/MLEUtility.rb index eb792e41..6b6ba4b6 100644 --- a/lib/AuthenticationSDK/util/MLEUtility.rb +++ b/lib/AuthenticationSDK/util/MLEUtility.rb @@ -50,10 +50,6 @@ def self.encrypt_request_payload(merchantConfig, requestBody) begin serial_number = self.extract_serial_number_from_certificate(mleCertificate) - if serial_number.nil? - @log_obj.logger.error('Serial number not found in certificate for MLE') - raise StandardError.new('Serial number not found in MLE certificate') - end jwk = JOSE::JWK.from_key(mleCertificate.public_key) if jwk.nil? @@ -80,11 +76,12 @@ def self.encrypt_request_payload(merchantConfig, requestBody) end def self.extract_serial_number_from_certificate(certificate) - return nil if certificate.subject.to_s.empty? && certificate.issuer.to_s.empty? + raise StandardError.new('Certificate cannot be nil') if certificate.nil? + raise StandardError.new('Certificate subject and issuer cannot both be empty') if certificate.subject.to_s.empty? && certificate.issuer.to_s.empty? certificate.subject.to_a.each do |attribute| return attribute[1] if attribute[0].include?('serialNumber') end - certificate.serial.nil? ? nil : certificate.serial.to_s + raise StandardError.new('Serial number not found in certificate subject') end def self.create_request_payload(compact_jwe) @@ -184,4 +181,65 @@ def self.get_mle_response_private_key(merchantConfig) end private_class_method :get_mle_response_private_key + + def self.validate_and_auto_extract_response_mle_kid(merchant_config) + @log_obj ||= Log.new(merchant_config.log_config, 'MLEUtility') + + if !merchant_config.responseMlePrivateKey.nil? && !merchant_config.responseMlePrivateKey.to_s.strip.empty? + @log_obj.logger.debug('responseMlePrivateKey is provided directly, using configured responseMleKID') + return merchant_config.responseMleKID + end + + @log_obj.logger.debug('Validating responseMleKID for JWT token generation') + cybs_kid = nil + p12_file = false + + # File path validity + begin + CertificateUtility.validatePathAndFile(merchant_config.responseMlePrivateKeyFilePath, 'responseMlePrivateKeyFilePath', merchant_config.log_config) + extension = File.extname(merchant_config.responseMlePrivateKeyFilePath).delete_prefix('.').downcase + if extension == 'p12' || extension == 'pfx' + p12_file = true + end + rescue IOError => e + @log_obj.logger.debug('No valid private key file path provided, skipping auto-extraction') + end + + if p12_file + @log_obj.logger.debug('P12/PFX file detected, checking if it is a CyberSource certificate') + cached_data = Cache.new.get_mle_kid_data_from_cache(merchant_config) + if !cached_data.nil? + if !cached_data.kid.nil? + # KID present means it's a CyberSource P12, use it + cybs_kid = cached_data.kid + else + # KID is null means either non-CyberSource P12 or extraction failed + @log_obj.logger.debug('Private key file is not a CyberSource generated P12/PFX file, skipping auto-extraction') + end + end + else + @log_obj.logger.debug('Private key file is not a P12/PFX file, skipping auto-extraction') + end + + if !cybs_kid.nil? + @log_obj.logger.debug('Successfully auto-extracted responseMleKID from CyberSource P12 certificate') + end + + configured_kid = merchant_config.responseMleKID + if cybs_kid.nil? && configured_kid.nil? + raise StandardError.new('responseMleKID is required when response MLE is enabled. ' + + 'Could not auto-extract from certificate and no manual configuration provided. ' + + 'Please provide responseMleKID explicitly in your configuration.' + ) + elsif cybs_kid.nil? + @log_obj.logger.debug('Using manually configured responseMleKID') + return configured_kid + elsif configured_kid.nil? + @log_obj.logger.debug('Using auto-extracted responseMleKID from CyberSource certificate') + return cybs_kid + elsif cybs_kid != configured_kid + @log_obj.logger.warn('Auto-extracted responseMleKID does not match manually configured responseMleKID. Using configured value as preference') + end + return configured_kid + end end diff --git a/lib/AuthenticationSDK/util/Utility.rb b/lib/AuthenticationSDK/util/Utility.rb index 269964a9..1949ea1c 100644 --- a/lib/AuthenticationSDK/util/Utility.rb +++ b/lib/AuthenticationSDK/util/Utility.rb @@ -1,5 +1,6 @@ require 'openssl' require 'base64' +require_relative '../util/Constants.rb' public @@ -32,5 +33,40 @@ def getResponseCodeMessage(responseCode) end return tempResponseCodeMessage end - end + def self.getCertificateBasedOnKeyAlias(certificateList, keyAlias) + return nil if certificateList.nil? + + certificateList.find do |cert| + cert.subject.to_a.any? { |_, value, _| value.include?(keyAlias) } + end + end + + def self.getCertificateCollectionAndPrivateKeyFromP12(certificateFilePath, keyPass) + p12File = File.binread(certificateFilePath) + p12Object = OpenSSL::PKCS12.new(p12File, keyPass) + + privateKey = OpenSSL::PKey::RSA.new(p12Object.key) + + primaryX5Certificate = p12Object.certificate + additionalX5Certificates = p12Object.ca_certs + + certificateList = [primaryX5Certificate] + certificateList.concat(additionalX5Certificates) if additionalX5Certificates + + return [privateKey, certificateList] + end + + def self.isP12GeneratedByCyberSource(filePath, password, logger = nil) + begin + _, certificateList = getCertificateCollectionAndPrivateKeyFromP12(filePath, password) + + foundCertificate = getCertificateBasedOnKeyAlias(certificateList, Constants::DEFAULT_ALIAS_FOR_MLE_CERT) + + return !foundCertificate.nil? + rescue => e + logger&.error("Error while checking if P12 is generated by CyberSource: #{e.message}") + return false + end + end + end