schleuder 4.0.3 → 5.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +6 -9
- data/db/migrate/20180110203100_add_sig_enc_to_headers_to_meta_defaults.rb +1 -1
- data/db/migrate/20220910170110_add_key_auto_import_from_email.rb +11 -0
- data/db/schema.rb +6 -6
- data/etc/list-defaults.yml +16 -0
- data/etc/schleuder.yml +29 -11
- data/lib/schleuder/cli.rb +14 -1
- data/lib/schleuder/conf.rb +23 -3
- data/lib/schleuder/email_key_importer.rb +91 -0
- data/lib/schleuder/filters/post_decryption/35_key_auto_import_from_attachments.rb +21 -0
- data/lib/schleuder/filters/post_decryption/80_receive_from_subscribed_emailaddresses_only.rb +1 -1
- data/lib/schleuder/filters/post_decryption/90_strip_html_from_alternative_if_keywords_present.rb +36 -4
- data/lib/schleuder/filters/pre_decryption/60_key_auto_import_from_autocrypt_header.rb +9 -0
- data/lib/schleuder/gpgme/ctx.rb +34 -93
- data/lib/schleuder/gpgme/key.rb +1 -1
- data/lib/schleuder/gpgme/key_extractor.rb +30 -0
- data/lib/schleuder/http.rb +56 -0
- data/lib/schleuder/key_fetcher.rb +89 -0
- data/lib/schleuder/keyword_handlers/key_management.rb +2 -2
- data/lib/schleuder/keyword_handlers/subscription_management.rb +19 -3
- data/lib/schleuder/list.rb +26 -10
- data/lib/schleuder/list_builder.rb +1 -1
- data/lib/schleuder/logger.rb +1 -1
- data/lib/schleuder/mail/gpg/decrypted_part.rb +20 -0
- data/lib/schleuder/mail/gpg/delivery_handler.rb +38 -0
- data/lib/schleuder/mail/gpg/encrypted_part.rb +29 -5
- data/lib/schleuder/mail/gpg/gpgme_ext.rb +8 -0
- data/lib/schleuder/mail/gpg/gpgme_helper.rb +155 -0
- data/lib/schleuder/mail/gpg/inline_decrypted_message.rb +82 -0
- data/lib/schleuder/mail/gpg/inline_signed_message.rb +73 -0
- data/lib/schleuder/mail/gpg/mime_signed_message.rb +28 -0
- data/lib/schleuder/mail/gpg/missing_keys_error.rb +6 -0
- data/lib/schleuder/mail/gpg/sign_part.rb +19 -9
- data/lib/schleuder/mail/gpg/signed_part.rb +37 -0
- data/lib/schleuder/mail/gpg/verified_part.rb +10 -0
- data/lib/schleuder/mail/gpg/verify_result_attribute.rb +32 -0
- data/lib/schleuder/mail/gpg/version_part.rb +22 -0
- data/lib/schleuder/mail/gpg.rb +236 -7
- data/lib/schleuder/mail/message.rb +98 -14
- data/lib/schleuder/sks_client.rb +18 -0
- data/lib/schleuder/version.rb +1 -1
- data/lib/schleuder/vks_client.rb +24 -0
- data/lib/schleuder-api-daemon/routes/key.rb +22 -1
- data/lib/schleuder.rb +11 -7
- data/locales/de.yml +22 -3
- data/locales/en.yml +22 -3
- metadata +72 -24
@@ -0,0 +1,30 @@
|
|
1
|
+
module GPGME
|
2
|
+
class KeyExtractor
|
3
|
+
# This takes key material and returns those keys from it, that have a UID
|
4
|
+
# matching the given email address, stripped by all other UIDs.
|
5
|
+
def self.extract_by_email_address(email_address, keydata)
|
6
|
+
orig_gnupghome = ENV['GNUPGHOME']
|
7
|
+
ENV['GNUPGHOME'] = Dir.mktmpdir
|
8
|
+
gpg = GPGME::Ctx.new(armor: true)
|
9
|
+
gpg_arg = %{ --import-filter keep-uid='mbox = #{email_address}'}
|
10
|
+
gpg.import_filtered(keydata, gpg_arg)
|
11
|
+
# Return the fingerprint and the exported, filtered keydata, because
|
12
|
+
# passing the key objects around led to strange problems with some keys,
|
13
|
+
# which produced only a blank string as return value of export().
|
14
|
+
result = {}
|
15
|
+
gpg.keys.each do |tmp_key|
|
16
|
+
# Skip this key if it has
|
17
|
+
# * no UID – because none survived the import-filter,
|
18
|
+
# * more than one UID – which means the import-filtering failed or
|
19
|
+
# something else went wrong during import.
|
20
|
+
if tmp_key.uids.size == 1
|
21
|
+
result[tmp_key.fingerprint] = tmp_key.armored
|
22
|
+
end
|
23
|
+
end
|
24
|
+
result
|
25
|
+
ensure
|
26
|
+
FileUtils.remove_entry(ENV['GNUPGHOME'])
|
27
|
+
ENV['GNUPGHOME'] = orig_gnupghome
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Schleuder
|
2
|
+
class NetworkError < StandardError; end
|
3
|
+
|
4
|
+
class NotFoundError < StandardError; end
|
5
|
+
|
6
|
+
class Http
|
7
|
+
attr_reader :request, :response
|
8
|
+
|
9
|
+
def initialize(url, options={})
|
10
|
+
@request = Typhoeus::Request.new(url, default_options.merge(options))
|
11
|
+
end
|
12
|
+
|
13
|
+
def run
|
14
|
+
@response = @request.run
|
15
|
+
if @response.success?
|
16
|
+
@response.body
|
17
|
+
elsif @response.timed_out?
|
18
|
+
raise_network_error(response, 'HTTP Request timed out.')
|
19
|
+
elsif @response.code == 404
|
20
|
+
NotFoundError.new
|
21
|
+
elsif @response.code == 0
|
22
|
+
# This happens e.g. if no response could be received.
|
23
|
+
raise_network_error(@response, 'No HTTP response received.')
|
24
|
+
else
|
25
|
+
RuntimeError.new(@response.body.to_s.presence || @response.return_message)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.get(url)
|
30
|
+
nth_attempt ||= 1
|
31
|
+
new(url).run
|
32
|
+
rescue NetworkError => error
|
33
|
+
nth_attempt += 1
|
34
|
+
if nth_attempt < 4
|
35
|
+
retry
|
36
|
+
else
|
37
|
+
return error
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def raise_network_error(response, fallback_msg)
|
44
|
+
raise NetworkError.new(
|
45
|
+
response.body.to_s.presence || response.return_message || fallback_msg
|
46
|
+
)
|
47
|
+
end
|
48
|
+
|
49
|
+
def default_options
|
50
|
+
{
|
51
|
+
followlocation: true,
|
52
|
+
proxy: Conf.http_proxy.presence
|
53
|
+
}
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
module Schleuder
|
2
|
+
class KeyFetcher
|
3
|
+
def initialize(list)
|
4
|
+
@list = list
|
5
|
+
end
|
6
|
+
|
7
|
+
def fetch(input, locale_key='key_fetched')
|
8
|
+
result = case input
|
9
|
+
when /^http/
|
10
|
+
fetch_key_by_url(input)
|
11
|
+
when Conf::EMAIL_REGEXP
|
12
|
+
fetch_key_from_keyserver('email', input)
|
13
|
+
when Conf::FINGERPRINT_REGEXP
|
14
|
+
fetch_key_from_keyserver('fingerprint', input)
|
15
|
+
else
|
16
|
+
return I18n.t('key_fetcher.invalid_input')
|
17
|
+
end
|
18
|
+
interpret_fetch_result(result, locale_key)
|
19
|
+
end
|
20
|
+
|
21
|
+
def fetch_key_by_url(url)
|
22
|
+
case result = Schleuder::Http.get(url)
|
23
|
+
when NotFoundError
|
24
|
+
NotFoundError.new(I18n.t('key_fetcher.url_not_found', url: url))
|
25
|
+
else
|
26
|
+
result
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def fetch_key_from_keyserver(type, input)
|
31
|
+
if Conf.vks_keyserver.present?
|
32
|
+
result = Schleuder::VksClient.get(type, input)
|
33
|
+
end
|
34
|
+
if (result.blank? || ! result.is_a?(String)) && Conf.sks_keyserver.present?
|
35
|
+
result = Schleuder::SksClient.get(input)
|
36
|
+
end
|
37
|
+
|
38
|
+
case result
|
39
|
+
when nil
|
40
|
+
RuntimeError.new('No keyserver configured, cannot query anything')
|
41
|
+
when NotFoundError
|
42
|
+
NotFoundError.new(I18n.t('key_fetcher.not_found', input: input))
|
43
|
+
else
|
44
|
+
result
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def interpret_fetch_result(result, locale_key)
|
51
|
+
case result
|
52
|
+
when ''
|
53
|
+
I18n.t('key_fetcher.general_error', error: 'Empty response from server')
|
54
|
+
when String
|
55
|
+
import(result, locale_key)
|
56
|
+
when NotFoundError
|
57
|
+
result.to_s
|
58
|
+
when StandardError
|
59
|
+
I18n.t('key_fetcher.general_error', error: result)
|
60
|
+
else
|
61
|
+
raise_unexpected_error(result)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def import(input, locale_key)
|
66
|
+
result = @list.gpg.import_filtered(input)
|
67
|
+
case result
|
68
|
+
when StandardError
|
69
|
+
I18n.t('key_fetcher.import_error', error: result)
|
70
|
+
when Hash
|
71
|
+
translate_output(locale_key, result).join("\n")
|
72
|
+
else
|
73
|
+
raise_unexpected_error(result)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def translate_output(locale_key, import_states)
|
78
|
+
import_states.map do |fingerprint, states|
|
79
|
+
key = @list.gpg.find_distinct_key(fingerprint)
|
80
|
+
I18n.t(locale_key, key_summary: key.summary,
|
81
|
+
states: states.to_sentence)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def raise_unexpected_error(thing)
|
86
|
+
raise "Unexpected output => #{thing.inspect}"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -15,7 +15,7 @@ module Schleuder
|
|
15
15
|
import_key_from_body
|
16
16
|
else
|
17
17
|
@list.logger.debug 'Found no attachments and an empty body - sending error message'
|
18
|
-
I18n.t('keyword_handlers.key_management.no_content_found')
|
18
|
+
return I18n.t('keyword_handlers.key_management.no_content_found')
|
19
19
|
end
|
20
20
|
|
21
21
|
import_stati = results.compact.collect(&:imports).flatten
|
@@ -125,7 +125,7 @@ module Schleuder
|
|
125
125
|
|
126
126
|
def import_keys_from_attachments
|
127
127
|
@mail.attachments.map do |attachment|
|
128
|
-
import_from_string(attachment.body.
|
128
|
+
import_from_string(attachment.body.decoded)
|
129
129
|
end
|
130
130
|
end
|
131
131
|
|
@@ -22,9 +22,25 @@ module Schleuder
|
|
22
22
|
while @arguments.first.present? && @arguments.first.match(/^(0x)?[a-f0-9]+$/i)
|
23
23
|
fingerprint << @arguments.shift.downcase
|
24
24
|
end
|
25
|
-
#
|
26
|
-
|
27
|
-
|
25
|
+
# If the collected values aren't a valid fingerprint, then the input
|
26
|
+
# didn't conform with what this code expects, and then the other
|
27
|
+
# values shouldn't be used.
|
28
|
+
unless GPGME::Key.valid_fingerprint?(fingerprint)
|
29
|
+
return I18n.t('keyword_handlers.subscription_management.subscribe_requires_arguments')
|
30
|
+
end
|
31
|
+
if @arguments.present?
|
32
|
+
# Use possibly remaining args as flags.
|
33
|
+
adminflag = @arguments.shift.to_s.downcase.presence
|
34
|
+
unless ['true', 'false'].include?(adminflag)
|
35
|
+
return I18n.t('keyword_handlers.subscription_management.subscribe_requires_arguments')
|
36
|
+
end
|
37
|
+
if @arguments.present?
|
38
|
+
deliveryflag = @arguments.shift.to_s.downcase.presence
|
39
|
+
unless ['true', 'false'].include?(deliveryflag)
|
40
|
+
return I18n.t('keyword_handlers.subscription_management.subscribe_requires_arguments')
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
28
44
|
end
|
29
45
|
|
30
46
|
sub, _ = @list.subscribe(email, fingerprint, adminflag, deliveryflag)
|
data/lib/schleuder/list.rb
CHANGED
@@ -4,10 +4,10 @@ module Schleuder
|
|
4
4
|
has_many :subscriptions, dependent: :destroy
|
5
5
|
before_destroy :delete_listdirs
|
6
6
|
|
7
|
-
serialize :headers_to_meta, JSON
|
8
|
-
serialize :bounces_drop_on_headers, JSON
|
9
|
-
serialize :keywords_admin_only, JSON
|
10
|
-
serialize :keywords_admin_notify, JSON
|
7
|
+
serialize :headers_to_meta, coder: JSON
|
8
|
+
serialize :bounces_drop_on_headers, coder: JSON
|
9
|
+
serialize :keywords_admin_only, coder: JSON
|
10
|
+
serialize :keywords_admin_notify, coder: JSON
|
11
11
|
|
12
12
|
validates :email, presence: true, uniqueness: true, email: true
|
13
13
|
validates :fingerprint, presence: true, fingerprint: true
|
@@ -23,7 +23,8 @@ module Schleuder
|
|
23
23
|
:bounces_notify_admins,
|
24
24
|
:include_list_headers,
|
25
25
|
:include_openpgp_header,
|
26
|
-
:forward_all_incoming_to_admins,
|
26
|
+
:forward_all_incoming_to_admins,
|
27
|
+
:key_auto_import_from_email, boolean: true
|
27
28
|
validates_each :headers_to_meta,
|
28
29
|
:keywords_admin_only,
|
29
30
|
:keywords_admin_notify do |record, attrib, value|
|
@@ -197,11 +198,26 @@ module Schleuder
|
|
197
198
|
end
|
198
199
|
|
199
200
|
def refresh_keys
|
200
|
-
|
201
|
+
# reorder keys so the update pattern is random
|
202
|
+
output = self.keys.shuffle.map do |key|
|
203
|
+
# Sleep a short while to make traffic analysis less easy.
|
204
|
+
sleep rand(1.0..5.0)
|
205
|
+
key_fetcher.fetch(key.fingerprint, 'key_updated').presence
|
206
|
+
end
|
207
|
+
# Filter out some "noise" (if a key was unchanged, it wasn't really updated, was it?)
|
208
|
+
# It would be nice to prevent these "false" lines in the first place, but I don't know how.
|
209
|
+
output.reject! do |line|
|
210
|
+
line.match('updated \(unchanged\)')
|
211
|
+
end
|
212
|
+
output.compact.join("\n")
|
201
213
|
end
|
202
214
|
|
203
215
|
def fetch_keys(input)
|
204
|
-
|
216
|
+
key_fetcher.fetch(input)
|
217
|
+
end
|
218
|
+
|
219
|
+
def key_fetcher
|
220
|
+
@key_fetcher ||= KeyFetcher.new(self)
|
205
221
|
end
|
206
222
|
|
207
223
|
def self.by_recipient(recipient)
|
@@ -350,7 +366,7 @@ module Schleuder
|
|
350
366
|
next
|
351
367
|
end
|
352
368
|
|
353
|
-
if ! self.deliver_selfsent && incoming_mail
|
369
|
+
if ! self.deliver_selfsent && incoming_mail&.was_validly_signed? && ( subscription == incoming_mail&.signer )
|
354
370
|
logger.info "Not sending to #{subscription.email}: delivery of self sent is disabled."
|
355
371
|
next
|
356
372
|
end
|
@@ -373,14 +389,14 @@ module Schleuder
|
|
373
389
|
end
|
374
390
|
|
375
391
|
def delete_listdirs
|
376
|
-
if File.
|
392
|
+
if File.exist?(self.listdir)
|
377
393
|
FileUtils.rm_rf(self.listdir, secure: true)
|
378
394
|
Schleuder.logger.info "Deleted #{self.listdir}"
|
379
395
|
end
|
380
396
|
# If listlogs_dir is different from lists_dir, the logfile still exists
|
381
397
|
# and needs to be deleted, too.
|
382
398
|
logfile_dir = File.dirname(self.logfile)
|
383
|
-
if File.
|
399
|
+
if File.exist?(logfile_dir)
|
384
400
|
FileUtils.rm_rf(logfile_dir, secure: true)
|
385
401
|
Schleuder.logger.info "Deleted #{logfile_dir}"
|
386
402
|
end
|
data/lib/schleuder/logger.rb
CHANGED
@@ -9,7 +9,7 @@ module Schleuder
|
|
9
9
|
def initialize
|
10
10
|
super('Schleuder', Syslog::LOG_MAIL)
|
11
11
|
# We need some sender-address different from the superadmin-address.
|
12
|
-
@from = "#{Etc.getlogin}@#{Socket.gethostname}"
|
12
|
+
@from = "#{Etc.getlogin}@#{Addrinfo.getaddrinfo(Socket.gethostname, nil).first.getnameinfo.first}"
|
13
13
|
@adminaddresses = Conf.superadmin
|
14
14
|
@level = ::Logger.const_get(Conf.log_level.upcase)
|
15
15
|
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'schleuder/mail/gpg/verified_part'
|
2
|
+
module Mail
|
3
|
+
module Gpg
|
4
|
+
class DecryptedPart < VerifiedPart
|
5
|
+
|
6
|
+
# options are:
|
7
|
+
#
|
8
|
+
# :verify: decrypt and verify
|
9
|
+
def initialize(cipher_part, options = {})
|
10
|
+
if cipher_part.mime_type != EncryptedPart::CONTENT_TYPE
|
11
|
+
raise EncodingError.new("RFC 3156 incorrect mime type for encrypted part '#{cipher_part.mime_type}'")
|
12
|
+
end
|
13
|
+
|
14
|
+
decrypted = GpgmeHelper.decrypt(cipher_part.body.decoded, options)
|
15
|
+
self.verify_result = decrypted.verify_result if options[:verify]
|
16
|
+
super(decrypted)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Mail
|
2
|
+
module Gpg
|
3
|
+
class DeliveryHandler
|
4
|
+
|
5
|
+
def self.deliver_mail(mail)
|
6
|
+
if mail.gpg
|
7
|
+
encrypted_mail = nil
|
8
|
+
begin
|
9
|
+
options = mail.gpg.is_a?(TrueClass) ? { encrypt: true } : mail.gpg
|
10
|
+
if options[:encrypt]
|
11
|
+
encrypted_mail = Mail::Gpg.encrypt(mail, options)
|
12
|
+
elsif options[:sign] || options[:sign_as]
|
13
|
+
encrypted_mail = Mail::Gpg.sign(mail, options)
|
14
|
+
else
|
15
|
+
# encrypt and sign are off -> do not encrypt or sign
|
16
|
+
yield
|
17
|
+
end
|
18
|
+
rescue StandardError
|
19
|
+
raise $! if mail.raise_encryption_errors
|
20
|
+
end
|
21
|
+
if encrypted_mail
|
22
|
+
if dm = mail.delivery_method
|
23
|
+
encrypted_mail.instance_variable_set :@delivery_method, dm
|
24
|
+
end
|
25
|
+
encrypted_mail.perform_deliveries = mail.perform_deliveries
|
26
|
+
encrypted_mail.raise_delivery_errors = mail.raise_delivery_errors
|
27
|
+
encrypted_mail.deliver
|
28
|
+
end
|
29
|
+
else
|
30
|
+
yield
|
31
|
+
end
|
32
|
+
rescue StandardError
|
33
|
+
raise $! if mail.raise_delivery_errors
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -1,13 +1,37 @@
|
|
1
|
-
module Mail
|
2
|
-
module Gpg
|
3
|
-
class EncryptedPart < Mail::Part
|
4
|
-
alias_method :initialize_mailgpg, :initialize
|
1
|
+
module Mail
|
2
|
+
module Gpg
|
3
|
+
class EncryptedPart < Mail::Part
|
5
4
|
|
5
|
+
CONTENT_TYPE = 'application/octet-stream'
|
6
|
+
|
7
|
+
# options are:
|
8
|
+
#
|
9
|
+
# :signers : sign using this key (give the corresponding email address)
|
10
|
+
# :password: passphrase for the signing key
|
11
|
+
# :recipients : array of receiver addresses
|
12
|
+
# :keys : A hash mapping recipient email addresses to public keys or public
|
13
|
+
# key ids. Imports any keys given here that are not already part of the
|
14
|
+
# local keychain before sending the mail. If this option is given, strictly
|
15
|
+
# only the key material from this hash is used, ignoring any keys for
|
16
|
+
# recipients that might have been added to the local key chain but are
|
17
|
+
# not mentioned here.
|
18
|
+
# :always_trust : send encrypted mail to untrusted receivers, true by default
|
19
|
+
# :filename : define a custom name for the encrypted file attachment
|
6
20
|
def initialize(cleartext_mail, options = {})
|
7
21
|
if cleartext_mail.protected_headers_subject
|
8
22
|
cleartext_mail.content_type_parameters['protected-headers'] = 'v1'
|
9
23
|
end
|
10
|
-
|
24
|
+
|
25
|
+
options = { always_trust: true }.merge options
|
26
|
+
|
27
|
+
encrypted = GpgmeHelper.encrypt(cleartext_mail.encoded, options)
|
28
|
+
super() do
|
29
|
+
body encrypted.to_s
|
30
|
+
filename = options[:filename] || 'encrypted.asc'
|
31
|
+
content_type "#{CONTENT_TYPE}; name=\"#{filename}\""
|
32
|
+
content_disposition "inline; filename=\"#{filename}\""
|
33
|
+
content_description 'OpenPGP encrypted message'
|
34
|
+
end
|
11
35
|
end
|
12
36
|
end
|
13
37
|
end
|
@@ -0,0 +1,155 @@
|
|
1
|
+
require 'schleuder/mail/gpg/gpgme_ext'
|
2
|
+
|
3
|
+
# GPGME methods for encryption/decryption/signing
|
4
|
+
module Mail
|
5
|
+
module Gpg
|
6
|
+
class GpgmeHelper
|
7
|
+
|
8
|
+
def self.encrypt(plain, options = {})
|
9
|
+
options = options.merge({armor: true})
|
10
|
+
|
11
|
+
plain_data = GPGME::Data.new(plain)
|
12
|
+
cipher_data = GPGME::Data.new(options[:output])
|
13
|
+
|
14
|
+
recipient_keys = keys_for_data options[:recipients], options.delete(:keys)
|
15
|
+
|
16
|
+
if recipient_keys.empty?
|
17
|
+
raise MissingKeysError.new('No keys to encrypt to!')
|
18
|
+
end
|
19
|
+
|
20
|
+
flags = 0
|
21
|
+
flags |= GPGME::ENCRYPT_ALWAYS_TRUST if options[:always_trust]
|
22
|
+
|
23
|
+
GPGME::Ctx.new(options) do |ctx|
|
24
|
+
begin
|
25
|
+
if options[:sign]
|
26
|
+
if options[:signers] && options[:signers].size > 0
|
27
|
+
signers = GPGME::Key.find(:secret, options[:signers], :sign)
|
28
|
+
ctx.add_signer(*signers)
|
29
|
+
end
|
30
|
+
ctx.encrypt_sign(recipient_keys, plain_data, cipher_data, flags)
|
31
|
+
else
|
32
|
+
ctx.encrypt(recipient_keys, plain_data, cipher_data, flags)
|
33
|
+
end
|
34
|
+
rescue GPGME::Error::UnusablePublicKey => exc
|
35
|
+
exc.keys = ctx.encrypt_result.invalid_recipients
|
36
|
+
raise exc
|
37
|
+
rescue GPGME::Error::UnusableSecretKey => exc
|
38
|
+
exc.keys = ctx.sign_result.invalid_signers
|
39
|
+
raise exc
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
cipher_data.seek(0)
|
44
|
+
cipher_data
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.decrypt(cipher, options = {})
|
48
|
+
cipher_data = GPGME::Data.new(cipher)
|
49
|
+
plain_data = GPGME::Data.new(options[:output])
|
50
|
+
|
51
|
+
GPGME::Ctx.new(options) do |ctx|
|
52
|
+
begin
|
53
|
+
if options[:verify]
|
54
|
+
ctx.decrypt_verify(cipher_data, plain_data)
|
55
|
+
plain_data.verify_result = ctx.verify_result
|
56
|
+
else
|
57
|
+
ctx.decrypt(cipher_data, plain_data)
|
58
|
+
end
|
59
|
+
rescue GPGME::Error::UnsupportedAlgorithm => exc
|
60
|
+
exc.algorithm = ctx.decrypt_result.unsupported_algorithm
|
61
|
+
raise exc
|
62
|
+
rescue GPGME::Error::WrongKeyUsage => exc
|
63
|
+
exc.key_usage = ctx.decrypt_result.wrong_key_usage
|
64
|
+
raise exc
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
plain_data.seek(0)
|
69
|
+
plain_data
|
70
|
+
end
|
71
|
+
|
72
|
+
def self.sign(plain, options = {})
|
73
|
+
options.merge!({
|
74
|
+
armor: true,
|
75
|
+
signer: options.delete(:sign_as),
|
76
|
+
mode: GPGME::SIG_MODE_DETACH
|
77
|
+
})
|
78
|
+
crypto = GPGME::Crypto.new
|
79
|
+
crypto.sign GPGME::Data.new(plain), options
|
80
|
+
end
|
81
|
+
|
82
|
+
# returns [success(bool), VerifyResult(from gpgme)]
|
83
|
+
# success will be true when there is at least one sig and no invalid sig
|
84
|
+
def self.sign_verify(plain, signature, options = {})
|
85
|
+
signed_data = GPGME::Data.new(plain)
|
86
|
+
signature = GPGME::Data.new(signature)
|
87
|
+
|
88
|
+
success = verify_result = nil
|
89
|
+
GPGME::Ctx.new(options) do |ctx|
|
90
|
+
ctx.verify signature, signed_data, nil
|
91
|
+
verify_result = ctx.verify_result
|
92
|
+
signatures = verify_result.signatures
|
93
|
+
success = signatures &&
|
94
|
+
signatures.size > 0 &&
|
95
|
+
signatures.detect{|s| !s.valid? }.nil?
|
96
|
+
end
|
97
|
+
return [success, verify_result]
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.inline_verify(signed_text, options = {})
|
101
|
+
signed_data = GPGME::Data.new(signed_text)
|
102
|
+
success = verify_result = nil
|
103
|
+
GPGME::Ctx.new(options) do |ctx|
|
104
|
+
ctx.verify signed_data, nil
|
105
|
+
verify_result = ctx.verify_result
|
106
|
+
signatures = verify_result.signatures
|
107
|
+
success = signatures &&
|
108
|
+
signatures.size > 0 &&
|
109
|
+
signatures.detect{|s| !s.valid? }.nil?
|
110
|
+
end
|
111
|
+
return [success, verify_result]
|
112
|
+
end
|
113
|
+
|
114
|
+
# normalizes the list of recipients' emails, key ids and key data to a
|
115
|
+
# list of Key objects
|
116
|
+
#
|
117
|
+
# if key_data is given, _only_ key material from there is used,
|
118
|
+
# and eventually already imported keys in the keychain are ignored.
|
119
|
+
def self.keys_for_data(emails_or_shas_or_keys, key_data = nil)
|
120
|
+
if key_data
|
121
|
+
# in this case, emails_or_shas_or_keys is supposed to be the list of
|
122
|
+
# recipients, and key_data the key material to be used.
|
123
|
+
# We now map these to whatever we find in key_data for each of these
|
124
|
+
# addresses.
|
125
|
+
[emails_or_shas_or_keys].flatten.map do |r|
|
126
|
+
k = key_data[r]
|
127
|
+
key_id = case k
|
128
|
+
when GPGME::Key
|
129
|
+
# assuming this is already imported
|
130
|
+
k.fingerprint
|
131
|
+
when nil, ''
|
132
|
+
# nothing
|
133
|
+
nil
|
134
|
+
when /-----BEGIN PGP/
|
135
|
+
# ASCII key data
|
136
|
+
GPGME::Key.import(k).imports.map(&:fpr)
|
137
|
+
else
|
138
|
+
# key id or fingerprint
|
139
|
+
k
|
140
|
+
end
|
141
|
+
unless key_id.nil? || key_id.empty?
|
142
|
+
GPGME::Key.find(:public, key_id, :encrypt)
|
143
|
+
end
|
144
|
+
end.flatten.compact
|
145
|
+
elsif emails_or_shas_or_keys && (emails_or_shas_or_keys.size > 0)
|
146
|
+
# key lookup in keychain for all receivers
|
147
|
+
GPGME::Key.find :public, emails_or_shas_or_keys, :encrypt
|
148
|
+
else
|
149
|
+
# empty array given
|
150
|
+
[]
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require 'schleuder/mail/gpg/verified_part'
|
2
|
+
|
3
|
+
# decryption of the so called 'PGP-Inline' message types
|
4
|
+
# this is not a standard, so the implementation is based on the notes
|
5
|
+
# here http://binblog.info/2008/03/12/know-your-pgp-implementation/
|
6
|
+
# and on test messages generated with the Mozilla Enigmail OpenPGP
|
7
|
+
# plugin https://www.enigmail.net
|
8
|
+
module Mail
|
9
|
+
module Gpg
|
10
|
+
class InlineDecryptedMessage < Mail::Message
|
11
|
+
|
12
|
+
# options are:
|
13
|
+
#
|
14
|
+
# :verify: decrypt and verify
|
15
|
+
def self.setup(cipher_mail, options = {})
|
16
|
+
if cipher_mail.multipart?
|
17
|
+
self.new do
|
18
|
+
Mail::Gpg.copy_headers cipher_mail, self
|
19
|
+
|
20
|
+
# Drop the HTML-part of a multipart/alternative-message if it is
|
21
|
+
# inline-encrypted: that ciphertext is probably wrapped in HTML,
|
22
|
+
# which GnuPG chokes upon, so we would have to parse the HTML to
|
23
|
+
# handle the message-part properly.
|
24
|
+
# Also it's not clear how to handle the resulting plain-text: is
|
25
|
+
# it HTML or simple text? That depends on the sending MUA and
|
26
|
+
# the original input.
|
27
|
+
# In summary, that's too much complications.
|
28
|
+
if cipher_mail.mime_type == 'multipart/alternative' &&
|
29
|
+
cipher_mail.html_part.present? &&
|
30
|
+
cipher_mail.html_part.body.decoded.include?('-----BEGIN PGP MESSAGE-----')
|
31
|
+
cipher_mail.parts.delete_if do |part|
|
32
|
+
part[:content_type].content_type == 'text/html'
|
33
|
+
end
|
34
|
+
# Set the content-type of the newly generated message to
|
35
|
+
# something less confusing.
|
36
|
+
content_type 'multipart/mixed'
|
37
|
+
# Leave a marker for other code.
|
38
|
+
header['X-MailGpg-Deleted-Html-Part'] = 'true'
|
39
|
+
end
|
40
|
+
|
41
|
+
cipher_mail.parts.each do |part|
|
42
|
+
p = VerifiedPart.new do |p|
|
43
|
+
if part.has_content_type? && /application\/(?:octet-stream|pgp-encrypted)/ =~ part.mime_type
|
44
|
+
# encrypted attachment, we set the content_type to the generic 'application/octet-stream'
|
45
|
+
# and remove the .pgp/gpg/asc from name/filename in header fields
|
46
|
+
decrypted = GpgmeHelper.decrypt(part.decoded, options)
|
47
|
+
p.verify_result decrypted.verify_result if options[:verify]
|
48
|
+
p.content_type part.content_type.sub(/application\/(?:octet-stream|pgp-encrypted)/, 'application/octet-stream')
|
49
|
+
.sub(/name=(?:"')?(.*)\.(?:pgp|gpg|asc)(?:"')?/, 'name="\1"')
|
50
|
+
p.content_disposition part.content_disposition.sub(/filename=(?:"')?(.*)\.(?:pgp|gpg|asc)(?:"')?/, 'filename="\1"')
|
51
|
+
p.content_transfer_encoding Mail::Encodings::Base64
|
52
|
+
p.body Mail::Encodings::Base64.encode(decrypted.to_s)
|
53
|
+
else
|
54
|
+
body = part.body.decoded
|
55
|
+
if body.include?('-----BEGIN PGP MESSAGE-----')
|
56
|
+
decrypted = GpgmeHelper.decrypt(body, options)
|
57
|
+
p.verify_result decrypted.verify_result if options[:verify]
|
58
|
+
p.body decrypted.to_s
|
59
|
+
else
|
60
|
+
p.content_type part.content_type
|
61
|
+
p.content_transfer_encoding part.content_transfer_encoding
|
62
|
+
p.body part.body.to_s
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
add_part p
|
67
|
+
end
|
68
|
+
end
|
69
|
+
else
|
70
|
+
decrypted = cipher_mail.body.empty? ? '' : GpgmeHelper.decrypt(cipher_mail.body.decoded, options)
|
71
|
+
self.new do
|
72
|
+
cipher_mail.header.fields.each do |field|
|
73
|
+
header[field.name] = field.value
|
74
|
+
end
|
75
|
+
body decrypted.to_s
|
76
|
+
verify_result decrypted.verify_result if options[:verify] && decrypted != ''
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|