lib/sup/crypto.rb (18212B) - raw
1 begin
2 require 'gpgme'
3 rescue LoadError
4 end
5
6 module Redwood
7
8 class CryptoManager
9 include Redwood::Singleton
10
11 class Error < StandardError; end
12
13 OUTGOING_MESSAGE_OPERATIONS = {
14 sign: "Sign",
15 sign_and_encrypt: "Sign and encrypt",
16 encrypt: "Encrypt only"
17 }
18
19 KEY_PATTERN = /(-----BEGIN PGP PUBLIC KEY BLOCK.*-----END PGP PUBLIC KEY BLOCK)/m
20 KEYSERVER_URL = "http://pool.sks-keyservers.net:11371/pks/lookup"
21
22 HookManager.register "gpg-options", <<EOS
23 Runs before gpg is called, allowing you to modify the options (most
24 likely you would want to add something to certain commands, like
25 {:always_trust => true} to encrypting a message, but who knows).
26
27 Variables:
28 operation: what operation will be done ("sign", "encrypt", "decrypt" or "verify")
29 options: a dictionary of values to be passed to GPGME
30
31 Return value: a dictionary to be passed to GPGME
32 EOS
33
34 HookManager.register "sig-output", <<EOS
35 Runs when the signature output is being generated, allowing you to
36 add extra information to your signatures if you want.
37
38 Variables:
39 signature: the signature object (class is GPGME::Signature)
40 from_key: the key that generated the signature (class is GPGME::Key)
41
42 Return value: an array of lines of output
43 EOS
44
45 HookManager.register "gpg-expand-keys", <<EOS
46 Runs when the list of encryption recipients is created, allowing you to
47 replace a recipient with one or more GPGME recipients. For example, you could
48 replace the email address of a mailing list with the key IDs that belong to
49 the recipients of that list. This is essentially what GPG groups do, which
50 are not supported by GPGME.
51
52 Variables:
53 recipients: an array of recipients of the current email
54
55 Return value: an array of recipients (email address or GPG key ID) to encrypt
56 the email for
57 EOS
58
59 def initialize
60 @mutex = Mutex.new
61
62 @not_working_reason = nil
63
64 # test if the gpgme gem is available
65 @gpgme_present =
66 begin
67 begin
68 begin
69 GPGME.check_version({:protocol => GPGME::PROTOCOL_OpenPGP})
70 rescue TypeError
71 GPGME.check_version(nil)
72 end
73 true
74 rescue GPGME::Error
75 false
76 rescue ArgumentError
77 # gpgme 2.0.0 raises this due to the hash->string conversion
78 false
79 end
80 rescue NameError
81 false
82 end
83
84 unless @gpgme_present
85 @not_working_reason = ['gpgme gem not present',
86 'Install the gpgme gem in order to use signed and encrypted emails']
87 return
88 end
89
90 # if gpg2 is available, it will start gpg-agent if required
91 if (bin = `which gpg2`.chomp) =~ /\S/
92 if GPGME.respond_to?('set_engine_info')
93 GPGME.set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
94 else
95 GPGME.gpgme_set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
96 end
97 else
98 # check if the gpg-options hook uses the passphrase_callback
99 # if it doesn't then check if gpg agent is present
100 gpg_opts = HookManager.run("gpg-options",
101 {:operation => "sign", :options => {}}) || {}
102 if gpg_opts[:passphrase_callback].nil?
103 if ENV['GPG_AGENT_INFO'].nil?
104 @not_working_reason = ["Environment variable 'GPG_AGENT_INFO' not set, is gpg-agent running?",
105 "If gpg-agent is running, try $ export `cat ~/.gpg-agent-info`"]
106 return
107 end
108
109 gpg_agent_socket_file = ENV['GPG_AGENT_INFO'].split(':')[0]
110 unless File.exist?(gpg_agent_socket_file)
111 @not_working_reason = ["gpg-agent socket file #{gpg_agent_socket_file} does not exist"]
112 return
113 end
114
115 s = File.stat(gpg_agent_socket_file)
116 unless s.socket?
117 @not_working_reason = ["gpg-agent socket file #{gpg_agent_socket_file} is not a socket"]
118 return
119 end
120 end
121 end
122 end
123
124 def have_crypto?; @not_working_reason.nil? end
125 def not_working_reason; @not_working_reason end
126
127 def sign from, to, payload
128 return unknown_status(@not_working_reason) unless @not_working_reason.nil?
129
130 # We grab this from the GPG::Ctx below after signing, so that we can set
131 # micalg in Content-Type to match the hash algorithm GPG decided to use.
132 hash_algo = nil
133
134 gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
135 gpg_opts.merge!(gen_sign_user_opts(from))
136 gpg_opts = HookManager.run("gpg-options",
137 {:operation => "sign", :options => gpg_opts}) || gpg_opts
138 begin
139 input = GPGME::Data.new(format_payload(payload))
140 output = GPGME::Data.new()
141 GPGME::Ctx.new(gpg_opts) do |ctx|
142 if gpg_opts[:signer]
143 signers = GPGME::Key.find(:secret, gpg_opts[:signer], :sign)
144 ctx.add_signer(*signers)
145 end
146 ctx.sign(input, output, GPGME::SIG_MODE_DETACH)
147 hash_algo = GPGME::hash_algo_name(ctx.sign_result.signatures[0].hash_algo)
148 end
149 output.seek(0)
150 sig = output.read
151 rescue GPGME::Error => exc
152 raise Error, gpgme_exc_msg(exc.message)
153 end
154
155 # if the key (or gpg-agent) is not available GPGME does not complain
156 # but just returns a zero length string. Let's catch that
157 if sig.length == 0
158 raise Error, gpgme_exc_msg("GPG failed to generate signature: check that gpg-agent is running and your key is available.")
159 end
160
161 envelope = RMail::Message.new
162 envelope.header["Content-Type"] = "multipart/signed; protocol=application/pgp-signature; micalg=pgp-#{hash_algo.downcase}"
163
164 envelope.add_part payload
165 signature = RMail::Message.make_attachment sig, "application/pgp-signature", nil, "signature.asc"
166 envelope.add_part signature
167 envelope
168 end
169
170 def encrypt from, to, payload, sign=false
171 return unknown_status(@not_working_reason) unless @not_working_reason.nil?
172
173 gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
174 if sign
175 gpg_opts.merge!(gen_sign_user_opts(from))
176 gpg_opts.merge!({:sign => true})
177 end
178 gpg_opts = HookManager.run("gpg-options",
179 {:operation => "encrypt", :options => gpg_opts}) || gpg_opts
180 ## On the sup side we use :signer for backwards compatibility, but GPGME wants :signers.
181 gpg_opts[:signers] = gpg_opts[:signer]
182 recipients = to + [from]
183 recipients = HookManager.run("gpg-expand-keys", { :recipients => recipients }) || recipients
184 begin
185 if GPGME.respond_to?('encrypt')
186 cipher = GPGME.encrypt(recipients, format_payload(payload), gpg_opts)
187 else
188 crypto = GPGME::Crypto.new
189 gpg_opts[:recipients] = recipients
190 cipher = crypto.encrypt(format_payload(payload), gpg_opts).read
191 end
192 rescue GPGME::Error => exc
193 raise Error, gpgme_exc_msg(exc.message)
194 end
195
196 # if the key (or gpg-agent) is not available GPGME does not complain
197 # but just returns a zero length string. Let's catch that
198 if cipher.length == 0
199 raise Error, gpgme_exc_msg("GPG failed to generate cipher text: check that gpg-agent is running and your key is available.")
200 end
201
202 encrypted_payload = RMail::Message.new
203 encrypted_payload.header["Content-Type"] = +"application/octet-stream"
204 encrypted_payload.header["Content-Disposition"] = +'inline; filename="msg.asc"'
205 encrypted_payload.body = cipher
206
207 control = RMail::Message.new
208 control.header["Content-Type"] = +"application/pgp-encrypted"
209 control.header["Content-Disposition"] = +"attachment"
210 control.body = "Version: 1\n"
211
212 envelope = RMail::Message.new
213 envelope.header["Content-Type"] = +"multipart/encrypted; protocol=application/pgp-encrypted"
214
215 envelope.add_part control
216 envelope.add_part encrypted_payload
217 envelope
218 end
219
220 def sign_and_encrypt from, to, payload
221 encrypt from, to, payload, true
222 end
223
224 def verified_ok? verify_result
225 valid = true
226 unknown = false
227 all_output_lines = []
228 all_trusted = true
229 unknown_fingerprint = nil
230
231 verify_result.signatures.each do |signature|
232 output_lines, trusted, unknown_fingerprint = sig_output_lines signature
233 all_output_lines << output_lines
234 all_output_lines.flatten!
235 all_trusted &&= trusted
236
237 err_code = GPGME::gpgme_err_code(signature.status)
238 if err_code == GPGME::GPG_ERR_BAD_SIGNATURE
239 valid = false
240 elsif err_code != GPGME::GPG_ERR_NO_ERROR
241 valid = false
242 unknown = true
243 end
244 end
245
246 if valid || !unknown
247 summary_line = simplify_sig_line(verify_result.signatures[0].to_s.dup, all_trusted)
248 end
249
250 if all_output_lines.length == 0
251 Chunk::CryptoNotice.new :valid, "Encrypted message wasn't signed", all_output_lines
252 elsif valid
253 if all_trusted
254 Chunk::CryptoNotice.new(:valid, summary_line, all_output_lines)
255 else
256 Chunk::CryptoNotice.new(:valid_untrusted, summary_line, all_output_lines)
257 end
258 elsif !unknown
259 Chunk::CryptoNotice.new(:invalid, summary_line, all_output_lines)
260 elsif unknown_fingerprint
261 Chunk::CryptoNotice.new(:unknown_key, "Unable to determine validity of cryptographic signature", all_output_lines, unknown_fingerprint)
262 else
263 unknown_status all_output_lines
264 end
265 end
266
267 def verify payload, signature, detached=true # both RubyMail::Message objects
268 return unknown_status(@not_working_reason) unless @not_working_reason.nil?
269
270 gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP}
271 gpg_opts = HookManager.run("gpg-options",
272 {:operation => "verify", :options => gpg_opts}) || gpg_opts
273 ctx = GPGME::Ctx.new(gpg_opts)
274 sig_data = GPGME::Data.from_str signature.decode
275 if detached
276 signed_text_data = GPGME::Data.from_str(format_payload(payload))
277 plain_data = nil
278 else
279 signed_text_data = nil
280 if GPGME::Data.respond_to?('empty')
281 plain_data = GPGME::Data.empty
282 else
283 plain_data = GPGME::Data.empty!
284 end
285 end
286 begin
287 ctx.verify(sig_data, signed_text_data, plain_data)
288 rescue GPGME::Error => exc
289 return unknown_status [gpgme_exc_msg(exc.message)]
290 end
291 begin
292 self.verified_ok? ctx.verify_result
293 rescue ArgumentError => exc
294 return unknown_status [gpgme_exc_msg(exc.message)]
295 end
296 end
297
298 ## returns decrypted_message, status, desc, lines
299 def decrypt payload, armor=false # a RubyMail::Message object
300 return unknown_status(@not_working_reason) unless @not_working_reason.nil?
301
302 gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP}
303 gpg_opts = HookManager.run("gpg-options",
304 {:operation => "decrypt", :options => gpg_opts}) || gpg_opts
305 ctx = GPGME::Ctx.new(gpg_opts)
306 cipher_data = GPGME::Data.from_str(format_payload(payload))
307 if GPGME::Data.respond_to?('empty')
308 plain_data = GPGME::Data.empty
309 else
310 plain_data = GPGME::Data.empty!
311 end
312 begin
313 ctx.decrypt_verify(cipher_data, plain_data)
314 rescue GPGME::Error => exc
315 return Chunk::CryptoNotice.new(:invalid, "This message could not be decrypted", gpgme_exc_msg(exc.message))
316 end
317 begin
318 sig = self.verified_ok? ctx.verify_result
319 rescue ArgumentError => exc
320 sig = unknown_status [gpgme_exc_msg(exc.message)]
321 end
322 plain_data.seek(0, IO::SEEK_SET)
323 output = plain_data.read
324 output.transcode(Encoding::ASCII_8BIT, output.encoding)
325
326 ## TODO: test to see if it is still necessary to do a 2nd run if verify
327 ## fails.
328 #
329 ## check for a valid signature in an extra run because gpg aborts if the
330 ## signature cannot be verified (but it is still able to decrypt)
331 #sigoutput = run_gpg "#{payload_fn.path}"
332 #sig = self.old_verified_ok? sigoutput, $?
333
334 if armor
335 msg = RMail::Message.new
336 # Look for Charset, they are put before the base64 crypted part
337 charsets = payload.body.split("\n").grep(/^Charset:/)
338 if !charsets.empty? and charsets[0] =~ /^Charset: (.+)$/
339 output.transcode($encoding, $1)
340 end
341 msg.body = output
342 else
343 # It appears that some clients use Windows new lines - CRLF - but RMail
344 # splits the body and header on "\n\n". So to allow the parse below to
345 # succeed, we will convert the newlines to what RMail expects
346 output = output.gsub(/\r\n/, "\n")
347 # This is gross. This decrypted payload could very well be a multipart
348 # element itself, as opposed to a simple payload. For example, a
349 # multipart/signed element, like those generated by Mutt when encrypting
350 # and signing a message (instead of just clearsigning the body).
351 # Supposedly, decrypted_payload being a multipart element ought to work
352 # out nicely because Message::multipart_encrypted_to_chunks() runs the
353 # decrypted message through message_to_chunks() again to get any
354 # children. However, it does not work as intended because these inner
355 # payloads need not carry a MIME-Version header, yet they are fed to
356 # RMail as a top-level message, for which the MIME-Version header is
357 # required. This causes for the part not to be detected as multipart,
358 # hence being shown as an attachment. If we detect this is happening,
359 # we force the decrypted payload to be interpreted as MIME.
360 msg = RMail::Parser.read output
361 if msg.header.content_type =~ %r{^multipart/} && !msg.multipart?
362 output = "MIME-Version: 1.0\n" + output
363 output.fix_encoding!
364 msg = RMail::Parser.read output
365 end
366 end
367 notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display"
368 [notice, sig, msg]
369 end
370
371 def retrieve fingerprint
372 require 'net/http'
373 uri = URI($config[:keyserver_url] || KEYSERVER_URL)
374 unless uri.scheme == "http" and not uri.host.nil? and not uri.host.empty?
375 return "Invalid url: #{uri}"
376 end
377
378 fingerprint = "0x" + fingerprint unless fingerprint[0..1] == "0x"
379 params = {op: "get", search: fingerprint}
380 uri.query = URI.encode_www_form(params)
381
382 begin
383 res = Net::HTTP.get_response(uri)
384 rescue SocketError # Host doesn't exist or we couldn't connect
385 end
386 return "Couldn't get key from keyserver at this address: #{uri}" unless res.is_a?(Net::HTTPSuccess)
387
388 match = KEY_PATTERN.match(res.body)
389 return "No key found" unless match && match.length > 0
390
391 GPGME::Key.import(match[0])
392
393 return nil
394 end
395
396 private
397
398 def unknown_status lines=[]
399 Chunk::CryptoNotice.new :unknown, "Unable to determine validity of cryptographic signature", lines
400 end
401
402 def gpgme_exc_msg msg
403 err_msg = "Exception in GPGME call: #{msg}"
404 #info err_msg
405 err_msg
406 end
407
408 ## here's where we munge rmail output into the format that signed/encrypted
409 ## PGP/GPG messages should be
410 def format_payload payload
411 payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n")
412 end
413
414 # remove the hex key_id and info in ()
415 def simplify_sig_line sig_line, trusted
416 sig_line.sub!(/from [0-9A-F]{16} /, "from ")
417 if !trusted
418 sig_line.sub!(/Good signature/, "Good (untrusted) signature")
419 end
420 sig_line
421 end
422
423 def sig_output_lines signature
424 # It appears that the signature.to_s call can lead to a EOFError if
425 # the key is not found. So start by looking for the key.
426 ctx = GPGME::Ctx.new
427 begin
428 from_key = ctx.get_key(signature.fingerprint)
429 if GPGME::gpgme_err_code(signature.status) == GPGME::GPG_ERR_GENERAL
430 first_sig = "General error on signature verification for #{signature.fingerprint}"
431 elsif signature.to_s
432 first_sig = signature.to_s.sub(/from [0-9A-F]{16} /, 'from "') + '"'
433 else
434 first_sig = "Unknown error or empty signature"
435 end
436 rescue EOFError
437 from_key = nil
438 first_sig = "No public key available for #{signature.fingerprint}"
439 unknown_fpr = signature.fingerprint
440 end
441
442 time_line = "Signature made " + signature.timestamp.strftime("%a %d %b %Y %H:%M:%S %Z") +
443 " using " + key_type(from_key, signature.fingerprint) +
444 "key ID " + signature.fingerprint[-8..-1]
445 output_lines = [time_line, first_sig]
446
447 trusted = false
448 if from_key
449 # first list all the uids
450 if from_key.uids.length > 1
451 aka_list = from_key.uids[1..-1]
452 aka_list.each { |aka| output_lines << ' aka "' + aka.uid + '"' }
453 end
454
455 # now we want to look at the trust of that key
456 if signature.validity != GPGME::GPGME_VALIDITY_FULL && signature.validity != GPGME::GPGME_VALIDITY_MARGINAL
457 output_lines << "WARNING: This key is not certified with a trusted signature!"
458 output_lines << "There is no indication that the signature belongs to the owner"
459 output_lines << "Full fingerprint is: " + (0..9).map {|i| signature.fpr[(i*4),4]}.join(":")
460 else
461 trusted = true
462 end
463
464 # finally, run the hook
465 output_lines << HookManager.run("sig-output",
466 {:signature => signature, :from_key => from_key})
467 end
468 return output_lines, trusted, unknown_fpr
469 end
470
471 def key_type key, fpr
472 return "" if key.nil?
473 subkey = key.subkeys.find {|subkey| subkey.fpr == fpr || subkey.keyid == fpr }
474 return "" if subkey.nil?
475
476 case subkey.pubkey_algo
477 when GPGME::PK_RSA then "RSA "
478 when GPGME::PK_DSA then "DSA "
479 when GPGME::PK_ELG then "ElGamel "
480 when GPGME::PK_ELG_E then "ElGamel "
481 else "unknown key type (#{subkey.pubkey_algo}) "
482 end
483 end
484
485 # logic is:
486 # if gpgkey set for this account, then use that
487 # elsif only one account, then leave blank so gpg default will be user
488 # else set --local-user from_email_address
489 # NOTE: multiple signers doesn't seem to work with gpgme (2.0.2, 1.0.8)
490 #
491 def gen_sign_user_opts from
492 account = AccountManager.account_for from
493 account ||= AccountManager.default_account
494 if !account.gpgkey.nil?
495 opts = {:signer => account.gpgkey}
496 elsif AccountManager.user_emails.length == 1
497 # only one account
498 opts = {}
499 else
500 opts = {:signer => from}
501 end
502 opts
503 end
504 end
505 end