kms_encrypted 0.3.0 → 1.2.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.
@@ -1,17 +1,25 @@
1
1
  module KmsEncrypted
2
2
  class LogSubscriber < ActiveSupport::LogSubscriber
3
- def decrypt_data_key(event)
3
+ def decrypt(event)
4
4
  return unless logger.debug?
5
5
 
6
- name = "Decrypt Data Key (#{event.duration.round(1)}ms)"
7
- debug " #{color(name, YELLOW, true)} Context: #{event.payload[:context].inspect}"
6
+ data_key = event.payload[:data_key]
7
+ name = data_key ? "Decrypt Data Key" : "Decrypt"
8
+ name += " (#{event.duration.round(1)}ms)"
9
+ context = event.payload[:context]
10
+ context = context.inspect if context.is_a?(Hash)
11
+ debug " #{color(name, YELLOW, true)} Context: #{context}"
8
12
  end
9
13
 
10
- def generate_data_key(event)
14
+ def encrypt(event)
11
15
  return unless logger.debug?
12
16
 
13
- name = "Generate Data Key (#{event.duration.round(1)}ms)"
14
- debug " #{color(name, YELLOW, true)} Context: #{event.payload[:context].inspect}"
17
+ data_key = event.payload[:data_key]
18
+ name = data_key ? "Encrypt Data Key" : "Encrypt"
19
+ name += " (#{event.duration.round(1)}ms)"
20
+ context = event.payload[:context]
21
+ context = context.inspect if context.is_a?(Hash)
22
+ debug " #{color(name, YELLOW, true)} Context: #{context}"
15
23
  end
16
24
  end
17
25
  end
@@ -1,158 +1,173 @@
1
1
  module KmsEncrypted
2
2
  module Model
3
- def has_kms_key(legacy_key_id = nil, name: nil, key_id: nil)
4
- key_id ||= legacy_key_id || ENV["KMS_KEY_ID"]
3
+ def has_kms_key(name: nil, key_id: nil, eager_encrypt: false, version: 1, previous_versions: nil, upgrade_context: false)
4
+ key_id ||= ENV["KMS_KEY_ID"]
5
5
 
6
6
  key_method = name ? "kms_key_#{name}" : "kms_key"
7
+ key_column = "encrypted_#{key_method}"
8
+ context_method = name ? "kms_encryption_context_#{name}" : "kms_encryption_context"
7
9
 
8
10
  class_eval do
9
- class << self
10
- def kms_keys
11
- @kms_keys ||= {}
12
- end unless respond_to?(:kms_keys)
11
+ @kms_keys ||= {}
12
+
13
+ unless respond_to?(:kms_keys)
14
+ def self.kms_keys
15
+ parent_keys =
16
+ if superclass.respond_to?(:kms_keys)
17
+ superclass.kms_keys
18
+ else
19
+ {}
20
+ end
21
+
22
+ parent_keys.merge(@kms_keys || {})
23
+ end
13
24
  end
14
- kms_keys[key_method.to_sym] = {key_id: key_id}
15
-
16
- # same pattern as attr_encrypted reload
17
- if method_defined?(:reload) && kms_keys.size == 1
18
- alias_method :reload_without_kms_encrypted, :reload
19
- def reload(*args, &block)
20
- result = reload_without_kms_encrypted(*args, &block)
21
- self.class.kms_keys.keys.each do |key_method|
22
- instance_variable_set("@#{key_method}", nil)
25
+
26
+ @kms_keys[key_method.to_sym] = {
27
+ key_id: key_id,
28
+ name: name,
29
+ version: version,
30
+ previous_versions: previous_versions,
31
+ upgrade_context: upgrade_context
32
+ }
33
+
34
+ if @kms_keys.size == 1
35
+ after_save :encrypt_kms_keys
36
+
37
+ # fetch all keys together so only need to update database once
38
+ def encrypt_kms_keys
39
+ updates = {}
40
+ self.class.kms_keys.each do |key_method, key|
41
+ instance_var = "@#{key_method}"
42
+ key_column = "encrypted_#{key_method}"
43
+ plaintext_key = instance_variable_get(instance_var)
44
+
45
+ if !send(key_column) && plaintext_key
46
+ updates[key_column] = KmsEncrypted::Database.new(self, key_method).encrypt(plaintext_key)
47
+ end
48
+ end
49
+ if updates.any?
50
+ current_time = current_time_from_proper_timezone
51
+ timestamp_attributes_for_update_in_model.each do |attr|
52
+ updates[attr] = current_time
53
+ end
54
+ update_columns(updates)
23
55
  end
