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.
- 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
|
[![Build Status](https://travis-ci.org/ankane/kms_encrypted.svg?branch=master)](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
|