lockbox 0.1.0 → 0.1.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: fa1a57ca8235a58fd8d638cab1ec624888e72b79f8a3f279588bb358ea8f6cb0
4
- data.tar.gz: ffe1edd74efb5e8bf121b73816edd6b9209b8a217590816a2791adf6d1ad3dad
3
+ metadata.gz: e4dcdb1c1115e0712d5d65dd8302c3d575a74a5456dd245692970d080d221fa0
4
+ data.tar.gz: 2278b9fe032f0159525a48a9a9c694c28a80bcbac371039a97729e6ec61087d7
5
5
  SHA512:
6
- metadata.gz: 80b49150b2d01aafceaddf9bd6c47b6412ee4cfb5361ff2f7f11633a65847d1f09dd7377794ef68b58c87f0f29210fe981d4b08a981840b51a553d7fb18ccc66
7
- data.tar.gz: b3e8ae7d2395cc13903d80da1e8f8f3f2f0416a9f9ec8b1fa29b6b6eb155f97724f0effd1a7afd7c5162421b1be35494b5a7d4a6a25344de202a1bab0ef15305
6
+ metadata.gz: 8afe130b6c4231667ea26f1ece4a25e4a577a535ec4930a5f85ac2b53b7b508fe77ec9e4b1720f1aab3f1fe513b03576646d8b24cf27bee3a6c5626c9484c35e
7
+ data.tar.gz: 6662d25470d89b327b2cddf45d9467cd7d2bd8e3e8664140a71c7ea4cb10f13a156d21c01c17b26f0c2dca928f6f3f0010403ba96e77349442ab185def552d10
@@ -1,3 +1,8 @@
1
+ ## 0.1.1
2
+
3
+ - Added support for hybrid cryptography
4
+ - Added support for database fields
5
+
1
6
  ## 0.1.0
2
7
 
3
8
  - First release
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2018 Andrew Kane
3
+ Copyright (c) 2018-2019 Andrew Kane
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -2,9 +2,13 @@
2
2
 
3
3
  :lock: File encryption for Ruby and Rails
4
4
 