24
- result
56
+ end
57
+
58
+ if method_defined?(:reload)
59
+ m = Module.new do
60
+ define_method(:reload) do |*args, &block|
61
+ result = super(*args, &block)
62
+ self.class.kms_keys.keys.each do |key_method|
63
+ instance_variable_set("@#{key_method}", nil)
64
+ end
65
+ result
66
+ end
67
+ end
68
+ prepend m
25
69
  end
26
70
  end
27
71
 
28
72
  define_method(key_method) do
29
- raise ArgumentError, "Missing key id" unless key_id
30
-
31
73
  instance_var = "@#{key_method}"
32
74
 
33
75
  unless instance_variable_get(instance_var)
34
- key_column = "encrypted_#{key_method}"
35
- context_method = name ? "kms_encryption_context_#{name}" : "kms_encryption_context"
36
- context = respond_to?(context_method, true) ? send(context_method) : {}
37
- default_encoding = "m"
38
-
39
- unless send(key_column)
40
- plaintext_key = nil
41
- encrypted_key = nil
42
-
43
- event = {
44
- key_id: key_id,
45
- context: context
46
- }
47
- ActiveSupport::Notifications.instrument("generate_data_key.kms_encrypted", event) do
48
- if key_id == "insecure-test-key"
49
- plaintext_key = "00000000000000000000000000000000"
50
- encrypted_key = "insecure-data-key-#{rand(1_000_000_000_000)}"
51
- elsif key_id.start_with?("projects/")
52
- # generate random AES-256 key
53
- plaintext_key = OpenSSL::Random.random_bytes(32)
54
-
55
- # encrypt it
56
- # load client first to ensure namespace is loaded
57
- client = KmsEncrypted.google_client
58
- request = ::Google::Apis::CloudkmsV1::EncryptRequest.new(
59
- plaintext: plaintext_key,
60
- additional_authenticated_data: context.to_json
61
- )
62
- response = client.encrypt_crypto_key(key_id, request)
63
- key_version = response.name
64
-
65
- # shorten key to save space
66
- short_key_id = Base64.encode64(key_version.split("/").select.with_index { |_, i| i.odd? }.join("/"))
67
-
68
- # build encrypted key
69
- # we reference the key in the field for easy rotation
70
- encrypted_key = "$gc$#{short_key_id}$#{[response.ciphertext].pack(default_encoding)}"
71
- elsif key_id.start_with?("vault/")
72
- # generate random AES-256 key
73
- plaintext_key = OpenSSL::Random.random_bytes(32)
74
-
75
- # encrypt it
76
- response = KmsEncrypted.vault_client.logical.write(
77
- "transit/encrypt/#{key_id.sub("vault/", "")}",
78
- plaintext: Base64.encode64(plaintext_key),
79
- context: Base64.encode64(context.to_json)
80
- )
81
-
82
- encrypted_key = response.data[:ciphertext]
83
- else
84
- # generate data key from API
85
- resp = KmsEncrypted.aws_client.generate_data_key(
86
- key_id: key_id,
87
- encryption_context: context,
88
- key_spec: "AES_256"
89
- )
90
- plaintext_key = resp.plaintext
91
- encrypted_key = [resp.ciphertext_blob].pack(default_encoding)
76
+ encrypted_key = send(key_column)
77
+ plaintext_key =
78
+ if encrypted_key
79
+ KmsEncrypted::Database.new(self, key_method).decrypt(encrypted_key)
80
+ else
81
+ key = SecureRandom.random_bytes(32)
82
+
83
+ if eager_encrypt == :fetch_id
84
+ raise ArgumentError, ":fetch_id only works with Postgres" unless self.class.connection.adapter_name =~ /postg/i
85
+ self.id ||= self.class.connection.execute("select nextval('#{self.class.sequence_name}')").first["nextval"]
92
86
  end
93
- end
94
87
 
