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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bec6f5799315832f67c01e6ffe79b58a5c5019e3ca8efa5a2c0795149a2b727c
4
- data.tar.gz: 8cbea27c197f2da4dbd1cad3bbee59c36c2404cd8eddb379c07be7ebbfc72a05
3
+ metadata.gz: 11c711222f049df42c663c9e68a2822fb7e318d3f3fc02f6346801c998ead328
4
+ data.tar.gz: 98200a87d8bf02a7398b075aaa6d79dff706a4d15d850584ebd952329980520b
5
5
  SHA512:
6
- metadata.gz: ba30a61a50245321fcca825ce5d36a4f350edb26b832f033d029b6051578a1f6d3ac8efb81f01339762a1a6869f6e70ca4a4fc780d3341ea80b4d17ca57724e8
7
- data.tar.gz: e511299714c5d035f000d7e85de6855be813fccc0a65fe7332a9418e0dc2733c6618ae42070f582877b48db98117aabc9b605ab1931537885180b20693fb7da0
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
@@ -1,4 +1,4 @@
1
- Copyright (c) 2017 Andrew Kane
1
+ Copyright (c) 2017-2018 Andrew Kane
2
2
 
3
3
  MIT License
4
4
 
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
- The attr_encrypted gem is great for encryption, but:
5
+ With KMS Encrypted:
6
6
 
7
- 1. Leaves you to manage the security of your keys
8
- 2. Doesnt provide an easy way to rotate your keys
9
- 3. Doesn’t have a great audit trail to see how data has been accessed
10
- 4. Doesn’t let you grant encryption and decryption permission separately
7
+ - Master encryption keys are not on application servers
8
+ - Theres 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
- Key management services address all of these issues and it’s easy to use them together.
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
- - [Amazon KMS](guides/Amazon.md)
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 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