module FileSafe
FileSafe
module has four module methods, two for file encryption/decryption, one for passphrase hashing, and one for reading a passphrase from a terminal.
Constants
- BLOCK_LEN
Cipher block length/size (128 bits/16 bytes for AES-256):
- CIPHER
Default cipher (AES-256 in CBC mode):
- FILE_CHUNK_LEN
Number of bytes to read from plaintext/ciphertext files at a time (64KB by default):
- HEADER_LEN
Default ciphertext file header size (key + IV + salt + HMAC = 1280 bits/160 bytes by default)
- HMAC_FUNC
Default hash function to use for HMAC (SHA-512 by default):
- HMAC_LEN
Default HMAC size/length (512 bits/64 bytes for HMAC-SHA-512):
- ITERATIONS
Number of iterations to use in PBKDF2 (16384 by default):
- IV_LEN
Cipher initialization vector (IV) length (128 bits/16 bytes for AES-256):
- KEY_LEN
Cipher key length (256 bits/32 bytes for AES-256):
- PASSHASH_SUFFIX
Temporary passphrase hash file suffix:
- SALT_LEN
Salt size/length (384 bits/48 bytes, KEY + IV size):
Public Class Methods
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.
# File lib/filesafe.rb, line 241 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? raise "*** ERROR: Failed to get passphrase. ABORTING!" 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 a file with the supplied passphrase–or if none is supplied, read a passphrase from the terminal.
# File lib/filesafe.rb, line 97 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
Read a passphrase from a terminal.
# File lib/filesafe.rb, line 76 def self.getphrase(check=false) console = IO.console phrase_a = nil begin puts "*** ALERT: Phrases DO NOT MATCH! Please try again." unless phrase_a.nil? phrase_a = console.getpass('Passphrase: ') return phrase_a unless check phrase_b = console.getpass('Repeat Passphrase: ') rescue Interrupt STDERR.puts "*** ALERT: Interrupted while reading passphrase. ABORTING!" exit(-1) end while phrase_a != phrase_b return phrase_a end
# File lib/filesafe.rb, line 91 def self.passmatch?(passphrase, salt, hash) pbkdf2(passphrase, salt, HMAC_LEN) == hash end
Execute PBKDF2 to generate the specified number of bytes of pseudo-random key material.
# File lib/filesafe.rb, line 336 def self.pbkdf2(passphrase, salt, len) OpenSSL::PKCS5.pbkdf2_hmac( passphrase, salt, ITERATIONS, len, OpenSSL::Digest.new(HMAC_FUNC) ) end