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:

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