Revert "fix GPG "hang" on malformed PGP message (detached signature)"
[sup:mainline.git] / lib / sup / crypto.rb
1 module Redwood
2
3 class CryptoManager
4   include Singleton
5
6   class Error < StandardError; end
7
8   OUTGOING_MESSAGE_OPERATIONS = OrderedHash.new(
9     [:sign, "Sign"],
10     [:sign_and_encrypt, "Sign and encrypt"],
11     [:encrypt, "Encrypt only"]
12   )
13
14   HookManager.register "gpg-args", <<EOS
15 Runs before gpg is executed, allowing you to modify the arguments (most
16 likely you would want to add something to certain commands, like
17 --trust-model always to signing/encrypting a message, but who knows).
18
19 Variables:
20 args: arguments for running GPG
21
22 Return value: the arguments for running GPG
23 EOS
24
25   def initialize
26     @mutex = Mutex.new
27
28     bin = `which gpg`.chomp
29     @cmd = case bin
30     when /\S/
31       debug "crypto: detected gpg binary in #{bin}"
32       "#{bin} --quiet --batch --no-verbose --logger-fd 1 --use-agent"
33     else
34       debug "crypto: no gpg binary detected"
35       nil
36     end
37   end
38
39   def have_crypto?; !@cmd.nil? end
40
41   def sign from, to, payload
42     payload_fn = Tempfile.new "redwood.payload"
43     payload_fn.write format_payload(payload)
44     payload_fn.close
45
46     sig_fn = Tempfile.new "redwood.signature"; sig_fn.close
47
48     message = run_gpg "--output #{sig_fn.path} --yes --armor --detach-sign --textmode --digest-algo sha256 --local-user '#{from}' #{payload_fn.path}", :interactive => true
49     unless $?.success?
50       info "Error while running gpg: #{message}"
51       raise Error, "GPG command failed. See log for details."
52     end
53
54     envelope = RMail::Message.new
55     envelope.header["Content-Type"] = 'multipart/signed; protocol=application/pgp-signature; micalg=pgp-sha256'
56
57     envelope.add_part payload
58     signature = RMail::Message.make_attachment IO.read(sig_fn.path), "application/pgp-signature", nil, "signature.asc"
59     envelope.add_part signature
60     envelope
61   end
62
63   def encrypt from, to, payload, sign=false
64     payload_fn = Tempfile.new "redwood.payload"
65     payload_fn.write format_payload(payload)
66     payload_fn.close
67
68     encrypted_fn = Tempfile.new "redwood.encrypted"; encrypted_fn.close
69
70     recipient_opts = (to + [ from ] ).map { |r| "--recipient '<#{r}>'" }.join(" ")
71     sign_opts = sign ? "--sign --local-user '#{from}'" : ""
72     message = run_gpg "--output #{encrypted_fn.path} --yes --armor --encrypt --textmode #{sign_opts} #{recipient_opts} #{payload_fn.path}", :interactive => true
73     unless $?.success?
74       info "Error while running gpg: #{message}"
75       raise Error, "GPG command failed. See log for details."
76     end
77
78     encrypted_payload = RMail::Message.new
79     encrypted_payload.header["Content-Type"] = "application/octet-stream"
80     encrypted_payload.header["Content-Disposition"] = 'inline; filename="msg.asc"'
81     encrypted_payload.body = IO.read(encrypted_fn.path)
82
83     control = RMail::Message.new
84     control.header["Content-Type"] = "application/pgp-encrypted"
85     control.header["Content-Disposition"] = "attachment"
86     control.body = "Version: 1\n"
87
88     envelope = RMail::Message.new
89     envelope.header["Content-Type"] = 'multipart/encrypted; protocol="application/pgp-encrypted"'
90
91     envelope.add_part control
92     envelope.add_part encrypted_payload
93     envelope
94   end
95
96   def sign_and_encrypt from, to, payload
97     encrypt from, to, payload, true
98   end
99
100   def verified_ok? output, rc
101     output_lines = output.split(/\n/)
102
103     if output =~ /^gpg: (.* signature from .*$)/
104       if rc == 0
105         Chunk::CryptoNotice.new :valid, $1, output_lines
106       else
107         Chunk::CryptoNotice.new :invalid, $1, output_lines
108       end
109     else
110       unknown_status output_lines
111     end
112   end
113
114   def verify payload, signature, detached=true # both RubyMail::Message objects
115     return unknown_status(cant_find_binary) unless @cmd
116
117     if detached
118       payload_fn = Tempfile.new "redwood.payload"
119       payload_fn.write format_payload(payload)
120       payload_fn.close
121     end
122
123     signature_fn = Tempfile.new "redwood.signature"
124     signature_fn.write signature.decode
125     signature_fn.close
126
127     if detached
128       output = run_gpg "--verify #{signature_fn.path} #{payload_fn.path}"
129     else
130       output = run_gpg "--verify #{signature_fn.path}"
131     end
132
133     self.verified_ok? output, $?
134   end
135
136   ## returns decrypted_message, status, desc, lines
137   def decrypt payload, armor=false # a RubyMail::Message object
138     return unknown_status(cant_find_binary) unless @cmd
139
140     payload_fn = Tempfile.new "redwood.payload"
141     payload_fn.write payload.to_s
142     payload_fn.close
143
144     output_fn = Tempfile.new "redwood.output"
145     output_fn.close
146
147     message = run_gpg "--output #{output_fn.path} --skip-verify --yes --decrypt #{payload_fn.path}", :interactive => true
148
149     unless $?.success?
150       info "Error while running gpg: #{message}"
151       return Chunk::CryptoNotice.new(:invalid, "This message could not be decrypted", message.split("\n"))
152     end
153
154     output = IO.read output_fn.path
155     output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding
156
157     ## check for a valid signature in an extra run because gpg aborts if the
158     ## signature cannot be verified (but it is still able to decrypt)
159     sigoutput = run_gpg "#{payload_fn.path}"
160     sig = self.verified_ok? sigoutput, $?
161
162     if armor
163       msg = RMail::Message.new
164       # Look for Charset, they are put before the base64 crypted part
165       charsets = payload.body.split("\n").grep(/^Charset:/)
166       if !charsets.empty? and charsets[0] =~ /^Charset: (.+)$/
167         output = Iconv.easy_decode($encoding, $1, output)
168       end
169       msg.body = output
170     else
171       # This is gross. This decrypted payload could very well be a multipart
172       # element itself, as opposed to a simple payload. For example, a
173       # multipart/signed element, like those generated by Mutt when encrypting
174       # and signing a message (instead of just clearsigning the body).
175       # Supposedly, decrypted_payload being a multipart element ought to work
176       # out nicely because Message::multipart_encrypted_to_chunks() runs the
177       # decrypted message through message_to_chunks() again to get any
178       # children. However, it does not work as intended because these inner
179       # payloads need not carry a MIME-Version header, yet they are fed to
180       # RMail as a top-level message, for which the MIME-Version header is
181       # required. This causes for the part not to be detected as multipart,
182       # hence being shown as an attachment. If we detect this is happening,
183       # we force the decrypted payload to be interpreted as MIME.
184       msg = RMail::Parser.read output
185       if msg.header.content_type =~ %r{^multipart/} && !msg.multipart?
186         output = "MIME-Version: 1.0\n" + output
187         output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding
188         msg = RMail::Parser.read output
189       end
190     end
191     notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display"
192     [notice, sig, msg]
193   end
194
195 private
196
197   def unknown_status lines=[]
198     Chunk::CryptoNotice.new :unknown, "Unable to determine validity of cryptographic signature", lines
199   end
200
201   def cant_find_binary
202     ["Can't find gpg binary in path."]
203   end
204
205   ## here's where we munge rmail output into the format that signed/encrypted
206   ## PGP/GPG messages should be
207   def format_payload payload
208     payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n").gsub(/^MIME-Version: .*\r\n/, "")
209   end
210
211   def run_gpg args, opts={}
212     args = HookManager.run("gpg-args", { :args => args }) || args
213     cmd = "LC_MESSAGES=C #{@cmd} #{args}"
214     if opts[:interactive] && BufferManager.instantiated?
215       output_fn = Tempfile.new "redwood.output"
216       output_fn.close
217       cmd += " > #{output_fn.path} 2> /dev/null"
218       debug "crypto: running: #{cmd}"
219       BufferManager.shell_out cmd
220       IO.read(output_fn.path) rescue "can't read output"
221     else
222       debug "crypto: running: #{cmd}"
223       `#{cmd} 2> /dev/null`
224     end
225   end
226 end
227 end