filesafe 1.0.0 → 1.1.0

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.
data/README.txt CHANGED
@@ -18,10 +18,62 @@ environments (Windows) however.
18
18
  This script was written and tested using Ruby 1.9.x. No attempts to
19
19
  adapt or test it under earlier Ruby versions have been made.
20
20
 
21
+ ENCRYPTED FILE FORMAT
22
+
23
+ Before a file is encrypted, some cryptographically secure random data
24
+ is obtained:
25
+
26
+ SALT = securely generated random salt data
27
+ F_KEY = securely generated random key data
28
+ F_IV = securely generated random initialization vector data
29
+
30
+ The F_KEY and F_IV will be used to encrypt the plaintext file. In
31
+ order to keep them secure, they will be encrypted using a master key
32
+ and initialization vector derived from the passphrase supplied by
33
+ the user and the SALT.
34
+
35
+ PASS = passphrase supplied by the user of the utility
36
+ M_KEY = master key derived as described below
37
+ M_IV = master initilization vector derived as described below
38
+
39
+ In order to derive M_KEY and M_IV, the PBKDF2 algorithm as described
40
+ in RFC2898 is used, passing PASS and SALT to it, using the configured
41
+ hash (SHA-512 by default) and number of iterations (4096 by default).
42
+
43
+ Once M_KEY and M_IV are obtained, 256-bit AES in CBC mode is used to
44
+ encrypt F_KEY + F_IV to obtain:
45
+
46
+ C_F_KEY = ciphertext encrypted version of F_KEY, encrypted using M_KEY and M_IV
47
+ C_F_IV = ciphertext encrypted version of F_IV, encrypted using M_KEY and M_IV
48
+
49
+ A new file is opened, and SALT + C_F_KEY + C_F_IV are written. The
50
+ contents of the plaintext file are then encrypted using F_KEY and F_IV
51
+ and written to the new file following the salt and encrypted key and vector.
52
+
53
+ Finally, a HMAC of SALT + C_F_KEY + C_F_IV + encrypted file text is written
54
+ to the end of the new file and it is closed.
55
+
56
+ This new file is the encrypted file. The old plaintext file is overwritten and
57
+ removed.
58
+
59
+ The HMAC uses the same PASS passphrase and the same hash algorithm that
60
+ PBKDF2 uses (SHA-512 by default).
61
+
62
+ To recover the file, an HMAC is calculated on the encrypted file contents
63
+ excluding the trailing saved HMAC data appended to the end. This calculated
64
+ HMAC is compared to the saved HMAC. If they don't match, then either the
65
+ file has been corrupted, or the passphrase is incorrect or different.
66
+
67
+ If the HMACs match, the SALT is read from the start of the file as well
68
+ as the encrypted master key material C_F_KEY and C_F_IV. Using PBKDF2
69
+ and the SALT and PASS, the master key material is recovered, M_KEY and M_IV,
70
+ which then are used to decrypt the file keys F_KEY and F_IV. These file
71
+ keys are used to decrypt and recover the plaintext.
72
+
21
73
 
22
74
  LICENSE
23
75
 
24
- This script is licensed under an MIT-style license. See the license header at the top of the script source code.
76
+ This script is licensed under an MIT-style license. See the LICENSE.txt file.
25
77
 
26
78
 
27
79
  REQUIREMENTS
data/Rakefile CHANGED
@@ -1,5 +1,6 @@
1
1
  require 'rake/gempackagetask'
2
2
  require 'rake/rdoctask'
3
+ require 'rake/testtask'
3
4
 
4
5
  gemspec = Gem::Specification.new do |spec|
5
6
  spec.name = 'filesafe'
@@ -11,11 +12,13 @@ gemspec = Gem::Specification.new do |spec|
11
12
  spec.description = 'A utility script for encrypting and decrypting files using a randomly generated 256-bit AES key and initialization vector secured using the PBKDF2 password/passphrase key derivation algorithm to secure the file key and IV.'
12
13
  spec.has_rdoc = false ## No documentation yet
13
14
  spec.extra_rdoc_files = [ 'README.txt' ]
