kms_encrypted 0.3.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,9 +1,11 @@
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
11
  class << self
@@ -11,143 +13,89 @@ module KmsEncrypted
11
13
  @kms_keys ||= {}
12
14
  end unless respond_to?(:kms_keys)
13
15
  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)
16
+ kms_keys[key_method.to_sym] = {
17
+ key_id: key_id,
18
+ name: name,
19
+ version: version,
20
+ previous_versions: previous_versions,
21
+ upgrade_context: upgrade_context
22
+ }
23
+
24
+ if kms_keys.size == 1
25
+ after_save :encrypt_kms_keys
26
+
27
+ # fetch all keys together so only need to update database once
28
+ def encrypt_kms_keys
29
+ updates = {}
30
+ self.class.kms_keys.each do |key_method, key|
31
+ instance_var = "@#{key_method}"
32
+ key_column = "encrypted_#{key_method}"
33
+ plaintext_key = instance_variable_get(instance_var)
34
+
35
+ if !send(key_column) && plaintext_key
36
+ updates[key_column] = KmsEncrypted::Database.new(self, key_method).encrypt(plaintext_key)
37
+ end
38
+ end
39
+ if updates.any?
40
+ current_time = current_time_from_proper_timezone
41
+ timestamp_attributes_for_update_in_model.each do |attr|
42
+ updates[attr] = current_time
43
+ end
44
+ update_columns(updates)
45
+ end
46
+ end
47
+
48
+ # same pattern as attr_encrypted reload
49
+ if method_defined?(:reload)
50
+ alias_method :reload_without_kms_encrypted, :reload
51
+ def reload(*args, &block)
52
+ result = reload_without_kms_encrypted(*args, &block)
53
+ self.class.kms_keys.keys.each do |key_method|
54
+ instance_variable_set("@#{key_method}", nil)
55
+ end
56
+ result
23
57
  end
24
- result
25
58
  end
26
59
  end
27
60
 
28
61
  define_method(key_method) do
29
- raise ArgumentError, "Missing key id" unless key_id
30
-
31
62
  instance_var = "@#{key_method}"
32
63
 
33
64
  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)
65
+ encrypted_key = send(key_column)
66
+ plaintext_key =
67
+ if encrypted_key
68
+ KmsEncrypted::Database.new(self, key_method).decrypt(encrypted_key)
69
+ else
70
+ key = SecureRandom.random_bytes(32)
71
+
72
+ if eager_encrypt == :fetch_id
73
+ raise ArgumentError, ":fetch_id only works with Postgres" unless self.class.connection.adapter_name =~ /postg/i
74
+ self.id ||= self.class.connection.execute("select nextval('#{self.class.sequence_name}')").first["nextval"]
92
75
  end
93
- end
94
76
 
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
77
+ if eager_encrypt == true || ([:try, :fetch_id].include?(eager_encrypt) && id)
78
+ encrypted_key = KmsEncrypted::Database.new(self, key_method).encrypt(key)
79
+ send("#{key_column}=", encrypted_key)
141
80
  end
142
- end
143
81
 
144
- instance_variable_set(instance_var, plaintext_key)
145
- end
82
+ key
83
+ end
84
+ instance_variable_set(instance_var, plaintext_key)
146
85
  end
147
86
 
148
87
  instance_variable_get(instance_var)
149
88
  end
150
89
 
90
+ define_method(context_method) do
91
+ raise KmsEncrypted::Error, "id needed for encryption context" unless id
92
+
93
+ {
94
+ model_name: model_name.to_s,
95
+ model_id: id
96
+ }
97
+ end
98
+
151
99
  define_method("rotate_#{key_method}!") do
152
100
  # decrypt
153
101
  plaintext_attributes = {}
@@ -1,3 +1,3 @@
1
1
  module KmsEncrypted
2
- VERSION = "0.3.0"
2
+ VERSION = "1.0.0"
3
3
  end
data/lib/kms_encrypted.rb CHANGED
@@ -1,12 +1,27 @@
1
1
  # dependencies
2
2
  require "active_support"
3
+ require "base64"
4
+ require "json"
5
+ require "securerandom"
3
6
 
4
7
  # modules
8
+ require "kms_encrypted/database"
5
9
  require "kms_encrypted/log_subscriber"
6
10
  require "kms_encrypted/model"
7
11
  require "kms_encrypted/version"
8
12
 
13
+ # clients
14
+ require "kms_encrypted/client"
15
+ require "kms_encrypted/clients/base"
16
+ require "kms_encrypted/clients/aws"
17
+ require "kms_encrypted/clients/google"
18
+ require "kms_encrypted/clients/test"
19
+ require "kms_encrypted/clients/vault"
20
+
9
21
  module KmsEncrypted
