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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -7
- data/LICENSE.txt +1 -1
- data/README.md +499 -15
- data/lib/kms_encrypted.rb +27 -2
- data/lib/kms_encrypted/box.rb +91 -0
- 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 +63 -0
- data/lib/kms_encrypted/log_subscriber.rb +14 -6
- data/lib/kms_encrypted/model.rb +143 -128
- data/lib/kms_encrypted/version.rb +1 -1
- metadata +45 -19
- 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
data/lib/kms_encrypted.rb
CHANGED
@@ -1,12 +1,28 @@
|
|
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/box"
|
9
|
+
require "kms_encrypted/database"
|
5
10
|
require "kms_encrypted/log_subscriber"
|
6
11
|
require "kms_encrypted/model"
|
7
12
|
require "kms_encrypted/version"
|
8
13
|
|
14
|
+
# clients
|
15
|
+
require "kms_encrypted/client"
|
16
|
+
require "kms_encrypted/clients/base"
|
17
|
+
require "kms_encrypted/clients/aws"
|
18
|
+
require "kms_encrypted/clients/google"
|
19
|
+
require "kms_encrypted/clients/test"
|
20
|
+
require "kms_encrypted/clients/vault"
|
21
|
+
|
9
22
|
module KmsEncrypted
|
23
|
+
class Error < StandardError; end
|
24
|
+
class DecryptionError < Error; end
|
25
|
+
|
10
26
|
class << self
|
11
27
|
attr_writer :aws_client
|
12
28
|
attr_writer :google_client
|
@@ -14,7 +30,7 @@ module KmsEncrypted
|
|
14
30
|
|
15
31
|
def aws_client
|
16
32
|
@aws_client ||= Aws::KMS::Client.new(
|
17
|
-
retry_limit:
|
33
|
+
retry_limit: 1,
|
18
34
|
http_open_timeout: 2,
|
19
35
|
http_read_timeout: 2
|
20
36
|
)
|
@@ -27,12 +43,21 @@ module KmsEncrypted
|
|
27
43
|
client.authorization = ::Google::Auth.get_application_default(
|
28
44
|
"https://www.googleapis.com/auth/cloud-platform"
|
29
45
|
)
|
46
|
+
client.client_options.log_http_requests = false
|
47
|
+
client.client_options.open_timeout_sec = 2
|
48
|
+
client.client_options.read_timeout_sec = 2
|
30
49
|
client
|
31
50
|
end
|
32
51
|
end
|
33
52
|
|
34
53
|
def vault_client
|
35
|
-
@vault_client ||= ::Vault
|
54
|
+
@vault_client ||= ::Vault::Client.new
|
55
|
+
end
|
56
|
+
|
57
|
+
# hash is independent of key, but specific to audit device
|
58
|
+
def context_hash(context, path:)
|
59
|
+
context = Base64.encode64(context.to_json)
|
60
|
+
vault_client.logical.write("sys/audit-hash/#{path}", input: context).data[:hash]
|
36
61
|
end
|
37
62
|
end
|
38
63
|
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
module KmsEncrypted
|
2
|
+
class Box
|
3
|
+
attr_reader :key_id, :version, :previous_versions
|
4
|
+
|
5
|
+
def initialize(key_id: nil, version: nil, previous_versions: nil)
|
6
|
+
@key_id = key_id || ENV["KMS_KEY_ID"]
|
7
|
+
@version = version || 1
|
8
|
+
@previous_versions = previous_versions || {}
|
9
|
+
end
|
10
|
+
|
11
|
+
def encrypt(plaintext, context: nil)
|
12
|
+
context = version_context(context, version)
|
13
|
+
key_id = version_key_id(version)
|
14
|
+
ciphertext = KmsEncrypted::Client.new(key_id: key_id, data_key: true).encrypt(plaintext, context: context)
|
15
|
+
"v#{version}:#{encode64(ciphertext)}"
|
16
|
+
end
|
17
|
+
|
18
|
+
def decrypt(ciphertext, context: nil)
|
19
|
+
m = /\Av(\d+):/.match(ciphertext)
|
20
|
+
if m
|
21
|
+
version = m[1].to_i
|
22
|
+
ciphertext = ciphertext.sub("v#{version}:", "")
|
23
|
+
else
|
24
|
+
version = 1
|
25
|
+
legacy_context = true
|
26
|
+
|
27
|
+
# legacy
|
28
|
+
if ciphertext.start_with?("$gc$")
|
29
|
+
_, _, short_key_id, ciphertext = ciphertext.split("$", 4)
|
30
|
+
|
31
|
+
# restore key, except for cryptoKeyVersion
|
32
|
+
stored_key_id = decode64(short_key_id).split("/")[0..3]
|
33
|
+
stored_key_id.insert(0, "projects")
|
34
|
+
stored_key_id.insert(2, "locations")
|
35
|
+
stored_key_id.insert(4, "keyRings")
|
36
|
+
stored_key_id.insert(6, "cryptoKeys")
|
37
|
+
key_id = stored_key_id.join("/")
|
38
|
+
elsif ciphertext.start_with?("vault:")
|
39
|
+
ciphertext = Base64.encode64(ciphertext)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
key_id ||= version_key_id(version)
|
44
|
+
ciphertext = decode64(ciphertext)
|
45
|
+
context = version_context(context, version)
|
46
|
+
|
47
|
+
KmsEncrypted::Client.new(
|
48
|
+
key_id: key_id,
|
49
|
+
data_key: true,
|
50
|
+
legacy_context: legacy_context
|
51
|
+
).decrypt(ciphertext, context: context)
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def version_key_id(version)
|
57
|
+
key_id =
|
58
|
+
if previous_versions[version]
|
59
|
+
previous_versions[version][:key_id]
|
60
|
+
elsif self.version == version
|
61
|
+
self.key_id
|
62
|
+
else
|
63
|
+
raise KmsEncrypted::Error, "Version not active: #{version}"
|
64
|
+
end
|
65
|
+
|
66
|
+
raise ArgumentError, "Missing key id" unless key_id
|
67
|
+
|
68
|
+
key_id
|
69
|
+
end
|
70
|
+
|
71
|
+
def version_context(context, version)
|
72
|
+
if context.respond_to?(:call)
|
73
|
+
if context.arity == 0
|
74
|
+
context.call
|
75
|
+
else
|
76
|
+
context.call(version)
|
77
|
+
end
|
78
|
+
else
|
79
|
+
context
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def encode64(bytes)
|
84
|
+
Base64.strict_encode64(bytes)
|
85
|
+
end
|
86
|
+
|
87
|
+
def decode64(bytes)
|
88
|
+
Base64.decode64(bytes)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -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,63 @@
|
|
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 version
|
12
|
+
@version ||= evaluate_option(:version).to_i
|
13
|
+
end
|
14
|
+
|
15
|
+
def key_id
|
16
|
+
@key_id ||= evaluate_option(:key_id)
|
17
|
+
end
|
18
|
+
|
19
|
+
def previous_versions
|
20
|
+
@previous_versions ||= evaluate_option(:previous_versions)
|
21
|
+
end
|
22
|
+
|
23
|
+
def context(version)
|
24
|
+
name = options[:name]
|
25
|
+
context_method = name ? "kms_encryption_context_#{name}" : "kms_encryption_context"
|
26
|
+
if record.method(context_method).arity == 0
|
27
|
+
record.send(context_method)
|
28
|
+
else
|
29
|
+
record.send(context_method, version: version)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def encrypt(plaintext)
|
34
|
+
context = context(version)
|
35
|
+
|
36
|
+
KmsEncrypted::Box.new(
|
37
|
+
key_id: key_id,
|
38
|
+
version: version,
|
39
|
+
previous_versions: previous_versions
|
40
|
+
).encrypt(plaintext, context: context)
|
41
|
+
end
|
42
|
+
|
43
|
+
def decrypt(ciphertext)
|
44
|
+
# determine version for context
|
45
|
+
m = /\Av(\d+):/.match(ciphertext)
|
46
|
+
version = m ? m[1].to_i : 1
|
47
|
+
context = (options[:upgrade_context] && !m) ? {} : context(version)
|
48
|
+
|
49
|
+
KmsEncrypted::Box.new(
|
50
|
+
key_id: key_id,
|
51
|
+
previous_versions: previous_versions
|
52
|
+
).decrypt(ciphertext, context: context)
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def evaluate_option(key)
|
58
|
+
opt = options[key]
|
59
|
+
opt = record.instance_exec(&opt) if opt.respond_to?(:call)
|
60
|
+
opt
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|