95
- instance_variable_set(instance_var, plaintext_key)
96
- self.send("#{key_column}=", encrypted_key)
97
- end
98
-
99
- unless instance_variable_get(instance_var)
100
- encrypted_key = send(key_column)
101
- plaintext_key = nil
102
-
103
- event = {
104
- key_id: key_id,
105
- context: context
106
- }
107
- ActiveSupport::Notifications.instrument("decrypt_data_key.kms_encrypted", event) do
108
- if encrypted_key.start_with?("insecure-data-key-")
109
- plaintext_key = "00000000000000000000000000000000".encode("BINARY")
110
- elsif encrypted_key.start_with?("$gc$")
111
- _, _, short_key_id, ciphertext = encrypted_key.split("$", 4)
112
-
113
- # restore key, except for cryptoKeyVersion
114
- stored_key_id = Base64.decode64(short_key_id).split("/")[0..3]
115
- stored_key_id.insert(0, "projects")
116
- stored_key_id.insert(2, "locations")
117
- stored_key_id.insert(4, "keyRings")
118
- stored_key_id.insert(6, "cryptoKeys")
119
- stored_key_id = stored_key_id.join("/")
120
-
121
- # load client first to ensure namespace is loaded
122
- client = KmsEncrypted.google_client
123
- request = ::Google::Apis::CloudkmsV1::DecryptRequest.new(
124
- ciphertext: ciphertext.unpack(default_encoding).first,
125
- additional_authenticated_data: context.to_json
126
- )
127
- plaintext_key = client.decrypt_crypto_key(stored_key_id, request).plaintext
128
- elsif encrypted_key.start_with?("vault:")
129
- response = KmsEncrypted.vault_client.logical.write(
130
- "transit/decrypt/#{key_id.sub("vault/", "")}",
131
- ciphertext: encrypted_key,
132
- context: Base64.encode64(context.to_json)
133
- )
134
-
135
- plaintext_key = Base64.decode64(response.data[:plaintext])
136
- else
137
- plaintext_key = KmsEncrypted.aws_client.decrypt(
138
- ciphertext_blob: encrypted_key.unpack(default_encoding).first,
139
- encryption_context: context
140
- ).plaintext
88
+ if eager_encrypt == true || ([:try, :fetch_id].include?(eager_encrypt) && id)
89
+ encrypted_key = KmsEncrypted::Database.new(self, key_method).encrypt(key)
90
+ send("#{key_column}=", encrypted_key)
141
91
  end
142
- end
143
92
 
144
- instance_variable_set(instance_var, plaintext_key)
145
- end
93
+ key
94
+ end
95
+ instance_variable_set(instance_var, plaintext_key)
146
96
  end
147
97
 
148
98
  instance_variable_get(instance_var)
149
99
  end
150
100
 
101
+ define_method(context_method) do
102
+ raise KmsEncrypted::Error, "id needed for encryption context" unless id
103
+
104
+ {
105
+ model_name: model_name.to_s,
106
+ model_id: id
107
+ }
108
+ end
109
+
110
+ # automatically detects attributes and files where the encryption key is:
111
+ # 1. a symbol that matches kms key method exactly
112
+ # does not detect attributes and files where the encryption key is:
113
+ # 1. callable (warns)
114
+ # 2. a symbol that internally calls kms key method
115
+ # it could try to get the exact key and compare
116
+ # (there's a very small chance this could have false positives)
117
+ # but bias towards simplicity for now
118
+ # TODO possibly raise error for callable keys in 2.0
119
+ # with option to override/specify attributes
151
120
  define_method("rotate_#{key_method}!") do
152
121
  # decrypt
153
122
  plaintext_attributes = {}
