Module: FileSafe

Defined in:
lib/filesafe.rb

Overview

FileSafe module has four module methods, two for file encryption/decryption, one for passphrase hashing, and one for reading a passphrase from a terminal.

Constant Summary collapse

PASSHASH_SUFFIX =

Temporary passphrase hash file suffix:

'.pass'
CIPHER =

Default cipher (AES-256 in CBC mode):

'aes-256-cbc'
BLOCK_LEN =

Cipher block length/size (128 bits/16 bytes for AES-256):

cipher.block_size
KEY_LEN =

Cipher key length (256 bits/32 bytes for AES-256):

cipher.key_len
IV_LEN =

Cipher initialization vector (IV) length (128 bits/16 bytes for AES-256):

cipher.iv_len
SALT_LEN =

Salt size/length (384 bits/48 bytes, KEY + IV size):

KEY_LEN + IV_LEN
HMAC_FUNC =

Default hash function to use for HMAC (SHA-512 by default):

OpenSSL::Digest.new('sha512')
HMAC_LEN =

Default HMAC size/length (512 bits/64 bytes for HMAC-SHA-512):

HMAC_FUNC.digest_length
HEADER_LEN =

Default ciphertext file header size (key + IV + salt + HMAC = 1280 bits/160 bytes by default)

KEY_LEN + IV_LEN + SALT_LEN + HMAC_LEN
ITERATIONS =

Number of iterations to use in PBKDF2 (16384 by default):

16384
FILE_CHUNK_LEN =

Number of bytes to read from plaintext/ciphertext files at a time (64KB by default):

65536

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.decrypt(file, passphrase = nil, notemp = true) ⇒ Object

Decrypt a file with the supplied passphrase–or if none is supplied, read a passphrase from the terminal. Optionally create a temporary file of the same name with a suffix (by default “.pass”) and store a passphrase hash in that file for future use.



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/filesafe.rb', line 237

def self.decrypt(file, passphrase=nil, notemp=true)
  raise "Cannot decrypt non-existent file: #{file.inspect}" unless File.exist?(file)
  raise "Cannot decrypt unreadable file: #{file.inspect}" unless File.readable?(file)
  raise "Cannot decrypt unwritable file: #{file.inspect}" unless File.writable?(file)
  fsize = File.size(file)
  raise "File is not in valid encrypted format: #{file.inspect}" unless fsize > HEADER_LEN && (fsize - HEADER_LEN) % BLOCK_LEN == 0
  salt = encrypted_file_key = encrypted_file_iv = nil
  interactive = passphrase.nil?
  loop do
    passphrase = getphrase if passphrase.nil?
    fp = File.open(file, File::RDONLY)
    salt               = fp.read(SALT_LEN)
    encrypted_file_key = fp.read(KEY_LEN)
    encrypted_file_iv  = fp.read(IV_LEN)
    file_check         = fp.read(HMAC_LEN)
    test_hmac = OpenSSL::HMAC.new(passphrase, HMAC_FUNC)
    test_hmac << salt
    test_hmac << encrypted_file_key
    test_hmac << encrypted_file_iv
    until fp.eof?
      data = fp.read(FILE_CHUNK_LEN)
      test_hmac << data unless data.bytesize == 0
    end
    fp.close
    break if pbkdf2(passphrase + test_hmac.digest, salt, HMAC_LEN) == file_check
    raise "Incorrect passphrase, or file is not encrypted." unless interactive
    puts "*** ERROR: Incorrect passphrase, or file is not encrypted. Try again or abort."
    passphrase = nil
  end

  ## Extract and decrypt the encrypted file key + IV.
  ## First, regenerate the password-based key material that encrypts the
  ## file key + IV:
  keymaterial = pbkdf2(passphrase, salt, KEY_LEN + IV_LEN)
  cipher = OpenSSL::Cipher.new(CIPHER)
  cipher.decrypt
  cipher.padding = 0 ## No padding is required for this operation
  cipher.key     = keymaterial[0,KEY_LEN]
  cipher.iv      = keymaterial[KEY_LEN,IV_LEN]
  ## Decrypt file key + IV:
  keymaterial = cipher.update(encrypted_file_key + encrypted_file_iv) + cipher.final
  file_key = keymaterial[0,KEY_LEN]
  file_iv  = keymaterial[KEY_LEN,IV_LEN]

  ## Decrypt file:
  cipher = OpenSSL::Cipher.new(CIPHER)
  cipher.decrypt
  cipher.padding = 1 ## File contents use PCKS#5 padding,OpenSSL's default method
  cipher.key     = file_key
  cipher.iv      = file_iv

  ## Open ciphertext file for reading:
  rfp = File.open(file, File::RDONLY|File::EXCL)

  ## Open a temporary plaintext file for writing:
  wfp = Tempfile.new(File.basename(rfp.path), File.dirname(rfp.path))

  ## Begin reading the ciphertext beyond the headers:
  rfp.pos = HEADER_LEN  ## Skip headers
  until rfp.eof?
    data = rfp.read(FILE_CHUNK_LEN)
    if data.bytesize > 0
      data = cipher.update(data)
      wfp.write(data)
    end
  end
  data = cipher.final
  wfp.write(data) if data.bytesize > 0

  ## Close the ciphertext source file:
  rfp.close

  ## Copy file ownership/permissions:
  stat = File.stat(rfp.path)
  wfp.chown(stat.uid, stat.gid)
  wfp.chmod(stat.mode)

  ## Close the plaintext temporary file without deleting:
  wfp.close(false)

  ## Rename temporary file to permanent name:
  File.rename(wfp.path, rfp.path)

  unless notemp
    ## Write password hash temp. file using PBKDF2 as an iterated hash of sorts of HMAC_LEN bytes:
    salt = SecureRandom.random_bytes(SALT_LEN) if salt.nil?
    File.open(file + PASSHASH_SUFFIX, File::WRONLY|File::EXCL|File::CREAT) do |f|
      f.write(salt + pbkdf2(passphrase, salt, HMAC_LEN))
    end
  end
