vidibus-secure 4.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d886f3398f7020f815d6e221eb3565ab20a7f4fb06dc5c871e6377081cfa76b
4
- data.tar.gz: 4c9b3d29d34cb6842501f77621bd237474a026f90ce79947b713d7173b2648df
3
+ metadata.gz: c9d380d702322a988f059216a27b6d95c586731ec993df538c825f6f54f926af
4
+ data.tar.gz: 728f0e8648e796193600aa80e1c273139fa2bb7cae94c7d6f218d826a4e9b37c
5
5
  SHA512:
6
- metadata.gz: ef9c37941a53fc3a7c6cabfe1fa8e2d815742f4b1ae0584f1ca2c136e3b6da40c443d89a77691ca6d1e61dd06639123c66f189c702d2198e1e41b6c4fe3635ea
7
- data.tar.gz: 98c0c152ccb1ccb413a1edd8329b02a1d346d49baf32b9d523b8f1501493f8fe75d43e7a2d50266300fcbbae749a60de919b40f8e391386d7afb7ee6e68813db
6
+ metadata.gz: 86f4aca57de013a1382e0daddf68fe1b1d98de481c26dc0f980ee5785a6054f0acebf67b6d9d2850c2553960b223246952c0b9a36dacfa168b39241b77e7f965
7
+ data.tar.gz: 72e92dc9831b38151ed908888e790845a49a056176ec281cc31f5ac596625b248351e10a193a4fe9aa47efb9e9f9ef5d1aaee058ae6bb3c0f531fb2f7b011615
data/README.md CHANGED
@@ -14,15 +14,59 @@ If you want to use Vidibus::Secure::Mongoid on your models, you should generate
14
14
 
15
15
  ## Usage
16
16
 
17
- ```
17
+ ```ruby
18
18
  class MyModel
19
19
  include Mongoid::Document
20
20
  include Vidibus::Secure::Mongoid
21
21
 
22
22
  attr_encrypted :my_secret
