schleuder 4.0.3 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|