daffy_lib 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,116 @@
1
+ [![CircleCI](https://circleci.com/gh/Zetatango/daffy_lib.svg?style=svg)](https://circleci.com/gh/Zetatango/daffy_lib) [![codecov](https://codecov.io/gh/Zetatango/daffy_lib/branch/master/graph/badge.svg?token=WxED9350q4)](https://codecov.io/gh/Zetatango/daffy_lib) [![Gem Version](https://badge.fury.io/rb/daffy_lib.svg)](https://badge.fury.io/rb/daffy_lib)
2
+ # DaffyLib
3
+
4
+ This gem is a caching encryptor which improves performance when encrypting/decrypting large amounts of data. It will keep a plaintext key cached for a given amount of time, as well as provide partitioning to allow entire rows to be encrypted with the same key. Keys are uniquely identified by a pair of a partition guid and an encryption epoch.
5
+
6
+ ## Installation
7
+
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem 'daffy_lib'
12
+ ```
13
+
14
+ And then execute:
15
+
16
+ $ bundle install
17
+
18
+ Or install it yourself as:
19
+
20
+ $ gem install daffy_lib
21
+
22
+ ## Usage
23
+
24
+ We illustrate usage of this library with an example. Suppose we have classes `User` and `User::Attribute` where attributes belong to users and have values that need to be encrypted. We wish for `User` to be the partition provider for `User::Attribute`.
25
+
26
+ The `User` class will need to `include DaffyLib::PartitionProvider` and implement the method `provider_partition_guid` which returns an identifier, for instance a user's `guid`.
27
+
28
+ The `User::Attribute` class will need to `include DaffyLib::PartitionProvider` as well as `include DaffyLib::HasEncryptedAttributes`. It will need to declare `partition_provider :user`.
29
+
30
+ It then needs to implement a `generate_partition_guid` which returns the linked `User`'s `guid`, as well as a `generate_encryption_epoch` method which defines the encryption epoch. The suggested implementations are:
31
+
32
+ ```
33
+ def generate_partition_guid
34
+ return partition_guid if partition_guid.present?
35
+
36
+ self.partition_guid = provider_partition_guid
37
+ end
38
+
39
+ def generate_encryption_epoch
40
+ return encryption_epoch if encryption_epoch.present?
41
+
42
+ self.encryption_epoch = DaffyLib::KeyManagementService.encryption_key_epoch(Time.now)
43
+ end
44
+ ```
45
+
46
+ Now suppose the `User::Attributes` has a `values` field to be encrypted. One declares
47
+
48
+ ```
49
+ attr_encrypted :values, encryptor: ZtCachingEncryptor, encrypt_method: :zt_encrypt, decrypt_method: :zt_decrypt,
50
+ encode: true, partition_guid: proc { |object| object.generate_partition_guid },
51
+ encryption_epoch: proc { |object| object.generate_encryption_epoch }, expires_in: 5.minutes
52
+ ```
53
+
54
+ where the `expires_in` field denotes how long a plaintext key should be kept in cache.
55
+
56
+ Note further that a class can be its own partition provider; i.e. if `User` itself had encrypted attributes, all the steps above for `User::Attributes` apply, except there is no need to declare `partition_provider`, and the recommended implementation for `generate_partition_guid` is to return (or create) the `guid` of the `User`.
57
+
58
+ There are partial rake tasks to assist with the necessary database migrations included.
59
+
60
+ Run `rake db:migrate:add_encryption_keys_table` to generate the migration file to add the encryption keys table. Add the following lines for indexing to the generated file.
61
+
62
+ ```
63
+ t.index [:guid], name: :index_encryption_keys_on_guid, unique: true
64
+ t.index [:partition_guid, :key_epoch], name: :index_encryption_keys, unique: true
65
+
66
+ ```
67
+ Next, if the models do not have existing records, one can run `rake db:migrate:add_encryption_fields[modelname]` to add the `partition_guid` and `encryption_epoch` columns.
68
+
69
+ However, if there may already be existing records, then the `generate_partition_guid` and `generate_encryption_epoch` methods need to be invoked before the new columns can be set to required. Below is a sample migration file for our example above, where one should replace `models` with their own.
70
+ ```
71
+ def up
72
+ models = %i[users users/attributes]
73
+
74
+ models.each do |model|
75
+
76
+ model_classname = model.to_s.camelize.singularize.constantize
77
+ model_tablename = model.to_s.gsub('/', '_')
78
+
79
+ add_column model_tablename, :partition_guid, :string
80
+ add_column model_tablename, :encryption_epoch, :datetime
81
+
82
+ model_classname.reset_column_information
83
+ model_classname.find_each do |record|
84
+ record.generate_partition_guid
85
+ record.generate_encryption_epoch
86
+
87
+ record.save!(validate: false)
88
+ end
89
+
90
+ change_column model_tablename, :partition_guid, :string, null: false
91
+ change_column model_tablename, :encryption_epoch, :datetime, null: false
92
+ end
93
+ end
94
+
95
+ def down
96
+ models = %i[users users/attributes]
97
+
98
+ models.each do |model|
99
+ model_tablename = model.to_s.gsub('/', '_')
100
+
101
+ remove_column model_tablename, :partition_guid
102
+ remove_column model_tablename, :encryption_epoch
103
+ end
104
+ end
105
+ end
106
+ ```
107
+
108
+
109
+ ## Development
110
+
111
+ Development on this project should occur on separate feature branches and pull requests should be submitted. When submitting a pull request, the pull request comment template should be filled out as much as possible to ensure a quick review and increase the likelihood of the pull request being accepted.
112
+
113
+ ## Contributing
114
+
115
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Zetatango/daffy_lib.
116
+
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,5 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ For reporting confirmed or suspected vulnerabilities, please refer to https://www.arioplatform.com/security.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "daffy_lib"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/daffy_lib/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "daffy_lib"
7
+ spec.version = DaffyLib::VERSION
8
+ spec.authors = ["Benoît Jeaurond, Weiyun Lu"]
9
+ spec.email = ["weiyun.lu@arioplatform.com"]
10
+
11
+ spec.summary = 'A library for caching encryptor'
12
+ spec.homepage = 'https://github.com/Zetatango/daffy_lib'
13
+
14
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
15
+ f.match(%r{^(test|spec|features)/})
16
+ end
17
+ spec.bindir = 'exe'
18
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_development_dependency 'attr_encrypted'
22
+ spec.add_development_dependency 'bundler'
23
+ spec.add_development_dependency 'bundler-audit'
24
+ spec.add_development_dependency 'codecov'
25
+ spec.add_development_dependency 'factory_bot_rails'
26
+ spec.add_development_dependency 'rake'
27
+ spec.add_development_dependency 'rspec'
28
+ spec.add_development_dependency 'rspec-collection_matchers'
29
+ spec.add_development_dependency 'rspec-mocks'
30
+ spec.add_development_dependency 'rspec-rails'
31
+ spec.add_development_dependency 'rspec_junit_formatter'
32
+ spec.add_development_dependency 'rubocop'
33
+ spec.add_development_dependency 'rubocop-performance'
34
+ spec.add_development_dependency 'rubocop-rspec'
35
+ spec.add_development_dependency 'rubocop_runner'
36
+ spec.add_development_dependency 'simplecov'
37
+ spec.add_development_dependency 'sqlite3'
38
+ spec.add_development_dependency 'timecop'
39
+
40
+ spec.add_dependency 'porky_lib'
41
+ spec.add_dependency 'rails'
42
+ spec.add_dependency 'redis'
43
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "daffy_lib/version"
4
+
5
+ module DaffyLib
6
+ require 'daffy_lib/caching_encryptor'
7
+ require 'daffy_lib/concerns/has_guid'
8
+ require 'daffy_lib/concerns/has_encrypted_attributes'
9
+ require 'daffy_lib/concerns/partition_provider'
10
+ require 'daffy_lib/models/application_record'
11
+ require 'daffy_lib/models/encryption_key'
12
+ require 'daffy_lib/railtie' if defined?(Rails) # for the rake tasks
13
+ require 'daffy_lib/services/key_management_service'
14
+ require 'daffy_lib/validators/string_validator'
15
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DaffyLib::CachingEncryptor
4
+ class CachingEncryptorException < StandardError; end
5
+
6
+ class EncryptionFailedException < CachingEncryptorException; end
7
+ class DecryptionFailedException < CachingEncryptorException; end
8
+ class InvalidParameterException < CachingEncryptorException; end
9
+
10
+ def self.zt_encrypt(*args, &_block)
11
+ data, partition_guid, encryption_epoch, expires_in, cmk_key_id = validate_encrypt_params(*args)
12
+
13
+ kms = DaffyLib::KeyManagementService.new(partition_guid, expires_in, cmk_key_id)
14
+
15
+ key_info = kms.find_or_create_encryption_key(encryption_epoch)
16
+
17
+ plaintext_key = kms.retrieve_plaintext_key(key_info)
18
+
19
+ encryption_result = PorkyLib::Symmetric.instance.encrypt_with_key(data, plaintext_key)
20
+
21
+ # The value returned from this method is stored in the encrypted_{attr} field in the DB, but there isn't a way to tell the attr_encrypted library
22
+ # the value of the nonce/IV to store or the value of the encryption key to store. As a result, we will store a JSON object as the encrypted_{attr},
23
+ # with the raw byte values Base64 encoded.
24
+ {
25
+ key_guid: key_info.guid,
26
+ # Store this with the data in case we need to decrypt outside the platform
27
+ key: key_info.encrypted_data_encryption_key,
28
+ data: Base64.encode64(encryption_result.ciphertext),
29
+ nonce: Base64.encode64(encryption_result.nonce)
30
+ }.to_json
31
+ rescue DaffyLib::KeyManagementService::KeyManagementServiceException => e
32
+ Rails.logger.error("KeyManagementService exception on encrypt: #{e.message}")
33
+
34
+ raise EncryptionFailedException
35
+ rescue RbNaCl::CryptoError, RbNaCl::LengthError => e
36
+ Rails.logger.error("RbNaCl exception on encrypt: #{e.message}")
37
+
38
+ raise EncryptionFailedException
39
+ end
40
+
41
+ def self.zt_decrypt(*args, &block)
42
+ value, expires_in, cmk_key_id = validate_decrypt_params(*args)
43
+
44
+ ciphertext_info = JSON.parse(value, symbolize_names: true)
45
+
46
+ # Call the legacy decrypt function if there is no key_guid present
47
+ return legacy_decrypt(*args, block) unless ciphertext_info.key?(:key_guid)
48
+
49
+ key_guid = ciphertext_info[:key_guid]
50
+ ciphertext = Base64.decode64(ciphertext_info[:data])
51
+ nonce = Base64.decode64(ciphertext_info[:nonce])
52
+
53
+ key_info = DaffyLib::EncryptionKey.find_by!(guid: key_guid)
54
+
55
+ kms = DaffyLib::KeyManagementService.new(key_info.partition_guid, expires_in, cmk_key_id)
56
+ plaintext_key = kms.retrieve_plaintext_key(key_info)
57
+
58
+ PorkyLib::Symmetric.instance.decrypt_with_key(
59
+ ciphertext,
60
+ plaintext_key,
61
+ nonce
62
+ ).plaintext
63
+ rescue JSON::JSONError => e
64
+ Rails.logger.error("JSON parse error on decryption: #{e.message}")
65
+
66
+ raise DecryptionFailedException
67
+ rescue ActiveRecord::RecordNotFound => e
68
+ Rails.logger.error("Failed to find encryption key for guid #{key_guid} on decrypt: #{e.message}")
69
+
70
+ raise DecryptionFailedException
71
+ rescue DaffyLib::KeyManagementService::KeyManagementServiceException => e
72
+ Rails.logger.error("KeyManagementService exception on decrypt: #{e.message}")
73
+
74
+ raise DecryptionFailedException
75
+ rescue RbNaCl::CryptoError, RbNaCl::LengthError => e
76
+ Rails.logger.error("RbNaCl exception on decrypt: #{e.message}")
77
+
78
+ raise DecryptionFailedException
79
+ end
80
+
81
+ def self.legacy_decrypt(*args, &_block)
82
+ ciphertext_data = JSON.parse(args.first[:value], symbolize_names: true)
83
+ ciphertext_key = Base64.decode64(ciphertext_data[:key])
84
+ ciphertext = Base64.decode64(ciphertext_data[:data])
85
+ nonce = Base64.decode64(ciphertext_data[:nonce])
86
+
87
+ PorkyLib::Symmetric.instance.decrypt(ciphertext_key, ciphertext, nonce).first
88
+ end
89
+ private_class_method :legacy_decrypt
90
+
91
+ def self.validate_encrypt_params(*args)
92
+ data = args.first[:value]
93
+ partition_guid = args.first[:partition_guid]
94
+ encryption_epoch = args.first[:encryption_epoch]
95
+ expires_in = args.first[:expires_in]
96
+ cmk_key_id = args.first[:cmk_key_id]
97
+
98
+ raise InvalidParameterException unless data.present? && partition_guid.present? && encryption_epoch.present? && expires_in.present? && cmk_key_id.present?
99
+
100
+ [data, partition_guid, encryption_epoch, expires_in, cmk_key_id]
101
+ end
102
+ private_class_method :validate_encrypt_params
103
+
104
+ def self.validate_decrypt_params(*args)
105
+ value = args.first[:value]
106
+ expires_in = args.first[:expires_in]
107
+ cmk_key_id = args.first[:cmk_key_id]
108
+
109
+ raise InvalidParameterException unless value.present? && expires_in.present? && cmk_key_id.present?
110
+
111
+ [value, expires_in, cmk_key_id]
112
+ end
113
+ private_class_method :validate_decrypt_params
114
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaffyLib::HasEncryptedAttributes
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ before_create :generate_partition_guid, :generate_encryption_epoch
8
+
9
+ validate :ensure_encryption_info_does_not_change
10
+ end
11
+
12
+ def generate_partition_guid
13
+ raise NoMethodError
14
+ end
15
+
16
+ def generate_encryption_epoch
17
+ raise NoMethodError
18
+ end
19
+
20
+ private
21
+
22
+ def ensure_encryption_info_does_not_change
23
+ return if new_record?
24
+
25
+ errors.add(:partition_guid, 'cannot be changed for persisted records') if partition_guid_changed?
26
+ errors.add(:encryption_epoch, 'cannot be changed for persisted records') if encryption_epoch_changed?
27
+ end
28
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+
5
+ module DaffyLib::HasGuid
6
+ extend ActiveSupport::Concern
7
+
8
+ REGEXP = /^[\w]+_[\w]+$/.freeze
9
+
10
+ mattr_accessor :registry do
11
+ {}
12
+ end
13
+
14
+ class_methods do
15
+ # rubocop:disable Naming/PredicateName
16
+
17
+ def has_guid(prefix)
18
+ if DaffyLib::HasGuid.registry[prefix].present?
19
+ raise ArgumentError, "Prefix #{prefix} has already been registered by class #{DaffyLib::HasGuid.registry[prefix]}"
20
+ end
21
+
22
+ DaffyLib::HasGuid.registry[prefix] = self
23
+
24
+ class_eval do
25
+ cattr_accessor :has_guid_prefix do
26
+ prefix
27
+ end
28
+
29
+ before_create :generate_guid
30
+ validate :ensure_guid_does_not_change
31
+
32
+ def dup
33
+ super.tap { |duplicate| duplicate.guid = nil if duplicate.respond_to?('guid=') }
34
+ end
35
+
36
+ def to_param
37
+ guid.to_param
38
+ end
39
+
40
+ def self.generate_guid
41
+ "#{has_guid_prefix}_#{SecureRandom.base58(16)}"
42
+ end
43
+
44
+ def generate_guid
45
+ self.guid = guid.presence || self.class.generate_guid
46
+ end
47
+
48
+ def self.validation_regexp
49
+ /^#{has_guid_prefix}_[\w]+$/
50
+ end
51
+
52
+ def ensure_guid_does_not_change
53
+ return if new_record?
54
+ return unless guid_changed?
55
+
56
+ errors.add(:guid, 'cannot be changed for persisted records')
57
+ end
58
+ end
59
+ end
60
+ # rubocop:enable Naming/PredicateName
61
+ end
62
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaffyLib::PartitionProvider
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ def partition_provider(attribute)
8
+ class_eval do
9
+ cattr_accessor :partition_provider_attribute do
10
+ attribute
11
+ end
12
+
13
+ def provider_partition_guid
14
+ provider_info
15
+ end
16
+ end
17
+ end
18
+
19
+ def partition_provider_guid(guid_method, model)
20
+ class_eval do
21
+ cattr_accessor :model do
22
+ model
23
+ end
24
+
25
+ cattr_accessor :guid_method do
26
+ guid_method
27
+ end
28
+
29
+ def provider_partition_guid
30
+ provider_record_info(record)
31
+ end
32
+
33
+ private
34
+
35
+ def record
36
+ model.find_by(guid: send(guid_method))
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ def provider_info
43
+ raise ActiveRecord::RecordInvalid if partition_provider_attribute.nil?
44
+
45
+ provider_record_info(send(partition_provider_attribute))
46
+ end
47
+
48
+ private
49
+
50
+ def provider_record_info(record)
51
+ raise ActiveRecord::RecordInvalid unless record.present? && record.is_a?(DaffyLib::PartitionProvider)
52
+
53
+ info = record.send(:provider_partition_guid)
54
+
55
+ raise ActiveRecord::RecordInvalid if info.nil?
56
+
57
+ info
58
+ end
59
+ end