4f2a55c6c7d3bddd7268511e02ba7d92e7ce4a49
[packages/precise/mcollective.git] / plugins / mcollective / security / aes_security.rb
1 module MCollective
2   module Security
3     # Impliments a security system that encrypts payloads using AES and secures
4     # the AES encrypted data using RSA public/private key encryption.
5     #
6     # The design goals of this plugin are:
7     #
8     # - Each actor - clients and servers - can have their own set of public and
9     #   private keys
10     # - All actors are uniquely and cryptographically identified
11     # - Requests are encrypted using the clients private key and anyone that has
12     #   the public key can see the request.  Thus an atacker may see the requests
13     #   given access to network or machine due to the broadcast nature of mcollective
14     # - The message time and TTL of messages are cryptographically secured making the
15     #   ensuring messages can not be replayed with fake TTLs or times
16     # - Replies are encrypted using the calling clients public key.  Thus no-one but
17     #   the caller can view the contents of replies.
18     # - Servers can all have their own RSA keys, or share one, or reuse keys created
19     #   by other PKI using software like Puppet
20     # - Requests from servers - like registration data - can be secured even to external
21     #   eaves droppers depending on the level of configuration you are prepared to do
22     # - Given a network where you can ensure third parties are not able to access the
23     #   middleware public key distribution can happen automatically
24     #
25     # Configuration Options:
26     # ======================
27     #
28     # Common Options:
29     #
30     #    # Enable this plugin
31     #    securityprovider = aes_security
32     #
33     #    # Use YAML as serializer
34     #    plugin.aes.serializer = yaml
35     #
36     #    # Send our public key with every request so servers can learn it
37     #    plugin.aes.send_pubkey = 1
38     #
39     # Clients:
40     #
41     #    # The clients public and private keys
42     #    plugin.aes.client_private = /home/user/.mcollective.d/user-private.pem
43     #    plugin.aes.client_public = /home/user/.mcollective.d/user.pem
44     #
45     # Servers:
46     #
47     #    # Where to cache client keys or find manually distributed ones
48     #    plugin.aes.client_cert_dir = /etc/mcollective/ssl/clients
49     #
50     #    # Cache public keys promiscuously from the network
51     #    plugin.aes.learn_pubkeys = 1
52     #
53     #    # Log but accept messages that may have been tampered with
54     #    plugin.aes.enforce_ttl = 0
55     #
56     #    # The servers public and private keys
57     #    plugin.aes.server_private = /etc/mcollective/ssl/server-private.pem
58     #    plugin.aes.server_public = /etc/mcollective/ssl/server-public.pem
59     #
60     class Aes_security<Base
61       def decodemsg(msg)
62         body = deserialize(msg.payload)
63
64         should_process_msg?(msg, body[:requestid])
65
66         # if we get a message that has a pubkey attached and we're set to learn
67         # then add it to the client_cert_dir this should only happen on servers
68         # since clients will get replies using their own pubkeys
69         if @config.pluginconf.include?("aes.learn_pubkeys") && @config.pluginconf["aes.learn_pubkeys"] == "1"
70           if body.include?(:sslpubkey)
71             if client_cert_dir
72               certname = certname_from_callerid(body[:callerid])
73               if certname
74                 certfile = "#{client_cert_dir}/#{certname}.pem"
75                 unless File.exist?(certfile)
76                   Log.debug("Caching client cert in #{certfile}")
77                   File.open(certfile, "w") {|f| f.print body[:sslpubkey]}
78                 end
79               end
80             end
81           end
82         end
83
84         cryptdata = {:key => body[:sslkey], :data => body[:body]}
85
86         if @initiated_by == :client
87           body[:body] = deserialize(decrypt(cryptdata, nil))
88         else
89           body[:body] = deserialize(decrypt(cryptdata, body[:callerid]))
90
91           # If we got a hash it's possible that this is a message with secure
92           # TTL and message time, attempt to decode that and transform into a
93           # traditional message.
94           #
95           # If it's not a hash it might be a old style message like old discovery
96           # ones that would just be a string so we allow that unaudited but only
97           # if enforce_ttl is disabled.  This is primarly to allow a mixed old and
98           # new plugin infrastructure to work
99           if body[:body].is_a?(Hash)
100             update_secure_property(body, :aes_ttl, :ttl, "TTL")
101             update_secure_property(body, :aes_msgtime, :msgtime, "Message Time")
102
103             body[:body] = body[:body][:aes_msg] if body[:body].include?(:aes_msg)
104           else
105             unless @config.pluginconf["aes.enforce_ttl"] == "0"
106               raise "Message %s is in an unknown or older security protocol, ignoring" % [request_description(body)]
107             end
108           end
109         end
110
111         return body
112       rescue MsgDoesNotMatchRequestID
113         raise
114
115       rescue OpenSSL::PKey::RSAError
116         raise MsgDoesNotMatchRequestID, "Could not decrypt message using our key, possibly directed at another client"
117
118       rescue Exception => e
119         Log.warn("Could not decrypt message from client: #{e.class}: #{e}")
120         raise SecurityValidationFailed, "Could not decrypt message"
121       end
122
123       # To avoid tampering we turn the origin body into a hash and copy some of the protocol keys
124       # like :ttl and :msg_time into the hash before encrypting it.
125       #
126       # This function compares and updates the unencrypted ones based on the encrypted ones.  By
127       # default it enforces matching and presense by raising exceptions, if aes.enforce_ttl is set
128       # to 0 it will only log warnings about violations
129       def update_secure_property(msg, secure_property, property, description)
130         req = request_description(msg)
131
132         unless @config.pluginconf["aes.enforce_ttl"] == "0"
133           raise "Request #{req} does not have a secure #{description}" unless msg[:body].include?(secure_property)
134           raise "Request #{req} #{description} does not match encrypted #{description} - possible tampering"  unless msg[:body][secure_property] == msg[property]
135         else
136           if msg[:body].include?(secure_property)
137             Log.warn("Request #{req} #{description} does not match encrypted #{description} - possible tampering") unless msg[:body][secure_property] == msg[property]
138           else
139             Log.warn("Request #{req} does not have a secure #{description}") unless msg[:body].include?(secure_property)
140           end
141         end
142
143         msg[property] = msg[:body][secure_property] if msg[:body].include?(secure_property)
144         msg[:body].delete(secure_property)
145       end
146
147       # Encodes a reply
148       def encodereply(sender, msg, requestid, requestcallerid)
149         crypted = encrypt(serialize(msg), requestcallerid)
150
151         req = create_reply(requestid, sender, crypted[:data])
152         req[:sslkey] = crypted[:key]
153
154         serialize(req)
155       end
156
157       # Encodes a request msg
158       def encoderequest(sender, msg, requestid, filter, target_agent, target_collective, ttl=60)
159         req = create_request(requestid, filter, nil, @initiated_by, target_agent, target_collective, ttl)
160
161         # embed the ttl and msgtime in the crypted data later we will use these in
162         # the decoding of a message to set the message ones from secure sources. this
163         # is to ensure messages are not tampered with to facility replay attacks etc
164         aes_msg = {:aes_msg => msg,
165           :aes_ttl => ttl,
166           :aes_msgtime => req[:msgtime]}
167
168         crypted = encrypt(serialize(aes_msg), callerid)
169
170         req[:body] = crypted[:data]
171         req[:sslkey] = crypted[:key]
172
173         if @config.pluginconf.include?("aes.send_pubkey") && @config.pluginconf["aes.send_pubkey"] == "1"
174           if @initiated_by == :client
175             req[:sslpubkey] = File.read(client_public_key)
176           else
177             req[:sslpubkey] = File.read(server_public_key)
178           end
179         end
180
181         serialize(req)
182       end
183
184       # Serializes a message using the configured encoder
185       def serialize(msg)
186         serializer = @config.pluginconf["aes.serializer"] || "marshal"
187
188         Log.debug("Serializing using #{serializer}")
189
190         case serializer
191         when "yaml"
192           return YAML.dump(msg)
193         else
194           return Marshal.dump(msg)
195         end
196       end
197
198       # De-Serializes a message using the configured encoder
199       def deserialize(msg)
200         serializer = @config.pluginconf["aes.serializer"] || "marshal"
201
202         Log.debug("De-Serializing using #{serializer}")
203
204         case serializer
205         when "yaml"
206           return YAML.load(msg)
207         else
208           return Marshal.load(msg)
209         end
210       end
211
212       # sets the caller id to the md5 of the public key
213       def callerid
214         if @initiated_by == :client
215           id = "cert=#{File.basename(client_public_key).gsub(/\.pem$/, '')}"
216           raise "Invalid callerid generated from client public key" unless valid_callerid?(id)
217         else
218           # servers need to set callerid as well, not usually needed but
219           # would be if you're doing registration or auditing or generating
220           # requests for some or other reason
221           id = "cert=#{File.basename(server_public_key).gsub(/\.pem$/, '')}"
222           raise "Invalid callerid generated from server public key" unless valid_callerid?(id)
223         end
224
225         return id
226       end
227
228       def encrypt(string, certid)
229         if @initiated_by == :client
230           @ssl ||= SSL.new(client_public_key, client_private_key)
231
232           Log.debug("Encrypting message using private key")
233           return @ssl.encrypt_with_private(string)
234         else
235           # when the server is initating requests like for registration
236           # then the certid will be our callerid
237           if certid == callerid
238             Log.debug("Encrypting message using private key #{server_private_key}")
239
240             ssl = SSL.new(server_public_key, server_private_key)
241             return ssl.encrypt_with_private(string)
242           else
243             Log.debug("Encrypting message using public key for #{certid}")
244
245             ssl = SSL.new(public_key_path_for_client(certid))
246             return ssl.encrypt_with_public(string)
247           end
248         end
249       end
250
251       def decrypt(string, certid)
252         if @initiated_by == :client
253           @ssl ||= SSL.new(client_public_key, client_private_key)
254
255           Log.debug("Decrypting message using private key")
256           return @ssl.decrypt_with_private(string)
257         else
258           Log.debug("Decrypting message using public key for #{certid}")
259
260           ssl = SSL.new(public_key_path_for_client(certid))
261           return ssl.decrypt_with_public(string)
262         end
263       end
264
265       # On servers this will look in the aes.client_cert_dir for public
266       # keys matching the clientid, clientid is expected to be in the format
267       # set by callerid
268       def public_key_path_for_client(clientid)
269         raise "Unknown callerid format in '#{clientid}'" unless clientid.match(/^cert=(.+)$/)
270
271         clientid = $1
272
273         client_cert_dir + "/#{clientid}.pem"
274       end
275
276       # Figures out the client private key either from MCOLLECTIVE_AES_PRIVATE or the
277       # plugin.aes.client_private config option
278       def client_private_key
279         return ENV["MCOLLECTIVE_AES_PRIVATE"] if ENV.include?("MCOLLECTIVE_AES_PRIVATE")
280
281         raise("No plugin.aes.client_private configuration option specified") unless @config.pluginconf.include?("aes.client_private")
282
283         return @config.pluginconf["aes.client_private"]
284       end
285
286       # Figures out the client public key either from MCOLLECTIVE_AES_PUBLIC or the
287       # plugin.aes.client_public config option
288       def client_public_key
289         return ENV["MCOLLECTIVE_AES_PUBLIC"] if ENV.include?("MCOLLECTIVE_AES_PUBLIC")
290
291         raise("No plugin.aes.client_public configuration option specified") unless @config.pluginconf.include?("aes.client_public")
292
293         return @config.pluginconf["aes.client_public"]
294       end
295
296       # Figures out the server public key from the plugin.aes.server_public config option
297       def server_public_key
298         raise("No aes.server_public configuration option specified") unless @config.pluginconf.include?("aes.server_public")
299         return @config.pluginconf["aes.server_public"]
300       end
301
302       # Figures out the server private key from the plugin.aes.server_private config option
303       def server_private_key
304         raise("No plugin.aes.server_private configuration option specified") unless @config.pluginconf.include?("aes.server_private")
305         @config.pluginconf["aes.server_private"]
306       end
307
308       # Figures out where to get client public certs from the plugin.aes.client_cert_dir config option
309       def client_cert_dir
310         raise("No plugin.aes.client_cert_dir configuration option specified") unless @config.pluginconf.include?("aes.client_cert_dir")
311         @config.pluginconf["aes.client_cert_dir"]
312       end
313
314       def request_description(msg)
315         "%s from %s@%s" % [msg[:requestid], msg[:callerid], msg[:senderid]]
316       end
317
318       # Takes our cert=foo callerids and return the foo bit else nil
319       def certname_from_callerid(id)
320         if id =~ /^cert=([\w\.\-]+)/
321           return $1
322         else
323           Log.warn("Received a callerid in an unexpected format: '#{id}', ignoring")
324           return nil
325         end
326       end
327     end
328   end
329 end