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 +53 -1
- data/Rakefile +10 -2
- data/VERSION.txt +1 -1
- data/bin/filesafe +8 -268
- data/lib/filesafe.rb +265 -0
- data/test/test_cli.rb +41 -0
- data/test/test_module.rb +55 -0
- metadata +5 -2
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
|
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
|
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.
|
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.
|
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 '
|
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
|
-
|
70
|
-
usage "This option only applies to decryption operations." unless opt
|
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
|
-
|
80
|
+
FileSafe.decrypt(file, opt[:phrase], opt[:notemp])
|
81
|
+
puts "FILE DECRYPTED: #{file}"
|
343
82
|
else
|
344
|
-
|
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
|
+
|
data/test/test_module.rb
ADDED
@@ -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
|
-
|
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: []
|