lockbox 0.2.0 → 0.2.1

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: b4e87cf5f769166cd6cf534608a6877e5cd9f11931441307d9eca11a3d2c4d18
4
- data.tar.gz: c52a2bded1142c80918f79de4317ec948c576c1168fd699dac0f69c7d2ca9b47
3
+ metadata.gz: af9a23ecd7c1ea6ea696daf88d7830e06dbb9972caa202fc23b214942a84e189
4
+ data.tar.gz: 5636aeb7e1f37a1b55055bd8bf50d5c0270a4abe9f2d13e62971f1257c9fe9f5
5
5
  SHA512:
6
- metadata.gz: 9a92f516956fe3d4feb26cc338bf25a5048fc46916bc4c4722a12028b73697e467ceae118733f1dce778a1585e3a4e97fbf9073c3c80fa7d5340d1e0676cb02c
7
- data.tar.gz: 60684645d7645b1b66a66a9b5393db04eb690095fc8bfbe16de50e8770225afe46e2236045cf4c0985a9e7b0e86aa7da16294804e0e6cebe2325193761d9965e
6
+ metadata.gz: 9d1bc707fbbe82a147920f8fc0c1f716620e9ae15fdf3d2583240b1bf32141395b401203d9851da009b7e43a896c25c93dff45ead2149289d58c89233685df37
7
+ data.tar.gz: cff72e261e485a1fbacd270cb7b5d71044d63ac7982cd1b364801578e7b8a51cdf7650ef841780bc3337ff5663b2632ccf878f6b5b00879a6768072416185bc7
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## 0.2.1
2
+
3
+ - Added support for types
4
+ - Added support for serialized attributes
5
+ - Added support for padding
6
+ - Added `encode` option for binary columns
7
+
1
8
  ## 0.2.0
2
9
 
3
10
  - Added `encrypts` method for database fields
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Lockbox
2
2
 
3
- :lock: Modern encryption for Rails
3
+ :package: Modern encryption for Rails
4
4
 
5
5
  - Uses state-of-the-art algorithms
6
6
  - Works with database fields, files, and strings
@@ -8,7 +8,7 @@
8
8
  - Requires you to only manage a single encryption key
9
9
  - Makes migrating existing data and key rotation easy
10
10
 
