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.
@@ -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: 2,
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