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 +4 -4
- data/README.md +46 -2
- data/lib/vidibus/secure/mongoid.rb +24 -21
- data/lib/vidibus/secure/version.rb +1 -1
- data/lib/vidibus/secure.rb +158 -53
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9d380d702322a988f059216a27b6d95c586731ec993df538c825f6f54f926af
|
|
4
|
+
data.tar.gz: 728f0e8648e796193600aa80e1c273139fa2bb7cae94c7d6f218d826a4e9b37c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
+
end
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
data/lib/vidibus/secure.rb
CHANGED
|
@@ -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
|
-
:
|
|
14
|
-
:
|
|
15
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
unless key
|
|
61
|
+
raise KeyError.new(
|
|
62
|
+
'Please provide a secret key to encrypt data with.'
|
|
63
|
+
)
|
|
51
64
|
end
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
return data unless
|
|
82
|
+
raw = decode(data, options)
|
|
83
|
+
plaintext = dispatch_decrypt(raw, key)
|
|
84
|
+
return data unless plaintext
|
|
63
85
|
begin
|
|
64
|
-
JSON.parse(
|
|
86
|
+
JSON.parse(plaintext)
|
|
65
87
|
rescue JSON::ParserError
|
|
66
|
-
|
|
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
|
-
|
|
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 ||=
|
|
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 =
|
|
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)
|
|
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}=)[^&]+/,
|
|
97
|
-
|
|
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
|
-
|
|
167
|
+
path == _path && params == _params
|
|
111
168
|
end
|
|
112
169
|
|
|
113
170
|
protected
|
|
114
171
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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(
|
|
233
|
+
data.unpack('H*').join
|
|
129
234
|
elsif options[:encoding] == :base64
|
|
130
|
-
[data].pack(
|
|
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(
|
|
242
|
+
[data].pack('H*')
|
|
138
243
|
elsif options[:encoding] == :base64
|
|
139
|
-
data.unpack(
|
|
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)
|
|
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
|