blind_index 0.3.5 → 1.0.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: 8f5aab7dbd2a784f6bcc2b464c14f76621c94886ec4764bc5e51f97ac74ae0d9
4
- data.tar.gz: 104135feff81321860030ca46946cd5021cfb97fcc5a3953742ac61cee842cfd
3
+ metadata.gz: d2a4c6e35691fe1efa918f077afda9902109591f695a3f4043ae9eb6350ffb44
4
+ data.tar.gz: dcbe4e3c535a2d36495370a7fa53078d6fd32979c8b7d07b7e29c3202e8fd393
5
5
  SHA512:
6
- metadata.gz: b79e2f7597a572b4732f326bde1ce82de7941ffd3e97dc609834931cacb86a59c2ae6f5cc981cda0f6827956823bd01d910b56c5092409547a539a5db010e1b8
7
- data.tar.gz: ba72cc3e13bb0fdd12e3502d1657e20074af745b41bb8fcb34df495e14fbddb36c3535e450950aa3ccb935a92c9cbbdcc70c7f1eaacccaba8c3fa358a3955470
6
+ metadata.gz: 1c80bec06e019677e606d4da2c52d0aa529f5aeb56e1091a79a138ce40b73dedb3244fdabf073a96d249e9a876e6557d73e38bd48dfddfd5b1963e19ebb91c93
7
+ data.tar.gz: e206e2619af60a2274f300eac56f1edf3a7f00822c8bdead7fa579252b81b7540d16832c083ed630f454c08049521b312c95f72c33e070706894a60f1c3f1eef
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## 1.0.0
2
+
3
+ - Added support for master key
4
+ - Added support for Argon2id
5
+ - Fixed `generate_key` for JRuby
6
+ - Dropped support for Rails 4.2
7
+
8
+ Breaking changes
9
+
10
+ - Made Argon2id the default algorithm
11
+ - Removed `encrypted_` prefix from columns
12
+ - Changed default encoding to Base64 strict
13
+
1
14
  ## 0.3.5
2
15
 
3
16
  - Added support for hex keys
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Securely search encrypted database fields
4
4
 
