lib/sup/crypto.rb in sup-0.12.1 vs lib/sup/crypto.rb in sup-0.13.0
- old
+ new
@@ -1,5 +1,12 @@
+begin
+ # gpgme broke its API in 2.0, so make sure we have the old version for now.
+ gem 'gpgme', '=1.0.8'
+ require 'gpgme'
+rescue LoadError
+end
+
module Redwood
class CryptoManager
include Singleton
@@ -9,80 +16,160 @@
[: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
+ HookManager.register "gpg-options", <<EOS
+Runs before gpg is called, allowing you to modify the options (most
likely you would want to add something to certain commands, like
---trust-model always to signing/encrypting a message, but who knows).
+{:always_trust => true} to encrypting a message, but who knows).
Variables:
-args: arguments for running GPG
+operation: what operation will be done ("sign", "encrypt", "decrypt" or "verify")
+options: a dictionary of values to be passed to GPGME
-Return value: the arguments for running GPG
+Return value: a dictionary to be passed to GPGME
EOS
+ HookManager.register "sig-output", <<EOS
+Runs when the signature output is being generated, allowing you to
+add extra information to your signatures if you want.
+
+Variables:
+signature: the signature object (class is GPGME::Signature)
+from_key: the key that generated the signature (class is GPGME::Key)
+
+Return value: an array of lines of output
+EOS
+
+ HookManager.register "gpg-expand-keys", <<EOS
+Runs when the list of encryption recipients is created, allowing you to
+replace a recipient with one or more GPGME recipients. For example, you could
+replace the email address of a mailing list with the key IDs that belong to
+the recipients of that list. This is essentially what GPG groups do, which
+are not supported by GPGME.
+
+Variables:
+recipients: an array of recipients of the current email
+
+Return value: an array of recipients (email address or GPG key ID) to encrypt
+the email for
+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"
+ @not_working_reason = nil
+
+ # test if the gpgme gem is available
+ @gpgme_present =
+ begin
+ begin
+ GPGME.check_version({:protocol => GPGME::PROTOCOL_OpenPGP})
+ true
+ rescue GPGME::Error
+ false
+ end
+ rescue NameError
+ false
+ end
+
+ unless @gpgme_present
+ @not_working_reason = ['gpgme gem not present',
+ 'Install the gpgme gem in order to use signed and encrypted emails']
+ return
+ end
+
+ # if gpg2 is available, it will start gpg-agent if required
+ if (bin = `which gpg2`.chomp) =~ /\S/
+ GPGME.set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
else
- debug "crypto: no gpg binary detected"
- nil
+ # check if the gpg-options hook uses the passphrase_callback
+ # if it doesn't then check if gpg agent is present
+ gpg_opts = HookManager.run("gpg-options",
+ {:operation => "sign", :options => {}}) || {}
+ if gpg_opts[:passphrase_callback].nil?
+ if ENV['GPG_AGENT_INFO'].nil?
+ @not_working_reason = ["Environment variable 'GPG_AGENT_INFO' not set, is gpg-agent running?",
+ "If gpg-agent is running, try $ export `cat ~/.gpg-agent-info`"]
+ return
+ end
+
+ gpg_agent_socket_file = ENV['GPG_AGENT_INFO'].split(':')[0]
+ unless File.exist?(gpg_agent_socket_file)
+ @not_working_reason = ["gpg-agent socket file #{gpg_agent_socket_file} does not exist"]
+ return
+ end
+
+ s = File.stat(gpg_agent_socket_file)
+ unless s.socket?
+ @not_working_reason = ["gpg-agent socket file #{gpg_agent_socket_file} is not a socket"]
+ return
+ end
+ end
end
end
- def have_crypto?; !@cmd.nil? end
+ def have_crypto?; @not_working_reason.nil? end
def sign from, to, payload
- payload_fn = Tempfile.new "redwood.payload"
- payload_fn.write format_payload(payload)
- payload_fn.close
+ return unknown_status(@not_working_reason) unless @not_working_reason.nil?
- sig_fn = Tempfile.new "redwood.signature"; sig_fn.close
+ gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
+ gpg_opts.merge!(gen_sign_user_opts(from))
+ gpg_opts = HookManager.run("gpg-options",
+ {:operation => "sign", :options => gpg_opts}) || gpg_opts
- 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."
+ begin
+ sig = GPGME.detach_sign(format_payload(payload), gpg_opts)
+ rescue GPGME::Error => exc
+ raise Error, gpgme_exc_msg(exc.message)
end
+ # if the key (or gpg-agent) is not available GPGME does not complain
+ # but just returns a zero length string. Let's catch that
+ if sig.length == 0
+ raise Error, gpgme_exc_msg("GPG failed to generate signature: check that gpg-agent is running and your key is available.")
+ end
+
envelope = RMail::Message.new
- envelope.header["Content-Type"] = 'multipart/signed; protocol=application/pgp-signature; micalg=pgp-sha256'
+ envelope.header["Content-Type"] = 'multipart/signed; protocol=application/pgp-signature'
envelope.add_part payload
- signature = RMail::Message.make_attachment IO.read(sig_fn.path), "application/pgp-signature", nil, "signature.asc"
+ signature = RMail::Message.make_attachment sig, "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
+ return unknown_status(@not_working_reason) unless @not_working_reason.nil?
- encrypted_fn = Tempfile.new "redwood.encrypted"; encrypted_fn.close
+ gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
+ if sign
+ gpg_opts.merge!(gen_sign_user_opts(from))
+ gpg_opts.merge!({:sign => true})
+ end
+ gpg_opts = HookManager.run("gpg-options",
+ {:operation => "encrypt", :options => gpg_opts}) || gpg_opts
+ recipients = to + [from]
+ recipients = HookManager.run("gpg-expand-keys", { :recipients => recipients }) || recipients
+ begin
+ cipher = GPGME.encrypt(recipients, format_payload(payload), gpg_opts)
+ rescue GPGME::Error => exc
+ raise Error, gpgme_exc_msg(exc.message)
+ end
- 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."
+ # if the key (or gpg-agent) is not available GPGME does not complain
+ # but just returns a zero length string. Let's catch that
+ if cipher.length == 0
+ raise Error, gpgme_exc_msg("GPG failed to generate cipher text: check that gpg-agent is running and your key is available.")
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)
+ encrypted_payload.body = cipher
control = RMail::Message.new
control.header["Content-Type"] = "application/pgp-encrypted"
control.header["Content-Disposition"] = "attachment"
control.body = "Version: 1\n"
@@ -97,74 +184,108 @@
def sign_and_encrypt from, to, payload
encrypt from, to, payload, true
end
- def verified_ok? output, rc
- output_lines = output.split(/\n/)
+ def verified_ok? verify_result
+ valid = true
+ unknown = false
+ all_output_lines = []
+ all_trusted = true
- if output =~ /^gpg: (.* signature from .*$)/
- if rc == 0
- Chunk::CryptoNotice.new :valid, $1, output_lines
+ verify_result.signatures.each do |signature|
+ output_lines, trusted = sig_output_lines signature
+ all_output_lines << output_lines
+ all_output_lines.flatten!
+ all_trusted &&= trusted
+
+ err_code = GPGME::gpgme_err_code(signature.status)
+ if err_code == GPGME::GPG_ERR_BAD_SIGNATURE
+ valid = false
+ elsif err_code != GPGME::GPG_ERR_NO_ERROR
+ valid = false
+ unknown = true
+ end
+ end
+
+ if valid || !unknown
+ summary_line = simplify_sig_line(verify_result.signatures[0].to_s, all_trusted)
+ end
+
+ if all_output_lines.length == 0
+ Chunk::CryptoNotice.new :valid, "Encrypted message wasn't signed", all_output_lines
+ elsif valid
+ if all_trusted
+ Chunk::CryptoNotice.new(:valid, summary_line, all_output_lines)
else
- Chunk::CryptoNotice.new :invalid, $1, output_lines
+ Chunk::CryptoNotice.new(:valid_untrusted, summary_line, all_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
+ elsif !unknown
+ Chunk::CryptoNotice.new(:invalid, summary_line, all_output_lines)
else
- unknown_status output_lines
+ unknown_status all_output_lines
end
end
def verify payload, signature, detached=true # both RubyMail::Message objects
- return unknown_status(cant_find_binary) unless @cmd
+ return unknown_status(@not_working_reason) unless @not_working_reason.nil?
+ gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP}
+ gpg_opts = HookManager.run("gpg-options",
+ {:operation => "verify", :options => gpg_opts}) || gpg_opts
+ ctx = GPGME::Ctx.new(gpg_opts)
+ sig_data = GPGME::Data.from_str signature.decode
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}"
+ signed_text_data = GPGME::Data.from_str(format_payload(payload))
+ plain_data = nil
else
- output = run_gpg "--verify #{signature_fn.path}"
+ signed_text_data = nil
+ plain_data = GPGME::Data.empty
end
-
- self.verified_ok? output, $?
+ begin
+ ctx.verify(sig_data, signed_text_data, plain_data)
+ rescue GPGME::Error => exc
+ return unknown_status [gpgme_exc_msg(exc.message)]
+ end
+ begin
+ self.verified_ok? ctx.verify_result
+ rescue ArgumentError => exc
+ return unknown_status [gpgme_exc_msg(exc.message)]
+ end
end
## returns decrypted_message, status, desc, lines
def decrypt payload, armor=false # a RubyMail::Message object
- return unknown_status(cant_find_binary) unless @cmd
+ return unknown_status(@not_working_reason) unless @not_working_reason.nil?
- 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"))
+ gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP}
+ gpg_opts = HookManager.run("gpg-options",
+ {:operation => "decrypt", :options => gpg_opts}) || gpg_opts
+ ctx = GPGME::Ctx.new(gpg_opts)
+ cipher_data = GPGME::Data.from_str(format_payload(payload))
+ plain_data = GPGME::Data.empty
+ begin
+ ctx.decrypt_verify(cipher_data, plain_data)
+ rescue GPGME::Error => exc
+ return Chunk::CryptoNotice.new(:invalid, "This message could not be decrypted", gpgme_exc_msg(exc.message))
end
-
- output = IO.read output_fn.path
+ begin
+ sig = self.verified_ok? ctx.verify_result
+ rescue ArgumentError => exc
+ sig = unknown_status [gpgme_exc_msg(exc.message)]
+ end
+ plain_data.seek(0, IO::SEEK_SET)
+ output = plain_data.read
output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding
+ ## TODO: test to see if it is still necessary to do a 2nd run if verify
+ ## fails.
+ #
## 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, $?
+ #sigoutput = run_gpg "#{payload_fn.path}"
+ #sig = self.old_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:/)
@@ -172,11 +293,11 @@
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
+ # 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
@@ -205,49 +326,105 @@
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."]
+ def gpgme_exc_msg msg
+ err_msg = "Exception in GPGME call: #{msg}"
+ info err_msg
+ err_msg
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
+ # remove the hex key_id and info in ()
+ def simplify_sig_line sig_line, trusted
+ sig_line.sub!(/from [0-9A-F]{16} /, "from ")
+ if !trusted
+ sig_line.sub!(/Good signature/, "Good (untrusted) signature")
+ end
+ sig_line
+ end
+
+ def sig_output_lines signature
+ # It appears that the signature.to_s call can lead to a EOFError if
+ # the key is not found. So start by looking for the key.
+ ctx = GPGME::Ctx.new
+ begin
+ from_key = ctx.get_key(signature.fingerprint)
+ if GPGME::gpgme_err_code(signature.status) == GPGME::GPG_ERR_GENERAL
+ first_sig = "General error on signature verification for #{signature.fingerprint}"
+ elsif signature.to_s
+ first_sig = signature.to_s.sub(/from [0-9A-F]{16} /, 'from "') + '"'
+ else
+ first_sig = "Unknown error or empty signature"
+ end
+ rescue EOFError
+ from_key = nil
+ first_sig = "No public key available for #{signature.fingerprint}"
+ end
+
+ time_line = "Signature made " + signature.timestamp.strftime("%a %d %b %Y %H:%M:%S %Z") +
+ " using " + key_type(from_key, signature.fingerprint) +
+ "key ID " + signature.fingerprint[-8..-1]
+ output_lines = [time_line, first_sig]
+
+ trusted = false
+ if from_key
+ # first list all the uids
+ if from_key.uids.length > 1
+ aka_list = from_key.uids[1..-1]
+ aka_list.each { |aka| output_lines << ' aka "' + aka.uid + '"' }
+ end
+
+ # now we want to look at the trust of that key
+ if signature.validity != GPGME::GPGME_VALIDITY_FULL && signature.validity != GPGME::GPGME_VALIDITY_MARGINAL
+ output_lines << "WARNING: This key is not certified with a trusted signature!"
+ output_lines << "There is no indication that the signature belongs to the owner"
+ output_lines << "Full fingerprint is: " + (0..9).map {|i| signature.fpr[(i*4),4]}.join(":")
+ else
+ trusted = true
+ end
+
+ # finally, run the hook
+ output_lines << HookManager.run("sig-output",
+ {:signature => signature, :from_key => from_key})
+ end
+ return output_lines, trusted
+ end
+
+ def key_type key, fpr
+ return "" if key.nil?
+ subkey = key.subkeys.find {|subkey| subkey.fpr == fpr || subkey.keyid == fpr }
+ return "" if subkey.nil?
+
+ case subkey.pubkey_algo
+ when GPGME::PK_RSA then "RSA "
+ when GPGME::PK_DSA then "DSA "
+ when GPGME::PK_ELG then "ElGamel "
+ when GPGME::PK_ELG_E then "ElGamel "
+ end
+ 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
+ account ||= AccountManager.default_account
if !account.gpgkey.nil?
- opts = "--local-user '#{account.gpgkey}'"
+ opts = {:signers => account.gpgkey}
elsif AccountManager.user_emails.length == 1
# only one account
- opts = ""
+ opts = {}
else
- opts = "--local-user '#{from}'"
+ opts = {:signers => 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