14
- spec.files = [
15
+ spec.files = FileList[
15
16
  'README.txt',
16
17
  'VERSION.txt',
17
18
  'Rakefile',
18
- 'bin/filesafe'
19
+ 'bin/*',
20
+ 'lib/*',
21
+ 'test/*'
19
22
  ]
20
23
  spec.executables = [ 'filesafe' ]
21
24
  spec.add_dependency('pbkdf2', '>= 0.1.0')
@@ -34,6 +37,11 @@ Rake::RDocTask.new do |rdoc|
34
37
  rdoc.rdoc_files.include('README.txt')
35
38
  end
36
39
 
40
+ Rake::TestTask.new do |t|
41
+ t.test_files = FileList['test/test*.rb']
42
+ t.verbose = true
43
+ end
44
+
37
45
  task :default => [
38
46
  'pkg/filesafe-' + File.open('VERSION.txt','r').to_a.join.strip + '.gem',
39
47
  :rdoc
data/VERSION.txt CHANGED
@@ -1 +1 @@
1
- 1.0.0
1
+ 1.1.0
data/bin/filesafe CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  #
3
3
  # FileSafe
4
- # Version: 1.0.0
4
+ # Version: 1.1.0
5
5
  # From: http://www.aarongifford.com/computers/filesafe/index.html
6
6
  #
7
7
  # A simple file encryption/decryption script that uses 256-bit AES encryption,
@@ -29,11 +29,7 @@
29
29
  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
30
30
  # THE SOFTWARE.
31
31
 
32
- require 'openssl' ## Encryption/HMAC/Hash algorithms
33
- require 'securerandom' ## Cryptographically secure source of random data
34
- require 'pbkdf2' ## PBKDF2 algorithm for key material generation
35
- require 'highline' ## For reading a passphrase from a terminal
36
- require 'tempfile' ## Temporary file creation
32
+ require 'filesafe'
37
33
 
38
34
  def usage(msg)
39
35
  STDERR.puts <<-EOM
@@ -66,8 +62,8 @@ while ARGV.size > 0 && ARGV[0][0,1] == '-'
66
62
  usage "Please specify a passphrase and one or more files." if ARGV.size < 2
67
63
  opt[:phrase] = ARGV.shift
68
64
  when '-n'
69
- ARV.shift
70
- usage "This option only applies to decryption operations." unless opt.key?(:decrypt)
65
+ ARGV.shift
66
+ usage "This option only applies to decryption operations." unless opt[:op] == :decrypt
71
67
  opt[:notemp] = true
72
68
  when '--'
73
69
  break
@@ -78,270 +74,14 @@ end
78
74
  usage "You did not specify a -e encrypt or -d decrypt operation." unless opt.key?(:op)
79
75
  usage "You must specify one or more files to operate on." if ARGV.size == 0
80
76
 
81
-
82
- ## CONFIGURATION ITEMS:
83
- PASSHASH_SUFFIX = '.pass'
84
- CIPHER = 'aes-256-cbc'
85
- cipher = OpenSSL::Cipher::Cipher.new(CIPHER)
86
- BLOCK_LEN = cipher.block_size
87
- KEY_LEN = cipher.key_len
88
- IV_LEN = cipher.iv_len
89
- SALT_LEN = KEY_LEN + IV_LEN
90
- HMAC_FUNC = 'sha512'
91
- HMAC_LEN = OpenSSL::HMAC.new('', HMAC_FUNC).digest.bytesize
92
- HEADER_LEN = KEY_LEN + IV_LEN + SALT_LEN + HMAC_LEN
93
- ITERATIONS = 4096
94
- FILE_CHUNK_LEN = 65536
95
-
96
- def getphrase(check=false)
97
- begin
98
- phrase = HighLine.new.ask('Passphrase: '){|q| q.echo = '*' ; q.overwrite = true }
99
- return phrase unless check
100
- tmp = HighLine.new.ask('Retype passphrase: '){|q| q.echo = '*' ; q.overwrite = true }
101
- return phrase if tmp == phrase
102
- rescue Interrupt
103
- exit -1
104
- end while true
105
- end
106
-
107
- def encrypt_file(file, passphrase=nil)
108
- raise "Cannot encrypt non-existent file: #{file.inspect}" unless File.exist?(file)
109
- raise "Cannot encrypt unreadable file: #{file.inspect}" unless File.readable?(file)
110
- raise "Cannot encrypt unwritable file: #{file.inspect}" unless File.writable?(file)
111
- passhash = false
112
- if File.exist?(file + PASSHASH_SUFFIX)
113
- raise "Cannot read password hash temporary file: #{(file + PASSHASH_SUFFIX).inspect}" unless File.readable?(file + PASSHASH_SUFFIX)
114
- raise "Password hash temporary file length is invalid: #{(file + PASSHASH_SUFFIX).inspect}" unless File.size(file + PASSHASH_SUFFIX) == SALT_LEN + HMAC_LEN
115
- fp = File.open(file + PASSHASH_SUFFIX, File::RDONLY)
116
- salt = fp.read(SALT_LEN)
117
- passcheck = fp.read(HMAC_LEN)
118
- loop do
119
- passphrase = getphrase if passphrase.nil?
120
- pbkdf2data = PBKDF2.new do |p|
121
- p.hash_function = HMAC_FUNC
122
- p.password = passphrase
123
- p.salt = salt
124
- p.iterations = ITERATIONS
125
- p.key_length = HMAC_LEN
126
- end.bin_string
127
- break if passcheck == pbkdf2data
128
- puts "*** ERROR: Passphrase mismatch. Try again, abort, or delete temporary file: #{file + PASSHASH_SUFFIX}"
129
- passphrase = nil
130
- end
131
- passhash = true
132
- elsif passphrase.nil?
133
- puts "*** ALERT: Enter your NEW passphrase twice. DO NOT FORGET IT, or you may lose your data!"
134
- passphrase = getphrase(true)
135
- end
136
-
137
- ## Use secure random data to populate salt, key, and IV:
138
- salt = SecureRandom.random_bytes(SALT_LEN) ## Acquire some fresh salt
139
- file_key = SecureRandom.random_bytes(KEY_LEN) ## Get some random key material
140
- file_iv = SecureRandom.random_bytes(IV_LEN) ## And a random initialization vector
141
-
142
- ## Encrypt the file key and IV using password-derived keying material:
143
- keymaterial = PBKDF2.new do |p|
144
- p.hash_function = HMAC_FUNC
145
- p.password = passphrase
146
- p.salt = salt
147
- p.iterations = ITERATIONS
148
- p.key_length = KEY_LEN + IV_LEN
149
- end.bin_string
150
- cipher = OpenSSL::Cipher::Cipher.new(CIPHER)
151
- cipher.encrypt
152
- ## No padding required for this operation since the file key + IV is
153
- ## an exact multiple of the cipher block length:
154
- cipher.padding = 0
155
- cipher.key = keymaterial[0,KEY_LEN]
156
- cipher.iv = keymaterial[KEY_LEN,IV_LEN]
157
- encrypted_keymaterial = cipher.update(file_key + file_iv) + cipher.final
158
- encrypted_file_key = encrypted_keymaterial[0,KEY_LEN]
159
- encrypted_file_iv = encrypted_keymaterial[KEY_LEN,IV_LEN]
160
-
161
- ## Open the plaintext file for reading (and later overwriting):
162
- rfp = File.open(file, File::RDWR|File::EXCL)
163
-
164
- ## Open a temporary ciphertext file for writing:
165
- wfp = Tempfile.new(File.basename(rfp.path), File.dirname(rfp.path))
166
-
167
- ## Write the salt and encrypted file key + IV and
168
- ## temporarily fill the HMAC slot with zero-bytes:
169
- wfp.write(salt + encrypted_file_key + encrypted_file_iv + (0.chr * HMAC_LEN))
170
-
171
- ## Start the HMAC:
172
- hmac = OpenSSL::HMAC.new(passphrase, HMAC_FUNC)
173
- hmac << salt
174
- hmac << encrypted_file_key
175
- hmac << encrypted_file_iv
176
-
177
- ## Encrypt file with file key + IV:
178
- cipher = OpenSSL::Cipher::Cipher.new(CIPHER)
179
- cipher.encrypt
180
- ## Encryption of file contents uses PCKS#5 padding which OpenSSL should
181
- ## have enabled by default. Nevertheless, we explicitly enable it here:
182
- cipher.padding = 1
183
- cipher.key = file_key
184
- cipher.iv = file_iv
185
- until rfp.eof?
186
- data = rfp.read(FILE_CHUNK_LEN)
187
- if data.bytesize > 0
188
- data = cipher.update(data)
189
- hmac << data
190
- wfp.write(data)
191
- end
192
- end
193
- data = cipher.final
194
- if data.bytesize > 0
195
- ## Save the last bit-o-data and update the HMAC:
196
- wfp.write(data)
197
- hmac << data
198
- end
199
-
200
- ## Write HMAC digest to file:
201
- wfp.pos = SALT_LEN + KEY_LEN + IV_LEN
202
- wfp.write(hmac.digest)
203
-
204
- ## Overwrite the original plaintext file with zero bytes.
205
- ## This adds a small measure of security against recovering
206
- ## the original unencrypted contents. It would likely be
207
- ## better to overwrite the file multiple times with different
208
- ## bit patterns, including one or more iterations using
209
- ## high-quality random data.
210
- rfp.seek(0,File::SEEK_END)
211
- fsize = rfp.pos
212
- rfp.pos = 0
213
- while rfp.pos + FILE_CHUNK_LEN < fsize
214
- rfp.write(0.chr * FILE_CHUNK_LEN)
215
- end
216
- rfp.write(0.chr * (fsize - rfp.pos)) if rfp.pos < fsize
217
- rfp.close
218
-
219
- ## Copy file ownership/permissions:
220
- stat = File.stat(rfp.path)
221
- wfp.chown(stat.uid, stat.gid)
222
- wfp.chmod(stat.mode)
223
-
224
- ## Close the ciphertext temporary file without deleting:
225
- wfp.close(false)
226
-
227
- ## Rename temporary file to permanent name:
228
- File.rename(wfp.path, rfp.path)
229
-
230
- ## Remove password hash temp. file:
231
- File.delete(file + PASSHASH_SUFFIX) if passhash
232
-
233
- puts "ENCRYPTED: #{file}"
234
- end
235
-
236
- def decrypt_file(file, passphrase=nil, notemp=nil)
237
- raise "Cannot decrypt non-existent file: #{file.inspect}" unless File.exist?(file)
238
- raise "Cannot decrypt unreadable file: #{file.inspect}" unless File.readable?(file)
239
- raise "Cannot decrypt unwritable file: #{file.inspect}" unless File.writable?(file)
240
- fsize = File.size(file)
241
- raise "File is not in valid encrypted format: #{file.inspect}" unless fsize > HEADER_LEN && (fsize - HEADER_LEN) % BLOCK_LEN == 0
242
- salt = encrypted_file_key = encrypted_file_iv = nil
243
- loop do
244
- passphrase = getphrase if passphrase.nil?
245
- fp = File.open(file, File::RDONLY)
246
- salt = fp.read(SALT_LEN)
247
- encrypted_file_key = fp.read(KEY_LEN)
248
- encrypted_file_iv = fp.read(IV_LEN)
249
- file_hmac = fp.read(HMAC_LEN)
250
- test_hmac = OpenSSL::HMAC.new(passphrase, HMAC_FUNC)
251
- test_hmac << salt
252
- test_hmac << encrypted_file_key
253
- test_hmac << encrypted_file_iv
254
- until fp.eof?
255
- data = fp.read(FILE_CHUNK_LEN)
256
- test_hmac << data unless data.bytesize == 0
257
- end
258
- fp.close
259
- break if test_hmac.digest == file_hmac
260
- puts "*** ERROR: Incorrect passphrase, or file is not encrypted. Try again or abort."
261
- passphrase = nil
262
- end
263
-
264
- ## Extract and decrypt the encrypted file key + IV.
265
- ## First, regenerate the password-based key material that encrypts the
266
- ## file key + IV:
267
- keymaterial = PBKDF2.new do |p|
268
- p.hash_function = HMAC_FUNC
269
- p.password = passphrase
270
- p.salt = salt
271
- p.iterations = ITERATIONS
272
- p.key_length = KEY_LEN + IV_LEN
273
- end.bin_string
274
- cipher = OpenSSL::Cipher::Cipher.new(CIPHER)
275
- cipher.decrypt
276
- cipher.padding = 0 ## No padding is required for this operation
277
- cipher.key = keymaterial[0,KEY_LEN]
278
- cipher.iv = keymaterial[KEY_LEN,IV_LEN]
279
- ## Decrypt file key + IV:
280
- keymaterial = cipher.update(encrypted_file_key + encrypted_file_iv) + cipher.final
281
- file_key = keymaterial[0,KEY_LEN]
282
- file_iv = keymaterial[KEY_LEN,IV_LEN]
283
-
284
- ## Decrypt file:
285
- cipher = OpenSSL::Cipher::Cipher.new(CIPHER)
286
- cipher.decrypt
287
- cipher.padding = 1 ## File contents use PCKS#5 padding,OpenSSL's default method
288
- cipher.key = file_key
289
- cipher.iv = file_iv
290
-
291
- ## Open ciphertext file for reading:
292
- rfp = File.open(file, File::RDONLY|File::EXCL)
293
-
294
- ## Open a temporary plaintext file for writing:
295
- wfp = Tempfile.new(File.basename(rfp.path), File.dirname(rfp.path))
296
-
297
- ## Begin reading the ciphertext beyond the headers:
298
- rfp.pos = HEADER_LEN ## Skip headers
299
- until rfp.eof?
300
- data = rfp.read(FILE_CHUNK_LEN)
301
- if data.bytesize > 0
302
- data = cipher.update(data)
303
- wfp.write(data)
304
- end
305
- end
306
- data = cipher.final
307
- wfp.write(data) if data.bytesize > 0
308
-
309
- ## Close the ciphertext source file:
310
- rfp.close
311
-
312
- ## Copy file ownership/permissions:
313
- stat = File.stat(rfp.path)
314
- wfp.chown(stat.uid, stat.gid)
315
- wfp.chmod(stat.mode)
316
-
317
- ## Close the plaintext temporary file without deleting:
318
- wfp.close(false)
319
-
320
- ## Rename temporary file to permanent name:
321
- File.rename(wfp.path, rfp.path)
322
-
323
- if notemp.nil?
324
- ## Write password hash temp. file using PBKDF2 as an iterated hash of sorts of HMAC_LEN bytes:
325
- salt = SecureRandom.random_bytes(SALT_LEN) ## Grab a new chunk of random data
326
- pbkdf2data = PBKDF2.new do |p|
327
- p.hash_function = HMAC_FUNC
328
- p.password = passphrase
329
- p.salt = salt
330
- p.iterations = ITERATIONS
331
- p.key_length = HMAC_LEN
332
- end.bin_string
333
- File.open(file + PASSHASH_SUFFIX, File::WRONLY|File::EXCL|File::CREAT) {|f| f.write(salt + pbkdf2data)}
334
- end
335
-
336
- puts "FILE DECRYPTED: #{file}"
337
- end
338
-
339
77
  ARGV.each do |file|
340
78
  begin
341
79
  if opt[:op] == :decrypt
342
- decrypt_file(file, opt[:phrase], opt[:notemp])
80
+ FileSafe.decrypt(file, opt[:phrase], opt[:notemp])
81
+ puts "FILE DECRYPTED: #{file}"
343
82
  else
344
- encrypt_file(file, opt[:phrase])
83
+ FileSafe.encrypt(file, opt[:phrase])
84
+ puts "ENCRYPTED: #{file}"
345
85
  end
346
86
  rescue StandardError => e
347
87
  STDERR.puts "*** Error while working on file #{file.inspect}: #{e}"
data/lib/filesafe.rb ADDED
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module FileSafe
4
+ require 'openssl' ## Encryption/HMAC/Hash algorithms
5
+ require 'securerandom' ## Cryptographically secure source of random data
6
+ require 'pbkdf2' ## PBKDF2 algorithm for key material generation
7
+ require 'highline' ## For reading a passphrase from a terminal
8
+ require 'tempfile' ## Temporary file creation
9
+
10
+ ## CONFIGURATION ITEMS:
11
+ PASSHASH_SUFFIX = '.pass'
12
+ CIPHER = 'aes-256-cbc'
13
+ cipher = OpenSSL::Cipher::Cipher.new(CIPHER)
14
+ BLOCK_LEN = cipher.block_size
15
+ KEY_LEN = cipher.key_len
16
+ IV_LEN = cipher.iv_len
17
+ SALT_LEN = KEY_LEN + IV_LEN
18
+ HMAC_FUNC = 'sha512'
19
+ HMAC_LEN = OpenSSL::HMAC.new('', HMAC_FUNC).digest.bytesize
20
+ HEADER_LEN = KEY_LEN + IV_LEN + SALT_LEN + HMAC_LEN
21
+ ITERATIONS = 4096
22
+ FILE_CHUNK_LEN = 65536
23
+
24
+ def self.getphrase(check=false)
25
+ begin
26
+ phrase = HighLine.new.ask('Passphrase: '){|q| q.echo = '*' ; q.overwrite = true }
27
+ return phrase unless check
28
+ tmp = HighLine.new.ask('Retype passphrase: '){|q| q.echo = '*' ; q.overwrite = true }
29
+ return phrase if tmp == phrase
30
+ rescue Interrupt
31
+ exit -1
32
+ end while true
33
+ end
34
+
35
+ def self.encrypt(file, passphrase=nil)
36
+ raise "Cannot encrypt non-existent file: #{file.inspect}" unless File.exist?(file)
37
+ raise "Cannot encrypt unreadable file: #{file.inspect}" unless File.readable?(file)
38
+ raise "Cannot encrypt unwritable file: #{file.inspect}" unless File.writable?(file)
39
+ passhash = false
40
+ if File.exist?(file + PASSHASH_SUFFIX)
41
+ raise "Cannot read password hash temporary file: #{(file + PASSHASH_SUFFIX).inspect}" unless File.readable?(file + PASSHASH_SUFFIX)
42
+ raise "Password hash temporary file length is invalid: #{(file + PASSHASH_SUFFIX).inspect}" unless File.size(file + PASSHASH_SUFFIX) == SALT_LEN + HMAC_LEN
43
+ fp = File.open(file + PASSHASH_SUFFIX, File::RDONLY)
44
+ salt = fp.read(SALT_LEN)
45
+ passcheck = fp.read(HMAC_LEN)
46
+ loop do
47
+ passphrase = getphrase if passphrase.nil?
48
+ phash = hashpass(passphrase, salt)
49
+ break if passcheck == phash[1]
50
+ puts "*** ERROR: Passphrase mismatch. Try again, abort, or delete temporary file: #{file + PASSHASH_SUFFIX}"
51
+ passphrase = nil
52
+ end
53
+ passhash = true
54
+ elsif passphrase.nil?
55
+ puts "*** ALERT: Enter your NEW passphrase twice. DO NOT FORGET IT, or you may lose your data!"
56
+ passphrase = getphrase(true)
57
+ end
58
+
59
+ ## Use secure random data to populate salt, key, and IV:
60
+ salt = SecureRandom.random_bytes(SALT_LEN) ## Acquire some fresh salt
61
+ file_key = SecureRandom.random_bytes(KEY_LEN) ## Get some random key material
62
+ file_iv = SecureRandom.random_bytes(IV_LEN) ## And a random initialization vector
63
+
64
+ ## Encrypt the file key and IV using password-derived keying material:
65
+ keymaterial = PBKDF2.new do |p|
66
+ p.hash_function = HMAC_FUNC
67
+ p.password = passphrase
68
+ p.salt = salt
69
+ p.iterations = ITERATIONS
70
+ p.key_length = KEY_LEN + IV_LEN
71
+ end.bin_string
72
+ cipher = OpenSSL::Cipher::Cipher.new(CIPHER)
73
+ cipher.encrypt
74
+ ## No padding required for this operation since the file key + IV is
75
+ ## an exact multiple of the cipher block length:
76
+ cipher.padding = 0
77
+ cipher.key = keymaterial[0,KEY_LEN]
78
+ cipher.iv = keymaterial[KEY_LEN,IV_LEN]
79
+ encrypted_keymaterial = cipher.update(file_key + file_iv) + cipher.final
80
+ encrypted_file_key = encrypted_keymaterial[0,KEY_LEN]
81
+ encrypted_file_iv = encrypted_keymaterial[KEY_LEN,IV_LEN]
82
+
83
+ ## Open the plaintext file for reading (and later overwriting):
84
+ rfp = File.open(file, File::RDWR|File::EXCL)
85
+
86
+ ## Open a temporary ciphertext file for writing:
87
+ wfp = Tempfile.new(File.basename(rfp.path), File.dirname(rfp.path))
88
+
89
+ ## Write the salt and encrypted file key + IV and
90
+ ## temporarily fill the HMAC slot with zero-bytes:
91
+ wfp.write(salt + encrypted_file_key + encrypted_file_iv + (0.chr * HMAC_LEN))
92
+
93
+ ## Start the HMAC:
94
+ hmac = OpenSSL::HMAC.new(passphrase, HMAC_FUNC)
95
+ hmac << salt
96
+ hmac << encrypted_file_key
97
+ hmac << encrypted_file_iv
98
+
99
+ ## Encrypt file with file key + IV:
100
+ cipher = OpenSSL::Cipher::Cipher.new(CIPHER)
101
+ cipher.encrypt
102
+ ## Encryption of file contents uses PCKS#5 padding which OpenSSL should
103
+ ## have enabled by default. Nevertheless, we explicitly enable it here:
104
+ cipher.padding = 1
105
+ cipher.key = file_key
106
+ cipher.iv = file_iv
107
+ until rfp.eof?
108
+ data = rfp.read(FILE_CHUNK_LEN)
109
+ if data.bytesize > 0
110
+ data = cipher.update(data)
111
+ hmac << data
112
+ wfp.write(data)
113
+ end
114
+ end
115
+ data = cipher.final
116
+ if data.bytesize > 0
117
+ ## Save the last bit-o-data and update the HMAC:
118
+ wfp.write(data)
119
+ hmac << data
120
+ end
121
+
122
+ ## Write HMAC digest to file:
123
+ wfp.pos = SALT_LEN + KEY_LEN + IV_LEN
124
+ wfp.write(hmac.digest)
125
+
126
+ ## Overwrite the original plaintext file with zero bytes.
127
+ ## This adds a small measure of security against recovering
128
+ ## the original unencrypted contents. It would likely be
129
+ ## better to overwrite the file multiple times with different
130
+ ## bit patterns, including one or more iterations using
131
+ ## high-quality random data.
132
+ rfp.seek(0,File::SEEK_END)
133
+ fsize = rfp.pos
134
+ rfp.pos = 0
135
+ while rfp.pos + FILE_CHUNK_LEN < fsize
136
+ rfp.write(0.chr * FILE_CHUNK_LEN)
137
+ end
138
+ rfp.write(0.chr * (fsize - rfp.pos)) if rfp.pos < fsize
139
+ rfp.close
140
+
141
+ ## Copy file ownership/permissions:
142
+ stat = File.stat(rfp.path)
143
+ wfp.chown(stat.uid, stat.gid)
144
+ wfp.chmod(stat.mode)
145
+
146
+ ## Close the ciphertext temporary file without deleting:
147
+ wfp.close(false)
148
+
149
+ ## Rename temporary file to permanent name:
150
+ File.rename(wfp.path, rfp.path)
151
+
152
+ ## Remove password hash temp. file:
153
+ File.delete(file + PASSHASH_SUFFIX) if passhash
154
+ end
155
+
156
+ def self.decrypt(file, passphrase=nil, notemp=true)
157
+ raise "Cannot decrypt non-existent file: #{file.inspect}" unless File.exist?(file)
158
+ raise "Cannot decrypt unreadable file: #{file.inspect}" unless File.readable?(file)
159
+ raise "Cannot decrypt unwritable file: #{file.inspect}" unless File.writable?(file)
160
+ fsize = File.size(file)
161
+ raise "File is not in valid encrypted format: #{file.inspect}" unless fsize > HEADER_LEN && (fsize - HEADER_LEN) % BLOCK_LEN == 0
162
+ salt = encrypted_file_key = encrypted_file_iv = nil
163
+ loop do
164
+ passphrase = getphrase if passphrase.nil?
165
+ fp = File.open(file, File::RDONLY)
166
+ salt = fp.read(SALT_LEN)
167
+ encrypted_file_key = fp.read(KEY_LEN)
168
+ encrypted_file_iv = fp.read(IV_LEN)
169
+ file_hmac = fp.read(HMAC_LEN)
170
+ test_hmac = OpenSSL::HMAC.new(passphrase, HMAC_FUNC)
171
+ test_hmac << salt
172
+ test_hmac << encrypted_file_key
173
+ test_hmac << encrypted_file_iv
174
+ until fp.eof?
175
+ data = fp.read(FILE_CHUNK_LEN)
176
+ test_hmac << data unless data.bytesize == 0
177
+ end
178
+ fp.close
179
+ break if test_hmac.digest == file_hmac
180
+ puts "*** ERROR: Incorrect passphrase, or file is not encrypted. Try again or abort."
181
+ passphrase = nil
182
+ end
183
+
184
+ ## Extract and decrypt the encrypted file key + IV.
185
+ ## First, regenerate the password-based key material that encrypts the
186
+ ## file key + IV:
187
+ keymaterial = PBKDF2.new do |p|
188
+ p.hash_function = HMAC_FUNC
189
+ p.password = passphrase
190
+ p.salt = salt
191
+ p.iterations = ITERATIONS
192
+ p.key_length = KEY_LEN + IV_LEN
193
+ end.bin_string
194
+ cipher = OpenSSL::Cipher::Cipher.new(CIPHER)
195
+ cipher.decrypt
196
+ cipher.padding = 0 ## No padding is required for this operation
197
+ cipher.key = keymaterial[0,KEY_LEN]
198
+ cipher.iv = keymaterial[KEY_LEN,IV_LEN]
199
+ ## Decrypt file key + IV:
200
+ keymaterial = cipher.update(encrypted_file_key + encrypted_file_iv) + cipher.final
201
+ file_key = keymaterial[0,KEY_LEN]
202
+ file_iv = keymaterial[KEY_LEN,IV_LEN]
203
+
204
+ ## Decrypt file:
205
+ cipher = OpenSSL::Cipher::Cipher.new(CIPHER)
206
+ cipher.decrypt
207
+ cipher.padding = 1 ## File contents use PCKS#5 padding,OpenSSL's default method
208
+ cipher.key = file_key
209
+ cipher.iv = file_iv
210
+
211
+ ## Open ciphertext file for reading:
212
+ rfp = File.open(file, File::RDONLY|File::EXCL)
213
+
214
+ ## Open a temporary plaintext file for writing:
215
+ wfp = Tempfile.new(File.basename(rfp.path), File.dirname(rfp.path))
216
+
217
+ ## Begin reading the ciphertext beyond the headers:
218
+ rfp.pos = HEADER_LEN ## Skip headers
219
+ until rfp.eof?
220
+ data = rfp.read(FILE_CHUNK_LEN)
221
+ if data.bytesize > 0
222
+ data = cipher.update(data)
223
+ wfp.write(data)
224
+ end
225
+ end
226
+ data = cipher.final
227
+ wfp.write(data) if data.bytesize > 0
228
+
229
+ ## Close the ciphertext source file:
230
+ rfp.close
231
+
232
+ ## Copy file ownership/permissions:
233
+ stat = File.stat(rfp.path)
234
+ wfp.chown(stat.uid, stat.gid)
235
+ wfp.chmod(stat.mode)
236
+
237
+ ## Close the plaintext temporary file without deleting:
238
+ wfp.close(false)
239
+
240
+ ## Rename temporary file to permanent name:
241
+ File.rename(wfp.path, rfp.path)
242
+
243
+ unless notemp
244
+ ## Write password hash temp. file using PBKDF2 as an iterated hash of sorts of HMAC_LEN bytes:
245
+ File.open(file + PASSHASH_SUFFIX, File::WRONLY|File::EXCL|File::CREAT) {|f| f.write(hashpass(passphrase).join)}
246
+ end
247
+ end
248
+
249
+ ## Use PBKDF2 as if it were a hash function with salt to generate a
250
+ ## next-to-impossible-to-reverse-or-deliberately-collide hash of the
251
+ ## supplied passphrase:
252
+ def self.hashpass(passphrase, salt=nil)
253
+ ## Grab a new chunk of secure random data if no salt was supplied:
254
+ salt = SecureRandom.random_bytes(SALT_LEN) if salt.nil?
255
+ hash = PBKDF2.new do |p|
256
+ p.hash_function = HMAC_FUNC
257
+ p.password = passphrase
258
+ p.salt = salt
259
+ p.iterations = ITERATIONS
260
+ p.key_length = HMAC_LEN
261
+ end.bin_string
262
+ [ salt, hash ]
263
+ end
264
+
265
+ end
data/test/test_cli.rb ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'test/unit'
4
+ require 'digest/sha2'
5
+
6
+ class FileSafeCLITest < Test::Unit::TestCase
7
+ FILESAFE = File.join(File.dirname(__FILE__), '..', 'bin', 'filesafe')
8
+ def setup
9
+ ## Create a temporary file:
10
+ @testfile = 'test.out'
11
+ @passphrase = 'this is the encryption passphrase'
12
+ File.open(@testfile,'w'){|f| f.puts "Test data"}
13
+
14
+ ## Save SHA256 digest of the file:
15
+ @plaintext_hash = file_hash(@testfile)
16
+ @plaintext_size = File.size(@testfile)
17
+ end
18
+
19
+ def file_hash(filename)
20
+ Digest::SHA256.hexdigest(File.open(filename, 'r'){|f| f.read})
21
+ end
22
+
23
+ def teardown
24
+ File.unlink(@testfile)
25
+ end
26
+
27
+ def test_cli
28
+ ## Encrypt file:
29
+ `#{FILESAFE} -e -p '#{@passphrase}' '#{@testfile}'`
30
+
31
+ assert(file_hash(@testfile) != @plaintext_hash)
32
+ assert(File.size(@testfile) != @plaintext_size)
33
+
34
+ ## Decrypt file:
35
+ `#{FILESAFE} -d -n -p '#{@passphrase}' '#{@testfile}'`
36
+
37
+ assert(file_hash(@testfile) == @plaintext_hash)
38
+ assert(File.size(@testfile) == @plaintext_size)
39
+ end
40
+ end
41
+
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'test/unit'
4
+ require 'digest/sha2'
5
+ require_relative '../lib/filesafe.rb'
6
+
7
+ class FileSafeModuleTest < Test::Unit::TestCase
8
+ FILESAFE = File.join(File.dirname(__FILE__), '..', 'bin', 'filesafe')
9
+ def setup
10
+ ## Create a temporary file:
11
+ @testfile = 'test.out'
12
+ @passphrase = 'four score and seven years ago'
13
+ File.open(@testfile,'w'){|f| f.puts "Some more test data"}
14
+
15
+ ## Save SHA256 digest of the file:
16
+ @plaintext_hash = file_hash(@testfile)
17
+ @plaintext_size = File.size(@testfile)
18
+ end
19
+
20
+ def file_hash(filename)
21
+ Digest::SHA256.hexdigest(File.open(filename, 'r'){|f| f.read})
22
+ end
23
+
24
+ def teardown
25
+ File.unlink(@testfile)
26
+ end
27
+
28
+ def test_module
29
+ ## Encrypt file:
30
+ FileSafe.encrypt(@testfile, @passphrase)
31
+
32
+ assert(file_hash(@testfile) != @plaintext_hash)
33
+ assert(File.size(@testfile) != @plaintext_size)
34
+
35
+ ## Decrypt file:
36
+ FileSafe.decrypt(@testfile, @passphrase, true)
37
+
38
+ assert(file_hash(@testfile) == @plaintext_hash)
39
+ assert(File.size(@testfile) == @plaintext_size)
40
+ end
41
+
42
+ def test_hashpass
43
+ pass = "When in the course of human events..."
44
+ salt = "01caf8e2e844a37810280f231f3059aca54e631528c1c57eb643df2c" +
45
+ "8c6c74bc4a6136784ecff873dcd09a80059f6e80"
46
+ goal = "6c726ee33ad9e171612d646403b3e01bba0451574cde9b0af90d957e" +
47
+ "1b33c0830db1ac63b986f755faa8b1e9a944dbf4c7086da2eae122c3" +
48
+ "9f42a359ef12536c"
49
+ salt = [salt].pack('H*')
50
+ goal = [goal].pack('H*')
51
+ hash = FileSafe.hashpass(pass, salt)
52
+ assert(hash[1] == goal)
53
+ end
54
+ end
55
+
metadata CHANGED
@@ -4,9 +4,9 @@ version: !ruby/object:Gem::Version
4
4
  prerelease: false
5
5
  segments:
6
6
  - 1
7
+ - 1
7
8
  - 0
8
- - 0
9
- version: 1.0.0
9
+ version: 1.1.0
10
10
  platform: ruby
11
11
  authors:
12
12
  - Aaron D. Gifford
@@ -60,6 +60,9 @@ files:
60
60
  - VERSION.txt
61
61
  - Rakefile
62
62
  - bin/filesafe
63
+ - lib/filesafe.rb
64
+ - test/test_module.rb
65
+ - test/test_cli.rb
63
66
  has_rdoc: true
64
67
  homepage: http://www.aarongifford.com/computers/filesafe/
65
68
  licenses: []