5
- Designed for use with [attr_encrypted](https://github.com/attr-encrypted/attr_encrypted)
5
+ Works with [Lockbox](https://github.com/ankane/lockbox) and [attr_encrypted](https://github.com/attr-encrypted/attr_encrypted)
6
6
 
7
7
  Here’s a [full example](https://ankane.org/securing-user-emails-in-rails) of how to use it
8
8
 
@@ -28,45 +28,67 @@ Add this line to your application’s Gemfile:
28
28
  gem 'blind_index'
29
29
  ```
30
30
 
31
- ## Getting Started
32
-
33
- > Note: Your model should already be set up with attr_encrypted. The examples are for a `User` model with `attr_encrypted :email`. See the [full example](https://ankane.org/securing-user-emails-in-rails) if needed.
34
-
35
- Create a migration to add a column for the blind index
31
+ On Windows, also add:
36
32
 
37
33
  ```ruby
38
- add_column :users, :encrypted_email_bidx, :string
39
- add_index :users, :encrypted_email_bidx
34
+ gem 'argon2', git: 'https://github.com/technion/ruby-argon2.git', submodules: true
40
35
  ```
41
36
 
42
- Next, generate a key
37
+ Until `argon2 >= 2.0.1` is released.
38
+
39
+ ## Getting Started
40
+
41
+ > Note: Your model should already be set up with Lockbox or attr_encrypted. The examples are for a `User` model with `encrypts :email` or `attr_encrypted :email`. See the [full example](https://ankane.org/securing-user-emails-in-rails) if needed.
42
+
43
+ First, generate a key
43
44
 
44
45
  ```ruby
45
46
  BlindIndex.generate_key
46
47
  ```
47
48
 
48
- Store the key with your other secrets. This is typically Rails credentials or an environment variable ([dotenv](https://github.com/bkeepers/dotenv) is great for this). Be sure to use different keys in development and production, and be sure this is different than the key you use for encryption. Keys don’t need to be hex-encoded, but it’s often easier to store them this way.
49
+ Store the key with your other secrets. This is typically Rails credentials or an environment variable ([dotenv](https://github.com/bkeepers/dotenv) is great for this). Be sure to use different keys in development and production. Keys don’t need to be hex-encoded, but it’s often easier to store them this way.
49
50
 
50
- Here’s a key you can use in development
51
+ Set the following environment variable with your key (you can use this one in development)
51
52
 
52
53
  ```sh
53
- EMAIL_BLIND_INDEX_KEY=ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
54
+ BLIND_INDEX_MASTER_KEY=ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
55
+ ```
56
+
57
+ or create `config/initializers/blind_index.rb` with something like
58
+
59
+ ```ruby
60
+ BlindIndex.master_key = Rails.application.credentials.blind_index_master_key
61
+ ```
62
+
63
+ Create a migration to add a column for the blind index
64
+
65
+ ```ruby
66
+ add_column :users, :email_bidx, :string
67
+ add_index :users, :email_bidx # unique: true if needed
54
68
  ```
55
69
 
56
70
  Add to your model
57
71
 
58
72
  ```ruby
59
73
  class User < ApplicationRecord
60
- blind_index :email, key: ENV["EMAIL_BLIND_INDEX_KEY"]
74
+ blind_index :email
75
+ end
76
+ ```
77
+
78
+ For more sensitive fields, use
79
+
80
+ ```ruby
81
+ class User < ApplicationRecord
82
+ blind_index :email, slow: true
61
83
  end
62
84
  ```
63
85
 
64
86
  Backfill existing records
65
87
 
66
88
  ```ruby
67
- User.find_each do |user|
89
+ User.unscoped.where(email_bidx: nil).find_each do |user|
68
90
  user.compute_email_bidx
69
- user.save!
91
+ user.save(validate: false)
70
92
  end
71
93
  ```
72
94
 
@@ -94,7 +116,7 @@ You can apply expressions to attributes before indexing and searching. This give
94
116
 
95
117
  ```ruby
96
118
  class User < ApplicationRecord
97
- blind_index :email, expression: ->(v) { v.downcase } ...
119
+ blind_index :email, expression: ->(v) { v.downcase }
98
120
  end
99
121
  ```
100
122
 
@@ -103,25 +125,25 @@ end
103
125
  You may want multiple blind indexes for an attribute. To do this, add another column:
104
126
 
105
127
  ```ruby
106
- add_column :users, :encrypted_email_ci_bidx, :string
107
- add_index :users, :encrypted_email_ci_bidx
128
+ add_column :users, :email_ci_bidx, :string
129
+ add_index :users, :email_ci_bidx
108
130
  ```
109
131
 
110
132
  Update your model
111
133
 
112
134
  ```ruby
113
135
  class User < ApplicationRecord
114
- blind_index :email, ...
115
- blind_index :email_ci, attribute: :email, expression: ->(v) { v.downcase } ...
136
+ blind_index :email
137
+ blind_index :email_ci, attribute: :email, expression: ->(v) { v.downcase }
116
138
  end
117
139
  ```
118
140
 
119
141
  Backfill existing records
120
142
 
121
143
  ```ruby
122
- User.find_each do |user|
144
+ User.unscoped.where(email_ci_bidx: nil).find_each do |user|
123
145
  user.compute_email_ci_bidx
124
- user.save!
146
+ user.save(validate: false)
125
147
  end
126
148
  ```
127
149
 
@@ -137,25 +159,23 @@ If you don’t need to store the original value (for instance, when just checkin
137
159
 
138
160
  ```ruby
139
161
  class User < ApplicationRecord
140
- attribute :email
141
- blind_index :email, ...
162
+ attribute :email, :string
163
+ blind_index :email
142
164
  end
143
165
  ```
144
166
 
145
- *Requires ActiveRecord 5.1+*
146
-
147
167
  ## Multiple Columns
148
168
 
149
169
  You can also use virtual attributes to index data from multiple columns:
150
170
 
151
171
  ```ruby
152
172
  class User < ApplicationRecord
153
- attribute :initials
173
+ attribute :initials, :string
154
174
 
155
175
  # must come before the blind_index method so it runs first
156
176
  before_validation :set_initials, if: -> { changes.key?(:first_name) || changes.key?(:last_name) }
157
177
 
158
- blind_index :initials, ...
178
+ blind_index :initials
159
179
 
160
180
  def set_initials
161
181
  self.initials = "#{first_name[0]}#{last_name[0]}"
@@ -163,134 +183,176 @@ class User < ApplicationRecord
163
183
  end
164
184
  ```
165
185
 
166
- *Requires ActiveRecord 5.1+*
167
-
168
- ## Algorithms
186
+ ## Key Rotation
169
187
 
170
- ### PBKDF2-SHA256
188
+ To rotate keys without downtime, add a new column:
171
189
 
172
- The default hashing algorithm. [Key stretching](https://en.wikipedia.org/wiki/Key_stretching) increases the amount of time required to compute hashes, which slows down brute-force attacks.
190
+ ```ruby
191
+ add_column :users, :email_bidx_v2, :string
192
+ add_index :users, :email_bidx_v2
193
+ ```
173
194
 
174
- The default number of iterations is 10,000. For highly sensitive fields, set this to at least 100,000.
195
+ And add to your model
175
196
 
176
197
  ```ruby
177
198
  class User < ApplicationRecord
178
- blind_index :email, iterations: 100000, ...
199
+ blind_index :email, rotate: {version: 2, master_key: ENV["BLIND_INDEX_MASTER_KEY_V2"]}
179
200
  end
180
201
  ```
181
202
 
182
- > Changing this requires you to recompute the blind index.
183
-
184
- ### Argon2
203
+ This will keep the new column synced going forward. Next, backfill the data:
185
204
 
186
- Argon2 is the state-of-the-art algorithm and recommended for best security.
205
+ ```ruby
206
+ User.unscoped.where(email_bidx_v2: nil).find_each do |user|
207
+ user.compute_rotated_email_bidx
208
+ user.save(validate: false)
209
+ end
210
+ ```
187
211
 
188
- To use it, add [argon2](https://github.com/technion/ruby-argon2) to your Gemfile and set:
212
+ Then update your model
189
213
 
190
214
  ```ruby
191
215
  class User < ApplicationRecord
192
- blind_index :email, algorithm: :argon2, ...
216
+ blind_index :email, version: 2, master_key: ENV["BLIND_INDEX_MASTER_KEY_V2"]
193
217
  end
194
218
  ```
195
219
 
196
- The default cost parameters are `{t: 3, m: 12}`. For highly sensitive fields, set this to at least `{t: 4, m: 15}`.
220
+ Finally, drop the old column.
221
+
222
+ ## Key Separation
223
+
224
+ The master key is used to generate unique keys for each blind index. This technique comes from [CipherSweet](https://ciphersweet.paragonie.com/internals/key-hierarchy). The table name and blind index column name are both used in this process. If you need to rename a table with blind indexes, or a blind index column itself, get the key:
225
+
226
+ ```ruby
227
+ BlindIndex.index_key(table: "users", bidx_attribute: "email_bidx")
228
+ ```
229
+
230
+ And set it directly before renaming:
197
231
 
198
232
  ```ruby
199
233
  class User < ApplicationRecord
200
- blind_index :email, algorithm: :argon2, cost: {t: 4, m: 15}, ...
234
+ blind_index :email, key: ENV["USER_EMAIL_BLIND_INDEX_KEY"]
201
235
  end
202
236
  ```
203
237
 
204
- > Changing this requires you to recompute the blind index.
238
+ ## Algorithm
205
239
 
206
- The variant used is Argon2i.
240
+ Argon2id is used for best security. The default cost parameters are 3 iterations and 4 MB of memory. For `slow: true`, the cost parameters are 4 iterations and 32 MB of memory.
207
241
 
208
- ### Other
242
+ A number of other algorithms are [also supported](docs/Other-Algorithms.md). Unless you have specific reasons to use them, go with Argon2id.
209
243
 
210
- scrypt is [also supported](docs/scrypt.md). Unless you have specific reasons to use it, go with Argon2 instead.
244
+ ## Fixtures
211
245
 
212
- ## Key Rotation
246
+ You can use blind indexes in fixtures with:
213
247
 
214
- To rotate keys without downtime, add a new column:
248
+ ```yml
249
+ test_user:
250
+ email_bidx: <%= User.generate_email_bidx("test@example.org").inspect %>
251
+ ```
252
+
253
+ Be sure to include the `inspect` at the end or it won’t be encoded properly in YAML.
254
+
255
+ ## Reference
256
+
257
+ Set default options in an initializer with:
215
258
 
216
259
  ```ruby
217
- add_column :users, :encrypted_email_v2_bidx, :string
218
- add_index :users, :encrypted_email_v2_bidx
260
+ BlindIndex.default_options = {algorithm: :pbkdf2_sha256}
219
261
  ```
220
262
 
221
- And add to your model
263
+ By default, blind indexes are encoded in Base64. Set a different encoding with:
222
264
 
223
265
  ```ruby
224
266
  class User < ApplicationRecord
225
- blind_index :email, key: ENV["EMAIL_BLIND_INDEX_KEY"]
226
- blind_index :email_v2, attribute: :email, key: ENV["EMAIL_V2_BLIND_INDEX_KEY"]
267
+ blind_index :email, encode: ->(v) { [v].pack("H*") }
227
268
  end
228
269
  ```
229
270
 
230
- Backfill the data
271
+ By default, blind indexes are 32 bytes. Set a smaller size with:
231
272
 
232
273
  ```ruby
233
- User.find_each do |user|
234
- user.compute_email_v2_bidx
235
- user.save!
274
+ class User < ApplicationRecord
275
+ blind_index :email, size: 16
236
276
  end
237
277
  ```
238
278
 
239
- Then update your model
279
+ Set a key directly for an index with:
240
280
 
241
281
  ```ruby
242
282
  class User < ApplicationRecord
243
- blind_index :email, bidx_attribute: :encrypted_email_v2_bidx, key: ENV["EMAIL_V2_BLIND_INDEX_KEY"]
244
-
245
- # remove this line after dropping column
246
- self.ignored_columns = ["encrypted_email_bidx"]
283
+ blind_index :email, key: ENV["USER_EMAIL_BLIND_INDEX_KEY"]
247
284
  end
248
285
  ```
249
286
 
250
- Finally, drop the old column.
287
+ ## Alternatives
251
288
 
252
- ## Fixtures
289
+ One alternative to blind indexing is to use a deterministic encryption scheme, like [AES-SIV](https://github.com/miscreant/miscreant). In this approach, the encrypted data will be the same for matches.
290
+
291
+ ## Upgrading
253
292
 
254
- You can use encrypted attributes and blind indexes in fixtures with:
293
+ ### 1.0.0
255
294
 
256
- ```yml
257
- test_user:
258
- encrypted_email: <%= User.encrypt_email("test@example.org", iv: Base64.decode64("0000000000000000")) %>
259
- encrypted_email_iv: "0000000000000000"
260
- encrypted_email_bidx: <%= User.compute_email_bidx("test@example.org").inspect %>
295
+ 1.0.0 brings a number of improvements. Here are a few to be aware of:
296
+
297
+ - Argon2id is the default algorithm for stronger security
298
+ - You can use a master key instead of individual keys for each column
299
+ - Columns no longer have an `encrypted_` prefix
300
+
301
+ For existing fields, add:
302
+
303
+ ```ruby
304
+ class User < ApplicationRecord
305
+ blind_index :email, legacy: true
306
+ end
261
307
  ```
262
308
 
263
- Be sure to include the `inspect` at the end, or it won’t be encoded properly in YAML.
309
+ #### Optional
264
310
 
265
- ## Reference
311
+ To rotate to new fields that use Argon2id and a master key, generate a master key:
266
312
 
267
- Set default options in an initializer with:
313
+ ```ruby
314
+ BlindIndex.generate_key
315
+ ```
316
+
317
+ And set `ENV["BLIND_INDEX_MASTER_KEY"]` or `BlindIndex.master_key`.
318
+
319
+ Add a new column without the `encrypted_` prefix:
268
320
 
269
321
  ```ruby
270
- BlindIndex.default_options[:algorithm] = :argon2
322
+ add_column :users, :email_bidx, :string
323
+ add_index :users, :email_bidx # unique: true if needed
271
324
  ```
272
325
 
273
- By default, blind indexes are encoded in Base64. Set a different encoding with:
326
+ And add to your model
274
327
 
275
328
  ```ruby
276
329
  class User < ApplicationRecord
277
- blind_index :email, encode: ->(v) { [v].pack("H*") }
330
+ blind_index :email, key: ENV["USER_EMAIL_BLIND_INDEX_KEY"], legacy: true, rotate: true
278
331
  end
279
332
  ```
280
333
 
281
- By default, blind indexes are 32 bytes. Set a smaller size with:
334
+ > For more sensitive fields, use `rotate: {slow: true}`
335
+
336
+ This will keep the new column synced going forward. Next, backfill the data:
282
337
 
283
338
  ```ruby
284
- class User < ApplicationRecord
285
- blind_index :email, size: 16
339
+ User.unscoped.where(email_bidx: nil).find_each do |user|
340
+ user.compute_rotated_email_bidx
341
+ user.save(validate: false)
286
342
  end
287
343
  ```
288
344
 
289
- ## Alternatives
345
+ Then update your model
290
346
 
291
- One alternative to blind indexing is to use a deterministic encryption scheme, like [AES-SIV](https://github.com/miscreant/miscreant). In this approach, the encrypted data will be the same for matches.
347
+ ```ruby
348
+ class User < ApplicationRecord
349
+ blind_index :email
350
+ end
351
+ ```
292
352
 
293
- ## Upgrading
353
+ > For more sensitive fields, add `slow: true`
354
+
355
+ Finally, drop the old column.
294
356
 
295
357
  ### 0.3.0
296
358
 
@@ -306,16 +368,16 @@ Update your model to convert the hex key to binary.
306
368
 
307
369
  ```ruby
308
370
  class User < ApplicationRecord
309
- blind_index :email, key: [ENV["EMAIL_BLIND_INDEX_KEY"]].pack("H*")
371
+ blind_index :email, key: [ENV["USER_EMAIL_BLIND_INDEX_KEY"]].pack("H*")
310
372
  end
311
373
  ```
312
374
 
313
375
  And recompute the blind index.
314
376
 
315
377
  ```ruby
316
- User.find_each do |user|
378
+ User.unscoped.find_each do |user|
317
379
  user.compute_email_bidx
318
- user.save!
380
+ user.save(validate: false)
319
381
  end
320
382
  ```
321
383
 
@@ -323,7 +385,7 @@ To continue without rotating, set:
323
385
 
324
386
  ```ruby
325
387
  class User < ApplicationRecord
326
- blind_index :email, insecure_key: true, ...
388
+ blind_index :email, insecure_key: true
327
389
  end
328
390
  ```
329
391
 
data/lib/blind_index.rb CHANGED
@@ -1,7 +1,10 @@
1
1
  # dependencies
2
2
  require "active_support"
3
+ require "openssl"
4
+ require "argon2"
3
5
 
4
6
  # modules
7
+ require "blind_index/key_generator"
5
8
  require "blind_index/model"
6
9
  require "blind_index/version"
7
10
 
@@ -10,23 +13,24 @@ module BlindIndex
10
13
 
11
14
  class << self
12
15
  attr_accessor :default_options
16
+ attr_writer :master_key
13
17
  end
14
18
  self.default_options = {}
15
19
 
20
+ def self.master_key
21
+ @master_key ||= ENV["BLIND_INDEX_MASTER_KEY"]
22
+ end
23
+
16
24
  def self.generate_bidx(value, key:, **options)
17
25
  options = {
18
- iterations: 10000,
19
- algorithm: :pbkdf2_sha256,
20
- insecure_key: false,
21
- encode: true,
22
- cost: {}
26
+ encode: true
23
27
  }.merge(default_options).merge(options)
24
28
 
25
29
  # apply expression
26
30
  value = options[:expression].call(value) if options[:expression]
27
31
 
28
32
  unless value.nil?
29
- algorithm = options[:algorithm].to_sym
33
+ algorithm = (options[:algorithm] || (options[:legacy] ? :pbkdf2_sha256 : :argon2id)).to_sym
30
34
  algorithm = :pbkdf2_sha256 if algorithm == :pbkdf2_hmac
31
35
  algorithm = :argon2i if algorithm == :argon2
32
36
 
@@ -35,18 +39,12 @@ module BlindIndex
35
39
 
36
40
  key = key.to_s
37
41
  unless options[:insecure_key] && algorithm == :pbkdf2_sha256
38
- # decode hex key
39
- if key.encoding != Encoding::BINARY && key =~ /\A[0-9a-f]{64}\z/i
40
- key = [key].pack("H*")
41
- end
42
-
43
- raise BlindIndex::Error, "Key must use binary encoding" if key.encoding != Encoding::BINARY
44
- raise BlindIndex::Error, "Key must be 32 bytes" if key.bytesize != 32
42
+ key = decode_key(key)
45
43
  end
46
44
 
47
45
  # gist to compare algorithm results
48
46
  # https://gist.github.com/ankane/fe3ac63fbf1c4550ee12554c664d2b8c
49
- cost_options = options[:cost]
47
+ cost_options = options[:cost] || {}
50
48
 
51
49
  # check size
52
50
  size = (options[:size] || 32).to_i
@@ -56,11 +54,20 @@ module BlindIndex
56
54
 
57
55
  value =
58
56
  case algorithm
59
- when :scrypt
60
- n = cost_options[:n] || 4096
61
- r = cost_options[:r] || 8
62
- cp = cost_options[:p] || 1
63
- SCrypt::Engine.scrypt(value, key, n, r, cp, size)
57
+ when :argon2id
58
+ t = (cost_options[:t] || (options[:slow] ? 4 : 3)).to_i
59
+ # use same bounds as rbnacl
60
+ raise BlindIndex::Error, "t must be between 3 and 10" if t < 3 || t > 10
61
+
62
+ # m is memory in kibibytes (1024 bytes)
63
+ m = (cost_options[:m] || (options[:slow] ? 15 : 12)).to_i
64
+ # use same bounds as rbnacl
65
+ raise BlindIndex::Error, "m must be between 3 and 22" if m < 3 || m > 22
66
+
67
+ [Argon2::Engine.hash_argon2id(value, key, t, m, size)].pack("H*")
68
+ when :pbkdf2_sha256
69
+ iterations = cost_options[:iterations] || options[:iterations] || (options[:slow] ? 100000 : 10000)
70
+ OpenSSL::PKCS5.pbkdf2_hmac(value, key, iterations, size, "sha256")
64
71
  when :argon2i
65
72
  t = (cost_options[:t] || 3).to_i
66
73
  # use same bounds as rbnacl
@@ -71,14 +78,12 @@ module BlindIndex
71
78
  # use same bounds as rbnacl
72
79
  raise BlindIndex::Error, "m must be between 3 and 22" if m < 3 || m > 22
73
80
 
74
- # 32 byte digest size is limitation of argon2 gem
75
- # this is no longer the case on master
76
- # TODO add conditional check when next version of argon2 is released
77
- raise BlindIndex::Error, "Size must be 32" unless size == 32
78
- [Argon2::Engine.hash_argon2i(value, key, t, m)].pack("H*")
79
- when :pbkdf2_sha256
80
- iterations = cost_options[:iterations] || options[:iterations]
81
- OpenSSL::PKCS5.pbkdf2_hmac(value, key, iterations, size, "sha256")
81
+ [Argon2::Engine.hash_argon2i(value, key, t, m, size)].pack("H*")
82
+ when :scrypt
83
+ n = cost_options[:n] || 4096
84
+ r = cost_options[:r] || 8
85
+ cp = cost_options[:p] || 1
86
+ SCrypt::Engine.scrypt(value, key, n, r, cp, size)
82
87
  else
83
88
  raise BlindIndex::Error, "Unknown algorithm"
84
89
  end
@@ -88,7 +93,7 @@ module BlindIndex
88
93
  if encode.respond_to?(:call)
89
94
  encode.call(value)
90
95
  else
91
- [value].pack("m")
96
+ [value].pack(options[:legacy] ? "m" : "m0")
92
97
  end
93
98
  else
94
99
  value
@@ -98,7 +103,29 @@ module BlindIndex
98
103
 
99
104
  def self.generate_key
100
105
  require "securerandom"
101
- SecureRandom.hex(32)
106
+ # force encoding to make JRuby consistent with MRI
107
+ SecureRandom.hex(32).force_encoding(Encoding::US_ASCII)
108
+ end
109
+
110
+ def self.index_key(table:, bidx_attribute:, master_key: nil, encode: true)
111
+ master_key ||= BlindIndex.master_key
112
+ raise BlindIndex::Error, "Missing master key" unless master_key
113
+
114
+ key = BlindIndex::KeyGenerator.new(master_key).index_key(table: table, bidx_attribute: bidx_attribute)
115
+ key = key.unpack("H*").first if encode
116
+ key
117
+ end
118
+
119
+ def self.decode_key(key)
120
+ # decode hex key
121
+ if key.encoding != Encoding::BINARY && key =~ /\A[0-9a-f]{64}\z/i
122
+ key = [key].pack("H*")
123
+ end
124
+
125
+ raise BlindIndex::Error, "Key must use binary encoding" if key.encoding != Encoding::BINARY
126
+ raise BlindIndex::Error, "Key must be 32 bytes" if key.bytesize != 32
127
+
128
+ key
102
129
  end
103
130
  end
104
131
 
@@ -0,0 +1,54 @@
1
+ module BlindIndex
2
+ class KeyGenerator
3
+ def initialize(master_key)
4
+ @master_key = master_key
5
+ end
6
+
7
+ # pattern ported from CipherSweet
8
+ # https://ciphersweet.paragonie.com/internals/key-hierarchy
9
+ def index_key(table:, bidx_attribute:)
10
+ raise ArgumentError, "Missing table for key generation" if table.to_s.empty?
11
+ raise ArgumentError, "Missing field for key generation" if bidx_attribute.to_s.empty?
12
+
13
+ c = "\x7E"*32
14
+ root_key = hkdf(BlindIndex.decode_key(@master_key), salt: table.to_s, info: "#{c}#{bidx_attribute}", length: 32, hash: "sha384")
15
+ hash_hmac("sha256", pack([table, bidx_attribute, bidx_attribute]), root_key)
16
+ end
17
+
18
+ private
19
+
20
+ def hash_hmac(hash, ikm, salt)
21
+ OpenSSL::HMAC.digest(hash, salt, ikm)
22
+ end
23
+
24
+ def hkdf(ikm, salt:, info:, length:, hash:)
25
+ if OpenSSL::KDF.respond_to?(:hkdf)
26
+ return OpenSSL::KDF.hkdf(ikm, salt: salt, info: info, length: length, hash: hash)
27
+ end
28
+
29
+ prk = hash_hmac(hash, ikm, salt)
30
+
31
+ # empty binary string
32
+ t = String.new
33
+ last_block = String.new
34
+ block_index = 1
35
+ while t.bytesize < length
36
+ last_block = hash_hmac(hash, last_block + info + [block_index].pack("C"), prk)
37
+ t << last_block
38
+ block_index += 1
39
+ end
40
+
41
+ t[0, length]
42
+ end
43
+
44
+ def pack(pieces)
45
+ output = String.new
46
+ output << [pieces.size].pack("V")
47
+ pieces.map(&:to_s).each do |piece|
48
+ output << [piece.bytesize].pack("Q<")
49
+ output << piece
50
+ end
51
+ output
52
+ end
53
+ end
54
+ end
@@ -1,60 +1,85 @@
1
1
  module BlindIndex
2
2
  module Model
3
- def blind_index(name, key: nil, iterations: nil, attribute: nil, expression: nil, bidx_attribute: nil, callback: true, algorithm: nil, insecure_key: nil, encode: nil, cost: nil, size: nil)
4
- iterations ||= 10000
5
- attribute ||= name
6
- bidx_attribute ||= :"encrypted_#{name}_bidx"
7
-
8
- name = name.to_sym
9
- attribute = attribute.to_sym
10
- method_name = :"compute_#{name}_bidx"
11
-
12
- class_eval do
13
- @blind_indexes ||= {}
14
-
15
- unless respond_to?(:blind_indexes)
16
- def self.blind_indexes
17
- parent_indexes =
18
- if superclass.respond_to?(:blind_indexes)
19
- superclass.blind_indexes
20
- else
21
- {}
22
- end
23
-
24
- parent_indexes.merge(@blind_indexes || {})
25
- end
26
- end
3
+ def blind_index(*attributes, rotate: false, migrating: false, **opts)
4
+ indexes = attributes.map { |a| [a, opts.dup] }
5
+ indexes.concat(attributes.map { |a| [a, rotate.merge(rotate: true)] }) if rotate
27
6
 
28
- raise BlindIndex::Error, "Duplicate blind index: #{name}" if blind_indexes[name]
29
-
30
- @blind_indexes[name] = {
31
- key: key,
32
- iterations: iterations,
33
- attribute: attribute,
34
- expression: expression,
35
- bidx_attribute: bidx_attribute,
36
- algorithm: algorithm,
37
- insecure_key: insecure_key,
38
- encode: encode,
39
- cost: cost,
40
- size: size
41
- }.reject { |_, v| v.nil? }
42
-
43
- # should have been named generate_#{name}_bidx
44
- define_singleton_method method_name do |value|
45
- BlindIndex.generate_bidx(value, blind_indexes[name])
46
- end
7
+ indexes.each do |name, options|
8
+ rotate = options.delete(:rotate)
47
9
 
48
- define_method method_name do
49
- self.send("#{bidx_attribute}=", self.class.send(method_name, send(attribute)))
50
- end
10
+ # check here so we validate rotate options as well
11
+ unknown_keywords = options.keys - [:algorithm, :attribute, :bidx_attribute,
12
+ :callback, :cost, :encode, :expression, :insecure_key, :iterations, :key,
13
+ :legacy, :master_key, :size, :slow]
14
+ raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
51
15
 
52
- if callback
53
- before_validation method_name, if: -> { changes.key?(attribute.to_s) }
16
+ attribute = options[:attribute] || name
17
+ version = (options[:version] || 1).to_i
18
+ callback = options[:callback].nil? ? true : options[:callback]
19
+ if options[:bidx_attribute]
20
+ bidx_attribute = options[:bidx_attribute]
21
+ else
22
+ bidx_attribute = name
23
+ bidx_attribute = "encrypted_#{bidx_attribute}" if options[:legacy]
24
+ bidx_attribute = "#{bidx_attribute}_bidx"
25
+ bidx_attribute = "#{bidx_attribute}_v#{version}" if version != 1
54
26
  end
55
27
 
56
- # use include so user can override
57
- include InstanceMethods if blind_indexes.size == 1
28
+ name = "migrated_#{name}" if migrating
29
+ name = "rotated_#{name}" if rotate
30
+ name = name.to_sym
31
+ attribute = attribute.to_sym
32
+ method_name = :"compute_#{name}_bidx"
33
+ class_method_name = :"generate_#{name}_bidx"
34
+
35
+ key = options[:key]
36
+ key ||= -> { BlindIndex.index_key(table: table_name, bidx_attribute: bidx_attribute, master_key: options[:master_key], encode: false) }
37
+
38
+ class_eval do
39
+ @blind_indexes ||= {}
40
+
41
+ unless respond_to?(:blind_indexes)
42
+ def self.blind_indexes
43
+ parent_indexes =
44
+ if superclass.respond_to?(:blind_indexes)
45
+ superclass.blind_indexes
46
+ else
47
+ {}
48
+ end
49
+
50
+ parent_indexes.merge(@blind_indexes || {})
51
+ end
52
+ end
53
+
54
+ raise BlindIndex::Error, "Duplicate blind index: #{name}" if blind_indexes[name]
55
+
56
+ @blind_indexes[name] = options.merge(
57
+ key: key,
58
+ attribute: attribute,
59
+ bidx_attribute: bidx_attribute,
60
+ migrating: migrating
61
+ )
62
+
63
+ define_singleton_method class_method_name do |value|
64
+ BlindIndex.generate_bidx(value, blind_indexes[name])
65
+ end
66
+
67
+ define_singleton_method method_name do |value|
68
+ ActiveSupport::Deprecation.warn("Use #{class_method_name} instead")
69
+ send(class_method_name, value)
70
+ end
71
+
72
+ define_method method_name do
73
+ self.send("#{bidx_attribute}=", self.class.send(class_method_name, send(attribute)))
74
+ end
75
+
76
+ if callback
77
+ before_validation method_name, if: -> { changes.key?(attribute.to_s) }
78
+ end
79
+
80
+ # use include so user can override
81
+ include InstanceMethods if blind_indexes.size == 1
82
+ end
58
83
  end
59
84
  end
60
85
  end
@@ -1,3 +1,3 @@
1
1
  module BlindIndex
2
- VERSION = "0.3.5"
2
+ VERSION = "1.0.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: blind_index
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-05-29 00:00:00.000000000 Z
11
+ date: 2019-07-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -16,14 +16,28 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '4.2'
19
+ version: '5'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '4.2'
26
+ version: '5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: argon2
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: bundler
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -98,16 +112,16 @@ dependencies:
98
112
  name: sqlite3
99
113
  requirement: !ruby/object:Gem::Requirement
100
114
  requirements:
101
- - - "~>"
115
+ - - ">="
102
116
  - !ruby/object:Gem::Version
103
- version: 1.3.0
117
+ version: '0'
104
118
  type: :development
105
119
  prerelease: false
106
120
  version_requirements: !ruby/object:Gem::Requirement
107
121
  requirements:
108
- - - "~>"
122
+ - - ">="
109
123
  - !ruby/object:Gem::Version
110
- version: 1.3.0
124
+ version: '0'
111
125
  - !ruby/object:Gem::Dependency
112
126
  name: scrypt
113
127
  requirement: !ruby/object:Gem::Requirement
@@ -123,7 +137,7 @@ dependencies:
123
137
  - !ruby/object:Gem::Version
124
138
  version: '0'
125
139
  - !ruby/object:Gem::Dependency
126
- name: argon2
140
+ name: benchmark-ips
127
141
  requirement: !ruby/object:Gem::Requirement
128
142
  requirements:
129
143
  - - ">="
@@ -137,19 +151,19 @@ dependencies:
137
151
  - !ruby/object:Gem::Version
138
152
  version: '0'
139
153
  - !ruby/object:Gem::Dependency
140
- name: benchmark-ips
154
+ name: lockbox
141
155
  requirement: !ruby/object:Gem::Requirement
142
156
  requirements:
143
157
  - - ">="
144
158
  - !ruby/object:Gem::Version
145
- version: '0'
159
+ version: '0.2'
146
160
  type: :development
147
161
  prerelease: false
148
162
  version_requirements: !ruby/object:Gem::Requirement
149
163
  requirements:
150
164
  - - ">="
151
165
  - !ruby/object:Gem::Version
152
- version: '0'
166
+ version: '0.2'
153
167
  description:
154
168
  email: andrew@chartkick.com
155
169
  executables: []
@@ -161,6 +175,7 @@ files:
161
175
  - README.md
162
176
  - lib/blind_index.rb
163
177
  - lib/blind_index/extensions.rb
178
+ - lib/blind_index/key_generator.rb
164
179
  - lib/blind_index/model.rb
165
180
  - lib/blind_index/version.rb
166
181
  homepage: https://github.com/ankane/blind_index
@@ -175,14 +190,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
175
190
  requirements:
176
191
  - - ">="
177
192
  - !ruby/object:Gem::Version
178
- version: '2.2'
193
+ version: '2.4'
179
194
  required_rubygems_version: !ruby/object:Gem::Requirement
180
195
  requirements:
181
196
  - - ">="
182
197
  - !ruby/object:Gem::Version
183
198
  version: '0'
184
199
  requirements: []
185
- rubygems_version: 3.0.3
200
+ rubygems_version: 3.0.4
186
201
  signing_key:
187
202
  specification_version: 4
188
203
  summary: Securely search encrypted database fields