end

.encrypt(file, passphrase = nil, notemp = true, debug_params = nil) ⇒ Object

Encrypt a file with the supplied passphrase–or if none is supplied, read a passphrase from the terminal.



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/filesafe.rb', line 93

def self.encrypt(file, passphrase=nil, notemp=true, debug_params=nil)
  raise "Cannot encrypt non-existent file: #{file.inspect}" unless File.exist?(file)
  raise "Cannot encrypt unreadable file: #{file.inspect}" unless File.readable?(file)
  raise "Cannot encrypt unwritable file: #{file.inspect}" unless File.writable?(file)
  passhash   = false
  if !notemp && File.exist?(file + PASSHASH_SUFFIX)
    raise "Cannot read password hash temporary file: #{(file + PASSHASH_SUFFIX).inspect}" unless File.readable?(file + PASSHASH_SUFFIX)
    raise "Password hash temporary file length is invalid: #{(file + PASSHASH_SUFFIX).inspect}" unless File.size(file + PASSHASH_SUFFIX) == SALT_LEN + HMAC_LEN

    passhash = true

    ## Read temporary passphrase hash file:
    psalt = pcheck = nil
    File.open(file + PASSHASH_SUFFIX, File::RDONLY) do |fp|
      psalt  = fp.read(SALT_LEN)
      pcheck = fp.read(HMAC_LEN)
    end

    ## Was a passphrase supplied?
    if passphrase.nil?
      ## No, so ask for one:
      loop do
        passphrase = getphrase
        ## Check the phrase against the stored hash:
        break if passmatch?(passphrase, psalt, pcheck)
        puts "*** ERROR: Passphrase mismatch. Try again, abort, or delete temporary file: #{file + PASSHASH_SUFFIX}"
      end
    else
      ## Yes, so check supplied phrase against the stored hash:
      raise "Passphrase mismatch" unless passmatch?(passphrase, psalt, pcheck)
    end
  elsif passphrase.nil?
    puts "*** ALERT: Enter your NEW passphrase twice. DO NOT FORGET IT, or you may lose your data!"
    passphrase = getphrase(true)
  end

  ## Use secure random data to populate salt, key, and iv (unless debugging data is provided):
  if debug_params.nil?
    file_key  = SecureRandom.random_bytes(KEY_LEN)   ## Get some random key material
    file_iv   = SecureRandom.random_bytes(IV_LEN)    ## And a random initialization vector
    salt      = SecureRandom.random_bytes(SALT_LEN)
  else
    ## Manually-provided debugging/testing data will be used instead
    ## (not recommended unless testing/debugging):
    raise "Invalid debugging parameter data provided!" unless debug_params.is_a?(String) &&
                                                       debug_params.encoding == Encoding::BINARY &&
                                                       debug_params.size == SALT_LEN + KEY_LEN + IV_LEN
    salt     = debug_params.slice!(0,SALT_LEN)
    file_key = debug_params.slice!(0,KEY_LEN)
    file_iv  = debug_params
  end

  ## Encrypt the file key and IV using password-derived keying material:
  keymaterial = pbkdf2(passphrase, salt, KEY_LEN + IV_LEN)
  cipher = OpenSSL::Cipher.new(CIPHER)
  cipher.encrypt
  ## No padding required for this operation since the file key + IV is
  ## an exact multiple of the cipher block length:
  cipher.padding = 0
  cipher.key     = keymaterial[0,KEY_LEN]
  cipher.iv      = keymaterial[KEY_LEN,IV_LEN]
  encrypted_keymaterial = cipher.update(file_key + file_iv) + cipher.final
  encrypted_file_key = encrypted_keymaterial[0,KEY_LEN]
  encrypted_file_iv  = encrypted_keymaterial[KEY_LEN,IV_LEN]

  ## Open the plaintext file for reading (and later overwriting):
  rfp = File.open(file, File::RDWR|File::EXCL)

  ## Open a temporary ciphertext file for writing:
  wfp = Tempfile.new(File.basename(rfp.path), File.dirname(rfp.path))

  ## Write the salt and encrypted file key + IV and
  ## temporarily fill the PBKDF2-hashed HMAC slot with zero-bytes:
  wfp.write(salt + encrypted_file_key + encrypted_file_iv + (0.chr * HMAC_LEN))

  ## Start the HMAC:
  hmac = OpenSSL::HMAC.new(passphrase, HMAC_FUNC)
  hmac << salt
  hmac << encrypted_file_key
  hmac << encrypted_file_iv

  ## Encrypt file with file key + IV:
  cipher = OpenSSL::Cipher.new(CIPHER)
  cipher.encrypt
  ## Encryption of file contents uses PCKS#5 padding which OpenSSL should
  ## have enabled by default.  Nevertheless, we explicitly enable it here:
  cipher.padding = 1
  cipher.key     = file_key
  cipher.iv      = file_iv
  until rfp.eof?
    data = rfp.read(FILE_CHUNK_LEN)
    if data.bytesize > 0
      data = cipher.update(data)
      hmac << data
      wfp.write(data)
    end
  end
  data = cipher.final
  if data.bytesize > 0
    ## Save the last bit-o-data and update the HMAC:
    wfp.write(data)
    hmac << data
  end

  ## Instead of storing the HMAC directly, use PBKDF2 to store data generated
  ## from the HMAC in hopes that PBKDF2's multiple iterations will make
  ## brute force dictionary attacks against the passphrase much more cumbersome:
  wfp.pos = SALT_LEN + KEY_LEN + IV_LEN
  wfp.write(pbkdf2(passphrase + hmac.digest, salt, HMAC_LEN))

  ## Overwrite the original plaintext file with zero bytes.
  ## This adds a small measure of security against recovering
  ## the original unencrypted contents.  It would likely be
  ## better to overwrite the file multiple times with different
  ## bit patterns, including one or more iterations using
  ## high-quality random data.
  rfp.seek(0,File::SEEK_END)
  fsize = rfp.pos
  rfp.pos = 0
  while rfp.pos + FILE_CHUNK_LEN < fsize
    rfp.write(0.chr * FILE_CHUNK_LEN)
  end
  rfp.write(0.chr * (fsize - rfp.pos)) if rfp.pos < fsize
  rfp.close

  ## Copy file ownership/permissions:
  stat = File.stat(rfp.path)
  wfp.chown(stat.uid, stat.gid)
  wfp.chmod(stat.mode)

  ## Close the ciphertext temporary file without deleting:
  wfp.close(false)

  ## Rename temporary file to permanent name:
  File.rename(wfp.path, rfp.path)

  ## Remove password hash temp. file:
  File.delete(file + PASSHASH_SUFFIX) if passhash