11
- Check out [this post](https://ankane.org/modern-encryption-rails) for more info on its design, and [this post](https://ankane.org/sensitive-data-rails) for more info on securing sensitive data with Rails
11
+ Learn [the principles behind it](https://ankane.org/modern-encryption-rails), [how to secure emails](https://ankane.org/securing-user-emails-lockbox), and [how to secure sensitive data in Rails](https://ankane.org/sensitive-data-rails)
12
12
 
13
13
  [![Build Status](https://travis-ci.org/ankane/lockbox.svg?branch=master)](https://travis-ci.org/ankane/lockbox)
14
14
 
@@ -72,6 +72,38 @@ User.create!(email: "hi@example.org")
72
72
 
73
73
  If you need to query encrypted fields, check out [Blind Index](https://github.com/ankane/blind_index).
74
74
 
75
+ ### Types
76
+
77
+ Specify the type of a field with:
78
+
79
+ ```ruby
80
+ class User < ApplicationRecord
81
+ encrypts :born_on, type: :date
82
+ encrypts :signed_at, type: :datetime
83
+ encrypts :active, type: :boolean
84
+ encrypts :salary, type: :integer
85
+ encrypts :latitude, type: :float
86
+ encrypts :video, type: :binary
87
+ encrypts :properties, type: :json
88
+ encrypts :settings, type: :hash
89
+ end
90
+ ```
91
+
92
+ **Note:** Always use a `text` or `binary` column in migrations, regardless of the type
93
+
94
+ Lockbox automatically works with serialized fields for maximum compatibility with existing code and libraries.
95
+
96
+ ```ruby
97
+ class User < ApplicationRecord
98
+ serialize :properties, JSON
99
+ encrypts :properties
100
+ end
101
+ ```
102
+
103
+ ### Validations
104
+
105
+ Validations work as expected with the exception of uniqueness. Uniqueness validations require a [blind index](https://github.com/ankane/blind_index).
106
+
75
107
  ## Files
76
108
 
77
109
  ### Active Storage
@@ -364,7 +396,7 @@ end
364
396
 
365
397
  Make sure `decryption_key` is `nil` on servers that shouldn’t decrypt.
366
398
 
367
- This uses X25519 for key exchange and XSalsa20-Poly1305 for encryption.
399
+ This uses X25519 for key exchange and XSalsa20 for encryption.
368
400
 
369
401
  ## Key Separation
370
402
 
@@ -402,6 +434,36 @@ end
402
434
 
403
435
  **Note:** KMS Encrypted’s key rotation does not know to rotate encrypted files, so avoid calling `record.rotate_kms_key!` on models with file uploads for now.
404
436
 
437
+ ## Padding
438
+
439
+ Add padding to conceal the exact length of messages.
440
+
441
+ ```ruby
442
+ Lockbox.new(padding: true)
443
+ ```
444
+
445
+ The block size for padding is 16 bytes by default. Change this with:
446
+
447
+ ```ruby
448
+ Lockbox.new(padding: 32) # bytes
449
+ ```
450
+
451
+ ## Reference
452
+
453
+ Set default options in an initializer with:
454
+
455
+ ```ruby
456
+ Lockbox.default_options = {algorithm: "xsalsa20"}
457
+ ```
458
+
459
+ For database fields, encrypted data is encoded in Base64. If you use `binary` columns instead of `text` columns, set:
460
+
461
+ ```ruby
462
+ class User < ApplicationRecord
463
+ encrypts :email, encode: false
464
+ end
465
+ ```
466
+
405
467
  ## Compatibility
406
468
 
407
469
  It’s easy to read encrypted data in another language if needed.
data/lib/lockbox.rb CHANGED
@@ -22,6 +22,7 @@ end
22
22
  class Lockbox
23
23
  class Error < StandardError; end
24
24
  class DecryptionError < Error; end
25
+ class PaddingError < Error; end
25
26
 
26
27
  class << self
27
28
  attr_accessor :default_options
@@ -139,6 +140,55 @@ class Lockbox
139
140
  str.unpack("H*").first
140
141
  end
141
142
 
143
+ PAD_FIRST_BYTE = "\x80".b
144
+ PAD_ZERO_BYTE = "\x00".b
145
+
146
+ # ISO/IEC 7816-4
147
+ # same as Libsodium
148
+ # https://libsodium.gitbook.io/doc/padding
149
+ # apply prior to encryption
150
+ # note: current implementation does not
151
+ # try to minimize side channels
152
+ def self.pad(str, size: 16)
153
+ raise ArgumentError, "Invalid size" if size < 1
154
+
155
+ str = str.dup.force_encoding(Encoding::BINARY)
156
+
157
+ pad_length = size - 1
158
+ pad_length -= str.bytesize % size
159
+
160
+ str << PAD_FIRST_BYTE
161
+ pad_length.times do
162
+ str << PAD_ZERO_BYTE
163
+ end
164
+
165
+ str
166
+ end
167
+
168
+ # note: current implementation does not
169
+ # try to minimize side channels
170
+ def self.unpad(str, size: 16)
171
+ raise ArgumentError, "Invalid size" if size < 1
172
+
173
+ if str.encoding != Encoding::BINARY
174
+ str = str.dup.force_encoding(Encoding::BINARY)
175
+ end
176
+
177
+ i = 1
178
+ while i <= size
179
+ case str[-i]
180
+ when PAD_ZERO_BYTE
181
+ i += 1
182
+ when PAD_FIRST_BYTE
183
+ return str[0..-(i + 1)]
184
+ else
185
+ break
186
+ end
187
+ end
188
+
189
+ raise Lockbox::PaddingError, "Invalid padding"
190
+ end
191
+
142
192
  private
143
193
 
144
194
  def check_string(str, name)
data/lib/lockbox/box.rb CHANGED
@@ -2,7 +2,7 @@ require "securerandom"
2
2
 
3
3
  class Lockbox
4
4
  class Box
5
- def initialize(key: nil, algorithm: nil, encryption_key: nil, decryption_key: nil)
5
+ def initialize(key: nil, algorithm: nil, encryption_key: nil, decryption_key: nil, padding: false)
6
6
  raise ArgumentError, "Cannot pass both key and public/private key" if key && (encryption_key || decryption_key)
7
7
 
8
8
  key = Lockbox::Utils.decode_key(key) if key
@@ -34,9 +34,11 @@ class Lockbox
34
34
  end
35
35
 
36
36
  @algorithm = algorithm
37
+ @padding = padding == true ? 16 : padding
37
38
  end
38
39
 
39
40
  def encrypt(message, associated_data: nil)
41
+ message = Lockbox.pad(message, size: @padding) if @padding
40
42
  case @algorithm
41
43
  when "hybrid"
42
44
  raise ArgumentError, "No public key set" unless @encryption_box
@@ -54,19 +56,22 @@ class Lockbox
54
56
  end
55
57
 
56
58
  def decrypt(ciphertext, associated_data: nil)
57
- case @algorithm
58
- when "hybrid"
59
- raise ArgumentError, "No private key set" unless @decryption_box
60
- raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
61
- nonce, ciphertext = extract_nonce(@decryption_box, ciphertext)
62
- @decryption_box.decrypt(nonce, ciphertext)
63
- when "xsalsa20"
64
- nonce, ciphertext = extract_nonce(@box, ciphertext)
65
- @box.decrypt(nonce, ciphertext)
66
- else
67
- nonce, ciphertext = extract_nonce(@box, ciphertext)
68
- @box.decrypt(nonce, ciphertext, associated_data)
69
- end
59
+ message =
60
+ case @algorithm
61
+ when "hybrid"
62
+ raise ArgumentError, "No private key set" unless @decryption_box
63
+ raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
64
+ nonce, ciphertext = extract_nonce(@decryption_box, ciphertext)
65
+ @decryption_box.decrypt(nonce, ciphertext)
66
+ when "xsalsa20"
67
+ nonce, ciphertext = extract_nonce(@box, ciphertext)
68
+ @box.decrypt(nonce, ciphertext)
69
+ else
70
+ nonce, ciphertext = extract_nonce(@box, ciphertext)
71
+ @box.decrypt(nonce, ciphertext, associated_data)
72
+ end
73
+ message = Lockbox.unpad(message, size: @padding) if @padding
74
+ message
70
75
  end
71
76
 
72
77
  # protect key for xchacha20 and hybrid
data/lib/lockbox/model.rb CHANGED
@@ -31,7 +31,37 @@ class Lockbox
31
31
  end
32
32
  end
33
33
 
34
- def encrypts(*attributes, **options)
34
+ def encrypts(*attributes, encode: true, **options)
35
+ # support objects
36
+ # case options[:type]
37
+ # when Date
38
+ # options[:type] = :date
39
+ # when Time
40
+ # options[:type] = :datetime
41
+ # when JSON
42
+ # options[:type] = :json
43
+ # when Hash
44
+ # options[:type] = :hash
45
+ # when String
46
+ # options[:type] = :string
47
+ # when Integer
48
+ # options[:type] = :integer
49
+ # when Float
50
+ # options[:type] = :float
51
+ # end
52
+
53
+ raise ArgumentError, "Unknown type: #{options[:type]}" unless [nil, :string, :boolean, :date, :datetime, :integer, :float, :binary, :json, :hash].include?(options[:type])
54
+
55
+ attribute_type =
56
+ case options[:type]
57
+ when nil, :json, :hash
58
+ :string
59
+ when :integer
60
+ ActiveModel::Type::Integer.new(limit: 8)
61
+ else
62
+ options[:type]
63
+ end
64
+
35
65
  attributes.each do |name|
36
66
  # add default options
37
67
  encrypted_attribute = "#{name}_ciphertext"
@@ -71,7 +101,7 @@ class Lockbox
71
101
  end
72
102
 
73
103
  raise "Duplicate encrypted attribute: #{original_name}" if lockbox_attributes[original_name]
74
- @lockbox_attributes[original_name] = options
104
+ @lockbox_attributes[original_name] = options.merge(encode: encode)
75
105
 
76
106
  if @lockbox_attributes.size == 1
77
107
  def serializable_hash(options = nil)
@@ -89,11 +119,63 @@ class Lockbox
89
119
  end
90
120
  "#<#{self.class} #{inspection.join(", ")}>"
91
121
  end
122
+
123
+ # needed for in-place modifications
124
+ # assigned attributes are encrypted on assignment
125
+ # and then again here
126
+ before_save do
127
+ self.class.lockbox_attributes.each do |_, lockbox_attribute|
128
+ attribute = lockbox_attribute[:attribute]
129
+
130
+ if changes.include?(attribute) && self.class.attribute_types[attribute].is_a?(ActiveRecord::Type::Serialized)
131
+ send("#{attribute}=", send(attribute))
132
+ end
133
+ end
134
+ end
92
135
  end
93
136
 
94
- attribute name, :string
137
+ serialize name, JSON if options[:type] == :json
138
+ serialize name, Hash if options[:type] == :hash
139
+
140
+ attribute name, attribute_type
95
141
 
96
142
  define_method("#{name}=") do |message|
143
+ original_message = message
144
+
145
+ unless message.nil?
146
+ case options[:type]
147
+ when :boolean
148
+ message = ActiveRecord::Type::Boolean.new.serialize(message)
149
+ message = nil if message == "" # for Active Record < 5.2
150
+ message = message ? "t" : "f" unless message.nil?
151
+ when :date
152
+ message = ActiveRecord::Type::Date.new.serialize(message)
153
+ # strftime should be more stable than to_s(:db)
154
+ message = message.strftime("%Y-%m-%d") unless message.nil?
155
+ when :datetime
156
+ message = ActiveRecord::Type::DateTime.new.serialize(message)
157
+ message = nil unless message.respond_to?(:iso8601) # for Active Record < 5.2
158
+ message = message.iso8601(9) unless message.nil?
159
+ when :integer
160
+ message = ActiveRecord::Type::Integer.new(limit: 8).serialize(message)
161
+ message = 0 if message.nil?
162
+ # signed 64-bit integer, big endian
163
+ message = [message].pack("q>")
164
+ when :float
165
+ message = ActiveRecord::Type::Float.new.serialize(message)
166
+ # double precision, big endian
167
+ message = [message].pack("G") unless message.nil?
168
+ when :string, :binary
169
+ # do nothing
170
+ # encrypt will convert to binary
171
+ else
172
+ type = self.class.attribute_types[name.to_s]
173
+ if type.is_a?(ActiveRecord::Type::Serialized)
174
+ message = type.serialize(message)
175
+ end
176
+ end
177
+ end
178
+
97
179
  # decrypt first for dirty tracking
98
180
  # don't raise error if can't decrypt previous
99
181
  begin
@@ -103,28 +185,54 @@ class Lockbox
103
185
  end
104
186
 
105
187
  ciphertext =
106
- if message.nil? || message == ""
188
+ if message.nil? || (message == "" && !options[:padding])
107
189
  message
108
190
  else
109
191
  self.class.send(class_method_name, message, context: self)
110
192
  end
111
193
  send("#{encrypted_attribute}=", ciphertext)
112
194
 
113
- super(message)
195
+ super(original_message)
114
196
  end
115
197
 
116
198
  define_method(name) do
117
199
  message = super()
200
+
118
201
  unless message
119
202
  ciphertext = send(encrypted_attribute)
120
203
  message =
121
- if ciphertext.nil? || ciphertext == ""
204
+ if ciphertext.nil? || (ciphertext == "" && !options[:padding])
122
205
  ciphertext
123
206
  else
124
- decoded = Base64.decode64(ciphertext)
125
- Lockbox::Utils.build_box(self, options, self.class.table_name, encrypted_attribute).decrypt(decoded)
207
+ ciphertext = Base64.decode64(ciphertext) if encode
208
+ Lockbox::Utils.build_box(self, options, self.class.table_name, encrypted_attribute).decrypt(ciphertext)
126
209
  end
127
210
 
211
+ unless message.nil?
212
+ case options[:type]
213
+ when :boolean
214
+ message = message == "t"
215
+ when :date
216
+ message = ActiveRecord::Type::Date.new.deserialize(message)
217
+ when :datetime
218
+ message = ActiveRecord::Type::DateTime.new.deserialize(message)
219
+ when :integer
220
+ message = ActiveRecord::Type::Integer.new(limit: 8).deserialize(message.unpack("q>").first)
221
+ when :float
222
+ message = ActiveRecord::Type::Float.new.deserialize(message.unpack("G").first)
223
+ when :string
224
+ message = message.encode(Encoding::UTF_8)
225
+ when :binary
226
+ # do nothing
227
+ # decrypt returns binary string
228
+ else
229
+ type = self.class.attribute_types[name.to_s]
230
+ if type.is_a?(ActiveRecord::Type::Serialized)
231
+ message = type.deserialize(message)
232
+ end
233
+ end
234
+ end
235
+
128
236
  # set previous attribute on first decrypt
129
237
  @attributes[name.to_s].instance_variable_set("@value_before_type_cast", message)
130
238
 
@@ -135,12 +243,15 @@ class Lockbox
135
243
  raw_write_attribute(name, message)
136
244
  end
137
245
  end
246
+
138
247
  message
139
248
  end
140
249
 
141
250
  # for fixtures
142
251
  define_singleton_method class_method_name do |message, **opts|
143
- Base64.strict_encode64(Lockbox::Utils.build_box(opts[:context], options, table_name, encrypted_attribute).encrypt(message))
252
+ ciphertext = Lockbox::Utils.build_box(opts[:context], options, table_name, encrypted_attribute).encrypt(message)
253
+ ciphertext = Base64.strict_encode64(ciphertext) if encode
254
+ ciphertext
144
255
  end
145
256
  end
146
257
  end
data/lib/lockbox/utils.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  class Lockbox
2
2
  class Utils
3
3
  def self.build_box(context, options, table, attribute)
4
- options = options.except(:attribute, :encrypted_attribute, :migrating, :attached)
4
+ options = options.except(:attribute, :encrypted_attribute, :migrating, :attached, :type, :encode)
5
5
  options.each do |k, v|
6
6
  if v.is_a?(Proc)
7
7
  options[k] = context.instance_exec(&v) if v.respond_to?(:call)
@@ -1,3 +1,3 @@
1
1
  class Lockbox
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lockbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
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-07-08 00:00:00.000000000 Z
11
+ date: 2019-07-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -122,6 +122,20 @@ dependencies:
122
122
  - - ">="
123
123
  - !ruby/object:Gem::Version
124
124
  version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: pg
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
125
139
  - !ruby/object:Gem::Dependency
126
140
  name: benchmark-ips
127
141
  requirement: !ruby/object:Gem::Requirement