kms_encrypted 0.3.0 → 1.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/CHANGELOG.md +11 -0
- data/LICENSE.txt +1 -1
- data/README.md +40 -13
- data/lib/kms_encrypted/client.rb +69 -0
- data/lib/kms_encrypted/clients/aws.rb +36 -0
- data/lib/kms_encrypted/clients/base.rb +45 -0
- data/lib/kms_encrypted/clients/google.rb +40 -0
- data/lib/kms_encrypted/clients/test.rb +29 -0
- data/lib/kms_encrypted/clients/vault.rb +48 -0
- data/lib/kms_encrypted/database.rb +93 -0
- data/lib/kms_encrypted/log_subscriber.rb +14 -6
- data/lib/kms_encrypted/model.rb +70 -122
- data/lib/kms_encrypted/version.rb +1 -1
- data/lib/kms_encrypted.rb +26 -2
- metadata +13 -15
- data/.gitignore +0 -9
- data/.travis.yml +0 -11
- data/Gemfile +0 -3
- data/Rakefile +0 -11
- data/guides/Amazon.md +0 -262
- data/guides/Google.md +0 -131
- data/guides/Vault.md +0 -143
- data/kms_encrypted.gemspec +0 -34
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 11c711222f049df42c663c9e68a2822fb7e318d3f3fc02f6346801c998ead328
|
4
|
+
data.tar.gz: 98200a87d8bf02a7398b075aaa6d79dff706a4d15d850584ebd952329980520b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7b3d37f2739f1a869a8a34c62e9300c3b54b0e8b87b98377883fa252d161aaafcb387044049bf0bfb070e86d481034bcf5c19b0e83d21a4668a513b02f3da151
|
7
|
+
data.tar.gz: b5596bc03466ac15b09d72cd1e00093eda05e2dcd3ca17607484ff678da808647bb7d364be27b5cfc912cdcccb991cbb55b18fd94206a4cb4510d17eaeb4c0da
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,14 @@
|
|
1
|
+
## 1.0.0
|
2
|
+
|
3
|
+
- Added versioning
|
4
|
+
- Added `context_hash` method
|
5
|
+
|
6
|
+
Breaking changes
|
7
|
+
|
8
|
+
- There’s now a default encryption context with the model name and id
|
9
|
+
- ActiveSupport notifications were changed from `generate_data_key` and `decrypt_data_key` to `encrypt` and `decrypt`
|
10
|
+
- AWS KMS uses the `Encrypt` operation instead of `GenerateDataKey`
|
11
|
+
|
1
12
|
## 0.3.0
|
2
13
|
|
3
14
|
- Added support for Vault
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
@@ -2,39 +2,66 @@
|
|
2
2
|
|
3
3
|
Simple, secure key management for [attr_encrypted](https://github.com/attr-encrypted/attr_encrypted)
|
4
4
|
|
5
|
-
|
5
|
+
With KMS Encrypted:
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
7
|
+
- Master encryption keys are not on application servers
|
8
|
+
- There’s an immutable audit log of all activity
|
9
|
+
- Decryption can be disabled if an attack is detected
|
10
|
+
- Encrypt and decrypt permissions can be granted separately
|
11
|
+
- It’s easy to rotate keys
|
11
12
|
|
12
|
-
|
13
|
-
|
14
|
-
Supports [Amazon KMS](https://aws.amazon.com/kms/), [Google KMS](https://cloud.google.com/kms/), and [Vault](https://www.vaultproject.io/)
|
13
|
+
Supports [AWS KMS](https://aws.amazon.com/kms/), [Google Cloud KMS](https://cloud.google.com/kms/), and [Vault](https://www.vaultproject.io/)
|
15
14
|
|
16
15
|
[](https://travis-ci.org/ankane/kms_encrypted)
|
17
16
|
|
18
17
|
## How It Works
|
19
18
|
|
20
|
-
This approach uses KMS to manage encryption keys and attr_encrypted to do the encryption.
|
19
|
+
This approach uses a key management service (KMS) to manage encryption keys and attr_encrypted to do the encryption.
|
21
20
|
|
22
|
-
To encrypt an attribute, we first generate a data key and encrypt it with KMS. This is known as [envelope encryption](https://cloud.google.com/kms/docs/envelope-encryption). We pass the unencrypted version to attr_encrypted and store the encrypted version in the `encrypted_kms_key` column. For each record, we generate a different data key.
|
21
|
+
To encrypt an attribute, we first generate a data key and encrypt it with the KMS. This is known as [envelope encryption](https://cloud.google.com/kms/docs/envelope-encryption). We pass the unencrypted version to attr_encrypted and store the encrypted version in the `encrypted_kms_key` column. For each record, we generate a different data key.
|
23
22
|
|
24
|
-
To decrypt an attribute, we first decrypt the data key with KMS. Once we have the decrypted key, we pass it to attr_encrypted to decrypt the data. We can easily track decryptions since we have a different data key for each record.
|
23
|
+
To decrypt an attribute, we first decrypt the data key with the KMS. Once we have the decrypted key, we pass it to attr_encrypted to decrypt the data. We can easily track decryptions since we have a different data key for each record.
|
25
24
|
|
26
25
|
## Getting Started
|
27
26
|
|
28
27
|
Follow the instructions for your key management service:
|
29
28
|
|
30
|
-
- [
|
31
|
-
- [Google KMS](guides/Google.md)
|
29
|
+
- [AWS KMS](guides/Amazon.md)
|
30
|
+
- [Google Cloud KMS](guides/Google.md)
|
32
31
|
- [Vault](guides/Vault.md)
|
33
32
|
|
34
33
|
## Related Projects
|
35
34
|
|
36
35
|
To securely search encrypted data, check out [Blind Index](https://github.com/ankane/blind_index).
|
37
36
|
|
37
|
+
## Upgrading
|
38
|
+
|
39
|
+
### 1.0
|
40
|
+
|
41
|
+
KMS Encrypted 1.0 brings a number of improvements. Here are a few breaking changes to be aware of:
|
42
|
+
|
43
|
+
- There’s now a default encryption context with the model name and id
|
44
|
+
- ActiveSupport notifications were changed from `generate_data_key` and `decrypt_data_key` to `encrypt` and `decrypt`
|
45
|
+
- AWS KMS uses the `Encrypt` operation instead of `GenerateDataKey`
|
46
|
+
|
47
|
+
If you didn’t previously use encryption context, add the `upgrade_context` option to your models:
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
class User < ApplicationRecord
|
51
|
+
has_kms_key upgrade_context: true
|
52
|
+
end
|
53
|
+
```
|
54
|
+
|
55
|
+
Then run:
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
User.where("encrypted_kms_key NOT LIKE 'v1:%'").find_each do |user|
|
59
|
+
user.rotate_kms_key!
|
60
|
+
end
|
61
|
+
```
|
62
|
+
|
63
|
+
And remove the `upgrade_context` option.
|
64
|
+
|
38
65
|
## History
|
39
66
|
|
40
67
|
View the [changelog](CHANGELOG.md)
|
@@ -0,0 +1,69 @@
|
|
1
|
+
module KmsEncrypted
|
2
|
+
class Client
|
3
|
+
delegate :encrypt, :decrypt, to: :client
|
4
|
+
|
5
|
+
attr_reader :key_id, :data_key
|
6
|
+
|
7
|
+
def initialize(key_id: nil, legacy_context: false, data_key: false)
|
8
|
+
@key_id = key_id || ENV["KMS_KEY_ID"]
|
9
|
+
@legacy_context = legacy_context
|
10
|
+
@data_key = data_key
|
11
|
+
end
|
12
|
+
|
13
|
+
def encrypt(plaintext, context: nil)
|
14
|
+
event = {
|
15
|
+
key_id: key_id,
|
16
|
+
context: context,
|
17
|
+
data_key: data_key
|
18
|
+
}
|
19
|
+
|
20
|
+
ActiveSupport::Notifications.instrument("encrypt.kms_encrypted", event) do
|
21
|
+
client.encrypt(plaintext, context: context)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def decrypt(ciphertext, context: nil)
|
26
|
+
event = {
|
27
|
+
key_id: key_id,
|
28
|
+
context: context,
|
29
|
+
data_key: data_key
|
30
|
+
}
|
31
|
+
|
32
|
+
ActiveSupport::Notifications.instrument("decrypt.kms_encrypted", event) do
|
33
|
+
client.decrypt(ciphertext, context: context)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def provider
|
40
|
+
if key_id == "insecure-test-key"
|
41
|
+
:test
|
42
|
+
elsif key_id.start_with?("vault/")
|
43
|
+
:vault
|
44
|
+
elsif key_id.start_with?("projects/")
|
45
|
+
:google
|
46
|
+
else
|
47
|
+
:aws
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def client
|
52
|
+
@client ||= begin
|
53
|
+
klass =
|
54
|
+
case provider
|
55
|
+
when :test
|
56
|
+
KmsEncrypted::Clients::Test
|
57
|
+
when :vault
|
58
|
+
KmsEncrypted::Clients::Vault
|
59
|
+
when :google
|
60
|
+
KmsEncrypted::Clients::Google
|
61
|
+
else
|
62
|
+
KmsEncrypted::Clients::Aws
|
63
|
+
end
|
64
|
+
|
65
|
+
klass.new(key_id: key_id, legacy_context: @legacy_context)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module KmsEncrypted
|
2
|
+
module Clients
|
3
|
+
class Aws < Base
|
4
|
+
def encrypt(plaintext, context: nil)
|
5
|
+
options = {
|
6
|
+
key_id: key_id,
|
7
|
+
plaintext: plaintext
|
8
|
+
}
|
9
|
+
options[:encryption_context] = generate_context(context) if context
|
10
|
+
|
11
|
+
KmsEncrypted.aws_client.encrypt(options).ciphertext_blob
|
12
|
+
end
|
13
|
+
|
14
|
+
def decrypt(ciphertext, context: nil)
|
15
|
+
options = {
|
16
|
+
ciphertext_blob: ciphertext
|
17
|
+
}
|
18
|
+
options[:encryption_context] = generate_context(context) if context
|
19
|
+
|
20
|
+
begin
|
21
|
+
KmsEncrypted.aws_client.decrypt(options).plaintext
|
22
|
+
rescue ::Aws::KMS::Errors::InvalidCiphertextException
|
23
|
+
decryption_failed!
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
# make integers strings for convenience
|
30
|
+
def generate_context(context)
|
31
|
+
raise ArgumentError, "Context must be a hash" unless context.is_a?(Hash)
|
32
|
+
Hash[context.map { |k, v| [k, context_value(v)] }]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module KmsEncrypted
|
2
|
+
module Clients
|
3
|
+
class Base
|
4
|
+
attr_reader :key_id
|
5
|
+
|
6
|
+
def initialize(key_id: nil, legacy_context: false)
|
7
|
+
@key_id = key_id
|
8
|
+
@legacy_context = legacy_context
|
9
|
+
end
|
10
|
+
|
11
|
+
protected
|
12
|
+
|
13
|
+
def decryption_failed!
|
14
|
+
raise DecryptionError, "Decryption failed"
|
15
|
+
end
|
16
|
+
|
17
|
+
# keys must be ordered consistently
|
18
|
+
# values are checked for validity
|
19
|
+
# then converted to strings
|
20
|
+
def generate_context(context)
|
21
|
+
if @legacy_context
|
22
|
+
context.to_json
|
23
|
+
elsif context.is_a?(Hash)
|
24
|
+
Hash[context.sort_by { |k| k.to_s }.map { |k, v| [context_key(k), context_value(v)] }].to_json
|
25
|
+
else
|
26
|
+
context
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def context_key(k)
|
31
|
+
unless k.is_a?(String) || k.is_a?(Symbol)
|
32
|
+
raise ArgumentError, "Context keys must be a string or symbol"
|
33
|
+
end
|
34
|
+
k.to_s
|
35
|
+
end
|
36
|
+
|
37
|
+
def context_value(v)
|
38
|
+
unless v.is_a?(String) || v.is_a?(Integer)
|
39
|
+
raise ArgumentError, "Context values must be a string or integer"
|
40
|
+
end
|
41
|
+
v.to_s
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module KmsEncrypted
|
2
|
+
module Clients
|
3
|
+
class Google < Base
|
4
|
+
attr_reader :last_key_version
|
5
|
+
|
6
|
+
def encrypt(plaintext, context: nil)
|
7
|
+
options = {
|
8
|
+
plaintext: plaintext
|
9
|
+
}
|
10
|
+
options[:additional_authenticated_data] = generate_context(context) if context
|
11
|
+
|
12
|
+
# ensure namespace gets loaded
|
13
|
+
client = KmsEncrypted.google_client
|
14
|
+
request = ::Google::Apis::CloudkmsV1::EncryptRequest.new(options)
|
15
|
+
response = client.encrypt_crypto_key(key_id, request)
|
16
|
+
|
17
|
+
@last_key_version = response.name
|
18
|
+
|
19
|
+
response.ciphertext
|
20
|
+
end
|
21
|
+
|
22
|
+
def decrypt(ciphertext, context: nil)
|
23
|
+
options = {
|
24
|
+
ciphertext: ciphertext
|
25
|
+
}
|
26
|
+
options[:additional_authenticated_data] = generate_context(context) if context
|
27
|
+
|
28
|
+
# ensure namespace gets loaded
|
29
|
+
client = KmsEncrypted.google_client
|
30
|
+
request = ::Google::Apis::CloudkmsV1::DecryptRequest.new(options)
|
31
|
+
begin
|
32
|
+
client.decrypt_crypto_key(key_id, request).plaintext
|
33
|
+
rescue ::Google::Apis::ClientError => e
|
34
|
+
decryption_failed! if e.message.include?("Decryption failed")
|
35
|
+
raise e
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module KmsEncrypted
|
2
|
+
module Clients
|
3
|
+
class Test < Base
|
4
|
+
PREFIX = Base64.decode64("insecure+data+A")
|
5
|
+
|
6
|
+
def encrypt(plaintext, context: nil)
|
7
|
+
parts = [PREFIX, Base64.strict_encode64(plaintext)]
|
8
|
+
parts << generate_context(context) if context
|
9
|
+
parts.join(":")
|
10
|
+
end
|
11
|
+
|
12
|
+
def decrypt(ciphertext, context: nil)
|
13
|
+
prefix, plaintext, stored_context = ciphertext.split(":")
|
14
|
+
|
15
|
+
context = generate_context(context) if context
|
16
|
+
decryption_failed! if context != stored_context
|
17
|
+
|
18
|
+
Base64.decode64(plaintext)
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
# turn hash into json
|
24
|
+
def generate_context(context)
|
25
|
+
Base64.encode64(super)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module KmsEncrypted
|
2
|
+
module Clients
|
3
|
+
class Vault < Base
|
4
|
+
def encrypt(plaintext, context: nil)
|
5
|
+
options = {
|
6
|
+
plaintext: Base64.encode64(plaintext)
|
7
|
+
}
|
8
|
+
options[:context] = generate_context(context) if context
|
9
|
+
|
10
|
+
response = KmsEncrypted.vault_client.logical.write(
|
11
|
+
"transit/encrypt/#{key_id.sub("vault/", "")}",
|
12
|
+
options
|
13
|
+
)
|
14
|
+
|
15
|
+
response.data[:ciphertext]
|
16
|
+
end
|
17
|
+
|
18
|
+
def decrypt(ciphertext, context: nil)
|
19
|
+
options = {
|
20
|
+
ciphertext: ciphertext
|
21
|
+
}
|
22
|
+
options[:context] = generate_context(context) if context
|
23
|
+
|
24
|
+
response =
|
25
|
+
begin
|
26
|
+
KmsEncrypted.vault_client.logical.write(
|
27
|
+
"transit/decrypt/#{key_id.sub("vault/", "")}",
|
28
|
+
options
|
29
|
+
)
|
30
|
+
rescue ::Vault::HTTPClientError => e
|
31
|
+
decryption_failed! if e.message.include?("unable to decrypt")
|
32
|
+
raise e
|
33
|
+
rescue Encoding::UndefinedConversionError
|
34
|
+
decryption_failed!
|
35
|
+
end
|
36
|
+
|
37
|
+
Base64.decode64(response.data[:plaintext])
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
# turn hash into json
|
43
|
+
def generate_context(context)
|
44
|
+
Base64.encode64(super)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module KmsEncrypted
|
2
|
+
class Database
|
3
|
+
attr_reader :record, :key_method, :options
|
4
|
+
|
5
|
+
def initialize(record, key_method)
|
6
|
+
@record = record
|
7
|
+
@key_method = key_method
|
8
|
+
@options = record.class.kms_keys[key_method.to_sym]
|
9
|
+
end
|
10
|
+
|
11
|
+
def name
|
12
|
+
options[:name]
|
13
|
+
end
|
14
|
+
|
15
|
+
def current_version
|
16
|
+
@version ||= begin
|
17
|
+
version = options[:version]
|
18
|
+
version = record.instance_exec(&version) if version.respond_to?(:call)
|
19
|
+
version.to_i
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def key_version(version)
|
24
|
+
versions = (options[:previous_versions] || {}).dup
|
25
|
+
versions[current_version] ||= options.slice(:key_id)
|
26
|
+
|
27
|
+
raise KmsEncrypted::Error, "Version not active: #{version}" unless versions[version]
|
28
|
+
|
29
|
+
key_id = versions[version][:key_id]
|
30
|
+
|
31
|
+
raise ArgumentError, "Missing key id" unless key_id
|
32
|
+
|
33
|
+
key_id
|
34
|
+
end
|
35
|
+
|
36
|
+
def context(version)
|
37
|
+
context_method = name ? "kms_encryption_context_#{name}" : "kms_encryption_context"
|
38
|
+
if record.method(context_method).arity == 0
|
39
|
+
record.send(context_method)
|
40
|
+
else
|
41
|
+
record.send(context_method, version: version)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def encrypt(plaintext)
|
46
|
+
key_id = key_version(current_version)
|
47
|
+
context = context(current_version)
|
48
|
+
ciphertext = KmsEncrypted::Client.new(key_id: key_id, data_key: true).encrypt(plaintext, context: context)
|
49
|
+
"v#{current_version}:#{encode64(ciphertext)}"
|
50
|
+
end
|
51
|
+
|
52
|
+
def decrypt(ciphertext)
|
53
|
+
m = /\Av(\d+):/.match(ciphertext)
|
54
|
+
if m
|
55
|
+
version = m[1].to_i
|
56
|
+
ciphertext = ciphertext.sub("v#{version}:", "")
|
57
|
+
else
|
58
|
+
version = 1
|
59
|
+
context = {} if options[:upgrade_context]
|
60
|
+
legacy_context = true
|
61
|
+
|
62
|
+
# legacy
|
63
|
+
if ciphertext.start_with?("$gc$")
|
64
|
+
_, _, short_key_id, ciphertext = ciphertext.split("$", 4)
|
65
|
+
|
66
|
+
# restore key, except for cryptoKeyVersion
|
67
|
+
stored_key_id = decode64(short_key_id).split("/")[0..3]
|
68
|
+
stored_key_id.insert(0, "projects")
|
69
|
+
stored_key_id.insert(2, "locations")
|
70
|
+
stored_key_id.insert(4, "keyRings")
|
71
|
+
stored_key_id.insert(6, "cryptoKeys")
|
72
|
+
key_id = stored_key_id.join("/")
|
73
|
+
elsif ciphertext.start_with?("vault:")
|
74
|
+
ciphertext = Base64.encode64(ciphertext)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
key_id ||= key_version(version)
|
79
|
+
context ||= context(version)
|
80
|
+
ciphertext = decode64(ciphertext)
|
81
|
+
|
82
|
+
KmsEncrypted::Client.new(key_id: key_id, data_key: true, legacy_context: legacy_context).decrypt(ciphertext, context: context)
|
83
|
+
end
|
84
|
+
|
85
|
+
def encode64(bytes)
|
86
|
+
Base64.strict_encode64(bytes)
|
87
|
+
end
|
88
|
+
|
89
|
+
def decode64(bytes)
|
90
|
+
Base64.decode64(bytes)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -1,17 +1,25 @@
|
|
1
1
|
module KmsEncrypted
|
2
2
|
class LogSubscriber < ActiveSupport::LogSubscriber
|
3
|
-
def
|
3
|
+
def decrypt(event)
|
4
4
|
return unless logger.debug?
|
5
5
|
|
6
|
-
|
7
|
-
|
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
|
14
|
+
def encrypt(event)
|
11
15
|
return unless logger.debug?
|
12
16
|
|
13
|
-
|
14
|
-
|
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
|