module MCollective module 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 # class Aes_security body[:sslkey], :data => body[:body]} if @initiated_by == :client body[:body] = deserialize(decrypt(cryptdata, nil)) else certname = certname_from_callerid(body[:callerid]) certfile = "#{client_cert_dir}/#{certname}.pem" # if aes.ca_cert is set every certificate is validated before we try and use it if @config.pluginconf.fetch("aes.ca_cert", nil) && !validate_certificate(File.read(certfile), certname) raise "Unable to validate certificate '#{certname}' against CA" end body[:body] = deserialize(decrypt(cryptdata, body[:callerid])) # If we got a hash it's possible that this is a message with secure # TTL and message time, attempt to decode that and transform into a # traditional message. # # If it's not a hash it might be a old style message like old discovery # ones that would just be a string so we allow that unaudited but only # if enforce_ttl is disabled. This is primarly to allow a mixed old and # new plugin infrastructure to work if body[:body].is_a?(Hash) update_secure_property(body, :aes_ttl, :ttl, "TTL") update_secure_property(body, :aes_msgtime, :msgtime, "Message Time") body[:body] = body[:body][:aes_msg] if body[:body].include?(:aes_msg) else unless @config.pluginconf["aes.enforce_ttl"] == "0" raise "Message %s is in an unknown or older security protocol, ignoring" % [request_description(body)] end end end return body rescue MsgDoesNotMatchRequestID raise rescue OpenSSL::PKey::RSAError raise MsgDoesNotMatchRequestID, "Could not decrypt message using our key, possibly directed at another client" rescue Exception => e Log.warn("Could not decrypt message from client: #{e.class}: #{e}") raise SecurityValidationFailed, "Could not decrypt message" 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 def update_secure_property(msg, secure_property, property, description) req = request_description(msg) unless @config.pluginconf["aes.enforce_ttl"] == "0" raise "Request #{req} does not have a secure #{description}" unless msg[:body].include?(secure_property) raise "Request #{req} #{description} does not match encrypted #{description} - possible tampering" unless msg[:body][secure_property] == msg[property] else if msg[:body].include?(secure_property) Log.warn("Request #{req} #{description} does not match encrypted #{description} - possible tampering") unless msg[:body][secure_property] == msg[property] else Log.warn("Request #{req} does not have a secure #{description}") unless msg[:body].include?(secure_property) end end msg[property] = msg[:body][secure_property] if msg[:body].include?(secure_property) msg[:body].delete(secure_property) end # Encodes a reply def encodereply(sender, msg, requestid, requestcallerid) crypted = encrypt(serialize(msg), requestcallerid) req = create_reply(requestid, sender, crypted[:data]) req[:sslkey] = crypted[:key] serialize(req) end # Encodes a request msg def encoderequest(sender, msg, requestid, filter, target_agent, target_collective, ttl=60) req = create_request(requestid, filter, nil, @initiated_by, target_agent, target_collective, ttl) # embed the ttl and msgtime in the crypted data later we will use these in # the decoding of a message to set the message ones from secure sources. this # is to ensure messages are not tampered with to facility replay attacks etc aes_msg = {:aes_msg => msg, :aes_ttl => ttl, :aes_msgtime => req[:msgtime]} crypted = encrypt(serialize(aes_msg), callerid) req[:body] = crypted[:data] req[:sslkey] = crypted[:key] if @config.pluginconf.include?("aes.send_pubkey") && @config.pluginconf["aes.send_pubkey"] == "1" if @initiated_by == :client req[:sslpubkey] = File.read(client_public_key) else req[:sslpubkey] = File.read(server_public_key) end end serialize(req) end # Serializes a message using the configured encoder def serialize(msg) serializer = @config.pluginconf["aes.serializer"] || "marshal" Log.debug("Serializing using #{serializer}") case serializer when "yaml" return YAML.dump(msg) else return Marshal.dump(msg) end end # De-Serializes a message using the configured encoder def deserialize(msg) serializer = @config.pluginconf["aes.serializer"] || "marshal" Log.debug("De-Serializing using #{serializer}") case serializer when "yaml" return YAML.load(msg) else return Marshal.load(msg) end end # sets the caller id to the md5 of the public key def callerid if @initiated_by == :client key = client_public_key else key = server_public_key end # First try and create a X509 certificate object. If that is possible, # we lift the callerid from the cert begin ssl_cert = OpenSSL::X509::Certificate.new(File.read(key)) id = "cert=#{certname_from_certificate(ssl_cert)}" rescue # If the public key is not a certificate, use the file name as callerid id = "cert=#{File.basename(key).gsub(/\.pem$/, '')}" end return id end def encrypt(string, certid) if @initiated_by == :client @ssl ||= SSL.new(client_public_key, client_private_key) Log.debug("Encrypting message using private key") return @ssl.encrypt_with_private(string) else # when the server is initating requests like for registration # then the certid will be our callerid if certid == callerid Log.debug("Encrypting message using private key #{server_private_key}") ssl = SSL.new(server_public_key, server_private_key) return ssl.encrypt_with_private(string) else Log.debug("Encrypting message using public key for #{certid}") ssl = SSL.new(public_key_path_for_client(certid)) return ssl.encrypt_with_public(string) end end end def decrypt(string, certid) if @initiated_by == :client @ssl ||= SSL.new(client_public_key, client_private_key) Log.debug("Decrypting message using private key") return @ssl.decrypt_with_private(string) else Log.debug("Decrypting message using public key for #{certid}") ssl = SSL.new(public_key_path_for_client(certid)) return ssl.decrypt_with_public(string) end end def validate_certificate(client_cert, certid) cert_file = @config.pluginconf.fetch("aes.ca_cert", nil) begin ssl_cert = OpenSSL::X509::Certificate.new(client_cert) rescue OpenSSL::X509::CertificateError Log.warn("Received public key that is not a X509 certficate") return false end ssl_certname = certname_from_certificate(ssl_cert) if certid != ssl_certname Log.warn("certname '#{certid}' doesn't match certificate '#{ssl_certname}'") return false end Log.debug("Loading CA Cert for verification") ca_cert = OpenSSL::X509::Store.new ca_cert.add_file cert_file if ca_cert.verify(ssl_cert) Log.debug("Verified certificate '#{ssl_certname}' against CA") else # TODO add cert id Log.warn("Unable to validate certificate '#{ssl_certname}'' against CA") return false end return true 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 def public_key_path_for_client(clientid) raise "Unknown callerid format in '#{clientid}'" unless clientid.match(/^cert=(.+)$/) clientid = $1 client_cert_dir + "/#{clientid}.pem" end # Figures out the client private key either from MCOLLECTIVE_AES_PRIVATE or the # plugin.aes.client_private config option def client_private_key return ENV["MCOLLECTIVE_AES_PRIVATE"] if ENV.include?("MCOLLECTIVE_AES_PRIVATE") raise("No plugin.aes.client_private configuration option specified") unless @config.pluginconf.include?("aes.client_private") return @config.pluginconf["aes.client_private"] end # Figures out the client public key either from MCOLLECTIVE_AES_PUBLIC or the # plugin.aes.client_public config option def client_public_key return ENV["MCOLLECTIVE_AES_PUBLIC"] if ENV.include?("MCOLLECTIVE_AES_PUBLIC") raise("No plugin.aes.client_public configuration option specified") unless @config.pluginconf.include?("aes.client_public") return @config.pluginconf["aes.client_public"] end # Figures out the server public key from the plugin.aes.server_public config option def server_public_key raise("No aes.server_public configuration option specified") unless @config.pluginconf.include?("aes.server_public") return @config.pluginconf["aes.server_public"] end # Figures out the server private key from the plugin.aes.server_private config option def server_private_key raise("No plugin.aes.server_private configuration option specified") unless @config.pluginconf.include?("aes.server_private") @config.pluginconf["aes.server_private"] end # Figures out where to get client public certs from the plugin.aes.client_cert_dir config option def client_cert_dir raise("No plugin.aes.client_cert_dir configuration option specified") unless @config.pluginconf.include?("aes.client_cert_dir") @config.pluginconf["aes.client_cert_dir"] end def request_description(msg) "%s from %s@%s" % [msg[:requestid], msg[:callerid], msg[:senderid]] end # Takes our cert=foo callerids and return the foo bit else nil def certname_from_callerid(id) if id =~ /^cert=([\w\.\-]+)/ return $1 else raise("Received a callerid in an unexpected format: '#{id}', ignoring") end end def certname_from_certificate(cert) id = cert.subject if id.to_s =~ /^\/CN=([\w\.\-]+)/ return $1 else raise("Received a callerid in an unexpected format in an SSL certificate: '#{id}', ignoring") end end end end end