From: wmorgan Date: Thu, 13 Dec 2007 23:06:39 +0000 (+0000) Subject: finally! gpg sign, encrypt, and both support on outgoing email X-Git-Url: https://git.cworth.org/git?a=commitdiff_plain;h=3ef5e2e01079b557432ad57cabadee36a02b9cf4;p=sup finally! gpg sign, encrypt, and both support on outgoing email git-svn-id: svn://rubyforge.org/var/svn/sup/trunk@768 5c8cc53c-5e98-4d25-b20a-d8db53a31250 --- diff --git a/lib/sup.rb b/lib/sup.rb index 60679ee..25809dd 100644 --- a/lib/sup.rb +++ b/lib/sup.rb @@ -204,6 +204,7 @@ else :ask_for_subject => true, :confirm_no_attachments => true, :confirm_top_posting => true, + :discard_snippets_from_encrypted_messages => false, } begin FileUtils.mkdir_p Redwood::BASE_DIR diff --git a/lib/sup/crypto.rb b/lib/sup/crypto.rb index 5db576b..0f9cdfd 100644 --- a/lib/sup/crypto.rb +++ b/lib/sup/crypto.rb @@ -3,6 +3,8 @@ module Redwood class CryptoManager include Singleton + class Error < StandardError; end + OUTGOING_MESSAGE_OPERATIONS = OrderedHash.new( [:sign, "Sign"], [:sign_and_encrypt, "Sign and encrypt"], @@ -14,7 +16,6 @@ class CryptoManager self.class.i_am_the_instance self bin = `which gpg`.chomp - bin = `which pgp`.chomp unless bin =~ /\S/ @cmd = case bin @@ -29,25 +30,71 @@ class CryptoManager 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 + + output = run_gpg "--output - --armor --detach-sign --textmode --local-user '#{from}' #{payload_fn.path}" + + raise Error, (output || "gpg command failed: #{cmd}") unless $?.success? + + envelope = RMail::Message.new + envelope.header["Content-Type"] = 'multipart/signed; protocol="application/pgp-signature"; micalg=pgp-sha1' + + envelope.add_part payload + signature = RMail::Message.make_attachment output, "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 + + recipient_opts = to.map { |r| "--recipient '#{r}'" }.join(" ") + sign_opts = sign ? "--sign --local-user '#{from}'" : "" + gpg_output = run_gpg "--output - --armor --encrypt --textmode #{sign_opts} #{recipient_opts} #{payload_fn.path}" + raise Error, (gpg_output || "gpg command failed: #{cmd}") unless $?.success? + + 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 = gpg_output + + 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 verify payload, signature # both RubyMail::Message objects return unknown_status(cant_find_binary) unless @cmd payload_fn = Tempfile.new "redwood.payload" - payload_fn.write payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n").gsub(/^MIME-Version: .*\r\n/, "") + payload_fn.write format_payload(payload) payload_fn.close signature_fn = Tempfile.new "redwood.signature" signature_fn.write signature.decode signature_fn.close - cmd = "#{@cmd} --verify #{signature_fn.path} #{payload_fn.path} 2> /dev/null" - - #Redwood::log "gpg: running: #{cmd}" - gpg_output = `#{cmd}` - #Redwood::log "got output: #{gpg_output.inspect}" - output_lines = gpg_output.split(/\n/) + output = run_gpg "--verify #{signature_fn.path} #{payload_fn.path}" + output_lines = output.split(/\n/) - if gpg_output =~ /^gpg: (.* signature from .*$)/ + if output =~ /^gpg: (.* signature from .*$)/ if $? == 0 Chunk::CryptoNotice.new :valid, $1, output_lines else @@ -58,35 +105,22 @@ class CryptoManager end end - # returns decrypted_message, status, desc, lines - def decrypt payload # RubyMail::Message objects + ## returns decrypted_message, status, desc, lines + def decrypt payload # a RubyMail::Message object return unknown_status(cant_find_binary) unless @cmd -# cmd = "#{@cmd} --decrypt 2> /dev/null" - -# Redwood::log "gpg: running: #{cmd}" - -# gpg_output = -# IO.popen(cmd, "a+") do |f| -# f.puts payload.to_s -# f.gets -# end - payload_fn = Tempfile.new "redwood.payload" payload_fn.write payload.to_s payload_fn.close - cmd = "#{@cmd} --decrypt #{payload_fn.path} 2> /dev/null" - Redwood::log "gpg: running: #{cmd}" - gpg_output = `#{cmd}` - Redwood::log "got output: #{gpg_output.inspect}" + output = run_gpg "--decrypt #{payload_fn.path}" - if $? == 0 # successful decryption + if $?.success? decrypted_payload, sig_lines = - if gpg_output =~ /\A(.*?)((^gpg: .*$)+)\Z/m + if output =~ /\A(.*?)((^gpg: .*$)+)\Z/m [$1, $2] else - [gpg_output, nil] + [output, nil] end sig = @@ -101,7 +135,7 @@ class CryptoManager notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display" [RMail::Parser.read(decrypted_payload), sig, notice] else - notice = Chunk::CryptoNotice.new :invalid, "This message could not be decrypted", gpg_output.split("\n") + notice = Chunk::CryptoNotice.new :invalid, "This message could not be decrypted", output.split("\n") [nil, nil, notice] end end @@ -113,7 +147,19 @@ private end def cant_find_binary - ["Can't find gpg or pgp binary in path"] + ["Can't find gpg binary in path."] + end + + def format_payload payload + payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n").gsub(/^MIME-Version: .*\r\n/, "") + end + + def run_gpg args + cmd = "#{@cmd} #{args} 2> /dev/null" + Redwood::log "crypto: running: #{cmd}" + output = `#{cmd}` + Redwood::log "crypto: output: #{output.inspect}" unless $?.success? + output end end end diff --git a/lib/sup/index.rb b/lib/sup/index.rb index 30a4271..fd46495 100644 --- a/lib/sup/index.rb +++ b/lib/sup/index.rb @@ -171,13 +171,20 @@ EOS end to = (m.to + m.cc + m.bcc).map { |x| x.email }.join(" ") + snippet = + if m.snippet_contains_encrypted_content? && $config[:discard_snippets_from_encrypted_messages] + "" + else + m.snippet + end + d = { :message_id => m.id, :source_id => source_id, :source_info => m.source_info, :date => m.date.to_indexable_s, :body => m.content, - :snippet => m.snippet, + :snippet => snippet, :label => m.labels.uniq.join(" "), :from => m.from ? m.from.email : "", :to => (m.to + m.cc + m.bcc).map { |x| x.email }.join(" "), diff --git a/lib/sup/message.rb b/lib/sup/message.rb index fbea1f6..4d9e04f 100644 --- a/lib/sup/message.rb +++ b/lib/sup/message.rb @@ -41,17 +41,19 @@ class Message :cc, :bcc, :labels, :list_address, :recipient_email, :replyto, :source_info, :chunks, :list_subscribe, :list_unsubscribe - bool_reader :dirty, :source_marked_read + bool_reader :dirty, :source_marked_read, :snippet_contains_encrypted_content ## if you specify a :header, will use values from that. otherwise, ## will try and load the header from the source. def initialize opts @source = opts[:source] or raise ArgumentError, "source can't be nil" @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil" - @snippet = opts[:snippet] || "" - @have_snippet = !opts[:snippet].nil? + @snippet = opts[:snippet] + @snippet_contains_encrypted_content = false + @have_snippet = !(opts[:snippet].nil? || opts[:snippet].empty?) @labels = [] + (opts[:labels] || []) @dirty = false + @encrypted = false @chunks = nil parse_header(opts[:header] || @source.load_header(@source_info)) @@ -116,7 +118,7 @@ class Message end private :parse_header - def snippet; @snippet || chunks && @snippet; end + def snippet; @snippet || (chunks && @snippet); end def is_list_message?; !@list_address.nil?; end def is_draft?; @source.is_a? DraftLoader; end def draft_filename @@ -301,7 +303,6 @@ private end def multipart_encrypted_to_chunks m - Redwood::log ">> multipart ENCRYPTED: #{m.header['Content-Type']}: #{m.body.size}" if m.body.size != 2 Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)" return @@ -324,11 +325,11 @@ private end decryptedm, sig, notice = CryptoManager.decrypt payload - children = message_to_chunks(decryptedm) if decryptedm + children = message_to_chunks(decryptedm, true) if decryptedm [notice, sig, children].flatten.compact end - def message_to_chunks m, sibling_types=[] + def message_to_chunks m, encrypted=false, sibling_types=[] if m.multipart? chunks = case m.header.content_type @@ -340,7 +341,7 @@ private unless chunks sibling_types = m.body.map { |p| p.header.content_type } - chunks = m.body.map { |p| message_to_chunks p, sibling_types }.flatten.compact + chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact end chunks @@ -378,7 +379,7 @@ private ## otherwise, it's body text else body = Message.convert_from m.decode, m.charset - text_to_chunks((body || "").normalize_whitespace.split("\n")) + text_to_chunks (body || "").normalize_whitespace.split("\n"), encrypted end end end @@ -398,7 +399,7 @@ private ## parse the lines of text into chunk objects. the heuristics here ## need tweaking in some nice manner. TODO: move these heuristics ## into the classes themselves. - def text_to_chunks lines + def text_to_chunks lines, encrypted state = :text # one of :text, :quote, or :sig chunks = [] chunk_lines = [] @@ -450,11 +451,14 @@ private when :block_quote, :sig chunk_lines << line end - + if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/ + @snippet ||= "" @snippet += " " unless @snippet.empty? @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ") @snippet = @snippet[0 ... SNIPPET_LEN].chomp + @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages] + @snippet_contains_encrypted_content = true if encrypted end end diff --git a/lib/sup/modes/edit-message-mode.rb b/lib/sup/modes/edit-message-mode.rb index fa34790..3df3176 100644 --- a/lib/sup/modes/edit-message-mode.rb +++ b/lib/sup/modes/edit-message-mode.rb @@ -257,7 +257,6 @@ protected return false if $config[:confirm_no_attachments] && mentions_attachments? && @attachments.size == 0 && !BufferManager.ask_yes_or_no("You haven't added any attachments. Really send?")#" stupid ruby-mode return false if $config[:confirm_top_posting] && top_posting? && !BufferManager.ask_yes_or_no("You're top-posting. That makes you a bad person. Really send?") #" stupid ruby-mode - date = Time.now from_email = if @header["From"] =~ /?$/ $1 @@ -269,13 +268,15 @@ protected BufferManager.flash "Sending..." begin - IO.popen(acct.sendmail, "w") { |p| write_full_message_to p, date, false } + date = Time.now + m = build_message date + IO.popen(acct.sendmail, "w") { |p| p.puts m } raise SendmailCommandFailed, "Couldn't execute #{acct.sendmail}" unless $? == 0 - SentManager.write_sent_message(date, from_email) { |f| write_full_message_to f, date, true } + SentManager.write_sent_message(date, from_email) { |f| f.puts sanitize_body(m.to_s) } BufferManager.kill_buffer buffer BufferManager.flash "Message sent!" true - rescue SystemCallError, SendmailCommandFailed => e + rescue SystemCallError, SendmailCommandFailed, CryptoManager::Error => e Redwood::log "Problem sending mail: #{e.message}" BufferManager.flash "Problem sending mail: #{e.message}" false @@ -288,8 +289,32 @@ protected BufferManager.flash "Saved for later editing." end - def write_full_message_to f, date=Time.now, escape=false + def build_message date m = RMail::Message.new + m.header["Content-Type"] = "text/plain; charset=#{$encoding}" + m.body = @body.join + m.body = m.body + m.body += sig_lines.join("\n") unless $config[:edit_signature] + + ## there are attachments, so wrap body in an attachment of its own + unless @attachments.empty? + body_m = m + body_m.header["Content-Disposition"] = "inline" + m = RMail::Message.new + + m.add_part body_m + @attachments.each { |a| m.add_part a } + end + + ## do whatever crypto transformation is necessary + if @crypto_selector && @crypto_selector.val != :none + from_email = PersonManager.person_for(@header["From"]).email + to_email = (@header["To"] + @header["Cc"] + @header["Bcc"]).map { |p| PersonManager.person_for(p).email } + + m = CryptoManager.send @crypto_selector.val, from_email, to_email, m + end + + ## finally, set the top-level headers @header.each do |k, v| next if v.nil? || v.empty? m.header[k] = @@ -300,28 +325,10 @@ protected v.join ", " end end - m.header["Date"] = date.rfc2822 m.header["Message-Id"] = @message_id m.header["User-Agent"] = "Sup/#{Redwood::VERSION}" - - if @attachments.empty? - m.header["Content-Type"] = "text/plain; charset=#{$encoding}" - m.body = @body.join - m.body = sanitize_body m.body if escape - m.body += sig_lines.join("\n") unless $config[:edit_signature] - else - body_m = RMail::Message.new - body_m.body = @body.join - body_m.body = sanitize_body body_m.body if escape - body_m.body += sig_lines.join("\n") unless $config[:edit_signature] - body_m.header["Content-Type"] = "text/plain; charset=#{$encoding}" - body_m.header["Content-Disposition"] = "inline" - - m.add_part body_m - @attachments.each { |a| m.add_part a } - end - f.puts m.to_s + m end ## TODO: remove this. redundant with write_full_message_to. diff --git a/lib/sup/util.rb b/lib/sup/util.rb index bd150b4..2164e85 100644 --- a/lib/sup/util.rb +++ b/lib/sup/util.rb @@ -70,17 +70,17 @@ module RMail a = Message.new a.header.add "Content-Disposition", "attachment; filename=#{filename.inspect}" a.header.add "Content-Type", "#{mime_type}; name=#{filename.inspect}" - a.header.add "Content-Transfer-Encoding", encoding + a.header.add "Content-Transfer-Encoding", encoding if encoding a.body = case encoding when "base64" [payload].pack "m" when "quoted-printable" [payload].pack "M" - when "7bit", "8bit" + when "7bit", "8bit", nil payload else - raise EncodingUnsupportedError, t.encoding + raise EncodingUnsupportedError, encoding.inspect end a end