22
+ class Error < StandardError; end
23
+ class DecryptionError < Error; end
24
+
10
25
  class << self
11
26
  attr_writer :aws_client
12
27
  attr_writer :google_client
@@ -14,7 +29,7 @@ module KmsEncrypted
14
29
 
15
30
  def aws_client
16
31
  @aws_client ||= Aws::KMS::Client.new(
17
- retry_limit: 2,
32
+ retry_limit: 1,
18
33
  http_open_timeout: 2,
19
34
  http_read_timeout: 2
20
35
  )
@@ -27,12 +42,21 @@ module KmsEncrypted
27
42
  client.authorization = ::Google::Auth.get_application_default(
28
43
  "https://www.googleapis.com/auth/cloud-platform"
29
44
  )
45
+ client.client_options.log_http_requests = false
46
+ client.client_options.open_timeout_sec = 2
47
+ client.client_options.read_timeout_sec = 2
30
48
  client
31
49
  end
32
50
  end
33
51
 
34
52
  def vault_client
35
- @vault_client ||= ::Vault
53
+ @vault_client ||= ::Vault::Client.new
54
+ end
55
+
56
+ # hash is independent of key, but specific to audit device
57
+ def context_hash(context, path:)
58
+ context = Base64.encode64(context.to_json)
59
+ vault_client.logical.write("sys/audit-hash/#{path}", input: context).data[:hash]
36
60
  end
37
61
  end
38
62
  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.0.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: 2018-12-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -151,24 +151,22 @@ dependencies:
151
151
  - !ruby/object:Gem::Version
152
152
  version: '0'
153
153
  description:
154
- email:
155
- - andrew@chartkick.com
154
+ email: andrew@chartkick.com
156
155
  executables: []
157
156
  extensions: []
158
157
  extra_rdoc_files: []
159
158
  files:
160
- - ".gitignore"
161
- - ".travis.yml"
162
159
  - CHANGELOG.md
163
- - Gemfile
164
160
  - LICENSE.txt
165
161
  - README.md
166
- - Rakefile
167
- - guides/Amazon.md
168
- - guides/Google.md
169
- - guides/Vault.md
170
- - kms_encrypted.gemspec
171
162
  - lib/kms_encrypted.rb
163
+ - lib/kms_encrypted/client.rb
164
+ - lib/kms_encrypted/clients/aws.rb
165
+ - lib/kms_encrypted/clients/base.rb
166
+ - lib/kms_encrypted/clients/google.rb
167
+ - lib/kms_encrypted/clients/test.rb
168
+ - lib/kms_encrypted/clients/vault.rb
169
+ - lib/kms_encrypted/database.rb
172
170
  - lib/kms_encrypted/log_subscriber.rb
173
171
  - lib/kms_encrypted/model.rb
174
172
  - lib/kms_encrypted/version.rb
@@ -184,7 +182,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
184
182
  requirements:
185
183
  - - ">="
186
184
  - !ruby/object:Gem::Version
187
- version: '0'
185
+ version: '2.2'
188
186
  required_rubygems_version: !ruby/object:Gem::Requirement
189
187
  requirements:
190
188
  - - ">="
@@ -192,7 +190,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
192
190
  version: '0'
193
191
  requirements: []
194
192
  rubyforge_project:
195
- rubygems_version: 2.7.7
193
+ rubygems_version: 2.7.6
196
194
  signing_key:
197
195
  specification_version: 4
198
196
  summary: Simple, secure key management for attr_encrypted
