gen_sign_user_opts: fall back to default account if no account matches sender
[sup:mainline.git] / lib / sup / crypto.rb
1 begin
2   require 'gpgme'
3 rescue LoadError
4 end
5
6 module Redwood
7
8 class CryptoManager
9   include Singleton
10
11   class Error < StandardError; end
12
13   OUTGOING_MESSAGE_OPERATIONS = OrderedHash.new(
14     [:sign, "Sign"],
15     [:sign_and_encrypt, "Sign and encrypt"],
16     [:encrypt, "Encrypt only"]
17   )
18
19   HookManager.register "gpg-options", <<EOS
20 Runs before gpg is called, allowing you to modify the options (most
21 likely you would want to add something to certain commands, like
22 {:always_trust => true} to encrypting a message, but who knows).
23
24 Variables:
25 operation: what operation will be done ("sign", "encrypt", "decrypt" or "verify")
26 options: a dictionary of values to be passed to GPGME
27
28 Return value: a dictionary to be passed to GPGME
29 EOS
30
31   HookManager.register "sig-output", <<EOS
32 Runs when the signature output is being generated, allowing you to
33 add extra information to your signatures if you want.
34
35 Variables:
36 signature: the signature object (class is GPGME::Signature)
37 from_key: the key that generated the signature (class is GPGME::Key)
38
39 Return value: an array of lines of output
40 EOS
41
42   def initialize
43     @mutex = Mutex.new
44
45     # test if the gpgme gem is available
46     @gpgme_present = true
47     begin
48     GPGME.check_version({:protocol => GPGME::PROTOCOL_OpenPGP})
49     rescue NameError, GPGME::Error
50       @gpgme_present = false
51     end
52   end
53
54   def have_crypto?; @gpgme_present end
55
56   def sign from, to, payload
57     return unknown_status(cant_find_gpgme) unless @gpgme_present
58
59     gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
60     gpg_opts.merge(gen_sign_user_opts(from))
61     gpg_opts = HookManager.run("gpg-options",
62                                {:operation => "sign", :options => gpg_opts}) || gpg_opts
63
64     begin
65       sig = GPGME.detach_sign(format_payload(payload), gpg_opts)
66     rescue GPGME::Error => exc
67       info "Error while running gpg: #{exc.message}"
68       raise Error, "GPG command failed. See log for details."
69     end
70
71     envelope = RMail::Message.new
72     envelope.header["Content-Type"] = 'multipart/signed; protocol=application/pgp-signature'
73
74     envelope.add_part payload
75     signature = RMail::Message.make_attachment sig, "application/pgp-signature", nil, "signature.asc"
76     envelope.add_part signature
77     envelope
78   end
79
80   def encrypt from, to, payload, sign=false
81     return unknown_status(cant_find_gpgme) unless @gpgme_present
82
83     gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
84     if sign
85       gpg_opts.merge(gen_sign_user_opts(from))
86       gpg_opts.merge({:sign => true})
87     end
88     gpg_opts = HookManager.run("gpg-options",
89                                {:operation => "encrypt", :options => gpg_opts}) || gpg_opts
90     recipients = to + [from]
91
92     begin
93       cipher = GPGME.encrypt(recipients, format_payload(payload), gpg_opts)
94     rescue GPGME::Error => exc
95       info "Error while running gpg: #{exc.message}"
96       raise Error, "GPG command failed. See log for details."
97     end
98
99     encrypted_payload = RMail::Message.new
100     encrypted_payload.header["Content-Type"] = "application/octet-stream"
101     encrypted_payload.header["Content-Disposition"] = 'inline; filename="msg.asc"'
102     encrypted_payload.body = cipher
103
104     control = RMail::Message.new
105     control.header["Content-Type"] = "application/pgp-encrypted"
106     control.header["Content-Disposition"] = "attachment"
107     control.body = "Version: 1\n"
108
109     envelope = RMail::Message.new
110     envelope.header["Content-Type"] = 'multipart/encrypted; protocol=application/pgp-encrypted'
111
112     envelope.add_part control
113     envelope.add_part encrypted_payload
114     envelope
115   end
116
117   def sign_and_encrypt from, to, payload
118     encrypt from, to, payload, true
119   end
120
121   def verified_ok? verify_result
122     valid = true
123     unknown = false
124     all_output_lines = []
125     all_trusted = true
126
127     verify_result.signatures.each do |signature|
128       output_lines, trusted = sig_output_lines signature
129       all_output_lines << output_lines
130       all_output_lines.flatten!
131       all_trusted &&= trusted
132
133       err_code = GPGME::gpgme_err_code(signature.status)
134       if err_code == GPGME::GPG_ERR_BAD_SIGNATURE
135         valid = false
136       elsif err_code != GPGME::GPG_ERR_NO_ERROR
137         valid = false
138         unknown = true
139       end
140     end
141
142     if all_output_lines.length == 0
143       Chunk::CryptoNotice.new :valid, "Encrypted message wasn't signed", all_output_lines
144     elsif valid
145       if all_trusted
146         Chunk::CryptoNotice.new(:valid, simplify_sig_line(verify_result.signatures[0].to_s), all_output_lines)
147       else
148         Chunk::CryptoNotice.new(:valid_untrusted, simplify_sig_line(verify_result.signatures[0].to_s), all_output_lines)
149       end
150     elsif !unknown
151       Chunk::CryptoNotice.new(:invalid, simplify_sig_line(verify_result.signatures[0].to_s), all_output_lines)
152     else
153       unknown_status all_output_lines
154     end
155   end
156
157   def verify payload, signature, detached=true # both RubyMail::Message objects
158     return unknown_status(cant_find_gpgme) unless @gpgme_present
159
160     gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP}
161     gpg_opts = HookManager.run("gpg-options",
162                                {:operation => "verify", :options => gpg_opts}) || gpg_opts
163     ctx = GPGME::Ctx.new(gpg_opts)
164     sig_data = GPGME::Data.from_str signature.decode
165     if detached
166       signed_text_data = GPGME::Data.from_str(format_payload(payload))
167       plain_data = nil
168     else
169       signed_text_data = nil
170       plain_data = GPGME::Data.empty
171     end
172     begin
173       ctx.verify(sig_data, signed_text_data, plain_data)
174     rescue GPGME::Error => exc
175       return unknown_status exc.message
176     end
177     self.verified_ok? ctx.verify_result
178   end
179
180   ## returns decrypted_message, status, desc, lines
181   def decrypt payload, armor=false # a RubyMail::Message object
182     return unknown_status(cant_find_gpgme) unless @gpgme_present
183
184     gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP}
185     gpg_opts = HookManager.run("gpg-options",
186                                {:operation => "decrypt", :options => gpg_opts}) || gpg_opts
187     ctx = GPGME::Ctx.new(gpg_opts)
188     cipher_data = GPGME::Data.from_str(format_payload(payload))
189     plain_data = GPGME::Data.empty
190     begin
191       ctx.decrypt_verify(cipher_data, plain_data)
192     rescue GPGME::Error => exc
193       info "Error while running gpg: #{exc.message}"
194       return Chunk::CryptoNotice.new(:invalid, "This message could not be decrypted", exc.message)
195     end
196     sig = self.verified_ok? ctx.verify_result
197     plain_data.seek(0, IO::SEEK_SET)
198     output = plain_data.read
199     output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding
200
201     ## TODO: test to see if it is still necessary to do a 2nd run if verify
202     ## fails.
203     #
204     ## check for a valid signature in an extra run because gpg aborts if the
205     ## signature cannot be verified (but it is still able to decrypt)
206     #sigoutput = run_gpg "#{payload_fn.path}"
207     #sig = self.old_verified_ok? sigoutput, $?
208
209     if armor
210       msg = RMail::Message.new
211       # Look for Charset, they are put before the base64 crypted part
212       charsets = payload.body.split("\n").grep(/^Charset:/)
213       if !charsets.empty? and charsets[0] =~ /^Charset: (.+)$/
214         output = Iconv.easy_decode($encoding, $1, output)
215       end
216       msg.body = output
217     else
218       # It appears that some clients use Windows new lines - CRLF - but RMail
219       # splits the body and header on "\n\n". So to allow the parse below to
220       # succeed, we will convert the newlines to what RMail expects
221       output = output.gsub(/\r\n/, "\n")
222       # This is gross. This decrypted payload could very well be a multipart
223       # element itself, as opposed to a simple payload. For example, a
224       # multipart/signed element, like those generated by Mutt when encrypting
225       # and signing a message (instead of just clearsigning the body).
226       # Supposedly, decrypted_payload being a multipart element ought to work
227       # out nicely because Message::multipart_encrypted_to_chunks() runs the
228       # decrypted message through message_to_chunks() again to get any
229       # children. However, it does not work as intended because these inner
230       # payloads need not carry a MIME-Version header, yet they are fed to
231       # RMail as a top-level message, for which the MIME-Version header is
232       # required. This causes for the part not to be detected as multipart,
233       # hence being shown as an attachment. If we detect this is happening,
234       # we force the decrypted payload to be interpreted as MIME.
235       msg = RMail::Parser.read output
236       if msg.header.content_type =~ %r{^multipart/} && !msg.multipart?
237         output = "MIME-Version: 1.0\n" + output
238         output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding
239         msg = RMail::Parser.read output
240       end
241     end
242     notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display"
243     [notice, sig, msg]
244   end
245
246 private
247
248   def unknown_status lines=[]
249     Chunk::CryptoNotice.new :unknown, "Unable to determine validity of cryptographic signature", lines
250   end
251
252   def cant_find_gpgme
253     ["Can't find gpgme gem."]
254   end
255
256   ## here's where we munge rmail output into the format that signed/encrypted
257   ## PGP/GPG messages should be
258   def format_payload payload
259     payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n")
260   end
261
262   # remove the hex key_id and info in ()
263   def simplify_sig_line sig_line
264     sig_line.sub(/from [0-9A-F]{16} /, "from ")
265   end
266
267   def sig_output_lines signature
268     # It appears that the signature.to_s call can lead to a EOFError if
269     # the key is not found. So start by looking for the key.
270     ctx = GPGME::Ctx.new
271     begin
272       from_key = ctx.get_key(signature.fingerprint)
273       first_sig = signature.to_s.sub(/from [0-9A-F]{16} /, 'from "') + '"'
274     rescue EOFError
275       from_key = nil
276       first_sig = "No public key available for #{signature.fingerprint}"
277     end
278
279     time_line = "Signature made " + signature.timestamp.strftime("%a %d %b %Y %H:%M:%S %Z") +
280                 " using " + key_type(from_key, signature.fingerprint) +
281                 "key ID " + signature.fingerprint[-8..-1]
282     output_lines = [time_line, first_sig]
283
284     trusted = false
285     if from_key
286       # first list all the uids
287       if from_key.uids.length > 1
288         aka_list = from_key.uids[1..-1]
289         aka_list.each { |aka| output_lines << '                aka "' + aka.uid + '"' }
290       end
291
292       # now we want to look at the trust of that key
293       if signature.validity != GPGME::GPGME_VALIDITY_FULL && signature.validity != GPGME::GPGME_VALIDITY_MARGINAL
294         output_lines << "WARNING: This key is not certified with a trusted signature!"
295         output_lines << "There is no indication that the signature belongs to the owner"
296       else
297         trusted = true
298       end
299
300       # finally, run the hook
301       output_lines << HookManager.run("sig-output",
302                                {:signature => signature, :from_key => from_key})
303     end
304     return output_lines, trusted
305   end
306
307   def key_type key, fpr
308     return "" if key.nil?
309     subkey = key.subkeys.find {|subkey| subkey.fpr == fpr || subkey.keyid == fpr }
310     return "" if subkey.nil?
311
312     case subkey.pubkey_algo
313     when GPGME::PK_RSA then "RSA "
314     when GPGME::PK_DSA then "DSA "
315     when GPGME::PK_ELG then "ElGamel "
316     when GPGME::PK_ELG_E then "ElGamel "
317     end
318   end
319
320   # logic is:
321   # if    gpgkey set for this account, then use that
322   # elsif only one account,            then leave blank so gpg default will be user
323   # else                                    set --local-user from_email_address
324   def gen_sign_user_opts from
325     account = AccountManager.account_for from
326     account ||= AccountManager.default_account
327     if !account.gpgkey.nil?
328       opts = {:signers => account.gpgkey}
329     elsif AccountManager.user_emails.length == 1
330       # only one account
331       opts = {}
332     else
333       opts = {:signers => from}
334     end
335     opts
336   end
337 end
338 end