root/lib/mail/gpg.rb @ ad54b915
ad54b915 | Marc Dequènes (Duck) | #--
|
|
# Copyright (c) 2013 Marc Dequènes (Duck) <Duck@DuckCorp.org>
|
|||
#
|
|||
# Permission is hereby granted, free of charge, to any person obtaining
|
|||
# a copy of this software and associated documentation files (the
|
|||
# "Software"), to deal in the Software without restriction, including
|
|||
# without limitation the rights to use, copy, modify, merge, publish,
|
|||
# distribute, sublicense, and/or sell copies of the Software, and to
|
|||
# permit persons to whom the Software is furnished to do so, subject to
|
|||
# the following conditions:
|
|||
#
|
|||
# The above copyright notice and this permission notice shall be
|
|||
# included in all copies or substantial portions of the Software.
|
|||
#
|
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|||
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|||
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|||
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||
#
|
|||
#++
|
|||
# Attempt to handle PGP/GPG features in a RFC3156-compliant way
|
|||
#
|
|||
# notes: These methods have been designed to be able to sign, crypt,
|
|||
# or sign+crypt with RFC 1847 Encapsulation. There is no
|
|||
# support (yet?) for the combined method described in chapter
|
|||
# 6.2 of RFC3156.
|
|||
module Mail
|
|||
module GpgExtension
|
|||
def is_pgp_signed?
|
|||
mime_type == "multipart/signed" and
|
|||
content_type_parameters['protocol'] == "application/pgp-signature" and
|
|||
parts.size == 2 and
|
|||
pgp_signature_part
|
|||
end
|
|||
def pgp_signature_part
|
|||
parts.detect { |p| p.mime_type == "application/pgp-signature" && !p.attachment? }
|
|||
end
|
|||
def pgp_signed_part
|
|||
return nil unless is_pgp_signed?
|
|||
p = parts.detect { |p| p.mime_type != "application/pgp-signature" && !p.attachment? }
|
|||
end
|
|||
def verify_pgp_signature
|
|||
return nil unless is_pgp_signed?
|
|||
# use untouched data, or verification will fail
|
|||
content = pgp_signed_part.raw_source
|
|||
# RFC3156: properly convert all EOL to CRLF
|
|||
content.to_crlf
|
|||
# XXX: ruby-mail strangely adds a CRLF at the begining of the part
|
|||
content.sub!(/^\r\n/m, "")
|
|||
sig = pgp_signature_part.decoded
|
|||
sigs_check = nil
|
|||
crypto = GPGME::Crypto.new
|
|||
crypto.verify(sig, :signed_text => content) do |signature|
|
|||
sigs_check ||= []
|
|||
sigs_check << signature
|
|||
end
|
|||
sigs_check
|
|||
end
|
|||
def is_pgp_encrypted?
|
|||
mime_type == "multipart/encrypted" and
|
|||
content_type_parameters['protocol'] == "application/pgp-encrypted" and
|
|||
parts.size == 2 and
|
|||
pgp_crypt_info_part and
|
|||
pgp_encrypted_part
|
|||
end
|
|||
def pgp_crypt_info_part
|
|||
parts.detect { |p| p.mime_type == "application/pgp-encrypted" && !p.attachment? }
|
|||
end
|
|||
def pgp_crypt_info
|
|||
return nil unless is_pgp_encrypted?
|
|||
a = pgp_crypt_info_part.body.decoded.split("\n").collect do |l|
|
|||
l.chomp.split(": ") if l =~ /: /
|
|||
end.compact.flatten
|
|||
Hash[*a]
|
|||
end
|
|||
def pgp_encrypted_part
|
|||
parts.detect { |p| p.mime_type == "application/octet-stream" && !p.attachment? }
|
|||
end
|
|||
def pgp_decrypt(&passphrase_callback)
|
|||
return nil unless is_pgp_encrypted?
|
|||
protocol_version = pgp_crypt_info["Version"].to_i
|
|||
raise NotImplementedError, "pgp-encrypted protocol version #{protocol_version} is not implemented" unless
|
|||
protocol_version == 1
|
|||
clear_data = nil
|
|||
GPGME::Ctx.new(:textmode => true) do |gpg|
|
|||
gpg.set_passphrase_callback(method(:gpg_passphrase_callback_wrapper), passphrase_callback)
|
|||
clear_data = gpg.decrypt(GPGME::Data.new(pgp_encrypted_part.body.decoded))
|
|||
end
|
|||
clear_data.seek(0, IO::SEEK_SET)
|
|||
clear_data.read
|
|||
end
|
|||
def pgp_crypt(crypters_id)
|
|||
crypters_id = [crypters_id] unless crypters_id.is_a? Array
|
|||
crypters = crypters_id.collect{|key_id| gpg_key(key_id, false) }
|
|||
encrypted_data = nil
|
|||
GPGME::Ctx.new(:armor => true) do |gpg|
|
|||
encrypted_data = gpg.encrypt(crypters, GPGME::Data.new(self.encoded))
|
|||
end
|
|||
encrypted_data.seek(0, IO::SEEK_SET)
|
|||
encrypted_data.read
|
|||
end
|
|||
def pgp_sign(signers_id, &passphrase_callback)
|
|||
signers_id = [signers_id] unless signers_id.is_a? Array
|
|||
signers = signers_id.collect{|key_id| gpg_key(key_id, true) }
|
|||
# RFC3156:
|
|||
# - remove lines containing only whitespaces
|
|||
# - remove trailing whitespaces
|
|||
# - properly convert all EOL to CRLF
|
|||
prepared_data = self.encoded.split(/\r?\n/, -1).select do |l|
|
|||
not l =~ /^ +$/
|
|||
end.join("\n").gsub(/ +$/, "").to_crlf
|
|||
raise 'Mail ERROR: data to sign MUST be in 7bit format, using Quoted-Printable or Base64 encoding !' unless
|
|||
prepared_data.ascii_only?
|
|||
sig_data = GPGME::Data.new
|
|||
micalg = nil
|
|||
# we don't use GPGME::Crypto.sign(), because we need to get operation information to get
|
|||
# the hash_algo and compute the micalg parameter
|
|||
GPGME::Ctx.new(:armor => true) do |gpg|
|
|||
gpg.add_signer(*signers)
|
|||
gpg.set_passphrase_callback(method(:gpg_passphrase_callback_wrapper), passphrase_callback)
|
|||
gpg.sign(GPGME::Data.new(prepared_data), sig_data, GPGME::SIG_MODE_DETACH)
|
|||
hash_algo = gpg.sign_result.signatures.first.hash_algo
|
|||
micalg = "pgp-" + GPGME.gpgme_hash_algo_name(hash_algo).downcase
|
|||
# rewind
|
|||
sig_data.seek(0, IO::SEEK_SET)
|
|||
end
|
|||
{:signature => sig_data.read, :micalg => micalg}
|
|||
end
|
|||
def create_encrypted(crypters_id)
|
|||
# build properly encrypted mail
|
|||
# (preserving headers from original mail)
|
|||
mail = Mail::Message.new
|
|||
mail.copy_headers_from(self)
|
|||
# cryptographic info
|
|||
p_pgp = Mail::Part.new
|
|||
p_pgp.content_type = "application/pgp-encrypted"
|
|||
p_pgp.content_transfer_encoding = "7bit"
|
|||
p_pgp.content_disposition = "inline"
|
|||
p_pgp.body = "Version: 1\n"
|
|||
mail.add_part(p_pgp)
|
|||
encrypted_data = self.convert_to_part.pgp_crypt(crypters_id)
|
|||
# encrypted message
|
|||
p_encrypted = Mail::Part.new
|
|||
p_encrypted.content_type = "application/octet-stream"
|
|||
p_encrypted.content_transfer_encoding = "7bit"
|
|||
p_encryptedcontent_disposition = "inline"
|
|||
p_encrypted.body = encrypted_data
|
|||
mail.add_part(p_encrypted)
|
|||
# XXX: there is no way to have a body while being multipart in ruby-mail
|
|||
#mail.body = "This mail is a RFC3156 signed message.\n"
|
|||
# XXX: setup after mail has switched to multipart or settings are overwritten by ruby-mail
|
|||
# save previous content-type parameters, as they are lost when chaging the content-type
|
|||
prev_params = mail.content_type_parameters
|
|||
mail.content_type = "multipart/encrypted"
|
|||
prev_params.each_pair do |k, v|
|
|||
mail.content_type_parameters[k] = v
|
|||
end
|
|||
mail.content_type_parameters['protocol'] = "application/pgp-encrypted"
|
|||
mail.content_transfer_encoding = "7bit"
|
|||
mail.content_disposition = nil
|
|||
mail
|
|||
end
|
|||
def create_signed(signers_id)
|
|||
# build properly signed mail
|
|||
# (preserving headers from original mail)
|
|||
mail = Mail::Message.new
|
|||
mail.copy_headers_from(self)
|
|||
# signed message
|
|||
p_signed = self.convert_to_part
|
|||
mail.add_part(p_signed)
|
|||
signature_data = p_signed.pgp_sign(signers_id) do |uid_hint, passphrase_info, prev_was_bad|
|
|||
yield(uid_hint, passphrase_info, prev_was_bad)
|
|||
end
|
|||
# signature
|
|||
p_signature = Mail::Message.new
|
|||
p_signature.content_type = "application/pgp-signature"
|
|||
p_signature.content_transfer_encoding = "7bit"
|
|||
p_signature.content_disposition = "inline"
|
|||
p_signature.body = signature_data[:signature]
|
|||
mail.add_part(p_signature)
|
|||
# XXX: there is no way to have a body while being multipart in ruby-mail
|
|||
#mail.body = "This mail is a RFC3156 signed message.\n"
|
|||
# XXX: setup after mail has switched to multipart or settings are overwritten by ruby-mail
|
|||
# save previous content-type parameters, as they are lost when chaging the content-type
|
|||
prev_params = mail.content_type_parameters
|
|||
mail.content_type = "multipart/signed"
|
|||
prev_params.each_pair do |k, v|
|
|||
mail.content_type_parameters[k] = v
|
|||
end
|
|||
mail.content_type_parameters['protocol'] = "application/pgp-signature"
|
|||
mail.content_type_parameters['micalg'] = signature_data[:micalg]
|
|||
mail.content_transfer_encoding = "7bit"
|
|||
mail.content_disposition = nil
|
|||
mail
|
|||
end
|
|||
def create_signed_and_encrypted(signers_id, crypters_id)
|
|||
create_signed(signers_id) do |uid_hint, passphrase_info, prev_was_bad|
|
|||
yield(uid_hint, passphrase_info, prev_was_bad)
|
|||
end.create_encrypted(crypters_id)
|
|||
end
|
|||
def copy_headers_from(mail)
|
|||
# deep clone
|
|||
@header = Marshal.load(Marshal.dump(mail.header))
|
|||
end
|
|||
def has_content_disposition?
|
|||
header[:content_disposition] && header[:content_disposition].errors.blank?
|
|||
end
|
|||
def convert_to_part
|
|||
# build a part from a mail to get the generated content to be crypted/signed
|
|||
p = Mail::Part.new
|
|||
p.content_type = self.content_type
|
|||
p.content_transfer_encoding = self.content_transfer_encoding if has_content_transfer_encoding?
|
|||
p.content_disposition = self.content_disposition if has_content_disposition?
|
|||
if multipart?
|
|||
parts.each {|orig_p| p.add_part(orig_p) }
|
|||
else
|
|||
p.body = self.body.encoded
|
|||
end
|
|||
p
|
|||
end
|
|||
protected
|
|||
def gpg_key(fingerprint, secret = false)
|
|||
GPGME::Ctx.new do |gpg|
|
|||
gpg.get_key(fingerprint, secret)
|
|||
end
|
|||
rescue EOFError
|
|||
raise "Mail ERROR: #{secret ? "secret" : "public"} key not found (#{fingerprint})"
|
|||
end
|
|||
def gpg_passphrase_callback_wrapper(hook, uid_hint, passphrase_info, prev_was_bad, fd)
|
|||
io = IO.for_fd(fd, 'w')
|
|||
io.puts hook.call(uid_hint, passphrase_info, prev_was_bad)
|
|||
io.flush
|
|||
end
|
|||
end
|
|||
end
|
|||
begin
|
|||
require 'gpgme'
|
|||
Mail::Message.send(:include, Mail::GpgExtension)
|
|||
rescue LoadError
|
|||
end
|