end

.getphrase(check = false) ⇒ Object

Read a passphrase from a terminal.



76
77
78
79
80
81
82
83
84
85
# File 'lib/filesafe.rb', line 76

def self.getphrase(check=false)
  begin
    phrase = HighLine.new.ask('Passphrase: '){|q| q.echo = '*' ; q.overwrite = true ; q.validate = nil }
    return phrase unless check
    tmp = HighLine.new.ask('Retype passphrase: '){|q| q.echo = '*' ; q.overwrite = true ; q.validate = nil }
    return phrase if tmp == phrase
  rescue Interrupt
    exit - 1
  end while true
end

.passmatch?(passphrase, salt, hash) ⇒ Boolean

Returns:

  • (Boolean)


87
88
89
# File 'lib/filesafe.rb', line 87

def self.passmatch?(passphrase, salt, hash)
  pbkdf2(passphrase, salt, HMAC_LEN) == hash
end

.pbkdf2(passphrase, salt, len) ⇒ Object

Execute PBKDF2 to generate the specified number of bytes of pseudo-random key material.



331
332
333
334
335
336
337
338
339
# File 'lib/filesafe.rb', line 331

def self.pbkdf2(passphrase, salt, len)
  OpenSSL::PKCS5.pbkdf2_hmac(
    passphrase,
    salt,
    ITERATIONS,
    len,
    OpenSSL::Digest.new(HMAC_FUNC)
  )
end

Instance Method Details

#opensslObject

Encryption/HMAC/Hash/PBKDF2 algorithms



35
# File 'lib/filesafe.rb', line 35

require 'openssl'