154
- self.class.encrypted_attributes.select { |_, v| v[:key] == key_method.to_sym }.keys.each do |key|
155
- plaintext_attributes[key] = send(key)
123
+
124
+ # attr_encrypted
125
+ if self.class.respond_to?(:encrypted_attributes)
126
+ self.class.encrypted_attributes.each do |key, v|
127
+ if v[:key] == key_method.to_sym
128
+ plaintext_attributes[key] = send(key)
129
+ elsif v[:key].respond_to?(:call)
130
+ warn "[kms_encrypted] Can't detect if encrypted attribute uses this key"
131
+ end
132
+ end
133
+ end
134
+
135
+ # lockbox attributes
136
+ # only checks key, not previous versions
137
+ if self.class.respond_to?(:lockbox_attributes)
138
+ self.class.lockbox_attributes.each do |key, v|
139
+ if v[:key] == key_method.to_sym
140
+ plaintext_attributes[key] = send(key)
141
+ elsif v[:key].respond_to?(:call)
142
+ warn "[kms_encrypted] Can't detect if encrypted attribute uses this key"
143
+ end
144
+ end
145
+ end
146
+
147
+ # lockbox attachments
148
+ # only checks key, not previous versions
149
+ if self.class.respond_to?(:lockbox_attachments)
150
+ self.class.lockbox_attachments.each do |key, v|
151
+ if v[:key] == key_method.to_sym
152
+ # can likely add support at some point, but may be complicated
153
+ # ideally use rotate_encryption! from Lockbox
154
+ # but needs access to both old and new keys
155
+ # also need to update database atomically
156
+ raise KmsEncrypted::Error, "Can't rotate key used for encrypted files"
157
+ elsif v[:key].respond_to?(:call)
158
+ warn "[kms_encrypted] Can't detect if encrypted attachment uses this key"
159
+ end
160
+ end
161
+ end
162
+
163
+ # CarrierWave uploaders
164
+ if self.class.respond_to?(:uploaders)
165
+ self.class.uploaders.each do |_, uploader|
166
+ # for simplicity, only checks if key is callable
167
+ if uploader.respond_to?(:lockbox_options) && uploader.lockbox_options[:key].respond_to?(:call)
168
+ warn "[kms_encrypted] Can't detect if encrypted uploader uses this key"
169
+ end
170
+ end
156
171
  end
157
172
 
158
173
  # reset key
@@ -1,3 +1,3 @@
1
1
  module KmsEncrypted
2
- VERSION = "0.3.0"
2
+ VERSION = "1.2.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kms_encrypted
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2018-11-11 00:00:00.000000000 Z
11
+ date: 2020-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
19
+ version: '5'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '0'
26
+ version: '5'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: bundler
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -108,6 +108,20 @@ dependencies:
108
108
  - - ">="
109
109
  - !ruby/object:Gem::Version
110
110
  version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: lockbox
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: 0.4.7
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: 0.4.7
111
125
  - !ruby/object:Gem::Dependency
112
126
  name: aws-sdk-kms
113
127
  requirement: !ruby/object:Gem::Requirement
@@ -150,25 +164,38 @@ dependencies:
150
164
  - - ">="
151
165
  - !ruby/object:Gem::Version
152
166
  version: '0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: carrierwave
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
153
181
  description:
154
- email:
155
- - andrew@chartkick.com
182
+ email: andrew@chartkick.com
156
183
  executables: []
157
184
  extensions: []
158
185
  extra_rdoc_files: []
159
186
  files:
160
- - ".gitignore"
161
- - ".travis.yml"
162
187
  - CHANGELOG.md
163
- - Gemfile
164
188
  - LICENSE.txt
165
189
  - README.md
166
- - Rakefile
167
- - guides/Amazon.md
168
- - guides/Google.md
169
- - guides/Vault.md
170
- - kms_encrypted.gemspec
171
190
  - lib/kms_encrypted.rb
191
+ - lib/kms_encrypted/box.rb
192
+ - lib/kms_encrypted/client.rb
193
+ - lib/kms_encrypted/clients/aws.rb
194
+ - lib/kms_encrypted/clients/base.rb
195
+ - lib/kms_encrypted/clients/google.rb
196
+ - lib/kms_encrypted/clients/test.rb
197
+ - lib/kms_encrypted/clients/vault.rb
198
+ - lib/kms_encrypted/database.rb
172
199
  - lib/kms_encrypted/log_subscriber.rb
173
200
  - lib/kms_encrypted/model.rb
174
201
  - lib/kms_encrypted/version.rb
@@ -184,16 +211,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
184
211
  requirements:
185
212
  - - ">="
186
213
  - !ruby/object:Gem::Version
187
- version: '0'
214
+ version: '2.4'
188
215
  required_rubygems_version: !ruby/object:Gem::Requirement
189
216
  requirements:
190
217
  - - ">="
191
218
  - !ruby/object:Gem::Version
192
219
  version: '0'
193
220
  requirements: []
194
- rubyforge_project:
195
- rubygems_version: 2.7.7
221
+ rubygems_version: 3.1.2
196
222
  signing_key:
197
223
  specification_version: 4
198
- summary: Simple, secure key management for attr_encrypted
224
+ summary: Simple, secure key management for Lockbox and attr_encrypted
199
225
  test_files: []