module Redwood class CryptoManager include Singleton class Error < StandardError; end OUTGOING_MESSAGE_OPERATIONS = OrderedHash.new( [:sign, "Sign"], [:sign_and_encrypt, "Sign and encrypt"], [:encrypt, "Encrypt only"] ) HookManager.register "gpg-args", <<EOS Runs before gpg is executed, allowing you to modify the arguments (most likely you would want to add something to certain commands, like --trust-model always to signing/encrypting a message, but who knows). Variables: args: arguments for running GPG Return value: the arguments for running GPG EOS def initialize @mutex = Mutex.new bin = `which gpg`.chomp @cmd = case bin when /\S/ debug "crypto: detected gpg binary in #{bin}" "#{bin} --quiet --batch --no-verbose --logger-fd 1 --use-agent" else debug "crypto: no gpg binary detected" nil end end def have_crypto?; !@cmd.nil? end def sign from, to, payload payload_fn = Tempfile.new "redwood.payload" payload_fn.write format_payload(payload) payload_fn.close sig_fn = Tempfile.new "redwood.signature"; sig_fn.close sign_user_opts = gen_sign_user_opts from message = run_gpg "--output #{sig_fn.path} --yes --armor --detach-sign --textmode --digest-algo sha256 #{sign_user_opts} #{payload_fn.path}", :interactive => true unless $?.success? info "Error while running gpg: #{message}" raise Error, "GPG command failed. See log for details." end envelope = RMail::Message.new envelope.header["Content-Type"] = 'multipart/signed; protocol=application/pgp-signature; micalg=pgp-sha256' envelope.add_part payload signature = RMail::Message.make_attachment IO.read(sig_fn.path), "application/pgp-signature", nil, "signature.asc" envelope.add_part signature envelope end def encrypt from, to, payload, sign=false payload_fn = Tempfile.new "redwood.payload" payload_fn.write format_payload(payload) payload_fn.close encrypted_fn = Tempfile.new "redwood.encrypted"; encrypted_fn.close recipient_opts = (to + [ from ] ).map { |r| "--recipient '<#{r}>'" }.join(" ") sign_opts = "" sign_opts = "--sign --digest-algo sha256 " + gen_sign_user_opts(from) if sign message = run_gpg "--output #{encrypted_fn.path} --yes --armor --encrypt --textmode #{sign_opts} #{recipient_opts} #{payload_fn.path}", :interactive => true unless $?.success? info "Error while running gpg: #{message}" raise Error, "GPG command failed. See log for details." end encrypted_payload = RMail::Message.new encrypted_payload.header["Content-Type"] = "application/octet-stream" encrypted_payload.header["Content-Disposition"] = 'inline; filename="msg.asc"' encrypted_payload.body = IO.read(encrypted_fn.path) control = RMail::Message.new control.header["Content-Type"] = "application/pgp-encrypted" control.header["Content-Disposition"] = "attachment" control.body = "Version: 1\n" envelope = RMail::Message.new envelope.header["Content-Type"] = 'multipart/encrypted; protocol=application/pgp-encrypted' envelope.add_part control envelope.add_part encrypted_payload envelope end def sign_and_encrypt from, to, payload encrypt from, to, payload, true end def verified_ok? output, rc output_lines = output.split(/\n/) if output =~ /^gpg: (.* signature from .*$)/ if rc == 0 Chunk::CryptoNotice.new :valid, $1, output_lines else Chunk::CryptoNotice.new :invalid, $1, output_lines end elsif output_lines.length == 0 && rc == 0 # the message wasn't signed Chunk::CryptoNotice.new :valid, "Encrypted message wasn't signed", output_lines else unknown_status output_lines end end def verify payload, signature, detached=true # both RubyMail::Message objects return unknown_status(cant_find_binary) unless @cmd if detached payload_fn = Tempfile.new "redwood.payload" payload_fn.write format_payload(payload) payload_fn.close end signature_fn = Tempfile.new "redwood.signature" signature_fn.write signature.decode signature_fn.close if detached output = run_gpg "--verify #{signature_fn.path} #{payload_fn.path}" else output = run_gpg "--verify #{signature_fn.path}" end self.verified_ok? output, $? end ## returns decrypted_message, status, desc, lines def decrypt payload, armor=false # a RubyMail::Message object return unknown_status(cant_find_binary) unless @cmd payload_fn = Tempfile.new(["redwood.payload", ".asc"]) payload_fn.write payload.to_s payload_fn.close output_fn = Tempfile.new "redwood.output" output_fn.close message = run_gpg "--output #{output_fn.path} --skip-verify --yes --decrypt #{payload_fn.path}", :interactive => true unless $?.success? info "Error while running gpg: #{message}" return Chunk::CryptoNotice.new(:invalid, "This message could not be decrypted", message.split("\n")) end output = IO.read output_fn.path output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding ## check for a valid signature in an extra run because gpg aborts if the ## signature cannot be verified (but it is still able to decrypt) sigoutput = run_gpg "#{payload_fn.path}" sig = self.verified_ok? sigoutput, $? if armor msg = RMail::Message.new # Look for Charset, they are put before the base64 crypted part charsets = payload.body.split("\n").grep(/^Charset:/) if !charsets.empty? and charsets[0] =~ /^Charset: (.+)$/ output = Iconv.easy_decode($encoding, $1, output) end msg.body = output else # It appears that some clients use Windows new lines - CRLF - but RMail # splits the body and header on "\n\n". So to allow the parse below to # succeed, we will convert the newlines to what RMail expects output = output.gsub(/\r\n/, "\n") # This is gross. This decrypted payload could very well be a multipart # element itself, as opposed to a simple payload. For example, a # multipart/signed element, like those generated by Mutt when encrypting # and signing a message (instead of just clearsigning the body). # Supposedly, decrypted_payload being a multipart element ought to work # out nicely because Message::multipart_encrypted_to_chunks() runs the # decrypted message through message_to_chunks() again to get any # children. However, it does not work as intended because these inner # payloads need not carry a MIME-Version header, yet they are fed to # RMail as a top-level message, for which the MIME-Version header is # required. This causes for the part not to be detected as multipart, # hence being shown as an attachment. If we detect this is happening, # we force the decrypted payload to be interpreted as MIME. msg = RMail::Parser.read output if msg.header.content_type =~ %r{^multipart/} && !msg.multipart? output = "MIME-Version: 1.0\n" + output output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding msg = RMail::Parser.read output end end notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display" [notice, sig, msg] end private def unknown_status lines=[] Chunk::CryptoNotice.new :unknown, "Unable to determine validity of cryptographic signature", lines end def cant_find_binary ["Can't find gpg binary in path."] end ## here's where we munge rmail output into the format that signed/encrypted ## PGP/GPG messages should be def format_payload payload payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n") end # logic is: # if gpgkey set for this account, then use that # elsif only one account, then leave blank so gpg default will be user # else set --local-user from_email_address def gen_sign_user_opts from account = AccountManager.account_for from if !account.gpgkey.nil? opts = "--local-user '#{account.gpgkey}'" elsif AccountManager.user_emails.length == 1 # only one account opts = "" else opts = "--local-user '#{from}'" end opts end def run_gpg args, opts={} args = HookManager.run("gpg-args", { :args => args }) || args cmd = "LC_MESSAGES=C #{@cmd} #{args}" if opts[:interactive] && BufferManager.instantiated? output_fn = Tempfile.new "redwood.output" output_fn.close cmd += " > #{output_fn.path} 2> /dev/null" debug "crypto: running: #{cmd}" BufferManager.shell_out cmd IO.read(output_fn.path) rescue "can't read output" else debug "crypto: running: #{cmd}" `#{cmd} 2> /dev/null` end end end end