Updated mcollective.init according to OSCI-658
[packages/precise/mcollective.git] / plugins / mcollective / security / aes_security.rb
diff --git a/plugins/mcollective/security/aes_security.rb b/plugins/mcollective/security/aes_security.rb
new file mode 100644 (file)
index 0000000..4f2a55c
--- /dev/null
@@ -0,0 +1,329 @@
+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
+    #    plugin.aes.learn_pubkeys = 1
+    #
+    #    # 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<Base
+      def decodemsg(msg)
+        body = deserialize(msg.payload)
+
+        should_process_msg?(msg, body[:requestid])
+
+        # if we get a message that has a pubkey attached and we're set to learn
+        # then add it to the client_cert_dir this should only happen on servers
+        # since clients will get replies using their own pubkeys
+        if @config.pluginconf.include?("aes.learn_pubkeys") && @config.pluginconf["aes.learn_pubkeys"] == "1"
+          if body.include?(:sslpubkey)
+            if client_cert_dir
+              certname = certname_from_callerid(body[:callerid])
+              if certname
+                certfile = "#{client_cert_dir}/#{certname}.pem"
+                unless File.exist?(certfile)
+                  Log.debug("Caching client cert in #{certfile}")
+                  File.open(certfile, "w") {|f| f.print body[:sslpubkey]}
+                end
+              end
+            end
+          end
+        end
+
+        cryptdata = {:key => body[:sslkey], :data => body[:body]}
+
+        if @initiated_by == :client
+          body[:body] = deserialize(decrypt(cryptdata, nil))
+        else
+          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
+          id = "cert=#{File.basename(client_public_key).gsub(/\.pem$/, '')}"
+          raise "Invalid callerid generated from client public key" unless valid_callerid?(id)
+        else
+          # servers need to set callerid as well, not usually needed but
+          # would be if you're doing registration or auditing or generating
+          # requests for some or other reason
+          id = "cert=#{File.basename(server_public_key).gsub(/\.pem$/, '')}"
+          raise "Invalid callerid generated from server public key" unless valid_callerid?(id)
+        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
+
+      # 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
+          Log.warn("Received a callerid in an unexpected format: '#{id}', ignoring")
+          return nil
+        end
+      end
+    end
+  end
+end