23
+ end
24
+ ```
25
+
26
+ Defining `attr_encrypted :my_secret` creates a setter and a getter for `my_secret`. You can use it like a normal attribute, but the value is stored encrypted with AES-256-GCM.
27
+
28
+
29
+ ## Encryption key
30
+
31
+ The Mongoid integration looks up the encryption key on every read and write. The lookup order is:
32
+
33
+ 1. `Vidibus::Secure.key_resolver` — a callable (lambda or proc) returning the key.
34
+ 2. `ENV["VIDIBUS_SECURE_KEY"]` — fallback for simple deployments.
35
+
36
+ If neither is set, `Vidibus::Secure::KeyError` is raised.
37
+
38
+ The key passed in IS the AES key (32 bytes). Anything shorter or longer is reduced to 32 bytes via `SHA256(key)`.
39
+
40
+
41
+ ## Per-request key resolution
42
+
43
+ For multi-tenant apps that need a different key per request (e.g. master DB vs. tenant DB), set a resolver early in the request lifecycle:
44
+
45
+ ```ruby
46
+ # config/initializers/vidibus_secure.rb
47
+ Vidibus::Secure.key_resolver = -> { Current.tenant&.encryption_key }
23
48
  ```
24
49
 
25
- Defining `attr_encrypted :my_secret` will create setter and getter for `my_secret`. You can use it like normal. But it will be stored encrypted.
50
+ The resolver is consulted on every encrypted read/write, so swapping `Current.tenant` mid-request swaps the key. Direct callers of `Vidibus::Secure.encrypt(data, key)` still pass the key explicitly — the resolver only affects the Mongoid integration.
51
+
52
+
53
+ ## Storage format and migration
54
+
55
+ New writes use AES-256-GCM with the layout `[0x02][12B IV][16B tag][ciphertext]` (then base64- or hex-encoded). Existing v4 (CBC) ciphertexts continue to decrypt transparently. To migrate stored values to the new format, iterate your records and reassign each encrypted attribute:
56
+
57
+ ```ruby
58
+ MyModel.each do |m|
59
+ m.update(my_secret: m.my_secret)
60
+ end
61
+ ```
62
+
63
+ Tampered ciphertexts raise `Vidibus::Secure::DecryptError` rather than returning garbage — that's the GCM auth-tag win.
64
+
65
+ A v4 ciphertext whose first byte happens to be `0x01` or `0x02` (≈0.78% of legacy blobs) will be misrouted by the version-byte dispatch and raise `DecryptError`. If you hit that during migration, force the legacy path:
66
+
67
+ ```ruby
68
+ Vidibus::Secure.legacy_decrypt(blob, key)
69
+ ```
26
70
 
27
71
 
28
72
  ## Copyright
@@ -2,33 +2,36 @@ module Vidibus
2
2
  module Secure
3
3
  module Mongoid
4
4
  extend ActiveSupport::Concern
5
+
5
6
  module ClassMethods
6
7
 
7
- # Sets encrypted attributes.
8
+ # Defines encrypted attributes. The encryption key is
9
+ # resolved on every read and write via
10
+ # Vidibus::Secure.current_key, so callers can swap keys
11
+ # per request (e.g. tenant-scoped DB context).
8
12
  def attr_encrypted(*args)
9
- key = ENV["VIDIBUS_SECURE_KEY"]
10
- options = args.extract_options!
11
- for field in args
12
-
13
- # Define Mongoid field
14
- encrypted_field = "#{field}_encrypted"
15
- self.send(:field, encrypted_field, type: BSON::Binary)
13
+ args.extract_options!
14
+ args.each do |attr|
15
+ encrypted_field = "#{attr}_encrypted"
16
+ field encrypted_field, type: BSON::Binary
16
17
 
17
- # Define setter
18
- class_eval <<-EOV
19
- def #{field}=(value)
20
- self.#{encrypted_field} = value ?
21
- Vidibus::Secure.encrypt(value, "#{key}") :
22
- nil
18
+ define_method("#{attr}=") do |value|
19
+ if value.nil?
20
+ self[encrypted_field] = nil
21
+ else
22
+ blob = Vidibus::Secure.encrypt(
23
+ value, Vidibus::Secure.current_key
24
+ )
25
+ self[encrypted_field] = BSON::Binary.new(blob)
23
26
  end
24
- EOV
27
+ end
25
28
 
26
- # Define getter
27
- class_eval <<-EOV
28
- def #{field}
29
- Vidibus::Secure.decrypt(#{encrypted_field}.data, "#{key}") if #{encrypted_field}
30
- end
31
- EOV
29
+ define_method(attr) do
30
+ raw = self[encrypted_field]
31
+ return nil unless raw
32
+ data = raw.respond_to?(:data) ? raw.data : raw
33
+ Vidibus::Secure.decrypt(data, Vidibus::Secure.current_key)
34
+ end
32
35
  end
33
36
  end
34
37
  end
@@ -1,5 +1,5 @@
1
1
  module Vidibus
2
2
  module Secure
3
- VERSION = '4.1.0'
3
+ VERSION = '5.0.0'
4
4
  end
5
5
  end
@@ -1,69 +1,116 @@
1
1
  module Vidibus
2
2
  module Secure
3
3
 
4
+ GCM_VERSION = 0x02
5
+ CBC_VERSION = 0x01
6
+ GCM_IV_LEN = 12
7
+ GCM_TAG_LEN = 16
8
+
4
9
  class Error < StandardError; end
5
10
  class KeyError < Error; end
6
11
  class InputError < Error; end
12
+ class DecryptError < Error; end
7
13
 
8
14
  class << self
9
15
 
16
+ attr_accessor :key_resolver
17
+
18
+ # Returns the key to use for Mongoid attr_encrypted.
19
+ # Resolver wins over ENV; raises if neither is set.
20
+ def current_key
21
+ resolved = key_resolver&.call
22
+ return resolved if resolved
23
+ return ENV['VIDIBUS_SECURE_KEY'] if ENV['VIDIBUS_SECURE_KEY']
24
+ raise KeyError,
25
+ 'No encryption key — set Vidibus::Secure.key_resolver ' \
26
+ 'or ENV["VIDIBUS_SECURE_KEY"]'
27
+ end
28
+
10
29
  # Define default settings for random, sign, and crypt.
11
30
  def settings
12
31
  @settings ||= {
13
- :random => { :length => 50, :encoding => :base64 },
14
- :sign => { :algorithm => "SHA256", :encoding => :hex },
15
- :crypt => { :algorithm => "AES-256-CBC", :encoding => :base64 }
32
+ random: { length: 50, encoding: :base64 },
33
+ sign: { algorithm: 'SHA256', encoding: :hex },
34
+ crypt: { algorithm: 'AES-256-GCM', encoding: :base64 }
16
35
  }
17
36
  end
18
37
 
19
38
  # Returns a truly random string.
20
- # Now it is not much more than an interface for Ruby's SecureRandom,
21
- # but that might change over time.
22
- #
23
- # Options:
24
- # :length Length of string to generate
25
- # :encoding Encoding of string; hex or base64
26
- #
27
- # Keep in mind that a hexadecimal string is less secure
28
- # than a base64 encoded string with the same length!
29
- #
30
39
  def random(options = {})
31
40
  options = settings[:random].merge(options)
32
41
  length = options[:length]
33
- SecureRandom.send(options[:encoding], length)[0,length]
42
+ SecureRandom.send(options[:encoding], length)[0, length]
34
43
  end
35
44
 
36
45
  # Returns signature of given data with given key.
37
46
  def sign(data, key, options = {})
38
- raise KeyError.new("Please provide a secret key to sign data with.") unless key
47
+ unless key
48
+ raise KeyError.new(
49
+ 'Please provide a secret key to sign data with.'
50
+ )
51
+ end
39
52
  options = settings[:sign].merge(options)
40
53
  digest = OpenSSL::Digest.new(options[:algorithm])
41
54
  signature = OpenSSL::HMAC.digest(digest, key, data)
42
55
  encode(signature, options)
43
56
  end
44
57
 
45
- # Encrypts given data with given key.
58
+ # Encrypts given data with given key (AES-256-GCM).
46
59
  def encrypt(data, key, options = {})
47
- raise KeyError.new("Please provide a secret key to encrypt data with.") unless key
48
- options = settings[:crypt].merge(options)
49
- unless data.is_a?(String)
50
- data = JSON.generate(data)
60
+ unless key
61
+ raise KeyError.new(
62
+ 'Please provide a secret key to encrypt data with.'
63
+ )
51
64
  end
52
- encrypted_data = crypt(:encrypt, data, key, options)
53
- encode(encrypted_data, options)
65
+ options = settings[:crypt].merge(options)
66
+ data = JSON.generate(data) unless data.is_a?(String)
67
+ blob = gcm_encrypt(data, derive_key(key))
68
+ encode(blob, options)
54
69
  end
55
70
 
56
- # Decrypts given data with given key.
71
+ # Decrypts given data with given key. Dispatches on the
72
+ # version byte (0x02 GCM, 0x01 legacy CBC) and falls back
73
+ # to bare CBC (v4 layout) when no version byte is present.
57
74
  def decrypt(data, key, options = {})
58
- raise KeyError.new("Please provide a secret key to decrypt data with.") unless key
75
+ unless key
76
+ raise KeyError.new(
77
+ 'Please provide a secret key to decrypt data with.'
78
+ )
79
+ end
80
+ return data if data.nil? || data == ''
59
81
  options = settings[:crypt].merge(options)
60
- decoded_data = decode(data, options)
61
- decrypted_data = crypt(:decrypt, decoded_data, key, options)
62
- return data unless decrypted_data
82
+ raw = decode(data, options)
83
+ plaintext = dispatch_decrypt(raw, key)
84
+ return data unless plaintext
63
85
  begin
64
- JSON.parse(decrypted_data)
86
+ JSON.parse(plaintext)
65
87
  rescue JSON::ParserError
66
- decrypted_data
88
+ plaintext
89
+ end
90
+ end
91
+
92
+ # Decrypts a v4 (CBC) ciphertext, forcing the legacy
93
+ # path. Use this only for the rare case where a v4 blob's
94
+ # first byte happens to be 0x01 or 0x02 and would
95
+ # otherwise be misrouted. The blob may carry a 0x01
96
+ # version byte preamble or be bare base64/hex.
97
+ def legacy_decrypt(data, key, options = {})
98
+ unless key
99
+ raise KeyError.new(
100
+ 'Please provide a secret key to decrypt data with.'
101
+ )
102
+ end
103
+ return data if data.nil? || data == ''
104
+ options = settings[:crypt].merge(options)
105
+ raw = decode(data, options)
106
+ return nil unless raw && raw != ''
107
+ ct = raw.getbyte(0) == CBC_VERSION ?
108
+ raw.byteslice(1, raw.bytesize - 1) : raw
109
+ plaintext = cbc_decrypt(ct, key)
110
+ begin
111
+ JSON.parse(plaintext)
112
+ rescue JSON::ParserError
113
+ plaintext
67
114
  end
68
115
  end
69
116
 
@@ -71,30 +118,40 @@ module Vidibus
71
118
  def sign_request(verb, path, params, key, signature_param = nil)
72
119
  default_signature_param = :sign
73
120
  params_given = !!params
74
- raise InputError.new("Given params is not a Hash.") if params_given and !params.is_a?(Hash)
121
+ if params_given && !params.is_a?(Hash)
122
+ raise InputError.new('Given params is not a Hash.')
123
+ end
75
124
  params = {} unless params_given
76
- signature_param ||= (params_given and params.keys.first.is_a?(String)) ? default_signature_param.to_s : default_signature_param
125
+ signature_param ||=
126
+ if params_given && params.keys.first.is_a?(String)
127
+ default_signature_param.to_s
128
+ else
129
+ default_signature_param
130
+ end
77
131
 
78
132
  uri = URI.parse(path)
79
133
  path_params = Rack::Utils.parse_nested_query(uri.query)
80
134
  uri.query = nil
81
135
 
82
136
  _verb = verb.to_s.downcase
83
- _params = (params.merge(path_params)).except(signature_param.to_s, signature_param.to_s.to_sym)
137
+ _params = params.merge(path_params).
138
+ except(signature_param.to_s, signature_param.to_s.to_sym)
84
139
 
85
140
  signature_string = [
86
141
  _verb,
87
- uri.to_s.gsub(/\/+$/, ""),
88
- _params.any? ? params_identifier(_params) : ""
89
- ].join("|")
142
+ uri.to_s.gsub(/\/+$/, ''),
143
+ _params.any? ? params_identifier(_params) : ''
144
+ ].join('|')
90
145
 
91
146
  signature = sign(signature_string, key)
92
147
 
93
- if %w[post put].include?(_verb) or (params_given and path_params.empty?)
148
+ if %w[post put].include?(_verb) ||
149
+ (params_given && path_params.empty?)
94
150
  params[signature_param] = signature
95
151
  else
96
- unless path.gsub!(/(#{signature_param}=)[^&]+/, "\\1#{signature}")
97
- glue = path.match(/\?/) ? "&" : "?"
152
+ unless path.gsub!(/(#{signature_param}=)[^&]+/,
153
+ "\\1#{signature}")
154
+ glue = path.match(/\?/) ? '&' : '?'
98
155
  path << "#{glue}#{signature_param}=#{signature}"
99
156
  end
100
157
  end
@@ -107,36 +164,84 @@ module Vidibus
107
164
  _path = path.dup
108
165
  _params = params.dup
109
166
  sign_request(verb, _path, _params, key, signature_param)
110
- return (path == _path and params == _params)
167
+ path == _path && params == _params
111
168
  end
112
169
 
113
170
  protected
114
171
 
115
- def crypt(cipher_method, data, key, options = {})
116
- return unless data && data != ''
117
- cipher = OpenSSL::Cipher.new(options[:algorithm])
118
- digest = OpenSSL::Digest::SHA512.new(key).digest
119
- cipher.send(cipher_method)
120
- cipher.pkcs5_keyivgen(digest)
121
- result = cipher.update(data)
122
- result << cipher.final
172
+ # Ensure the AES key is exactly 32 bytes. SHA256-derive
173
+ # otherwise (per 5.0 no more pkcs5_keyivgen for GCM).
174
+ def derive_key(key)
175
+ return key if key.is_a?(String) && key.bytesize == 32
176
+ OpenSSL::Digest::SHA256.new(key.to_s).digest
177
+ end
178
+
179
+ def gcm_encrypt(plaintext, key)
180
+ cipher = OpenSSL::Cipher.new('AES-256-GCM')
181
+ cipher.encrypt
182
+ cipher.key = key
183
+ iv = cipher.random_iv # 12 bytes by default for GCM
184
+ ct = cipher.update(plaintext) + cipher.final
185
+ tag = cipher.auth_tag(GCM_TAG_LEN)
186
+ ([GCM_VERSION].pack('C') + iv + tag + ct).b
187
+ end
188
+
189
+ def gcm_decrypt(raw, key)
190
+ body = raw.byteslice(1, raw.bytesize - 1) || ''
191
+ if body.bytesize < GCM_IV_LEN + GCM_TAG_LEN
192
+ raise DecryptError, 'GCM payload too short'
193
+ end
194
+ iv = body.byteslice(0, GCM_IV_LEN)
195
+ tag = body.byteslice(GCM_IV_LEN, GCM_TAG_LEN)
196
+ ct = body.byteslice(GCM_IV_LEN + GCM_TAG_LEN,
197
+ body.bytesize - GCM_IV_LEN - GCM_TAG_LEN)
198
+ cipher = OpenSSL::Cipher.new('AES-256-GCM')
199
+ cipher.decrypt
200
+ cipher.key = key
201
+ cipher.iv = iv
202
+ cipher.auth_tag = tag
203
+ cipher.update(ct.to_s) + cipher.final
204
+ rescue OpenSSL::Cipher::CipherError => e
205
+ raise DecryptError, "GCM decryption failed: #{e.message}"
206
+ end
207
+
208
+ def cbc_decrypt(ct, key)
209
+ cipher = OpenSSL::Cipher.new('AES-256-CBC')
210
+ cipher.decrypt
211
+ cipher.pkcs5_keyivgen(OpenSSL::Digest::SHA512.new(key).digest)
212
+ cipher.update(ct) + cipher.final
213
+ rescue OpenSSL::Cipher::CipherError => e
214
+ raise DecryptError, "CBC decryption failed: #{e.message}"
215
+ end
216
+
217
+ def dispatch_decrypt(raw, key)
218
+ return nil unless raw && raw != ''
219
+ case raw.getbyte(0)
220
+ when GCM_VERSION
221
+ gcm_decrypt(raw, derive_key(key))
222
+ when CBC_VERSION
223
+ cbc_decrypt(raw.byteslice(1, raw.bytesize - 1), key)
224
+ else
225
+ # bare CBC (v4 layout, no version byte)
226
+ cbc_decrypt(raw, key)
227
+ end
123
228
  end
124
229
 
125
230
  def encode(data, options = {})
126
231
  return unless data
127
232
  if options[:encoding] == :hex
128
- data.unpack("H*").join
233
+ data.unpack('H*').join
129
234
  elsif options[:encoding] == :base64
130
- [data].pack("m*")
235
+ [data].pack('m*')
131
236
  end
132
237
  end
133
238
 
134
239
  def decode(data, options = {})
135
240
  return unless data
136
241
  if options[:encoding] == :hex
137
- [data].pack("H*")
242
+ [data].pack('H*')
138
243
  elsif options[:encoding] == :base64
139
- data.unpack("m*").join
244
+ data.unpack('m*').join
140
245
  end
141
246
  end
142
247
 
@@ -149,12 +254,12 @@ module Vidibus
149
254
  def params_identifier(params, level = 1)
150
255
  array = []
151
256
  for key, value in params
152
- if value.is_a?(Array) or value.is_a?(Hash)
257
+ if value.is_a?(Array) || value.is_a?(Hash)
153
258
  value = params_identifier(value, level + 1)
154
259
  end
155
260
  array << "#{level}:#{key}:#{value}"
156
261
  end
157
- array.sort.join("|")
262
+ array.sort.join('|')
158
263
  end
159
264
  end
160
265
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vidibus-secure
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.0
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andre Pankratz