schleuder 3.2.2 → 3.5.2

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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +21 -11
  3. data/Rakefile +18 -10
  4. data/bin/schleuder +2 -1
  5. data/bin/schleuder-api-daemon +3 -2
  6. data/db/migrate/20180110203100_add_sig_enc_to_headers_to_meta_defaults.rb +30 -0
  7. data/db/migrate/20180723173900_add_deliver_selfsent_to_list.rb +11 -0
  8. data/db/migrate/20190906194820_add_autocrypt_header_to_list.rb +11 -0
  9. data/db/schema.rb +4 -2
  10. data/etc/list-defaults.yml +13 -3
  11. data/etc/schleuder.yml +11 -0
  12. data/lib/schleuder-api-daemon.rb +9 -354
  13. data/lib/schleuder-api-daemon/helpers/schleuder-api-daemon-helper.rb +143 -0
  14. data/lib/schleuder-api-daemon/routes/key.rb +40 -0
  15. data/lib/schleuder-api-daemon/routes/list.rb +69 -0
  16. data/lib/schleuder-api-daemon/routes/status.rb +5 -0
  17. data/lib/schleuder-api-daemon/routes/subscription.rb +99 -0
  18. data/lib/schleuder-api-daemon/routes/version.rb +5 -0
  19. data/lib/schleuder.rb +12 -3
  20. data/lib/schleuder/cli.rb +33 -3
  21. data/lib/schleuder/cli/subcommand_fix.rb +1 -1
  22. data/lib/schleuder/conf.rb +7 -1
  23. data/lib/schleuder/errors/active_model_error.rb +2 -5
  24. data/lib/schleuder/errors/decryption_failed.rb +2 -7
  25. data/lib/schleuder/errors/key_adduid_failed.rb +1 -5
  26. data/lib/schleuder/errors/key_generation_failed.rb +1 -8
  27. data/lib/schleuder/errors/keyword_admin_only.rb +1 -5
  28. data/lib/schleuder/errors/list_not_found.rb +1 -5
  29. data/lib/schleuder/errors/listdir_problem.rb +2 -7
  30. data/lib/schleuder/errors/loading_list_settings_failed.rb +2 -5
  31. data/lib/schleuder/errors/message_empty.rb +1 -5
  32. data/lib/schleuder/errors/message_not_from_admin.rb +2 -5
  33. data/lib/schleuder/errors/message_sender_not_subscribed.rb +2 -5
  34. data/lib/schleuder/errors/message_too_big.rb +2 -5
  35. data/lib/schleuder/errors/message_unauthenticated.rb +1 -4
  36. data/lib/schleuder/errors/message_unencrypted.rb +2 -5
  37. data/lib/schleuder/errors/message_unsigned.rb +2 -5
  38. data/lib/schleuder/errors/too_many_keys.rb +1 -8
  39. data/lib/schleuder/filters/{request_filter.rb → post_decryption/10_request.rb} +0 -0
  40. data/lib/schleuder/filters/{max_message_size.rb → post_decryption/20_max_message_size.rb} +0 -0
  41. data/lib/schleuder/filters/{forward_filter.rb → post_decryption/30_forward_to_owner.rb} +0 -0
  42. data/lib/schleuder/filters/post_decryption/40_receive_admin_only.rb +10 -0
  43. data/lib/schleuder/filters/post_decryption/50_receive_authenticated_only.rb +10 -0
  44. data/lib/schleuder/filters/post_decryption/60_receive_signed_only.rb +10 -0
  45. data/lib/schleuder/filters/post_decryption/70_receive_encrypted_only.rb +10 -0
  46. data/lib/schleuder/filters/post_decryption/80_receive_from_subscribed_emailaddresses_only.rb +10 -0
  47. data/lib/schleuder/filters/post_decryption/90_strip_html_from_alternative_if_keywords_present.rb +21 -0
  48. data/lib/schleuder/filters/{bounces_filter.rb → pre_decryption/10_forward_bounce_to_admins.rb} +0 -0
  49. data/lib/schleuder/filters/{forward_incoming.rb → pre_decryption/20_forward_all_incoming_to_admins.rb} +0 -0
  50. data/lib/schleuder/filters/{send_key_filter.rb → pre_decryption/30_send_key.rb} +0 -0
  51. data/lib/schleuder/filters/{hotmail_message_filter.rb → pre_decryption/40_fix_exchange_messages.rb} +5 -3
  52. data/lib/schleuder/filters/{strip_alternative_filter.rb → pre_decryption/50_strip_html_from_alternative.rb} +1 -1
  53. data/lib/schleuder/filters_runner.rb +41 -31
  54. data/lib/schleuder/gpgme/ctx.rb +24 -3
  55. data/lib/schleuder/gpgme/import_status.rb +13 -7
  56. data/lib/schleuder/gpgme/key.rb +8 -0
  57. data/lib/schleuder/list.rb +26 -4
  58. data/lib/schleuder/logger_notifications.rb +8 -1
  59. data/lib/schleuder/mail/encrypted_part.rb +14 -0
  60. data/lib/schleuder/mail/gpg.rb +15 -0
  61. data/lib/schleuder/mail/message.rb +97 -49
  62. data/lib/schleuder/plugins/attach_listkey.rb +6 -10
  63. data/lib/schleuder/plugins/key_management.rb +34 -26
  64. data/lib/schleuder/plugins/resend.rb +14 -11
  65. data/lib/schleuder/plugins/subscription_management.rb +70 -3
  66. data/lib/schleuder/runner.rb +49 -10
  67. data/lib/schleuder/subscription.rb +5 -9
  68. data/lib/schleuder/validators/fingerprint_validator.rb +1 -1
  69. data/lib/schleuder/version.rb +1 -1
  70. data/locales/de.yml +101 -9
  71. data/locales/en.yml +107 -11
  72. metadata +72 -34
  73. data/lib/schleuder/errors/file_not_found.rb +0 -14
  74. data/lib/schleuder/errors/invalid_listname.rb +0 -13
  75. data/lib/schleuder/errors/list_exists.rb +0 -13
  76. data/lib/schleuder/errors/unknown_list_option.rb +0 -14
  77. data/lib/schleuder/filters/auth_filter.rb +0 -39