5
- Supports Active Storage and CarrierWave
5
+ - Supports Active Storage and CarrierWave
6
+ - Uses AES-GCM by default for [authenticated encryption](https://tonyarcieri.com/all-the-crypto-code-youve-ever-written-is-probably-broken)
7
+ - Makes key rotation easy
6
8
 
7
- Uses AES-GCM by default for [authenticated encryption](https://tonyarcieri.com/all-the-crypto-code-youve-ever-written-is-probably-broken)
9
+ Check out [this post](https://ankane.org/sensitive-data-rails) for more info on securing sensitive data with Rails
10
+
11
+ [![Build Status](https://travis-ci.org/ankane/lockbox.svg?branch=master)](https://travis-ci.org/ankane/lockbox)
8
12
 
9
13
  ## Installation
10
14
 
@@ -16,13 +20,13 @@ gem 'lockbox'
16
20
 
17
21
  ## Key Generation
18
22
 
19
- Generate an encryption key.
23
+ Generate an encryption key
20
24
 
21
25
  ```ruby
22
26
  SecureRandom.hex(32)
23
27
  ```
24
28
 
25
- Store the key with your other secrets (typically Rails secrets or an environment variable).
29
+ 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.
26
30
 
27
31
  Alternatively, you can use a [key management service](#key-management) to manage your keys.
28
32
 
@@ -37,13 +41,13 @@ box = Lockbox.new(key: key)
37
41
  Encrypt
38
42
 
39
43
  ```ruby
40
- box.encrypt(File.binread("license.jpg"))
44
+ ciphertext = box.encrypt(File.binread("license.jpg"))
41
45
  ```
42
46
 
43
47
  Decrypt
44
48
 
45
49
  ```ruby
46
- box.decrypt(File.binread("license.jpg.enc"))
50
+ box.decrypt(ciphertext)
47
51
  ```
48
52
 
49
53
  ## Active Storage
@@ -135,11 +139,11 @@ user.license.rotate_encryption!
135
139
 
136
140
  ### AES-GCM
137
141
 
138
- The default algorithm is AES-GCM with a 256-bit key. Rotate the key every 2 billion files to minimize the chance of a [nonce collision](https://www.cryptologie.net/article/402/is-symmetric-security-solved/).
142
+ The default algorithm is AES-GCM with a 256-bit key. Rotate the key every 2 billion files to minimize the chance of a [nonce collision](https://www.cryptologie.net/article/402/is-symmetric-security-solved/), which will leak the key.
139
143
 
140
144
  ### XChaCha20
141
145
 
142
- [Install Libsodium](https://github.com/crypto-rb/rbnacl/wiki/Installing-libsodium) and add [rbnacl](https://github.com/crypto-rb/rbnacl) to your application’s Gemfile:
146
+ [Install Libsodium](https://github.com/crypto-rb/rbnacl/wiki/Installing-libsodium) >= 1.0.12 and add [rbnacl](https://github.com/crypto-rb/rbnacl) to your application’s Gemfile:
143
147
 
144
148
  ```ruby
145
149
  gem 'rbnacl'
@@ -170,6 +174,43 @@ Lockbox.default_options = {algorithm: "xchacha20"}
170
174
 
171
175
  You can also pass an algorithm to `previous_versions` for key rotation.
172
176
 
177
+ ## Hybrid Cryptography
178
+
179
+ [Hybrid cryptography](https://en.wikipedia.org/wiki/Hybrid_cryptosystem) allows servers to encrypt data without being able to decrypt it.
180
+
181
+ [Install Libsodium](https://github.com/crypto-rb/rbnacl/wiki/Installing-libsodium) and add [rbnacl](https://github.com/crypto-rb/rbnacl) to your application’s Gemfile:
182
+
183
+ ```ruby
184
+ gem 'rbnacl'
185
+ ```
186
+
187
+ Generate a key pair with:
188
+
189
+ ```ruby
190
+ Lockbox.generate_key_pair
191
+ ```
192
+
193
+ Store the keys with your other secrets. Then use:
194
+
195
+ ```ruby
196
+ # files
197
+ box = Lockbox.new(algorithm: "hybrid", encryption_key: encryption_key, decryption_key: decryption_key)
198
+
199
+ # Active Storage
200
+ class User < ApplicationRecord
201
+ attached_encrypted :license, algorithm: "hybrid", encryption_key: encryption_key, decryption_key: decryption_key
202
+ end
203
+
204
+ # CarrierWave
205
+ class LicenseUploader < CarrierWave::Uploader::Base
206
+ encrypt algorithm: "hybrid", encryption_key: encryption_key, decryption_key: decryption_key
207
+ end
208
+ ```
209
+
210
+ Make sure `decryption_key` is `nil` on servers that shouldn’t decrypt.
211
+
212
+ This uses X25519 for key exchange and XSalsa20-Poly1305 for encryption.
213
+
173
214
  ## Key Management
174
215
 
175
216
  You can use a key management service to manage your keys with [KMS Encrypted](https://github.com/ankane/kms_encrypted).
@@ -192,6 +233,65 @@ end
192
233
 
193
234
  **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.
194
235
 
236
+ ## Compatibility
237
+
238
+ It’s easy to read encrypted files in another language if needed.
239
+
240
+ Here are [some examples](docs/Compatibility.md).
241
+
242
+ The format for AES-GCM is:
243
+
244
+ - nonce (IV) - 12 bytes
245
+ - ciphertext - variable length
246
+ - authentication tag - 16 bytes
247
+
248
+ For XChaCha20, use the appropriate [Libsodium library](https://libsodium.gitbook.io/doc/bindings_for_other_languages).
249
+
250
+ ## Database Fields
251
+
252
+ Lockbox can also be used with [attr_encrypted](https://github.com/attr-encrypted/attr_encrypted) for database fields. This gives you:
253
+
254
+ 1. Easy key rotation
255
+ 2. XChaCha20
256
+ 3. Hybrid cryptography
257
+ 4. No need for separate IV columns
258
+
259
+ Add to your Gemfile:
260
+
261
+ ```ruby
262
+ gem 'attr_encrypted'
263
+ ```
264
+
265
+ Create a migration to add a new column for the encrypted data. We don’t need a separate IV column, as this will be included in the encrypted data.
266
+
267
+ ```ruby
268
+ class AddEncryptedPhoneToUsers < ActiveRecord::Migration[5.2]
269
+ def change
270
+ add_column :users, :encrypted_phone, :string
271
+ end
272
+ end
273
+ ```
274
+
275
+ All Lockbox options are supported.
276
+
277
+ ```ruby
278
+ class User < ApplicationRecord
279
+ attr_encrypted :phone, encryptor: Lockbox::Encryptor, key: key, algorithm: "xchacha20", previous_versions: [{key: previous_key}]
280
+
281
+ attribute :encrypted_phone_iv # prevent attr_encrypted error
282
+ end
283
+ ```
284
+
285
+ For hybrid cryptography, use:
286
+
287
+ ```ruby
288
+ class User < ApplicationRecord
289
+ attr_encrypted :phone, encryptor: Lockbox::Encryptor, algorithm: "hybrid", encryption_key: encryption_key, decryption_key: decryption_key
290
+
291
+ attribute :encrypted_phone_iv # prevent attr_encrypted error
292
+ end
293
+ ```
294
+
195
295
  ## Reference
196
296
 
197
297
  Pass associated data to encryption and decryption
@@ -1,9 +1,6 @@
1
- # dependencies
2
- require "openssl"
3
- require "securerandom"
4
-
5
1
  # modules
6
2
  require "lockbox/box"
3
+ require "lockbox/encryptor"
7
4
  require "lockbox/utils"
8
5
  require "lockbox/version"
9
6
 
@@ -20,26 +17,24 @@ class Lockbox
20
17
  end
21
18
  self.default_options = {algorithm: "aes-gcm"}
22
19
 
23
- def initialize(key: nil, algorithm: nil, previous_versions: nil)
24
- default_options = self.class.default_options
25
- key ||= default_options[:key]
26
- algorithm ||= default_options[:algorithm]
27
- previous_versions ||= default_options[:previous_versions]
20
+ def initialize(**options)
21
+ options = self.class.default_options.merge(options)
22
+ previous_versions = options.delete(:previous_versions)
28
23
 
29
24
  @boxes =
30
- [Box.new(key, algorithm: algorithm)] +
31
- Array(previous_versions).map { |v| Box.new(v[:key], algorithm: v[:algorithm]) }
25
+ [Box.new(options)] +
26
+ Array(previous_versions).map { |v| Box.new(v) }
32
27
  end
33
28
 
34
- def encrypt(*args)
35
- @boxes.first.encrypt(*args)
29
+ def encrypt(message, **options)
30
+ message = check_string(message, "message")
31
+ @boxes.first.encrypt(message, **options)
36
32
  end
37
33
 
38
34
  def decrypt(ciphertext, **options)
39
- raise TypeError, "can't convert ciphertext to string" unless ciphertext.respond_to?(:to_str)
35
+ ciphertext = check_string(ciphertext, "ciphertext")
40
36
 
41
37
  # ensure binary
42
- ciphertext = ciphertext.to_str
43
38
  if ciphertext.encoding != Encoding::BINARY
44
39
  # dup to prevent mutation
45
40
  ciphertext = ciphertext.dup.force_encoding(Encoding::BINARY)
@@ -48,9 +43,38 @@ class Lockbox
48
43
  @boxes.each_with_index do |box, i|
49
44
  begin
50
45
  return box.decrypt(ciphertext, **options)
51
- rescue DecryptionError, RbNaCl::LengthError, RbNaCl::CryptoError
52
- raise DecryptionError, "Decryption failed" if i == @boxes.size - 1
46
+ rescue => e
47
+ error_classes = [DecryptionError]
48
+ error_classes << RbNaCl::LengthError if defined?(RbNaCl::LengthError)
49
+ error_classes << RbNaCl::CryptoError if defined?(RbNaCl::CryptoError)
50
+ if error_classes.any? { |ec| e.is_a?(ec) }
51
+ raise DecryptionError, "Decryption failed" if i == @boxes.size - 1
52
+ else
53
+ raise e
54
+ end
53
55
  end
54
56
  end
55
57
  end
58
+
59
+ def self.generate_key_pair
60
+ require "rbnacl"
61
+ # encryption and decryption servers exchange public keys
62
+ # this produces smaller ciphertext than sealed box
63
+ alice = RbNaCl::PrivateKey.generate
64
+ bob = RbNaCl::PrivateKey.generate
65
+ # alice is sending message to bob
66
+ # use bob first in both cases to prevent keys being swappable
67
+ {
68
+ encryption_key: (bob.public_key.to_bytes + alice.to_bytes).unpack("H*").first,
69
+ decryption_key: (bob.to_bytes + alice.public_key.to_bytes).unpack("H*").first
70
+ }
71
+ end
72
+
73
+ private
74
+
75
+ def check_string(str, name)
76
+ str = str.read if str.respond_to?(:read)
77
+ raise TypeError, "can't convert #{name} to string" unless str.respond_to?(:to_str)
78
+ str.to_str
79
+ end
56
80
  end
@@ -1,3 +1,7 @@
1
+ # ideally encrypt and decrypt would happen at the blob/service level
2
+ # however, there isn't really a great place to define encryption settings there
3
+ # instead, we encrypt and decrypt at the attachment level,
4
+ # and we define encryption settings at the model level
1
5
  class Lockbox
2
6
  module ActiveStorageExtensions
3
7
  module Attached
@@ -15,7 +19,7 @@ class Lockbox
15
19
 
16
20
  case attachable
17
21
  when ActiveStorage::Blob
18
- raise NotImplemented, "Not supported yet"
22
+ raise NotImplemented, "Not supported"
19
23
  when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
20
24
  attachable = {
21
25
  io: StringIO.new(box.encrypt(attachable.read)),
@@ -29,7 +33,7 @@ class Lockbox
29
33
  content_type: attachable[:content_type]
30
34
  }
31
35
  when String
32
- raise NotImplemented, "Not supported yet"
36
+ raise NotImplemented, "Not supported"
33
37
  else
34
38
  nil
35
39
  end
@@ -1,3 +1,5 @@
1
+ require "openssl"
2
+
1
3
  class Lockbox
2
4
  class AES_GCM
3
5
  def initialize(key)
@@ -9,6 +11,7 @@ class Lockbox
9
11
 
10
12
  def encrypt(nonce, message, associated_data)
11
13
  cipher = OpenSSL::Cipher.new("aes-256-gcm")
14
+ # do not change order of operations
12
15
  cipher.encrypt
13
16
  cipher.key = @key
14
17
  cipher.iv = nonce
@@ -31,6 +34,7 @@ class Lockbox
31
34
  fail_decryption if ciphertext.to_s.bytesize == 0
32
35
 
33
36
  cipher = OpenSSL::Cipher.new("aes-256-gcm")
37
+ # do not change order of operations
34
38
  cipher.decrypt
35
39
  cipher.key = @key
36
40
  cipher.iv = nonce
@@ -1,54 +1,85 @@
1
+ require "securerandom"
2
+
1
3
  class Lockbox
2
4
  class Box
3
- def initialize(key, algorithm: nil)
4
- # decode hex key
5
- if key.encoding != Encoding::BINARY && key =~ /\A[0-9a-f]{64}\z/i
6
- key = [key].pack("H*")
7
- end
5
+ def initialize(key: nil, algorithm: nil, encryption_key: nil, decryption_key: nil)
6
+ raise ArgumentError, "Cannot pass both key and public/private key" if key && (encryption_key || decryption_key)
7
+
8
+ key = decode_key(key) if key
9
+ encryption_key = decode_key(encryption_key) if encryption_key
10
+ decryption_key = decode_key(decryption_key) if decryption_key
8
11
 
9
12
  algorithm ||= "aes-gcm"
10
13
 
11
14
  case algorithm
12
15
  when "aes-gcm"
16
+ raise ArgumentError, "Missing key" unless key
13
17
  require "lockbox/aes_gcm"
14
18
  @box = AES_GCM.new(key)
15
19
  when "xchacha20"
20
+ raise ArgumentError, "Missing key" unless key
16
21
  require "rbnacl"
17
22
  @box = RbNaCl::AEAD::XChaCha20Poly1305IETF.new(key)
23
+ when "hybrid"
24
+ raise ArgumentError, "Missing key" unless encryption_key || decryption_key
25
+ require "rbnacl"
26
+ @encryption_box = RbNaCl::Boxes::Curve25519XSalsa20Poly1305.new(encryption_key.slice(0, 32), encryption_key.slice(32..-1)) if encryption_key
27
+ @decryption_box = RbNaCl::Boxes::Curve25519XSalsa20Poly1305.new(decryption_key.slice(32..-1), decryption_key.slice(0, 32)) if decryption_key
18
28
  else
19
29
  raise ArgumentError, "Unknown algorithm: #{algorithm}"
20
30
  end
31
+
32
+ @algorithm = algorithm
21
33
  end
22
34
 
23
35
  def encrypt(message, associated_data: nil)
24
- nonce = generate_nonce
25
- ciphertext = @box.encrypt(nonce, message, associated_data)
36
+ if @algorithm == "hybrid"
37
+ raise ArgumentError, "No public key set" unless @encryption_box
38
+ raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
39
+ nonce = generate_nonce(@encryption_box)
40
+ ciphertext = @encryption_box.encrypt(nonce, message)
41
+ else
42
+ nonce = generate_nonce(@box)
43
+ ciphertext = @box.encrypt(nonce, message, associated_data)
44
+ end
26
45
  nonce + ciphertext
27
46
  end
28
47
 
29
48
  def decrypt(ciphertext, associated_data: nil)
30
- nonce, ciphertext = extract_nonce(ciphertext)
31
- @box.decrypt(nonce, ciphertext, associated_data)
49
+ if @algorithm == "hybrid"
50
+ raise ArgumentError, "No private key set" unless @decryption_box
51
+ raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
52
+ nonce, ciphertext = extract_nonce(@decryption_box, ciphertext)
53
+ @decryption_box.decrypt(nonce, ciphertext)
54
+ else
55
+ nonce, ciphertext = extract_nonce(@box, ciphertext)
56
+ @box.decrypt(nonce, ciphertext, associated_data)
57
+ end
32
58
  end
33
59
 
34
- # protect key for xchacha20
60
+ # protect key for xchacha20 and hybrid
35
61
  def inspect
36
62
  to_s
37
63
  end
38
64
 
39
65
  private
40
66
 
41
- def nonce_bytes
42
- @box.nonce_bytes
43
- end
44
-
45
- def generate_nonce
46
- SecureRandom.random_bytes(nonce_bytes)
67
+ def generate_nonce(box)
68
+ SecureRandom.random_bytes(box.nonce_bytes)
47
69
  end
48
70
 
49
- def extract_nonce(bytes)
71
+ def extract_nonce(box, bytes)
72
+ nonce_bytes = box.nonce_bytes
50
73
  nonce = bytes.slice(0, nonce_bytes)
51
74
  [nonce, bytes.slice(nonce_bytes..-1)]
52
75
  end
76
+
77
+ # decode hex key
78
+ def decode_key(key)
79
+ if key.encoding != Encoding::BINARY && key =~ /\A[0-9a-f]{64,128}\z/i
80
+ key = [key].pack("H*")
81
+ end
82
+ key
83
+ end
53
84
  end
54
85
  end
@@ -0,0 +1,17 @@
1
+ class Lockbox
2
+ class Encryptor
3
+ def self.encrypt(options)
4
+ box(options).encrypt(options[:value])
5
+ end
6
+
7
+ def self.decrypt(options)
8
+ box(options).decrypt(options[:value])
9
+ end
10
+
11
+ def self.box(options)
12
+ options = options.slice(:key, :encryption_key, :decryption_key, :algorithm, :previous_versions)
13
+ options[:algorithm] = "aes-gcm" if options[:algorithm] == "aes-256-gcm"
14
+ Lockbox.new(options)
15
+ end
16
+ end
17
+ end
@@ -1,3 +1,3 @@
1
1
  class Lockbox
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.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.1.0
4
+ version: 0.1.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-01-02 00:00:00.000000000 Z
11
+ date: 2019-03-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -110,6 +110,20 @@ dependencies:
110
110
  version: '0'
111
111
  - !ruby/object:Gem::Dependency
112
112
  name: sqlite3
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: 1.3.0
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: 1.3.0
125
+ - !ruby/object:Gem::Dependency
126
+ name: rbnacl
113
127
  requirement: !ruby/object:Gem::Requirement
114
128
  requirements:
115
129
  - - ">="
@@ -123,7 +137,7 @@ dependencies:
123
137
  - !ruby/object:Gem::Version
124
138
  version: '0'
125
139
  - !ruby/object:Gem::Dependency
126
- name: rbnacl
140
+ name: attr_encrypted
127
141
  requirement: !ruby/object:Gem::Requirement
128
142
  requirements:
129
143
  - - ">="
@@ -150,6 +164,7 @@ files:
150
164
  - lib/lockbox/aes_gcm.rb
151
165
  - lib/lockbox/box.rb
152
166
  - lib/lockbox/carrier_wave_extensions.rb
167
+ - lib/lockbox/encryptor.rb
153
168
  - lib/lockbox/railtie.rb
154
169
  - lib/lockbox/utils.rb
155
170
  - lib/lockbox/version.rb