daffy_lib 0.1.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.
@@ -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