class MCollective::Security::Aes_security - mcollective version 2.12.5

# 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

callerid() click to toggle source

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

certname_from_callerid(id) click to toggle source

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

certname_from_certificate(cert) click to toggle source

        # 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

client_cert_dir() click to toggle source

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

client_private_key() click to toggle source

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

client_public_key() click to toggle source

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

decodemsg(msg) click to toggle source

        # 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

decrypt(string, certid) click to toggle source

        # 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

deserialize(msg) click to toggle source

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

encodereply(sender, msg, requestid, requestcallerid) click to toggle source

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

encoderequest(sender, msg, requestid, filter, target_agent, target_collective, ttl=60) click to toggle source

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

encrypt(string, certid) click to toggle source

        # 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

public_key_path_for_client(clientid) click to toggle source

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

request_description(msg) click to toggle source

        # 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

serialize(msg) click to toggle source

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

server_private_key() click to toggle source

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

server_public_key() click to toggle source

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

update_secure_property(msg, secure_property, property, description) click to toggle source

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

validate_certificate(client_cert, certid) click to toggle source

        # 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