Project

General

Profile

« Previous | Next » 

Revision ad54b915

Added by Marc Dequènes almost 12 years ago

[evol] ported the GPG extension from TMail to Mail (CyborgHood::Mail needs to be adapted now)

View differences:

lib/mail/gpg.rb
#--
# 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
lib/tmail_gpg.rb
#--
# Copyright (c) 2009-2010 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.
#
#++
class String
def is_7bit?
each_byte{|c| return false if c >= 128 }
true
end
end
# 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 TMail
module GpgExtension
def is_pgp_signed?
content_type == "multipart/signed" and
header['content-type'].params['protocol'] == "application/pgp-signature" and
parts.size == 2 and
parts[1].content_type == "application/pgp-signature"
end
def pgp_signature_part
return nil unless is_pgp_signed?
parts[1].decoded
end
def pgp_signature
return nil unless is_pgp_signed?
parts[1].body
end
# don't forget lines may not be normalized
def pgp_signed_part
return nil unless is_pgp_signed?
# remove final CRLF added to respect OpenPGP convention
# (see note in chapter 5 of RFC3156)
signed_part = parts[0].raw.chomp
return TMail::Mail.parse(signed_part)
end
def verify_pgp_signature
return nil unless is_pgp_signed?
content = parts[0].to_rfc3156(true)
sig = pgp_signature()
sigs_check = nil
gpg = GPGME::Ctx.new
gpg.verify(GPGME::Data.new(sig), GPGME::Data.new(content)) do |signature|
sigs_check ||= []
sigs_check << signature
end
gpg.release
sigs_check
end
def is_pgp_encrypted?
content_type == "multipart/encrypted" and
header['content-type'].params['protocol'] == "application/pgp-encrypted" and
parts.size == 2 and
parts[0].content_type == "application/pgp-encrypted" and
parts[1].content_type == "application/octet-stream"
end
def pgp_crypt_info
return nil unless is_pgp_encrypted?
a = parts[0].body.split("\n").collect{|l| l.chomp.split(": ") if l =~ /: / }.compact.flatten
Hash[*a]
end
def pgp_encrypted_part
return nil unless is_pgp_encrypted?
parts[1].body
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
encrypted_data = pgp_encrypted_part()
gpg = GPGME::Ctx.new({:passphrase_callback => method(:gpg_passphrase_callback_wrapper),
:passphrase_callback_value => passphrase_callback, :textmode => true})
gpg.decrypt(GPGME::Data.new(encrypted_data))
gpg.release
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) }
gpg = GPGME::Ctx.new({:armor => true})
gpg.encrypt(crypters, GPGME::Data.new(self.to_s))
gpg.release
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) }
# we don't use GPGME::Crypto.sign(), because we need to get operation information to get the hash_algo and compute the micalg parameter
gpg = GPGME::Ctx.new({:signers => signers, :passphrase_callback => method(:gpg_passphrase_callback_wrapper),
:passphrase_callback_value => passphrase_callback, :armor => true})
gpg.add_signer(*signers)
sig_data = GPGME::Data.new
# adds a final CRLF to respect OpenPGP convention (see note in chapter 5 of RFC3156)
prepared_data = self.to_rfc3156 + "\r\n"
raise 'TMail ERROR: data to sign MUST be in 7bit format, using Quotes-Printable or Base64 encoding !' unless prepared_data.is_7bit?
gpg.sign(GPGME::Data.new(prepared_data), GPGME::Data.new(sig_data), GPGME::SIG_MODE_DETACH)
hash_algo = GPGME.gpgme_op_sign_result(gpg).signatures.first.hash_algo
micalg = "pgp-" + GPGME.gpgme_hash_algo_name(hash_algo).downcase
sig_data.seek(0, IO::SEEK_SET)
gpg.release
{:signature => sig_data.read, :micalg => micalg}
end
def to_rfc3156(untouched = false)
# - using RAW part, without any decoding
# - remove last EOL due to MIME protocol
# - remove lines containing only whitespaces
# - remove trailing whitespaces
# - properly convert all EOL to CRLF
txt = untouched ? self.raw : self.to_s
txt.chomp.split(/\r?\n/, -1).select{|l| not l =~ /^ +$/}.join("\n").gsub(/ +$/, "").gsub(/\r?\n/, "\r\n")
end
def create_encrypted(crypters_id)
clear_data = build_intermediate_mail()
encrypted_data = clear_data.pgp_crypt(crypters_id)
# build properly encrypted mail
# (preserving headers from original mail)
mail = TMail::Mail.new
mail.copy_headers_from(self)
mail.set_content_type("multipart", "encrypted", {'boundary' => TMail.new_boundary, "protocol" => "application/pgp-encrypted"})
mail.transfer_encoding = "7bit"
mail['content-disposition'] = nil
mail.parts.clear
mail.quoted_body = "This mail is a RFC3156 encrypted message.\n"
# cryptographic info
p_pgp = TMail::Mail.new
p_pgp.set_content_type("application", "pgp-encrypted")
p_pgp.transfer_encoding = "7bit"
p_pgp.content_disposition = "inline"
p_pgp.body = "Version: 1\n"
mail.parts << p_pgp
# encrypted message
p_encrypted = TMail::Mail.new
p_encrypted.set_content_type("application", "octet-stream")
p_encrypted.transfer_encoding = "7bit"
p_encrypted.content_disposition = "inline"
p_encrypted.body = encrypted_data
mail.parts << p_encrypted
# store the calculated content, to be able to use parsing methods
mail.write_back
mail
end
def create_signed(signers_id)
data = build_intermediate_mail()
sign_data = data.pgp_sign(signers_id) do |uid_hint, passphrase_info, prev_was_bad|
yield(uid_hint, passphrase_info, prev_was_bad)
end
# build properly signed mail
# (preserving headers from original mail)
mail = TMail::Mail.new
mail.copy_headers_from(self)
mail.set_content_type("multipart", "signed", {'boundary' => TMail.new_boundary, 'protocol' => "application/pgp-signature", 'micalg' => sign_data[:micalg]})
mail.transfer_encoding = "7bit"
mail['content-disposition'] = nil
mail.parts.clear
mail.quoted_body = "This mail is a RFC3156 signed message.\n"
# signed message
p_signed = data
mail.parts << p_signed
# signature
p_signature = TMail::Mail.new
p_signature.set_content_type("application", "pgp-signature")
p_signature.transfer_encoding = "7bit"
p_signature.content_disposition = "inline"
p_signature.body = sign_data[:signature]
mail.parts << p_signature
# store the calculated content, to be able to use parsing methods
mail.write_back
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
protected
def build_intermediate_mail
# build a fake mail to get the generated content to be crypted/signed
fake_mail = TMail::Mail.new
fake_mail['content-type'] = self['content-type'].to_s
fake_mail.transfer_encoding = self.transfer_encoding if self.transfer_encoding
fake_mail.content_disposition = self.content_disposition if self.content_disposition
if self.multipart?
self.each_part {|p| fake_mail.parts << p }
end
fake_mail.quoted_body = self.quoted_body
# store the calculated content, to be able to use parsing methods
fake_mail.write_back
fake_mail
end
def gpg_key(fingerprint, secret = false)
gpg = GPGME::Ctx.new
gpg.get_key(fingerprint, secret)
rescue EOFError
raise "TMail 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
def raw
@port.read_all
end
end
end
begin
require 'gpgme'
TMail::Mail.send(:include, TMail::GpgExtension)
rescue LoadError
end

Also available in: Unified diff