sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/

lib/sup/crypto.rb (18212B) - raw

      1 begin
      2   require 'gpgme'
      3 rescue LoadError
      4 end
      5 
      6 module Redwood
      7 
      8 class CryptoManager
      9   include Redwood::Singleton
     10 
     11   class Error < StandardError; end
     12 
     13   OUTGOING_MESSAGE_OPERATIONS = {
     14     sign: "Sign",
     15     sign_and_encrypt: "Sign and encrypt",
     16     encrypt: "Encrypt only"
     17   }
     18 
     19   KEY_PATTERN = /(-----BEGIN PGP PUBLIC KEY BLOCK.*-----END PGP PUBLIC KEY BLOCK)/m
     20   KEYSERVER_URL = "http://pool.sks-keyservers.net:11371/pks/lookup"
     21 
     22   HookManager.register "gpg-options", <<EOS
     23 Runs before gpg is called, allowing you to modify the options (most
     24 likely you would want to add something to certain commands, like
     25 {:always_trust => true} to encrypting a message, but who knows).
     26 
     27 Variables:
     28 operation: what operation will be done ("sign", "encrypt", "decrypt" or "verify")
     29 options: a dictionary of values to be passed to GPGME
     30 
     31 Return value: a dictionary to be passed to GPGME
     32 EOS
     33 
     34   HookManager.register "sig-output", <<EOS
     35 Runs when the signature output is being generated, allowing you to
     36 add extra information to your signatures if you want.
     37 
     38 Variables:
     39 signature: the signature object (class is GPGME::Signature)
     40 from_key: the key that generated the signature (class is GPGME::Key)
     41 
     42 Return value: an array of lines of output
     43 EOS
     44 
     45   HookManager.register "gpg-expand-keys", <<EOS
     46 Runs when the list of encryption recipients is created, allowing you to
     47 replace a recipient with one or more GPGME recipients. For example, you could
     48 replace the email address of a mailing list with the key IDs that belong to
     49 the recipients of that list. This is essentially what GPG groups do, which
     50 are not supported by GPGME.
     51 
     52 Variables:
     53 recipients: an array of recipients of the current email
     54 
     55 Return value: an array of recipients (email address or GPG key ID) to encrypt
     56 the email for
     57 EOS
     58 
     59   def initialize
     60     @mutex = Mutex.new
     61 
     62     @not_working_reason = nil
     63 
     64     # test if the gpgme gem is available
     65     @gpgme_present =
     66       begin
     67         begin
     68           begin
     69             GPGME.check_version({:protocol => GPGME::PROTOCOL_OpenPGP})
     70           rescue TypeError
     71             GPGME.check_version(nil)
     72           end
     73           true
     74         rescue GPGME::Error
     75           false
     76         rescue ArgumentError
     77           # gpgme 2.0.0 raises this due to the hash->string conversion
     78           false
     79         end
     80       rescue NameError
     81         false
     82       end
     83 
     84     unless @gpgme_present
     85       @not_working_reason = ['gpgme gem not present',
     86         'Install the gpgme gem in order to use signed and encrypted emails']
     87       return
     88     end
     89 
     90     # if gpg2 is available, it will start gpg-agent if required
     91     if (bin = `which gpg2`.chomp) =~ /\S/
     92       if GPGME.respond_to?('set_engine_info')
     93         GPGME.set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
     94       else
     95         GPGME.gpgme_set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
     96       end
     97     else
     98       # check if the gpg-options hook uses the passphrase_callback
     99       # if it doesn't then check if gpg agent is present
    100       gpg_opts = HookManager.run("gpg-options",
    101                                {:operation => "sign", :options => {}}) || {}
    102       if gpg_opts[:passphrase_callback].nil?
    103         if ENV['GPG_AGENT_INFO'].nil?
    104           @not_working_reason = ["Environment variable 'GPG_AGENT_INFO' not set, is gpg-agent running?",
    105                              "If gpg-agent is running, try $ export `cat ~/.gpg-agent-info`"]
    106           return
    107         end
    108 
    109         gpg_agent_socket_file = ENV['GPG_AGENT_INFO'].split(':')[0]
    110         unless File.exist?(gpg_agent_socket_file)
    111           @not_working_reason = ["gpg-agent socket file #{gpg_agent_socket_file} does not exist"]
    112           return
    113         end
    114 
    115         s = File.stat(gpg_agent_socket_file)
    116         unless s.socket?
    117           @not_working_reason = ["gpg-agent socket file #{gpg_agent_socket_file} is not a socket"]
    118           return
    119         end
    120       end
    121     end
    122   end
    123 
    124   def have_crypto?; @not_working_reason.nil? end
    125   def not_working_reason; @not_working_reason end
    126 
    127   def sign from, to, payload
    128     return unknown_status(@not_working_reason) unless @not_working_reason.nil?
    129 
    130     # We grab this from the GPG::Ctx below after signing, so that we can set
    131     # micalg in Content-Type to match the hash algorithm GPG decided to use.
    132     hash_algo = nil
    133 
    134     gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
    135     gpg_opts.merge!(gen_sign_user_opts(from))
    136     gpg_opts = HookManager.run("gpg-options",
    137                                {:operation => "sign", :options => gpg_opts}) || gpg_opts
    138     begin
    139       input = GPGME::Data.new(format_payload(payload))
    140       output = GPGME::Data.new()
    141       GPGME::Ctx.new(gpg_opts) do |ctx|
    142         if gpg_opts[:signer]
    143           signers = GPGME::Key.find(:secret, gpg_opts[:signer], :sign)
    144           ctx.add_signer(*signers)
    145         end
    146         ctx.sign(input, output, GPGME::SIG_MODE_DETACH)
    147         hash_algo = GPGME::hash_algo_name(ctx.sign_result.signatures[0].hash_algo)
    148       end
    149       output.seek(0)
    150       sig = output.read
    151     rescue GPGME::Error => exc
    152       raise Error, gpgme_exc_msg(exc.message)
    153     end
    154 
    155     # if the key (or gpg-agent) is not available GPGME does not complain
    156     # but just returns a zero length string. Let's catch that
    157     if sig.length == 0
    158       raise Error, gpgme_exc_msg("GPG failed to generate signature: check that gpg-agent is running and your key is available.")
    159     end
    160 
    161     envelope = RMail::Message.new
    162     envelope.header["Content-Type"] = "multipart/signed; protocol=application/pgp-signature; micalg=pgp-#{hash_algo.downcase}"
    163 
    164     envelope.add_part payload
    165     signature = RMail::Message.make_attachment sig, "application/pgp-signature", nil, "signature.asc"
    166     envelope.add_part signature
    167     envelope
    168   end
    169 
    170   def encrypt from, to, payload, sign=false
    171     return unknown_status(@not_working_reason) unless @not_working_reason.nil?
    172 
    173     gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
    174     if sign
    175       gpg_opts.merge!(gen_sign_user_opts(from))
    176       gpg_opts.merge!({:sign => true})
    177     end
    178     gpg_opts = HookManager.run("gpg-options",
    179                                {:operation => "encrypt", :options => gpg_opts}) || gpg_opts
    180     ## On the sup side we use :signer for backwards compatibility, but GPGME wants :signers.
    181     gpg_opts[:signers] = gpg_opts[:signer]
    182     recipients = to + [from]
    183     recipients = HookManager.run("gpg-expand-keys", { :recipients => recipients }) || recipients
    184     begin
    185       if GPGME.respond_to?('encrypt')
    186         cipher = GPGME.encrypt(recipients, format_payload(payload), gpg_opts)
    187       else
    188         crypto = GPGME::Crypto.new
    189         gpg_opts[:recipients] = recipients
    190         cipher = crypto.encrypt(format_payload(payload), gpg_opts).read
    191       end
    192     rescue GPGME::Error => exc
    193       raise Error, gpgme_exc_msg(exc.message)
    194     end
    195 
    196     # if the key (or gpg-agent) is not available GPGME does not complain
    197     # but just returns a zero length string. Let's catch that
    198     if cipher.length == 0
    199       raise Error, gpgme_exc_msg("GPG failed to generate cipher text: check that gpg-agent is running and your key is available.")
    200     end
    201 
    202     encrypted_payload = RMail::Message.new
    203     encrypted_payload.header["Content-Type"] = +"application/octet-stream"
    204     encrypted_payload.header["Content-Disposition"] = +'inline; filename="msg.asc"'
    205     encrypted_payload.body = cipher
    206 
    207     control = RMail::Message.new
    208     control.header["Content-Type"] = +"application/pgp-encrypted"
    209     control.header["Content-Disposition"] = +"attachment"
    210     control.body = "Version: 1\n"
    211 
    212     envelope = RMail::Message.new
    213     envelope.header["Content-Type"] = +"multipart/encrypted; protocol=application/pgp-encrypted"
    214 
    215     envelope.add_part control
    216     envelope.add_part encrypted_payload
    217     envelope
    218   end
    219 
    220   def sign_and_encrypt from, to, payload
    221     encrypt from, to, payload, true
    222   end
    223 
    224   def verified_ok? verify_result
    225     valid = true
    226     unknown = false
    227     all_output_lines = []
    228     all_trusted = true
    229     unknown_fingerprint = nil
    230 
    231     verify_result.signatures.each do |signature|
    232       output_lines, trusted, unknown_fingerprint = sig_output_lines signature
    233       all_output_lines << output_lines
    234       all_output_lines.flatten!
    235       all_trusted &&= trusted
    236 
    237       err_code = GPGME::gpgme_err_code(signature.status)
    238       if err_code == GPGME::GPG_ERR_BAD_SIGNATURE
    239         valid = false
    240       elsif err_code != GPGME::GPG_ERR_NO_ERROR
    241         valid = false
    242         unknown = true
    243       end
    244     end
    245 
    246     if valid || !unknown
    247       summary_line = simplify_sig_line(verify_result.signatures[0].to_s.dup, all_trusted)
    248     end
    249 
    250     if all_output_lines.length == 0
    251       Chunk::CryptoNotice.new :valid, "Encrypted message wasn't signed", all_output_lines
    252     elsif valid
    253       if all_trusted
    254         Chunk::CryptoNotice.new(:valid, summary_line, all_output_lines)
    255       else
    256         Chunk::CryptoNotice.new(:valid_untrusted, summary_line, all_output_lines)
    257       end
    258     elsif !unknown
    259       Chunk::CryptoNotice.new(:invalid, summary_line, all_output_lines)
    260     elsif unknown_fingerprint
    261       Chunk::CryptoNotice.new(:unknown_key, "Unable to determine validity of cryptographic signature", all_output_lines, unknown_fingerprint)
    262     else
    263       unknown_status all_output_lines
    264     end
    265   end
    266 
    267   def verify payload, signature, detached=true # both RubyMail::Message objects
    268     return unknown_status(@not_working_reason) unless @not_working_reason.nil?
    269 
    270     gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP}
    271     gpg_opts = HookManager.run("gpg-options",
    272                                {:operation => "verify", :options => gpg_opts}) || gpg_opts
    273     ctx = GPGME::Ctx.new(gpg_opts)
    274     sig_data = GPGME::Data.from_str signature.decode
    275     if detached
    276       signed_text_data = GPGME::Data.from_str(format_payload(payload))
    277       plain_data = nil
    278     else
    279       signed_text_data = nil
    280       if GPGME::Data.respond_to?('empty')
    281         plain_data = GPGME::Data.empty
    282       else
    283         plain_data = GPGME::Data.empty!
    284       end
    285     end
    286     begin
    287       ctx.verify(sig_data, signed_text_data, plain_data)
    288     rescue GPGME::Error => exc
    289       return unknown_status [gpgme_exc_msg(exc.message)]
    290     end
    291     begin
    292       self.verified_ok? ctx.verify_result
    293     rescue ArgumentError => exc
    294       return unknown_status [gpgme_exc_msg(exc.message)]
    295     end
    296   end
    297 
    298   ## returns decrypted_message, status, desc, lines
    299   def decrypt payload, armor=false # a RubyMail::Message object
    300     return unknown_status(@not_working_reason) unless @not_working_reason.nil?
    301 
    302     gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP}
    303     gpg_opts = HookManager.run("gpg-options",
    304                                {:operation => "decrypt", :options => gpg_opts}) || gpg_opts
    305     ctx = GPGME::Ctx.new(gpg_opts)
    306     cipher_data = GPGME::Data.from_str(format_payload(payload))
    307     if GPGME::Data.respond_to?('empty')
    308       plain_data = GPGME::Data.empty
    309     else
    310       plain_data = GPGME::Data.empty!
    311     end
    312     begin
    313       ctx.decrypt_verify(cipher_data, plain_data)
    314     rescue GPGME::Error => exc
    315       return Chunk::CryptoNotice.new(:invalid, "This message could not be decrypted", gpgme_exc_msg(exc.message))
    316     end
    317     begin
    318       sig = self.verified_ok? ctx.verify_result
    319     rescue ArgumentError => exc
    320       sig = unknown_status [gpgme_exc_msg(exc.message)]
    321     end
    322     plain_data.seek(0, IO::SEEK_SET)
    323     output = plain_data.read
    324     output.transcode(Encoding::ASCII_8BIT, output.encoding)
    325 
    326     ## TODO: test to see if it is still necessary to do a 2nd run if verify
    327     ## fails.
    328     #
    329     ## check for a valid signature in an extra run because gpg aborts if the
    330     ## signature cannot be verified (but it is still able to decrypt)
    331     #sigoutput = run_gpg "#{payload_fn.path}"
    332     #sig = self.old_verified_ok? sigoutput, $?
    333 
    334     if armor
    335       msg = RMail::Message.new
    336       # Look for Charset, they are put before the base64 crypted part
    337       charsets = payload.body.split("\n").grep(/^Charset:/)
    338       if !charsets.empty? and charsets[0] =~ /^Charset: (.+)$/
    339         output.transcode($encoding, $1)
    340       end
    341       msg.body = output
    342     else
    343       # It appears that some clients use Windows new lines - CRLF - but RMail
    344       # splits the body and header on "\n\n". So to allow the parse below to
    345       # succeed, we will convert the newlines to what RMail expects
    346       output = output.gsub(/\r\n/, "\n")
    347       # This is gross. This decrypted payload could very well be a multipart
    348       # element itself, as opposed to a simple payload. For example, a
    349       # multipart/signed element, like those generated by Mutt when encrypting
    350       # and signing a message (instead of just clearsigning the body).
    351       # Supposedly, decrypted_payload being a multipart element ought to work
    352       # out nicely because Message::multipart_encrypted_to_chunks() runs the
    353       # decrypted message through message_to_chunks() again to get any
    354       # children. However, it does not work as intended because these inner
    355       # payloads need not carry a MIME-Version header, yet they are fed to
    356       # RMail as a top-level message, for which the MIME-Version header is
    357       # required. This causes for the part not to be detected as multipart,
    358       # hence being shown as an attachment. If we detect this is happening,
    359       # we force the decrypted payload to be interpreted as MIME.
    360       msg = RMail::Parser.read output
    361       if msg.header.content_type =~ %r{^multipart/} && !msg.multipart?
    362         output = "MIME-Version: 1.0\n" + output
    363         output.fix_encoding!
    364         msg = RMail::Parser.read output
    365       end
    366     end
    367     notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display"
    368     [notice, sig, msg]
    369   end
    370 
    371   def retrieve fingerprint
    372     require 'net/http'
    373     uri = URI($config[:keyserver_url] || KEYSERVER_URL)
    374     unless uri.scheme == "http" and not uri.host.nil? and not uri.host.empty?
    375       return "Invalid url: #{uri}"
    376     end
    377 
    378     fingerprint = "0x" + fingerprint unless fingerprint[0..1] == "0x"
    379     params = {op: "get", search: fingerprint}
    380     uri.query = URI.encode_www_form(params)
    381 
    382     begin
    383       res = Net::HTTP.get_response(uri)
    384     rescue SocketError # Host doesn't exist or we couldn't connect
    385     end
    386     return "Couldn't get key from keyserver at this address: #{uri}" unless res.is_a?(Net::HTTPSuccess)
    387 
    388     match = KEY_PATTERN.match(res.body)
    389     return "No key found" unless match && match.length > 0
    390 
    391     GPGME::Key.import(match[0])
    392 
    393     return nil
    394   end
    395 
    396 private
    397 
    398   def unknown_status lines=[]
    399     Chunk::CryptoNotice.new :unknown, "Unable to determine validity of cryptographic signature", lines
    400   end
    401 
    402   def gpgme_exc_msg msg
    403     err_msg = "Exception in GPGME call: #{msg}"
    404     #info err_msg
    405     err_msg
    406   end
    407 
    408   ## here's where we munge rmail output into the format that signed/encrypted
    409   ## PGP/GPG messages should be
    410   def format_payload payload
    411     payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n")
    412   end
    413 
    414   # remove the hex key_id and info in ()
    415   def simplify_sig_line sig_line, trusted
    416     sig_line.sub!(/from [0-9A-F]{16} /, "from ")
    417     if !trusted
    418       sig_line.sub!(/Good signature/, "Good (untrusted) signature")
    419     end
    420     sig_line
    421   end
    422 
    423   def sig_output_lines signature
    424     # It appears that the signature.to_s call can lead to a EOFError if
    425     # the key is not found. So start by looking for the key.
    426     ctx = GPGME::Ctx.new
    427     begin
    428       from_key = ctx.get_key(signature.fingerprint)
    429       if GPGME::gpgme_err_code(signature.status) == GPGME::GPG_ERR_GENERAL
    430         first_sig = "General error on signature verification for #{signature.fingerprint}"
    431       elsif signature.to_s
    432         first_sig = signature.to_s.sub(/from [0-9A-F]{16} /, 'from "') + '"'
    433       else
    434         first_sig = "Unknown error or empty signature"
    435       end
    436     rescue EOFError
    437       from_key = nil
    438       first_sig = "No public key available for #{signature.fingerprint}"
    439       unknown_fpr = signature.fingerprint
    440     end
    441 
    442     time_line = "Signature made " + signature.timestamp.strftime("%a %d %b %Y %H:%M:%S %Z") +
    443                 " using " + key_type(from_key, signature.fingerprint) +
    444                 "key ID " + signature.fingerprint[-8..-1]
    445     output_lines = [time_line, first_sig]
    446 
    447     trusted = false
    448     if from_key
    449       # first list all the uids
    450       if from_key.uids.length > 1
    451         aka_list = from_key.uids[1..-1]
    452         aka_list.each { |aka| output_lines << '                aka "' + aka.uid + '"' }
    453       end
    454 
    455       # now we want to look at the trust of that key
    456       if signature.validity != GPGME::GPGME_VALIDITY_FULL && signature.validity != GPGME::GPGME_VALIDITY_MARGINAL
    457         output_lines << "WARNING: This key is not certified with a trusted signature!"
    458         output_lines << "There is no indication that the signature belongs to the owner"
    459         output_lines << "Full fingerprint is: " + (0..9).map {|i| signature.fpr[(i*4),4]}.join(":")
    460       else
    461         trusted = true
    462       end
    463 
    464       # finally, run the hook
    465       output_lines << HookManager.run("sig-output",
    466                                {:signature => signature, :from_key => from_key})
    467     end
    468     return output_lines, trusted, unknown_fpr
    469   end
    470 
    471   def key_type key, fpr
    472     return "" if key.nil?
    473     subkey = key.subkeys.find {|subkey| subkey.fpr == fpr || subkey.keyid == fpr }
    474     return "" if subkey.nil?
    475 
    476     case subkey.pubkey_algo
    477     when GPGME::PK_RSA then "RSA "
    478     when GPGME::PK_DSA then "DSA "
    479     when GPGME::PK_ELG then "ElGamel "
    480     when GPGME::PK_ELG_E then "ElGamel "
    481     else "unknown key type (#{subkey.pubkey_algo}) "
    482     end
    483   end
    484 
    485   # logic is:
    486   # if    gpgkey set for this account, then use that
    487   # elsif only one account,            then leave blank so gpg default will be user
    488   # else                                    set --local-user from_email_address
    489   # NOTE: multiple signers doesn't seem to work with gpgme (2.0.2, 1.0.8)
    490   #
    491   def gen_sign_user_opts from
    492     account = AccountManager.account_for from
    493     account ||= AccountManager.default_account
    494     if !account.gpgkey.nil?
    495       opts = {:signer => account.gpgkey}
    496     elsif AccountManager.user_emails.length == 1
    497       # only one account
    498       opts = {}
    499     else
    500       opts = {:signer => from}
    501     end
    502     opts
    503   end
    504 end
    505 end