@@ -1,16 +1,12 @@
1
1
  module Schleuder
2
2
  module ListPlugins
3
3
  def self.attach_listkey(arguments, list, mail)
4
- filename = "#{list.fingerprint}.pgpkey"
5
- # "Mail" only really converts to multipart if the content-type is blank.
6
- mail.content_type = nil
7
- mail.add_file({
8
- filename: filename,
9
- content: list.export_key
10
- })
11
- mail.attachments[filename].content_type = 'application/pgp-keys'
12
- mail.attachments[filename].content_description = "OpenPGP public key of #{list.email}"
13
- mail.attachments[filename].content_disposition = "attachment; filename=#{filename}"
4
+ new_part = Mail::Part.new
5
+ new_part.body = list.export_key
6
+ new_part.content_type = 'application/pgp-keys'
7
+ new_part.content_description = "OpenPGP public key of #{list.email}"
8
+ new_part.content_disposition = "attachment; filename=#{list.fingerprint}.pgpkey"
9
+ mail.add_part new_part
14
10
  nil
15
11
  end
16
12
  end
@@ -1,7 +1,6 @@
1
1
  module Schleuder
2
2
  module RequestPlugins
3
3
  def self.add_key(arguments, list, mail)
4
- out = [I18n.t('plugins.key_management.import_result')]
5
4
 
6
5
  if mail.has_attachments?
7
6
  results = self.import_keys_from_attachments(list, mail)
@@ -9,15 +8,35 @@ module Schleuder
9
8
  results = [self.import_key_from_body(list, mail)]
10
9
  end
11
10
 
12
- out << results.compact.collect(&:imports).flatten.map do |import_status|
13
- str = I18n.t("plugins.key_management.key_import_status.#{import_status.action}")
14
- "#{import_status.fpr}: #{str}"
11
+ import_stati = results.compact.collect(&:imports).flatten
12
+
13
+ if import_stati.blank?
14
+ return I18n.t('plugins.key_management.no_imports')
15
+ end
16
+
17
+ out = []
18
+
19
+ import_stati.each do |import_status|
20
+ if import_status.action == 'error'
21
+ out << I18n.t("plugins.key_management.key_import_status.error", fingerprint: import_status.fingerprint)
22
+ else
23
+ key = list.gpg.find_distinct_key(import_status.fingerprint)
24
+ if key
25
+ out << I18n.t("plugins.key_management.key_import_status.#{import_status.action}", key_oneline: key.oneline)
26
+ end
27
+ end
15
28
  end
