lockbox 0.2.0 → 0.2.1

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.
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