data/.gitignore DELETED
@@ -1,9 +0,0 @@
1
- /.bundle/
2
- /.yardoc
3
- /Gemfile.lock
4
- /_yardoc/
5
- /coverage/
6
- /doc/
7
- /pkg/
8
- /spec/reports/
9
- /tmp/
data/.travis.yml DELETED
@@ -1,11 +0,0 @@
1
- language: ruby
2
- rvm: 2.4.2
3
- gemfile:
4
- - Gemfile
5
- sudo: false
6
- before_install: gem install bundler
7
- script: bundle exec rake test
8
- notifications:
9
- email:
10
- on_success: never
11
- on_failure: change
data/Gemfile DELETED
@@ -1,3 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- gemspec
data/Rakefile DELETED
@@ -1,11 +0,0 @@
1
- require "bundler/gem_tasks"
2
- require "rake/testtask"
3
-
4
- Rake::TestTask.new(:test) do |t|
5
- t.libs << "test"
6
- t.libs << "lib"
7
- t.test_files = FileList["test/**/*_test.rb"]
8
- t.warning = false
9
- end
10
-
11
- task default: :test
data/guides/Amazon.md DELETED
@@ -1,262 +0,0 @@
1
- # Amazon KMS
2
-
3
- Add this line to your application’s Gemfile:
4
-
5
- ```ruby
6
- gem 'aws-sdk-kms'
7
- gem 'kms_encrypted'
8
- ```
9
-
10
- Add columns for the encrypted data and the encrypted KMS data keys
11
-
12
- ```ruby
13
- add_column :users, :encrypted_email, :text
14
- add_column :users, :encrypted_email_iv, :text
15
- add_column :users, :encrypted_kms_key, :text
16
- ```
17
-
18
- Create an [Amazon Web Services](https://aws.amazon.com/) account if you don’t have one. KMS works great whether or not you run your infrastructure on AWS.
19
-
20
- Create a [KMS master key](https://console.aws.amazon.com/iam/home#/encryptionKeys) and set it in your environment along with your AWS credentials ([dotenv](https://github.com/bkeepers/dotenv) is great for this)
21
-
22
- ```sh
23
- KMS_KEY_ID=arn:aws:kms:...
24
- AWS_ACCESS_KEY_ID=...
25
- AWS_SECRET_ACCESS_KEY=...
26
- ```
27
-
28
- You can also use the alias
29
-
30
- ```sh
31
- KMS_KEY_ID=alias/my-alias
32
- ```
33
-
34
- And update your model
35
-
36
- ```ruby
37
- class User < ApplicationRecord
38
- has_kms_key
39
-
40
- attr_encrypted :email, key: :kms_key
41
- end
42
- ```
43
-
44
- For each encrypted attribute, use the `kms_key` method for its key.
45
-
46
- ## Auditing
47
-
48
- [AWS CloudTrail](https://aws.amazon.com/cloudtrail/) logs all decryption calls. However, to know what data is being decrypted, you’ll need to add context.
49
-
50
- Add a `kms_encryption_context` method to your model.
51
-
52
- ```ruby
53
- class User < ApplicationRecord
54
- def kms_encryption_context
55
- # some hash
56
- end
57
- end
58
- ```
59
-
60
- The context is used as part of the encryption and decryption process, so it must be a value that doesn’t change. Otherwise, you won’t be able to decrypt. Read more about [encryption context here](https://docs.aws.amazon.com/kms/latest/developerguide/encryption-context.html).
61
-
62
- The primary key is a good choice, but auto-generated ids aren’t available until a record is created, and we need to encrypt before this. One solution is to preload the primary key. Here’s what it looks like with Postgres:
63
-
64
- ```ruby
65
- class User < ApplicationRecord
66
- def kms_encryption_context
67
- self.id ||= self.class.connection.execute("select nextval('#{self.class.sequence_name}')").first["nextval"]
68
- {"Record" => "#{model_name}/#{id}"}
69
- end
70
- end
71
- ```
72
-
73
- [Amazon Athena](https://aws.amazon.com/athena/) is great for querying CloudTrail logs. Create a table (thanks to [this post](http://www.1strategy.com/blog/2017/07/25/auditing-aws-activity-with-cloudtrail-and-athena/) for the table structure) with:
74
-
75
- ```sql
76
- CREATE EXTERNAL TABLE cloudtrail_logs (
77
- eventversion STRING,
78
- userIdentity STRUCT<
79
- type:STRING,
80
- principalid:STRING,
81
- arn:STRING,
82
- accountid:STRING,
83
- invokedby:STRING,
84
- accesskeyid:STRING,
85
- userName:String,
86
- sessioncontext:STRUCT<
87
- attributes:STRUCT<
88
- mfaauthenticated:STRING,
89
- creationdate:STRING>,
90
- sessionIssuer:STRUCT<
91
- type:STRING,
92
- principalId:STRING,
93
- arn:STRING,
94
- accountId:STRING,
95
- userName:STRING>>>,
96
- eventTime STRING,
97
- eventSource STRING,
98
- eventName STRING,
99
- awsRegion STRING,
100
- sourceIpAddress STRING,
101
- userAgent STRING,
102
- errorCode STRING,
103
- errorMessage STRING,
104
- requestId STRING,
105
- eventId STRING,
106
- resources ARRAY<STRUCT<
107
- ARN:STRING,
108
- accountId:STRING,
109
- type:STRING>>,
110
- eventType STRING,
111
- apiVersion STRING,
112
- readOnly BOOLEAN,
113
- recipientAccountId STRING,
114
- sharedEventID STRING,
115
- vpcEndpointId STRING,
116
- requestParameters STRING,
117
- responseElements STRING,
118
- additionalEventData STRING,
119
- serviceEventDetails STRING
120
- )
121
- ROW FORMAT SERDE 'com.amazon.emr.hive.serde.CloudTrailSerde'
122
- STORED AS INPUTFORMAT 'com.amazon.emr.cloudtrail.CloudTrailInputFormat'
123
- OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
124
- LOCATION 's3://my-cloudtrail-logs/'
125
- ```
126
-
127
- Change the last line to point to your CloudTrail log bucket and query away
128
-
129
- ```sql
130
- SELECT
131
- eventTime,
132
- userIdentity.userName,
133
- requestParameters
134
- FROM
135
- cloudtrail_logs
136
- WHERE
137
- eventName = 'Decrypt'
138
- AND resources[1].arn = 'arn:aws:kms:...'
139
- ORDER BY 1
140
- ```
141
-
142
- There will also be `GenerateDataKey` events.
143
-
144
- ## Alerting
145
-
146
- We recommend setting up alerts on suspicious behavior.
147
-
148
- ## Key Rotation
149
-
150
- KMS supports [automatic key rotation](https://docs.aws.amazon.com/kms/latest/developerguide/rotate-keys.html). No action is required in this case.
151
-
152
- To manually rotate keys, replace the old KMS key id with the new key id in your model. Your app does not need the old key id to perform rotation (however, the key must still be enabled in your AWS account).
153
-
154
- ```sh
155
- KMS_KEY_ID=arn:aws:kms:...
156
- ```
157
-
158
- and run
159
-
160
- ```ruby
161
- User.find_each do |user|
162
- user.rotate_kms_key!
163
- end
164
- ```
165
-
166
- ## IAM Permissions
167
-
168
- A great feature of KMS is the ability to grant encryption and decryption permission separately.
169
-
170
- To encrypt the data, use a policy with:
171
-
172
- ```json
173
- {
174
- "Version": "2012-10-17",
175
- "Statement": [
176
- {
177
- "Sid": "EncryptData",
178
- "Effect": "Allow",
179
- "Action": "kms:GenerateDataKey",
180
- "Resource": "arn:aws:kms:..."
181
- }
182
- ]
183
- }
184
- ```
185
-
186
- If a system can only encrypt, you must clear out existing data and data keys before updates.
187
-
188
- ```ruby
189
- user.encrypted_email = nil
190
- user.encrypted_kms_key = nil
191
- # before user.save or user.update
192
- ```
193
-
194
- To decrypt the data, use a policy with:
195
-
196
- ```json
197
- {
198
- "Version": "2012-10-17",
199
- "Statement": [
200
- {
201
- "Sid": "DecryptData",
202
- "Effect": "Allow",
203
- "Action": "kms:Decrypt",
204
- "Resource": "arn:aws:kms:..."
205
- }
206
- ]
207
- }
208
- ```
209
-
210
- Be extremely selective of systems you allow to decrypt.
211
-
212
- ## Testing
213
-
214
- For testing, you can prevent network calls to KMS by setting:
215
-
216
- ```sh
217
- KMS_KEY_ID=insecure-test-key
218
- ```
219
-
220
- ## Multiple Keys Per Record
221
-
222
- You may want to protect different columns with different data keys (or even master keys).
223
-
224
- To do this, add more columns
225
-
226
- ```ruby
227
- add_column :users, :encrypted_phone, :text
228
- add_column :users, :encrypted_phone_iv, :text
229
- add_column :users, :encrypted_kms_key_phone, :text
230
- ```
231
-
232
- And update your model
233
-
234
- ```ruby
235
- class User < ApplicationRecord
236
- has_kms_key
237
- has_kms_key name: :phone, key_id: "..."
238
-
239
- attr_encrypted :email, key: :kms_key
240
- attr_encrypted :phone, key: :kms_key_phone
241
- end
242
- ```
243
-
244
- For context, use:
245
-
246
- ```ruby
247
- class User < ApplicationRecord
248
- def kms_encryption_context_phone
249
- # some hash
250
- end
251
- end
252
- ```
253
-
254
- To rotate keys, use:
255
-
256
- ```ruby
257
- user.rotate_kms_key_phone!
258
- ```
259
-
260
- ## File Uploads
261
-
262
- While outside the scope of this gem, you can also use KMS for sensitive file uploads. Check out [this guide](https://ankane.org/aws-client-side-encryption) to learn more.