16
29
 
17
- out.join("\n")
30
+ out.join("\n\n")
18
31
  end
19
32
 
20
33
  def self.delete_key(arguments, list, mail)
34
+ if arguments.blank?
35
+ return I18n.t(
36
+ "plugins.key_management.delete_key_requires_arguments"
37
+ )
38
+ end
39
+
21
40
  arguments.map do |argument|
22
41
  keys = list.keys(argument)
23
42
  case keys.size
@@ -26,9 +45,9 @@ module Schleuder
26
45
  when 1
27
46
  begin
28
47
  keys.first.delete!
29
- I18n.t('plugins.key_management.deleted', key_string: keys.first.fingerprint)
48
+ I18n.t('plugins.key_management.deleted', key_string: keys.first.oneline)
30
49
  rescue GPGME::Error::Conflict
31
- I18n.t('plugins.key_management.not_deletable', key_string: keys.first.fingerprint)
50
+ I18n.t('plugins.key_management.not_deletable', key_string: keys.first.oneline)
32
51
  end
33
52
  else
34
53
  I18n.t('errors.too_many_matching_keys', {
@@ -71,6 +90,12 @@ module Schleuder
71
90
  end
72
91
 
73
92
  def self.fetch_key(arguments, list, mail)
93
+ if arguments.blank?
94
+ return I18n.t(
95
+ "plugins.key_management.fetch_key_requires_arguments"
96
+ )
97
+ end
98
+
74
99
  arguments.map do |argument|
75
100
  list.fetch_keys(argument)
76
101
  end
@@ -79,35 +104,18 @@ module Schleuder
79
104
  # helper methods
80
105
  private
81
106
 
82
- def self.is_armored_key?(material)
83
- return false unless /^-----BEGIN PGP PUBLIC KEY BLOCK-----$/ =~ material
84
- return false unless /^-----END PGP PUBLIC KEY BLOCK-----$/ =~ material
85
-
86
- lines = material.split("\n").reject(&:empty?)
87
- # remove header
88
- lines.shift
89
- # remove tail
90
- lines.pop
91
- # verify the rest
92
- # TODO: verify length except for lasts lines?
93
- # headers according to https://tools.ietf.org/html/rfc4880#section-6.2
94
- lines.map do |line|
95
- /\A((comment|version|messageid|hash|charset):.*|[0-9a-z\/=+]+)\Z/i =~ line
96
- end.all?
97
- end
98
-
99
107
  def self.import_keys_from_attachments(list, mail)
100
108
  mail.attachments.map do |attachment|
101
109
  material = attachment.body.to_s
102
110
 
103
- list.import_key(material) if self.is_armored_key?(material)
111
+ list.import_key(material)
104
112
  end
105
113
  end
106
114
 
107
115
  def self.import_key_from_body(list, mail)
108
116
  key_material = mail.first_plaintext_part.body.to_s
109
117
 
110
- list.import_key(key_material) if self.is_armored_key?(key_material)
118
+ list.import_key(key_material)
111
119
  end
112
120
  end
113
121
  end
@@ -56,6 +56,9 @@ module Schleuder
56
56
 
57
57
  # Only continue if all recipients are still here.
58
58
  if recip_map.size < arguments.size
59
+ recip_map.keys.each do |aborted_sender|
60
+ mail.add_pseudoheader(:error, I18n.t("plugins.resend.aborted", email: aborted_sender))
61
+ end
59
62
  return
60
63
  end
61
64
 
@@ -117,22 +120,22 @@ module Schleuder
117
120
  Array(recipients).inject({}) do |hash, email|
118
121
  keys = mail.list.keys(email)
119
122
  # Exclude unusable keys.
120
- keys.select! { |key| key.usable_for?(:encrypt) }
121
- case keys.size
123
+ usable_keys = keys.select { |key| key.usable_for?(:encrypt) }
124
+ case usable_keys.size
122
125
  when 1
123
- hash[email] = keys.first
126
+ hash[email] = usable_keys.first
124
127
  when 0
125
128
  if encrypted_only
126
129
  # Don't add the email to the result to exclude it from the
127
130
  # recipients.
128
- add_keys_error(mail, email, keys.size)
131
+ add_resend_msg(mail, email, :error, 'not_resent_no_keys', usable_keys.size, keys.size)
129
132
  else
130
133
  hash[email] = ''
131
134
  end
132
135
  else
133
136
  # Always report this situation, regardless of sending or not. It's
134
137
  # bad and should be fixed.
135
- add_keys_error(mail, email, keys.size)
138
+ add_resend_msg(mail, email, :notice, 'not_resent_encrypted_no_keys', usable_keys.size, keys.size)
136
139
  if ! encrypted_only
137
140
  hash[email] = ''
138
141
  end
@@ -152,8 +155,8 @@ module Schleuder
152
155
  gpg_opts
153
156
  end
154
157
 
155
- def self.add_keys_error(mail, email, keys_size)
156
- mail.add_pseudoheader(:error, I18n.t("plugins.resend.not_resent_no_keys", email: email, num_keys: keys_size))
158
+ def self.add_resend_msg(mail, email, severity, msg, usable_keys_size, all_keys_size)
159
+ mail.add_pseudoheader(severity, I18n.t("plugins.resend.#{msg}", email: email, usable_keys: usable_keys_size, all_keys: all_keys_size))
157
160
  end
158
161
 
159
162
  def self.add_error_header(mail, recipients_map)
@@ -163,15 +166,15 @@ module Schleuder
163
166
  def self.add_resent_headers(mail, recipients_map, to_or_cc, sent_encrypted)
164
167
  if sent_encrypted
165
168
  prefix = I18n.t('plugins.resend.encrypted_to')
166
- str = recipients_map.map do |email, key|
169
+ str = "\n" + recipients_map.map do |email, key|
167
170
  "#{email} (#{key.fingerprint})"
168
- end.join(', ')
171
+ end.join(",\n")
169
172
  else
170
173
  prefix = I18n.t('plugins.resend.unencrypted_to')
171
- str = recipients_map.keys.join(', ')
174
+ str = ' ' + recipients_map.keys.join(", ")
172
175
  end
173
176
  headername = resent_header_name(to_or_cc)
174
- mail.add_pseudoheader(headername, "#{prefix} #{str}")
177
+ mail.add_pseudoheader(headername, "#{prefix}#{str}")
175
178
  end
176
179
 
177
180
  def self.resent_header_name(to_or_cc)
@@ -1,12 +1,18 @@
1
1
  module Schleuder
2
2
  module RequestPlugins
3
3
  def self.subscribe(arguments, list, mail)
4
+ if arguments.blank?
5
+ return I18n.t(
6
+ "plugins.subscription_management.subscribe_requires_arguments"
7
+ )
8
+ end
9
+
4
10
  email = arguments.shift
5
11
 
6
12
  if arguments.present?
7
13
  # Collect all arguments that look like fingerprint-material
8
14
  fingerprint = ''
9
- while arguments.first.present? && arguments.first.match(/\A(0x)?[a-f0-9]+/i)
15
+ while arguments.first.present? && arguments.first.match(/^(0x)?[a-f0-9]+$/i)
10
16
  fingerprint << arguments.shift
11
17
  end
12
18
  # Use possibly remaining args as flags.
@@ -37,6 +43,13 @@ module Schleuder
37
43
  # If no address was given we unsubscribe the sender.
38
44
  email = arguments.first.presence || mail.signer.email
39
45
 
46
+ # Refuse to unsubscribe the last admin.
47
+ if list.admins.size == 1 && list.admins.first.email == email
48
+ return I18n.t(
49
+ "plugins.subscription_management.cannot_unsubscribe_last_admin", email: email
50
+ )
51
+ end
52
+
40
53
  # TODO: May signers have multiple UIDs? We don't match those currently.
41
54
  if ! list.from_admin?(mail) && email != mail.signer.email
42
55
  # Only admins may unsubscribe others.
@@ -98,6 +111,12 @@ module Schleuder
98
111
  end
99
112
 
100
113
  def self.set_fingerprint(arguments, list, mail)
114
+ if arguments.blank?
115
+ return I18n.t(
116
+ "plugins.subscription_management.set_fingerprint_requires_arguments"
117
+ )
118
+ end
119
+
101
120
  if arguments.first.match(/@/)
102
121
  if arguments.first == mail.signer.email || list.from_admin?(mail)
103
122
  email = arguments.shift
@@ -118,8 +137,15 @@ module Schleuder
118
137
  )
119
138
  end
120
139
 
121
- sub.fingerprint = arguments.join
140
+ fingerprint = arguments.join
141
+ unless GPGME::Key.valid_fingerprint?(fingerprint)
142
+ return I18n.t(
143
+ "plugins.subscription_management.set_fingerprint_requires_valid_fingerprint",
144
+ fingerprint: fingerprint
145
+ )
146
+ end
122
147
 
148
+ sub.fingerprint = fingerprint
123
149
  if sub.save
124
150
  I18n.t(
125
151
  "plugins.subscription_management.fingerprint_set",
@@ -130,7 +156,48 @@ module Schleuder
130
156
  I18n.t(
131
157
  "plugins.subscription_management.setting_fingerprint_failed",
132
158
  email: email,
133
- fingerprint: arguments.last,
159
+ fingerprint: sub.fingerprint,
160
+ errors: sub.errors.to_a.join("\n")
161
+ )
162
+ end
163
+ end
164
+
165
+ def self.unset_fingerprint(arguments, list, mail)
166
+ if arguments.blank?
167
+ return I18n.t(
168
+ "plugins.subscription_management.unset_fingerprint_requires_arguments"
169
+ )
170
+ end
171
+
172
+ email = arguments.first
173
+ unless email == mail.signer.email || list.from_admin?(mail)
174
+ return I18n.t(
175
+ "plugins.subscription_management.unset_fingerprint_only_self"
176
+ )
177
+ end
178
+ if email == mail.signer.email && list.from_admin?(mail) && arguments.last != 'force'
179
+ return I18n.t(
180
+ "plugins.subscription_management.unset_fingerprint_requires_arguments"
181
+ )
182
+ end
183
+
184
+ sub = list.subscriptions.where(email: email).first
185
+ if sub.blank?
186
+ return I18n.t(
187
+ "plugins.subscription_management.is_not_subscribed", email: email
188
+ )
189
+ end
190
+
191
+ sub.fingerprint = ''
192
+ if sub.save
193
+ I18n.t(
194
+ "plugins.subscription_management.fingerprint_unset",
195
+ email: email
196
+ )
197
+ else
198
+ I18n.t(
199
+ "plugins.subscription_management.unsetting_fingerprint_failed",
200
+ email: email,
134
201
  errors: sub.errors.to_a.join("\n")
135
202
  )
136
203
  end
@@ -5,20 +5,48 @@ module Schleuder
5
5
  return error if error
6
6
 
7
7
  logger.info "Parsing incoming email."
8
+
9
+ # is it valid utf-8?
10
+ msg_scrubbed = false
11
+ unless msg.valid_encoding?
12
+ logger.warn "Converting message due to invalid characters"
13
+ detection = CharlockHolmes::EncodingDetector.detect(msg)
14
+ begin
15
+ msg = CharlockHolmes::Converter.convert(msg, detection[:encoding], 'UTF-8')
16
+ rescue ArgumentError
17
+ # it looks like even icu wasn't able to convert
18
+ # so we scrub the invalid characters to be able to
19
+ # at least parse the message somehow. Though this might
20
+ # result in data loss.
21
+ logger.warn "Scrubbing message due to invalid characters"
22
+ msg = msg.scrub
23
+ msg_scrubbed = true
24
+ end
25
+ end
26
+
8
27
  @mail = Mail.create_message_to_list(msg, recipient, list)
9
28
 
10
- error = run_filters(Filters::Runner::PRE_SETUP_FILTERS)
29
+ if msg_scrubbed
30
+ @mail.add_pseudoheader(:note, I18n.t("pseudoheaders.scrubbed_message"))
31
+ end
32
+
33
+ error = run_filters('pre')
11
34
  return error if error
12
35
 
13
36
  begin
14
37
  # This decrypts, verifies, etc.
15
38
  @mail = @mail.setup
16
- rescue GPGME::Error::DecryptFailed
39
+
40
+ rescue GPGME::Error::BadPassphrase,
41
+ GPGME::Error::DecryptFailed,
42
+ GPGME::Error::NoData,
43
+ GPGME::Error::NoSecretKey
44
+
17
45
  logger.warn "Decryption of incoming message failed."
18
46
  return Errors::DecryptionFailed.new(list)
19
47
  end
20
48
 
21
- error = run_filters(Filters::Runner::POST_SETUP_FILTERS)
49
+ error = run_filters('post')
22
50
  return error if error
23
51
 
24
52
  if ! @mail.was_validly_signed?
@@ -45,8 +73,8 @@ module Schleuder
45
73
 
46
74
  # Subscriptions
47
75
  logger.debug "Creating clean copy of message"
48
- copy = @mail.clean_copy(true)
49
- list.send_to_subscriptions(copy)
76
+ copy = @mail.clean_copy(list.headers_to_meta.any?)
77
+ list.send_to_subscriptions(copy, @mail)
50
78
  nil
51
79
  end
52
80
 
@@ -56,8 +84,8 @@ module Schleuder
56
84
  @list
57
85
  end
58
86
 
59
- def run_filters(filters)
60
- error = filters_runner.run(@mail, filters)
87
+ def run_filters(filter_type)
88
+ error = filters_runner(filter_type).run(@mail)
61
89
  if error
62
90
  if list.bounces_notify_admins?
63
91
  text = "#{I18n.t('.bounces_notify_admins')}\n\n#{error}"
@@ -68,8 +96,19 @@ module Schleuder
68
96
  end
69
97
  end
70
98
 
71
- def filters_runner
72
- @filters_runner ||= Filters::Runner.new(list)
99
+ def filters_runner(filter_type)
100
+ if filter_type == 'pre'
101
+ filters_runner_pre_decryption
102
+ else
103
+ filters_runner_post_decryption
104
+ end
105
+ end
106
+
107
+ def filters_runner_pre_decryption
108
+ @filters_runner_pre_decryption ||= Filters::Runner.new(list,'pre')
109
+ end
110
+ def filters_runner_post_decryption
111
+ @filters_runner_post_decryption ||= Filters::Runner.new(list,'post')
73
112
  end
74
113
 
75
114
  def logger
@@ -94,7 +133,7 @@ module Schleuder
94
133
  return log_and_return(Errors::ListNotFound.new(recipient), true)
95
134
  end
96
135
 
97
- # Check neccessary permissions of crucial files.
136
+ # Check necessary permissions of crucial files.
98
137
  if ! File.exist?(@list.listdir)
99
138
  return log_and_return(Errors::ListdirProblem.new(@list.listdir, :not_existing))
100
139
  elsif ! File.directory?(@list.listdir)
@@ -23,10 +23,11 @@ module Schleuder
23
23
  end
24
24
 
25
25
  def fingerprint=(arg)
26
- # Allow input to contain whitespace and '0x'-prefix, but don't store it
27
- # into the DB.
28
- value = arg.to_s.gsub(/\s*/, '').gsub(/^0x/, '').chomp
29
- write_attribute(:fingerprint, value)
26
+ # Always assign the given value, because it must be possible to overwrite
27
+ # the previous fingerprint with an empty value. That value should better
28
+ # be nil instead of a blank string, but currently schleuder-cli (v0.1.0) expects
29
+ # only strings.
30
+ write_attribute(:fingerprint, arg.to_s.gsub(/\s*/, '').gsub(/^0x/, '').chomp.upcase)
30
31
  end
31
32
 
32
33
  def key
@@ -39,11 +40,6 @@ module Schleuder
39
40
  def send_mail(mail)
40
41
  list.logger.debug "Preparing sending to #{self.inspect}"
41
42
 
42
- if ! self.delivery_enabled
43
- list.logger.info "Not sending to #{self.email}: delivery is disabled."
44
- return false
45
- end
46
-
47
43
  mail = ensure_headers(mail)
48
44
  gpg_opts = self.list.gpg_sign_options
49
45
 
@@ -1,6 +1,6 @@
1
1
  class FingerprintValidator < ActiveModel::EachValidator
2
2
  def validate_each(record, attribute, value)
3
- unless value =~ /\A[a-f0-9]{32,}\z/i
3
+ unless GPGME::Key.valid_fingerprint?(value)
4
4
  record.errors[attribute] << (options[:message] || I18n.t("errors.invalid_fingerprint"))
5
5
  end
6
6
  end