class MCollective::Security::Aes_security
Impliments a security system that encrypts payloads using AES and secures the AES encrypted data using RSA public/private key encryption.
The design goals of this plugin are:
-
Each actor - clients and servers - can have their own set of public and private keys
-
All actors are uniquely and cryptographically identified
-
Requests are encrypted using the clients private key and anyone that has the public key can see the request. Thus an atacker may see the requests given access to network or machine due to the broadcast nature of mcollective
-
The message time and TTL of messages are cryptographically secured making the ensuring messages can not be replayed with fake TTLs or times
-
Replies are encrypted using the calling clients public key. Thus no-one but the caller can view the contents of replies.
-
Servers can all have their own RSA keys, or share one, or reuse keys created by other PKI using software like
Puppet
-
Requests from servers - like registration data - can be secured even to external eaves droppers depending on the level of configuration you are prepared to do
-
Given a network where you can ensure third parties are not able to access the middleware public key distribution can happen automatically
Configuration Options:
¶ ↑
Common Options:
# Enable this plugin securityprovider = aes_security # Use YAML as serializer plugin.aes.serializer = yaml # Send our public key with every request so servers can learn it plugin.aes.send_pubkey = 1
Clients:
# The clients public and private keys plugin.aes.client_private = /home/user/.mcollective.d/user-private.pem plugin.aes.client_public = /home/user/.mcollective.d/user.pem
Servers:
# Where to cache client keys or find manually distributed ones plugin.aes.client_cert_dir = /etc/mcollective/ssl/clients # Cache public keys promiscuously from the network (this requires either a ca_cert to be set or insecure_learning to be enabled) plugin.aes.learn_pubkeys = 1 # Do not check if client certificate can be verified by a CA plugin.aes.insecure_learning = 1 # CA cert used to verify public keys when in learning mode plugin.aes.ca_cert = /etc/mcollective/ssl/ca.cert # Log but accept messages that may have been tampered with plugin.aes.enforce_ttl = 0 # The servers public and private keys plugin.aes.server_private = /etc/mcollective/ssl/server-private.pem plugin.aes.server_public = /etc/mcollective/ssl/server-public.pem
Public Instance Methods
sets the caller id to the md5 of the public key
# File lib/mcollective/security/aes_security.rb 239 def callerid 240 if @initiated_by == :client 241 key = client_public_key 242 else 243 key = server_public_key 244 end 245 246 # First try and create a X509 certificate object. If that is possible, 247 # we lift the callerid from the cert 248 begin 249 ssl_cert = OpenSSL::X509::Certificate.new(File.read(key)) 250 id = "cert=#{certname_from_certificate(ssl_cert)}" 251 rescue 252 # If the public key is not a certificate, use the file name as callerid 253 id = "cert=#{File.basename(key).gsub(/\.pem$/, '')}" 254 end 255 256 return id 257 end
Takes our cert=foo callerids and return the foo bit else nil
# File lib/mcollective/security/aes_security.rb 380 def certname_from_callerid(id) 381 if id =~ /^cert=([\w\.\-]+)/ 382 return $1 383 else 384 raise("Received a callerid in an unexpected format: '#{id}', ignoring") 385 end 386 end
# File lib/mcollective/security/aes_security.rb 388 def certname_from_certificate(cert) 389 id = cert.subject 390 if id.to_s =~ /^\/CN=([\w\.\-]+)/ 391 return $1 392 else 393 raise("Received a callerid in an unexpected format in an SSL certificate: '#{id}', ignoring") 394 end 395 end
Figures out where to get client public certs from the plugin.aes.client_cert_dir config option
# File lib/mcollective/security/aes_security.rb 370 def client_cert_dir 371 raise("No plugin.aes.client_cert_dir configuration option specified") unless @config.pluginconf.include?("aes.client_cert_dir") 372 @config.pluginconf["aes.client_cert_dir"] 373 end
Figures out the client private key either from MCOLLECTIVE_AES_PRIVATE or the plugin.aes.client_private config option
# File lib/mcollective/security/aes_security.rb 339 def client_private_key 340 return ENV["MCOLLECTIVE_AES_PRIVATE"] if ENV.include?("MCOLLECTIVE_AES_PRIVATE") 341 342 raise("No plugin.aes.client_private configuration option specified") unless @config.pluginconf.include?("aes.client_private") 343 344 return @config.pluginconf["aes.client_private"] 345 end
Figures out the client public key either from MCOLLECTIVE_AES_PUBLIC or the plugin.aes.client_public config option
# File lib/mcollective/security/aes_security.rb 349 def client_public_key 350 return ENV["MCOLLECTIVE_AES_PUBLIC"] if ENV.include?("MCOLLECTIVE_AES_PUBLIC") 351 352 raise("No plugin.aes.client_public configuration option specified") unless @config.pluginconf.include?("aes.client_public") 353 354 return @config.pluginconf["aes.client_public"] 355 end
# File lib/mcollective/security/aes_security.rb 68 def decodemsg(msg) 69 body = deserialize(msg.payload) 70 71 should_process_msg?(msg, body[:requestid]) 72 # if we get a message that has a pubkey attached and we're set to learn 73 # then add it to the client_cert_dir this should only happen on servers 74 # since clients will get replies using their own pubkeys 75 if Util.str_to_bool(@config.pluginconf.fetch("aes.learn_pubkeys", false)) && body.include?(:sslpubkey) 76 certname = certname_from_callerid(body[:callerid]) 77 certfile = "#{client_cert_dir}/#{certname}.pem" 78 if !File.exist?(certfile) 79 if !Util.str_to_bool(@config.pluginconf.fetch("aes.insecure_learning", false)) 80 if !@config.pluginconf.fetch("aes.ca_cert", nil) 81 raise "Cannot verify certificate for '#{certname}'. No CA certificate specified." 82 end 83 84 if !validate_certificate(body[:sslpubkey], certname) 85 raise "Unable to validate certificate '#{certname}' against CA" 86 end 87 88 Log.debug("Verified certificate '#{certname}' against CA") 89 else 90 Log.warn("Insecure key learning is not a secure method of key distribution. Do NOT use this mode in sensitive environments.") 91 end 92 93 Log.debug("Caching client cert in #{certfile}") 94 File.open(certfile, "w") {|f| f.print body[:sslpubkey]} 95 else 96 Log.debug("Not caching client cert. File #{certfile} already exists.") 97 end 98 end 99 100 cryptdata = {:key => body[:sslkey], :data => body[:body]} 101 102 if @initiated_by == :client 103 body[:body] = deserialize(decrypt(cryptdata, nil)) 104 else 105 certname = certname_from_callerid(body[:callerid]) 106 certfile = "#{client_cert_dir}/#{certname}.pem" 107 # if aes.ca_cert is set every certificate is validated before we try and use it 108 if @config.pluginconf.fetch("aes.ca_cert", nil) && !validate_certificate(File.read(certfile), certname) 109 raise "Unable to validate certificate '#{certname}' against CA" 110 end 111 body[:body] = deserialize(decrypt(cryptdata, body[:callerid])) 112 113 # If we got a hash it's possible that this is a message with secure 114 # TTL and message time, attempt to decode that and transform into a 115 # traditional message. 116 # 117 # If it's not a hash it might be a old style message like old discovery 118 # ones that would just be a string so we allow that unaudited but only 119 # if enforce_ttl is disabled. This is primarly to allow a mixed old and 120 # new plugin infrastructure to work 121 if body[:body].is_a?(Hash) 122 update_secure_property(body, :aes_ttl, :ttl, "TTL") 123 update_secure_property(body, :aes_msgtime, :msgtime, "Message Time") 124 125 body[:body] = body[:body][:aes_msg] if body[:body].include?(:aes_msg) 126 else 127 unless @config.pluginconf["aes.enforce_ttl"] == "0" 128 raise "Message %s is in an unknown or older security protocol, ignoring" % [request_description(body)] 129 end 130 end 131 end 132 133 return body 134 rescue MsgDoesNotMatchRequestID 135 raise 136 137 rescue OpenSSL::PKey::RSAError 138 raise MsgDoesNotMatchRequestID, "Could not decrypt message using our key, possibly directed at another client" 139 140 rescue Exception => e 141 Log.warn("Could not decrypt message from client: #{e.class}: #{e}") 142 raise SecurityValidationFailed, "Could not decrypt message" 143 end
# File lib/mcollective/security/aes_security.rb 282 def decrypt(string, certid) 283 if @initiated_by == :client 284 @ssl ||= SSL.new(client_public_key, client_private_key) 285 286 Log.debug("Decrypting message using private key") 287 return @ssl.decrypt_with_private(string) 288 else 289 Log.debug("Decrypting message using public key for #{certid}") 290 ssl = SSL.new(public_key_path_for_client(certid)) 291 return ssl.decrypt_with_public(string) 292 end 293 end
De-Serializes a message using the configured encoder
# File lib/mcollective/security/aes_security.rb 221 def deserialize(msg) 222 serializer = @config.pluginconf["aes.serializer"] || "marshal" 223 224 Log.debug("De-Serializing using #{serializer}") 225 226 case serializer 227 when "yaml" 228 if YAML.respond_to? :safe_load 229 return YAML.safe_load(msg, [Symbol, Regexp]) 230 else 231 raise "YAML.safe_load not supported by Ruby #{RUBY_VERSION}. Please update to Ruby 2.1+." 232 end 233 else 234 return Marshal.load(msg) 235 end 236 end
Encodes a reply
# File lib/mcollective/security/aes_security.rb 170 def encodereply(sender, msg, requestid, requestcallerid) 171 crypted = encrypt(serialize(msg), requestcallerid) 172 173 req = create_reply(requestid, sender, crypted[:data]) 174 req[:sslkey] = crypted[:key] 175 176 serialize(req) 177 end
Encodes a request msg
# File lib/mcollective/security/aes_security.rb 180 def encoderequest(sender, msg, requestid, filter, target_agent, target_collective, ttl=60) 181 req = create_request(requestid, filter, nil, @initiated_by, target_agent, target_collective, ttl) 182 183 # embed the ttl and msgtime in the crypted data later we will use these in 184 # the decoding of a message to set the message ones from secure sources. this 185 # is to ensure messages are not tampered with to facility replay attacks etc 186 aes_msg = {:aes_msg => msg, 187 :aes_ttl => ttl, 188 :aes_msgtime => req[:msgtime]} 189 190 crypted = encrypt(serialize(aes_msg), callerid) 191 192 req[:body] = crypted[:data] 193 req[:sslkey] = crypted[:key] 194 195 if @config.pluginconf.include?("aes.send_pubkey") && @config.pluginconf["aes.send_pubkey"] == "1" 196 if @initiated_by == :client 197 req[:sslpubkey] = File.read(client_public_key) 198 else 199 req[:sslpubkey] = File.read(server_public_key) 200 end 201 end 202 203 serialize(req) 204 end
# File lib/mcollective/security/aes_security.rb 259 def encrypt(string, certid) 260 if @initiated_by == :client 261 @ssl ||= SSL.new(client_public_key, client_private_key) 262 263 Log.debug("Encrypting message using private key") 264 return @ssl.encrypt_with_private(string) 265 else 266 # when the server is initating requests like for registration 267 # then the certid will be our callerid 268 if certid == callerid 269 Log.debug("Encrypting message using private key #{server_private_key}") 270 271 ssl = SSL.new(server_public_key, server_private_key) 272 return ssl.encrypt_with_private(string) 273 else 274 Log.debug("Encrypting message using public key for #{certid}") 275 276 ssl = SSL.new(public_key_path_for_client(certid)) 277 return ssl.encrypt_with_public(string) 278 end 279 end 280 end
On servers this will look in the aes.client_cert_dir for public keys matching the clientid, clientid is expected to be in the format set by callerid
# File lib/mcollective/security/aes_security.rb 329 def public_key_path_for_client(clientid) 330 raise "Unknown callerid format in '#{clientid}'" unless clientid.match(/^cert=(.+)$/) 331 332 clientid = $1 333 334 client_cert_dir + "/#{clientid}.pem" 335 end
# File lib/mcollective/security/aes_security.rb 375 def request_description(msg) 376 "%s from %s@%s" % [msg[:requestid], msg[:callerid], msg[:senderid]] 377 end
Serializes a message using the configured encoder
# File lib/mcollective/security/aes_security.rb 207 def serialize(msg) 208 serializer = @config.pluginconf["aes.serializer"] || "marshal" 209 210 Log.debug("Serializing using #{serializer}") 211 212 case serializer 213 when "yaml" 214 return YAML.dump(msg) 215 else 216 return Marshal.dump(msg) 217 end 218 end
Figures out the server private key from the plugin.aes.server_private config option
# File lib/mcollective/security/aes_security.rb 364 def server_private_key 365 raise("No plugin.aes.server_private configuration option specified") unless @config.pluginconf.include?("aes.server_private") 366 @config.pluginconf["aes.server_private"] 367 end
Figures out the server public key from the plugin.aes.server_public config option
# File lib/mcollective/security/aes_security.rb 358 def server_public_key 359 raise("No aes.server_public configuration option specified") unless @config.pluginconf.include?("aes.server_public") 360 return @config.pluginconf["aes.server_public"] 361 end
To avoid tampering we turn the origin body into a hash and copy some of the protocol keys like :ttl and :msg_time into the hash before encrypting it.
This function compares and updates the unencrypted ones based on the encrypted ones. By default it enforces matching and presense by raising exceptions, if aes.enforce_ttl is set to 0 it will only log warnings about violations
# File lib/mcollective/security/aes_security.rb 151 def update_secure_property(msg, secure_property, property, description) 152 req = request_description(msg) 153 154 unless @config.pluginconf["aes.enforce_ttl"] == "0" 155 raise "Request #{req} does not have a secure #{description}" unless msg[:body].include?(secure_property) 156 raise "Request #{req} #{description} does not match encrypted #{description} - possible tampering" unless msg[:body][secure_property] == msg[property] 157 else 158 if msg[:body].include?(secure_property) 159 Log.warn("Request #{req} #{description} does not match encrypted #{description} - possible tampering") unless msg[:body][secure_property] == msg[property] 160 else 161 Log.warn("Request #{req} does not have a secure #{description}") unless msg[:body].include?(secure_property) 162 end 163 end 164 165 msg[property] = msg[:body][secure_property] if msg[:body].include?(secure_property) 166 msg[:body].delete(secure_property) 167 end
# File lib/mcollective/security/aes_security.rb 295 def validate_certificate(client_cert, certid) 296 cert_file = @config.pluginconf.fetch("aes.ca_cert", nil) 297 298 begin 299 ssl_cert = OpenSSL::X509::Certificate.new(client_cert) 300 rescue OpenSSL::X509::CertificateError 301 Log.warn("Received public key that is not a X509 certficate") 302 return false 303 end 304 305 ssl_certname = certname_from_certificate(ssl_cert) 306 307 if certid != ssl_certname 308 Log.warn("certname '#{certid}' doesn't match certificate '#{ssl_certname}'") 309 return false 310 end 311 312 Log.debug("Loading CA Cert for verification") 313 ca_cert = OpenSSL::X509::Store.new 314 ca_cert.add_file cert_file 315 316 if ca_cert.verify(ssl_cert) 317 Log.debug("Verified certificate '#{ssl_certname}' against CA") 318 else 319 # TODO add cert id 320 Log.warn("Unable to validate certificate '#{ssl_certname}'' against CA") 321 return false 322 end 323 return true 324 end