attr_keyring 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74bd2df52ec8dcaf0295310f9755c36ff64a9185b6ef80a45470173de5a3bd19
4
- data.tar.gz: 3471acffc4bcc55ef81ee715e8c4363d00c95f6b81cb559fb123d4f28cbb13f0
3
+ metadata.gz: 1b5e38839a79e65282d4964fefb09e9c9c53cf1cf4d85852c0a8405d89928074
4
+ data.tar.gz: 9a667ea4f855d5affb9f4612dbfdaa40d8330f4803d59fc9004c61062fce84cd
5
5
  SHA512:
6
- metadata.gz: 06277ed16d197dc8910b40960b85784072bf6fc4c544b95e59c13044a7cc02c514eec014aea016004039cbfbbfaa87adece295eb0b81e36ecf83de593ee05e01
7
- data.tar.gz: 00a790ac4d5d5bae7fa76c4a6023281fcf58b71839b22efb173e1d3fbe7ac65aa630ff2f8b877baead2e24c394584ec4e799f877e461e7e9ca8bc04ccd5bcb39
6
+ metadata.gz: 23229cced01974f35114c55a2d725b9e112f2021c0d3ddffe59cd8d64b25ffc84bf56131276941a879fe7970e414704d6ce4af5f2f5407205603fdb5cef596fd
7
+ data.tar.gz: 372331b1209502523de2e3282d6af0b0ff888f514c11762f4c0a270ff4b057a4aca952b7f2e8507a6fef04bd8cb24edc420a7f46a73f183afaaae1be1fbf7026
data/.rubocop.yml CHANGED
@@ -68,3 +68,6 @@ Style/AccessModifierDeclarations:
68
68
 
69
69
  Style/Alias:
70
70
  EnforcedStyle: prefer_alias_method
71
+
72
+ Style/TrailingUnderscoreVariable:
73
+ Enabled: false
data/.travis.yml CHANGED
@@ -6,6 +6,7 @@ notifications:
6
6
  rvm:
7
7
  - 2.5.3
8
8
  before_script:
9
+ - createdb test
9
10
  - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
10
11
  - chmod +x ./cc-test-reporter
11
12
  - "./cc-test-reporter before-build"
data/Gemfile.lock CHANGED
@@ -1,8 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- attr_keyring (0.3.1)
5
- activerecord
4
+ attr_keyring (0.4.0)
6
5
 
7
6
  GEM
8
7
  remote: https://rubygems.org/
@@ -23,19 +22,23 @@ GEM
23
22
  awesome_print (1.8.0)
24
23
  byebug (10.0.2)
25
24
  coderay (1.1.2)
26
- concurrent-ruby (1.1.3)
25
+ concurrent-ruby (1.1.4)
27
26
  docile (1.3.1)
28
- i18n (1.1.1)
27
+ i18n (1.2.0)
29
28
  concurrent-ruby (~> 1.0)
30
29
  jaro_winkler (1.5.1)
31
30
  json (2.1.0)
31
+ metaclass (0.0.4)
32
32
  method_source (0.9.2)
33
33
  minitest (5.11.3)
34
34
  minitest-utils (0.4.4)
35
35
  minitest
36
+ mocha (1.7.0)
37
+ metaclass (~> 0.0.1)
36
38
  parallel (1.12.1)
37
39
  parser (2.5.3.0)
38
40
  ast (~> 2.4.0)
41
+ pg (1.1.3)
39
42
  powerpack (0.1.2)
40
43
  pry (0.12.2)
41
44
  coderay (~> 1.1.0)
@@ -52,8 +55,8 @@ GEM
52
55
  pry (~> 0.9)
53
56
  slop (~> 3.0)
54
57
  rainbow (3.0.0)
55
- rake (12.3.1)
56
- rubocop (0.60.0)
58
+ rake (12.3.2)
59
+ rubocop (0.61.1)
57
60
  jaro_winkler (~> 1.5.1)
58
61
  parallel (~> 1.10)
59
62
  parser (>= 2.5, != 2.5.1.1)
@@ -62,13 +65,13 @@ GEM
62
65
  ruby-progressbar (~> 1.7)
63
66
  unicode-display_width (~> 1.4.0)
64
67
  ruby-progressbar (1.10.0)
68
+ sequel (5.15.0)
65
69
  simplecov (0.16.1)
66
70
  docile (~> 1.1)
67
71
  json (>= 1.8, < 3)
68
72
  simplecov-html (~> 0.10.0)
69
73
  simplecov-html (0.10.2)
70
74
  slop (3.6.0)
71
- sqlite3 (1.3.13)
72
75
  thread_safe (0.3.6)
73
76
  tzinfo (1.2.5)
74
77
  thread_safe (~> 0.1)
@@ -78,14 +81,17 @@ PLATFORMS
78
81
  ruby
79
82
 
80
83
  DEPENDENCIES
84
+ activerecord
81
85
  attr_keyring!
82
86
  bundler
83
87
  minitest-utils
88
+ mocha
89
+ pg
84
90
  pry-meta
85
91
  rake
86
92
  rubocop
93
+ sequel
87
94
  simplecov
88
- sqlite3
89
95
 
90
96
  BUNDLED WITH
91
97
  1.17.1
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- ![attr_keyring: Simple encryption-at-rest with key rotation support for ActiveRecord.](https://raw.githubusercontent.com/fnando/attr_keyring/master/attr_keyring.png)
1
+ ![attr_keyring: Simple encryption-at-rest with key rotation support for Ruby.](https://raw.githubusercontent.com/fnando/attr_keyring/master/attr_keyring.png)
2
2
 
3
3
  <p align="center">
4
4
  <a href="https://travis-ci.org/fnando/attr_keyring"><img src="https://travis-ci.org/fnando/attr_keyring.svg" alt="Travis-CI"></a>
@@ -30,45 +30,42 @@ Or install it yourself as:
30
30
 
31
31
  ## Usage
32
32
 
33
- ### Model Configuration
33
+ ### Configuration
34
34
 
35
- #### Migration
35
+ As far as database schema goes:
36
36
 
37
37
  1. You'll need a column to track the key that was used for encryption; by default it's called `keyring_id`.
38
38
  2. Every encrypted columns must follow the name `encrypted_<column name>`.
39
39
  3. Optionally, you can also have a `<column name>_digest` to help with searching (see Lookup section below).
40
40
 
41
- The following example shows how to create a column `twitter_oauth_token` without the digest, and another one called `social_security_number` with the digest column.
42
-
43
- ```ruby
44
- class CreateUsers < ActiveRecord::Migration[5.2]
45
- def change
46
- create_table :users do |t|
47
- t.citext :email, null: false
48
- t.timestamps
49
-
50
- # The following columns are used for encryption.
51
- t.binary :encrypted_twitter_oauth_token
52
- t.binary :encrypted_social_security_number
53
- t.text :social_security_number_digest
54
- t.integer :keyring_id
55
- end
56
-
57
- add_index :users, :email, unique: true
58
- add_index :users, :social_security_number_digest, unique: true
59
- end
60
- end
61
- ```
41
+ As far as model configuration goes, they're pretty similar, as you can see below:
62
42
 
63
43
  #### ActiveRecord
64
44
 
45
+ From Rails 5+, ActiveRecord models now inherit from `ApplicationRecord` instead. This is how you set it up:
46
+
65
47
  ```ruby
66
48
  class ApplicationRecord < ActiveRecord::Base
67
49
  self.abstract_class = true
50
+ include AttrKeyring.active_record
51
+ end
52
+ ```
53
+
54
+ #### Sequel
68
55
 
69
- include AttrKeyring
56
+ Sequel doesn't have an abstract model class (but it could), so you can set up the model class directly like the following:
57
+
58
+ ```ruby
59
+ class User < Sequel::Model
60
+ include AttrKeyring.sequel
70
61
  end
62
+ ```
63
+
64
+ ### Defining encrypted attributes
65
+
66
+ To set up your model, you have to define the keyring (set of encryption keys) and the attributes that will be encrypted. Both ActiveRecord and Sequel have the same API, so the examples below work for both ORMs.
71
67
 
68
+ ```ruby
72
69
  class User < ApplicationRecord
73
70
  attr_keyring ENV["USER_KEYRING"]
74
71
  attr_encrypt :twitter_oauth_token, :social_security_number
@@ -81,30 +78,17 @@ You can use the model as you would normally do.
81
78
 
82
79
  ```ruby
83
80
  user = User.create(
84
- email: "john@example.com",
85
- twitter_oauth_token: "TOKEN",
86
- social_security_number: "SSN"
81
+ email: "john@example.com"
87
82
  )
88
83
 
89
- user.twitter_oauth_token
90
- #=> TOKEN
84
+ user.email
85
+ #=> john@example.com
91
86
 
92
87
  user.keyring_id
93
88
  #=> 1
94
89
 
95
- user.encrypted_twitter_oauth_token
96
- #=> "\xF0\xFD\xE3\x98\x98\xBBBp\xCCV45\x17\xA8\xF2r\x99\xC8W\xB2i\xD0;\xC2>7[\xF0R\xAC\x00s\x8F\x82QW{\x0F\x01\x88\x86\x03w\x0E\xCBJ\xC6q"
97
- ```
98
-
99
- You may want to store a Base64 version instead of binary data (e.g. `jsonb` column with `store_accessor`). In this case, you may specify the option `encode: true`.
100
-
101
- ```ruby
102
- class User < ApplicationRecord
103
- store_accessor :meta, :twitter_oauth_token
104
-
105
- attr_keyring ENV["USER_KEYRING"]
106
- attr_encrypt :twitter_oauth_token, encode: true
107
- end
90
+ user.encrypted_email
91
+ #=> WG8Epo0ABz0Z1X5gX7kttc98w9Ei59B5uXGK36Zin9G0VqbxX3naOWOm4RI6w6Uu
108
92
  ```
109
93
 
110
94
  ### Encryption
@@ -126,11 +110,11 @@ class User < ApplicationRecord
126
110
  end
127
111
  ```
128
112
 
129
- To generate keys, use `bs=32` instead.
113
+ #### Key size
130
114
 
131
- ```console
132
- $ dd if=/dev/urandom bs=32 count=1 2>/dev/null | openssl base64
133
- ```
115
+ - `aes-128-cbc`: 16 bytes.
116
+ - `aes-192-cbc`: 24 bytes.
117
+ - `aes-256-cbc`: 32 bytes.
134
118
 
135
119
  #### About the encrypted message
136
120
 
@@ -173,15 +157,15 @@ Other possibilities (e.g. the keyring file is provided by configuration manageme
173
157
  One tricky aspect of encryption is looking up records by known secret. E.g.,
174
158
 
175
159
  ```ruby
176
- User.where(twitter_oauth_token: "241F596D-79FF-4C08-921A-A19E533B4F52")
160
+ User.where(email: "john@example.com")
177
161
  ```
178
162
 
179
163
  is trivial with plain text fields, but impossible with the model defined as above.
180
164
 
181
- If add a column `<attribute>_digest` exists, then a SHA1 digest from the value will be saved. This will allow you to lookup by that value instead and add unique indexes.
165
+ If a column `<attribute>_digest` exists, then a SHA1 digest from the value will be saved. This will allow you to lookup by that value instead and add unique indexes.
182
166
 
183
167
  ```ruby
184
- User.where(twitter_oauth_token_digest: Digest::SHA1.hexdigest("241F596D-79FF-4C08-921A-A19E533B4F52"))
168
+ User.where(email: Digest::SHA1.hexdigest("john@example.com"))
185
169
  ```
186
170
 
187
171
  ### Key Rotation
@@ -203,6 +187,36 @@ User.where(keyring_id: 1234).find_each do |user|
203
187
  end
204
188
  ```
205
189
 
190
+ ### What if I don't use ActiveRecord/Sequel?
191
+
192
+ You can also leverage the encryption mechanism of `attr_keyring` totally decoupled from ActiveRecord/Sequel. First, make sure you load `keyring` instead. Then you can create a keyring to encrypt/decrypt strings, without even touching the database.
193
+
194
+ ```ruby
195
+ require "keyring"
196
+
197
+ keyring = Keyring.new("1" => "QSXyoiRDPoJmfkJUZ4hJeQ==")
198
+
199
+ encrypted, keyring_id, digest = keyring.encrypt("super secret")
200
+
201
+ puts encrypted
202
+ #=> encrypted: +mOWmIWKMV01nCm076OBnzgPGhWAZqNs8Etaad/0s3I=
203
+
204
+ puts keyring_id
205
+ #=> 1
206
+
207
+ puts digest
208
+ #=> e24fe0dea7f9abe8cbb192702578715079689a3e
209
+
210
+ decrypted = keyring.decrypt(encrypted, keyring_id)
211
+
212
+ puts decrypted
213
+ #=> super secret
214
+ ```
215
+
216
+ ### Exchange data with Node.js
217
+
218
+ If you use Node.js, you may be interested in <https://github.com/fnando/keyring-node>, which is able to read and write messages using the same format.
219
+
206
220
  ## Development
207
221
 
208
222
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/Rakefile CHANGED
@@ -5,6 +5,7 @@ Rake::TestTask.new(:test) do |t|
5
5
  t.libs << "test"
6
6
  t.libs << "lib"
7
7
  t.test_files = FileList["test/**/*_test.rb"]
8
+ t.warning = false
8
9
  end
9
10
 
10
11
  desc "Run rubocop"
data/attr_keyring.gemspec CHANGED
@@ -20,12 +20,14 @@ Gem::Specification.new do |spec|
20
20
  spec.executables = spec.files.grep(%r{^exe/}) {|f| File.basename(f) }
21
21
  spec.require_paths = ["lib"]
22
22
 
23
- spec.add_dependency "activerecord"
23
+ spec.add_development_dependency "activerecord"
24
24
  spec.add_development_dependency "bundler"
25
25
  spec.add_development_dependency "minitest-utils"
26
+ spec.add_development_dependency "mocha"
27
+ spec.add_development_dependency "pg"
26
28
  spec.add_development_dependency "pry-meta"
27
29
  spec.add_development_dependency "rake"
28
30
  spec.add_development_dependency "rubocop"
31
+ spec.add_development_dependency "sequel"
29
32
  spec.add_development_dependency "simplecov"
30
- spec.add_development_dependency "sqlite3"
31
33
  end
@@ -0,0 +1,85 @@
1
+ require "bundler/inline"
2
+ require "stringio"
3
+
4
+ gemfile do
5
+ source "https://rubygems.org"
6
+ gem "sqlite3"
7
+ gem "activerecord", require: "active_record"
8
+ gem "attr_keyring",
9
+ path: File.expand_path("..", __dir__)
10
+ end
11
+
12
+ ActiveRecord::Base.establish_connection "sqlite3::memory:"
13
+
14
+ begin
15
+ previous_stdout = $stdout
16
+ $stdout = StringIO.new
17
+
18
+ ActiveRecord::Schema.define(version: 0) do
19
+ create_table :users do |t|
20
+ t.binary :encrypted_email, null: false
21
+ t.text :email_digest, null: false
22
+ t.integer :keyring_id, null: false
23
+ end
24
+
25
+ add_index :users, :email_digest, unique: true
26
+ end
27
+ ensure
28
+ $stdout = previous_stdout
29
+ end
30
+
31
+ class ApplicationRecord < ActiveRecord::Base
32
+ self.abstract_class = true
33
+
34
+ include AttrKeyring.active_record
35
+ end
36
+
37
+ class User < ApplicationRecord
38
+ attr_keyring "1" => "QSXyoiRDPoJmfkJUZ4hJeQ=="
39
+ attr_encrypt :email
40
+
41
+ validates_uniqueness_of :email_digest
42
+ end
43
+
44
+ john = User.create(email: "john@example.com")
45
+
46
+ puts "👱‍ attributes"
47
+ puts john.email
48
+ puts john.email_digest
49
+ puts john.encrypted_email
50
+ puts john.keyring_id
51
+ puts
52
+
53
+ puts "🔁 rotate key"
54
+ User.keyring["2"] = "r6AfOeilPDJomFsiOXLdfQ=="
55
+ puts john.keyring_rotate!
56
+ puts
57
+
58
+ puts "👱‍ attributes (after key rotation)"
59
+ puts john.email
60
+ puts john.email_digest
61
+ puts john.encrypted_email
62
+ puts john.keyring_id
63
+ puts
64
+
65
+ puts "👨 assign new email"
66
+ puts john.update(email: "jdoe@example.com")
67
+ puts john.email
68
+ puts john.email_digest
69
+ puts john.encrypted_email
70
+ puts john.keyring_id
71
+ puts
72
+
73
+ puts "🔎 search by email digest"
74
+ user = User.find_by_email_digest(Digest::SHA1.hexdigest("jdoe@example.com"))
75
+ puts user.email
76
+ puts user == john
77
+ puts
78
+
79
+ puts "❌ duplicated email address"
80
+ copycat = User.create(email: john.email)
81
+ p copycat.errors.to_h
82
+ puts
83
+
84
+ puts "🔑 retrieve latest key from keyring"
85
+ puts User.keyring.current_key
@@ -0,0 +1,18 @@
1
+ require "bundler/inline"
2
+
3
+ gemfile do
4
+ gem "attr_keyring",
5
+ require: "keyring",
6
+ path: File.expand_path("..", __dir__)
7
+ end
8
+
9
+ keyring = Keyring.new("1" => "QSXyoiRDPoJmfkJUZ4hJeQ==")
10
+
11
+ encrypted, keyring_id, digest = keyring.encrypt("super secret")
12
+
13
+ puts encrypted
14
+ puts keyring_id
15
+ puts digest
16
+
17
+ decrypted = keyring.decrypt(encrypted, keyring_id)
18
+ puts decrypted
@@ -0,0 +1,83 @@
1
+ require "bundler/inline"
2
+ require "stringio"
3
+
4
+ gemfile do
5
+ source "https://rubygems.org"
6
+ gem "sqlite3"
7
+ gem "sequel"
8
+ gem "pry-meta"
9
+ gem "attr_keyring",
10
+ path: File.expand_path("..", __dir__)
11
+ end
12
+
13
+ Sequel.extension :migration
14
+
15
+ DB = Sequel.sqlite
16
+
17
+ Sequel.migration do
18
+ up do
19
+ create_table(:users) do
20
+ primary_key :id
21
+ String :encrypted_email, null: false
22
+ String :email_digest, null: false
23
+ Integer :keyring_id, null: false
24
+ end
25
+ end
26
+ end.apply(DB, :up)
27
+
28
+ class User < Sequel::Model
29
+ include AttrKeyring.sequel
30
+ plugin :validation_helpers
31
+
32
+ attr_keyring "1" => "QSXyoiRDPoJmfkJUZ4hJeQ=="
33
+ attr_encrypt :email
34
+
35
+ def validate
36
+ super
37
+ validates_unique :email_digest
38
+ end
39
+ end
40
+
41
+ john = User.create(email: "john@example.com")
42
+
43
+ puts "👱‍ attributes"
44
+ puts john.email
45
+ puts john.email_digest
46
+ puts john.encrypted_email
47
+ puts john.keyring_id
48
+ puts
49
+
50
+ puts "🔁 rotate key"
51
+ User.keyring["2"] = "r6AfOeilPDJomFsiOXLdfQ=="
52
+ puts john.keyring_rotate!
53
+ puts
54
+
55
+ puts "👱‍ attributes (after key rotation)"
56
+ puts john.email
57
+ puts john.email_digest
58
+ puts john.encrypted_email
59
+ puts john.keyring_id
60
+ puts
61
+
62
+ puts "👨 assign new email"
63
+ puts john.update(email: "jdoe@example.com")
64
+ puts john.email
65
+ puts john.email_digest
66
+ puts john.encrypted_email
67
+ puts john.keyring_id
68
+ puts
69
+
70
+ puts "🔎 search by email digest"
71
+ user = User.first(email_digest: Digest::SHA1.hexdigest("jdoe@example.com"))
72
+ puts user.email
73
+ puts user == john
74
+ puts
75
+
76
+ puts "❌ duplicated email address"
77
+ copycat = User.new(email: john.email)
78
+ puts copycat.valid?
79
+ p copycat.errors.to_h
80
+ puts
81
+
82
+ puts "🔑 retrieve latest key from keyring"
83
+ puts User.keyring.current_key
data/lib/attr_keyring.rb CHANGED
@@ -1,41 +1,122 @@
1
1
  module AttrKeyring
2
- require "active_record"
3
- require "openssl"
4
- require "digest/sha1"
5
-
6
2
  require "attr_keyring/version"
7
- require "attr_keyring/active_record"
8
- require "attr_keyring/keyring"
9
- require "attr_keyring/key"
10
- require "attr_keyring/encryptor/aes"
11
- require "attr_keyring/encryptor/aes_128_cbc"
12
- require "attr_keyring/encryptor/aes_256_cbc"
3
+ require "keyring"
4
+
5
+ def self.active_record
6
+ require "attr_keyring/active_record"
7
+ ::AttrKeyring::ActiveRecord
8
+ end
13
9
 
14
- UnknownKey = Class.new(StandardError)
15
- InvalidSecret = Class.new(StandardError)
10
+ def self.sequel
11
+ require "attr_keyring/sequel"
12
+ ::AttrKeyring::Sequel
13
+ end
16
14
 
17
- def self.included(target)
15
+ def self.setup(target)
18
16
  target.class_eval do
19
- extend AttrKeyring::ActiveRecord::ClassMethods
20
- include AttrKeyring::ActiveRecord::InstanceMethods
17
+ extend ClassMethods
18
+ include InstanceMethods
21
19
 
22
20
  class << self
23
- attr_accessor :keyring_attrs
21
+ attr_accessor :encrypted_attributes
24
22
  attr_accessor :keyring
23
+ attr_accessor :keyring_column_name
24
+ end
25
25
 
26
- def inherited(subclass)
27
- super
26
+ self.encrypted_attributes = []
27
+ self.keyring = Keyring.new({})
28
+ self.keyring_column_name = :keyring_id
29
+ end
30
+ end
31
+
32
+ module ClassMethods
33
+ def inherited(subclass)
34
+ super
35
+
36
+ subclass.encrypted_attributes = encrypted_attributes.dup
37
+ subclass.keyring = keyring
38
+ subclass.keyring_column_name = keyring_column_name
39
+ end
28
40
 
29
- subclass.keyring_attrs = {}
30
- subclass.keyring = Keyring.new({})
31
- end
41
+ def attr_keyring(keyring, encryptor: Keyring::Encryptor::AES::AES128CBC)
42
+ self.keyring = Keyring.new(keyring, encryptor)
43
+ end
44
+
45
+ def attr_encrypt(*attributes)
46
+ self.encrypted_attributes ||= []
47
+ encrypted_attributes.push(*attributes)
48
+
49
+ attributes.each do |attribute|
50
+ define_attr_encrypt_writer(attribute)
51
+ define_attr_encrypt_reader(attribute)
32
52
  end
53
+ end
33
54
 
34
- cattr_accessor :keyring_column_name, default: "keyring_id"
35
- self.keyring_attrs = {}
36
- self.keyring = Keyring.new({})
55
+ def define_attr_encrypt_writer(attribute)
56
+ define_method("#{attribute}=") do |value|
57
+ attr_encrypt_column(attribute, value)
58
+ end
59
+ end
60
+
61
+ def define_attr_encrypt_reader(attribute)
62
+ define_method(attribute) do
63
+ attr_decrypt_column(attribute)
64
+ end
65
+ end
66
+ end
67
+
68
+ module InstanceMethods
69
+ private def attr_encrypt_column(attribute, value)
70
+ clear_decrypted_column_cache(attribute)
71
+ return reset_encrypted_column(attribute) unless value
72
+
73
+ value = value.to_s
74
+
75
+ previous_keyring_id = public_send(self.class.keyring_column_name)
76
+ encrypted_value, keyring_id, digest = self.class.keyring.encrypt(value, previous_keyring_id)
77
+
78
+ public_send("#{self.class.keyring_column_name}=", keyring_id)
79
+ public_send("encrypted_#{attribute}=", encrypted_value)
80
+ public_send("#{attribute}_digest=", digest) if respond_to?("#{attribute}_digest=")
81
+ end
82
+
83
+ private def attr_decrypt_column(attribute)
84
+ cache_name = :"@#{attribute}"
85
+ return instance_variable_get(cache_name) if instance_variable_defined?(cache_name)
86
+
87
+ encrypted_value = public_send("encrypted_#{attribute}")
88
+ return unless encrypted_value
89
+
90
+ decrypted_value = self.class.keyring.decrypt(encrypted_value, public_send(self.class.keyring_column_name))
91
+
92
+ instance_variable_set(cache_name, decrypted_value)
93
+ end
94
+
95
+ private def clear_decrypted_column_cache(attribute)
96
+ cache_name = :"@#{attribute}"
97
+ remove_instance_variable(cache_name) if instance_variable_defined?(cache_name)
98
+ end
99
+
100
+ private def reset_encrypted_column(attribute)
101
+ public_send("encrypted_#{attribute}=", nil)
102
+ public_send("#{attribute}_digest=", nil) if respond_to?("#{attribute}_digest=")
103
+ nil
104
+ end
105
+
106
+ private def migrate_to_latest_encryption_key
107
+ keyring_id = self.class.keyring.current_key.id
108
+
109
+ self.class.encrypted_attributes.each do |attribute|
110
+ value = public_send(attribute)
111
+ next if value.nil?
112
+
113
+ encrypted_value, _, digest = self.class.keyring.encrypt(value)
114
+
115
+ public_send("encrypted_#{attribute}=", encrypted_value)
116
+ public_send("#{attribute}_digest=", digest) if respond_to?("#{attribute}_digest")
117
+ end
37
118
 
38
- before_save :migrate_to_latest_encryption_key
119
+ public_send("#{self.class.keyring_column_name}=", keyring_id)
39
120
  end
40
121
  end
41
122
  end
@@ -1,87 +1,30 @@
1
+ require "active_record"
2
+
1
3
  module AttrKeyring
2
4
  module ActiveRecord
3
- module ClassMethods
4
- def attr_keyring(keyring, encryptor: Encryptor::AES128CBC)
5
- self.keyring = Keyring.new(keyring, encryptor)
6
- end
5
+ def self.included(target)
6
+ AttrKeyring.setup(target)
7
7
 
8
- def attr_encrypt(*attributes, encode: true)
9
- self.keyring_attrs ||= {}
10
-
11
- attributes.each do |attribute|
12
- keyring_attrs[attribute.to_sym] = {encode: encode}
13
- end
8
+ target.class_eval do
9
+ before_save :migrate_to_latest_encryption_key
14
10
 
15
- attributes.each do |attribute|
16
- define_attr_encrypt_writer(attribute)
17
- define_attr_encrypt_reader(attribute)
11
+ def keyring_rotate!
12
+ migrate_to_latest_encryption_key
13
+ save!
18
14
  end
19
15
  end
20
16
 
21
- def define_attr_encrypt_writer(attribute)
22
- define_method("#{attribute}=") do |value|
23
- return attr_reset_column(attribute) if value.nil?
17
+ target.prepend(
18
+ Module.new do
19
+ def reload(options = nil)
20
+ super
24
21
 
25
- options = self.class.keyring_attrs.fetch(attribute)
26
- stored_keyring_id = public_send(keyring_column_name)
27
- keyring_id = stored_keyring_id || self.class.keyring.current_key&.id
28
- encrypted_value = self.class.keyring.encrypt(value, keyring_id)
29
- encrypted_value = Base64.strict_encode64(encrypted_value) if options[:encode]
30
-
31
- public_send("#{keyring_column_name}=", keyring_id) unless stored_keyring_id
32
- public_send("encrypted_#{attribute}=", encrypted_value)
33
- attr_encrypt_digest(attribute, value)
22
+ self.class.encrypted_attributes.each do |attribute|
23
+ clear_decrypted_column_cache(attribute)
24
+ end
25
+ end
34
26
  end
35
- end
36
-
37
- def define_attr_encrypt_reader(attribute)
38
- define_method(attribute) do
39
- encrypted_value = public_send("encrypted_#{attribute}")
40
-
41
- return unless encrypted_value
42
-
43
- options = self.class.keyring_attrs.fetch(attribute)
44
- encrypted_value = Base64.strict_decode64(encrypted_value) if options[:encode]
45
- keyring_id = public_send(keyring_column_name)
46
- value = self.class.keyring.decrypt(encrypted_value, keyring_id)
47
- value
48
- end
49
- end
50
- end
51
-
52
- module InstanceMethods
53
- private def attr_reset_column(attribute)
54
- public_send("encrypted_#{attribute}=", nil)
55
- public_send("#{attribute}_digest=", nil) if respond_to?("#{attribute}_digest=")
56
- nil
57
- end
58
-
59
- private def attr_encrypt_digest(attribute, value)
60
- digest_column = "#{attribute}_digest"
61
- public_send("#{digest_column}=", Digest::SHA1.hexdigest(value)) if respond_to?(digest_column)
62
- end
63
-
64
- private def migrate_to_latest_encryption_key
65
- keyring_id = self.class.keyring.current_key.id
66
-
67
- self.class.keyring_attrs.each do |attribute, options|
68
- value = public_send(attribute)
69
- next if value.nil?
70
-
71
- encrypted_value = self.class.keyring.encrypt(value, keyring_id)
72
- encrypted_value = Base64.strict_encode64(encrypted_value) if options[:encode]
73
-
74
- public_send("encrypted_#{attribute}=", encrypted_value)
75
- attr_encrypt_digest(attribute, value)
76
- end
77
-
78
- public_send("#{keyring_column_name}=", keyring_id)
79
- end
80
-
81
- def keyring_rotate!
82
- migrate_to_latest_encryption_key
83
- save! if changed?
84
- end
27
+ )
85
28
  end
86
29
  end
87
30
  end
@@ -0,0 +1,21 @@
1
+ require "sequel"
2
+
3
+ module AttrKeyring
4
+ module Sequel
5
+ def self.included(target)
6
+ AttrKeyring.setup(target)
7
+
8
+ target.class_eval do
9
+ def before_save
10
+ super
11
+ migrate_to_latest_encryption_key
12
+ end
13
+
14
+ def keyring_rotate!
15
+ migrate_to_latest_encryption_key
16
+ save
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,3 +1,3 @@
1
1
  module AttrKeyring
2
- VERSION = "0.3.1".freeze
2
+ VERSION = "0.4.0".freeze
3
3
  end
data/lib/keyring.rb ADDED
@@ -0,0 +1,61 @@
1
+ module Keyring
2
+ require "openssl"
3
+ require "base64"
4
+ require "digest/sha1"
5
+
6
+ require "keyring/key"
7
+ require "keyring/encryptor/aes"
8
+
9
+ UnknownKey = Class.new(StandardError)
10
+ InvalidSecret = Class.new(StandardError)
11
+ EmptyKeyring = Class.new(StandardError)
12
+
13
+ class Base
14
+ def initialize(keyring, encryptor)
15
+ @encryptor = encryptor
16
+ @keyring = keyring.map do |id, value|
17
+ Key.new(id, value, @encryptor.key_size)
18
+ end
19
+ end
20
+
21
+ def current_key
22
+ @keyring.max_by(&:id)
23
+ end
24
+
25
+ def [](id)
26
+ raise EmptyKeyring, "keyring doesn't have any keys" if @keyring.empty?
27
+
28
+ key = @keyring.find {|k| k.id == id.to_i }
29
+ return key if key
30
+
31
+ raise UnknownKey, "key=#{id} is not available on keyring"
32
+ end
33
+
34
+ def []=(id, value)
35
+ @keyring << Key.new(id, value, @encryptor.key_size)
36
+ end
37
+
38
+ def clear
39
+ @keyring.clear
40
+ end
41
+
42
+ def encrypt(message, keyring_id = nil)
43
+ keyring_id ||= current_key&.id
44
+ digest = Digest::SHA1.hexdigest(message)
45
+
46
+ [
47
+ @encryptor.encrypt(self[keyring_id].value, message),
48
+ keyring_id,
49
+ digest
50
+ ]
51
+ end
52
+
53
+ def decrypt(message, keyring_id)
54
+ @encryptor.decrypt(self[keyring_id].value, message)
55
+ end
56
+ end
57
+
58
+ def self.new(keyring, encryptor = Encryptor::AES::AES128CBC)
59
+ Base.new(keyring, encryptor)
60
+ end
61
+ end
@@ -0,0 +1,57 @@
1
+ module Keyring
2
+ module Encryptor
3
+ module AES
4
+ class Base
5
+ def self.build_cipher
6
+ OpenSSL::Cipher.new(cipher_name)
7
+ end
8
+
9
+ def self.key_size
10
+ @key_size ||= build_cipher.key_len
11
+ end
12
+
13
+ def self.encrypt(key, message)
14
+ cipher = build_cipher
15
+ cipher.encrypt
16
+ iv = cipher.random_iv
17
+ cipher.iv = iv
18
+ cipher.key = key
19
+ encrypted = cipher.update(message) + cipher.final
20
+
21
+ Base64.strict_encode64("#{iv}#{encrypted}")
22
+ end
23
+
24
+ def self.decrypt(key, message)
25
+ cipher = build_cipher
26
+ cipher.decrypt
27
+
28
+ message = Base64.strict_decode64(message)
29
+ iv = message[0...cipher.iv_len]
30
+ encrypted = message[cipher.iv_len..-1]
31
+
32
+ cipher.iv = iv
33
+ cipher.key = key
34
+ cipher.update(encrypted) + cipher.final
35
+ end
36
+ end
37
+
38
+ class AES128CBC < Base
39
+ def self.cipher_name
40
+ "AES-128-CBC"
41
+ end
42
+ end
43
+
44
+ class AES192CBC < Base
45
+ def self.cipher_name
46
+ "AES-192-CBC"
47
+ end
48
+ end
49
+
50
+ class AES256CBC < Base
51
+ def self.cipher_name
52
+ "AES-256-CBC"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,4 +1,4 @@
1
- module AttrKeyring
1
+ module Keyring
2
2
  class Key
3
3
  attr_reader :id, :value
4
4
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: attr_keyring
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nando Vieira
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2018-12-09 00:00:00.000000000 Z
11
+ date: 2018-12-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -17,7 +17,7 @@ dependencies:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '0'
20
- type: :runtime
20
+ type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
@@ -52,6 +52,34 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: mocha
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: pg
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
55
83
  - !ruby/object:Gem::Dependency
56
84
  name: pry-meta
57
85
  requirement: !ruby/object:Gem::Requirement
@@ -95,7 +123,7 @@ dependencies:
95
123
  - !ruby/object:Gem::Version
96
124
  version: '0'
97
125
  - !ruby/object:Gem::Dependency
98
- name: simplecov
126
+ name: sequel
99
127
  requirement: !ruby/object:Gem::Requirement
100
128
  requirements:
101
129
  - - ">="
@@ -109,7 +137,7 @@ dependencies:
109
137
  - !ruby/object:Gem::Version
110
138
  version: '0'
111
139
  - !ruby/object:Gem::Dependency
112
- name: sqlite3
140
+ name: simplecov
113
141
  requirement: !ruby/object:Gem::Requirement
114
142
  requirements:
115
143
  - - ">="
@@ -143,14 +171,16 @@ files:
143
171
  - attr_keyring.svg
144
172
  - bin/console
145
173
  - bin/setup
174
+ - examples/active_record_sample.rb
175
+ - examples/keyring_sample.rb
176
+ - examples/sequel_sample.rb
146
177
  - lib/attr_keyring.rb
147
178
  - lib/attr_keyring/active_record.rb
148
- - lib/attr_keyring/encryptor/aes.rb
149
- - lib/attr_keyring/encryptor/aes_128_cbc.rb
150
- - lib/attr_keyring/encryptor/aes_256_cbc.rb
151
- - lib/attr_keyring/key.rb
152
- - lib/attr_keyring/keyring.rb
179
+ - lib/attr_keyring/sequel.rb
153
180
  - lib/attr_keyring/version.rb
181
+ - lib/keyring.rb
182
+ - lib/keyring/encryptor/aes.rb
183
+ - lib/keyring/key.rb
154
184
  homepage: https://github.com/fnando/attr_keyring
155
185
  licenses:
156
186
  - MIT
@@ -1,32 +0,0 @@
1
- module AttrKeyring
2
- module Encryptor
3
- class AES
4
- def self.build_cipher
5
- OpenSSL::Cipher.new(cipher_name)
6
- end
7
-
8
- def self.key_size
9
- @key_size ||= build_cipher.key_len
10
- end
11
-
12
- def self.encrypt(key, message)
13
- cipher = build_cipher
14
- cipher.encrypt
15
- iv = cipher.random_iv
16
- cipher.iv = iv
17
- cipher.key = key
18
- iv + cipher.update(message) + cipher.final
19
- end
20
-
21
- def self.decrypt(key, message)
22
- cipher = build_cipher
23
- cipher.decrypt
24
- iv = message[0...cipher.iv_len]
25
- encrypted = message[cipher.iv_len..-1]
26
- cipher.iv = iv
27
- cipher.key = key
28
- cipher.update(encrypted) + cipher.final
29
- end
30
- end
31
- end
32
- end
@@ -1,9 +0,0 @@
1
- module AttrKeyring
2
- module Encryptor
3
- class AES128CBC < AES
4
- def self.cipher_name
5
- "AES-128-CBC"
6
- end
7
- end
8
- end
9
- end
@@ -1,9 +0,0 @@
1
- module AttrKeyring
2
- module Encryptor
3
- class AES256CBC < AES
4
- def self.cipher_name
5
- "AES-256-CBC"
6
- end
7
- end
8
- end
9
- end
@@ -1,37 +0,0 @@
1
- module AttrKeyring
2
- class Keyring
3
- def initialize(keyring, encryptor = Encryptor::AES128CBC)
4
- @encryptor = encryptor
5
- @keyring = keyring.map do |id, value|
6
- Key.new(id, value, @encryptor.key_size)
7
- end
8
- end
9
-
10
- def current_key
11
- @keyring.max_by(&:id)
12
- end
13
-
14
- def [](id)
15
- key = @keyring.find {|k| k.id == id.to_i }
16
- return key if key
17
-
18
- raise UnknownKey, "key=#{id} is not available on keyring"
19
- end
20
-
21
- def []=(id, value)
22
- @keyring << Key.new(id, value, @encryptor.key_size)
23
- end
24
-
25
- def clear
26
- @keyring.clear
27
- end
28
-
29
- def encrypt(message, keyring_id = current_key.id)
30
- @encryptor.encrypt(self[keyring_id].value, message)
31
- end
32
-
33
- def decrypt(message, keyring_id)
34
- @encryptor.decrypt(self[keyring_id].value, message)
35
- end
36
- end
37
- end