diff --git a/README.md b/README.md index e19cc9f..d0b24c7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,45 @@ # scrambler Tool for encrypting and decrypting entire directories on-the-fly. + +scrambler uses a block cipher and passphrase to encrypt and decrypt files in-place, as a means for quickly securing content without having to move things and/or create archives. scrambler appends a `.enc` extension to encrypted files, so it can use context clues to determine whether you want to perform encryption or decryption. + +As of writing, this application uses an AES-256 block cipher, with a passphrase that's hashed by SHA-256. + +## Usage +```shell +# Show help information. +scrambler -h +scrambler --help + +# Encrypt or decrypt the current directory. +scrambler + +# Encrypt or decrypt a specified directory. +scrambler my-dir + +# Encrypt or decrypt a single file. +scrambler path/to/my/file.txt + +# Encrypt or decrypt a directory recursively. +scrambler my-dir -r + +# Add -v for verbose output. +scrambler myfile.txt -v + +# Explicitly choose to encrypt with -e. +scrambler my-dir -e + +# Explicitly choose to decrypt with -d. +scrambler my-encrypted-dir -d + +# Supply a passphrase from a file. +scrambler my-dir -p my-passphrase.txt +``` + +## Download +You can download a compatible release from the [releases](https://github.com/andrewlalis/scrambler/releases) page. If you can't find any release that's available for your system, you can build it yourself: +```shell +git clone git@github.com:andrewlalis/scrambler.git +cd scrambler +dub build --build=release +``` diff --git a/reset.sh b/reset.sh new file mode 100755 index 0000000..4599446 --- /dev/null +++ b/reset.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +rm -rf test-dir +cp -R test-dir-tpl test-dir \ No newline at end of file diff --git a/source/app.d b/source/app.d index 44334fb..b29bb90 100644 --- a/source/app.d +++ b/source/app.d @@ -14,6 +14,10 @@ import cipher_utils; import cli_utils; int main(string[] args) { + if (args.length >= 2 && (args[1] == "-h" || args[1] == "--help")) { + printUsage(); + return 0; + } Params params; int result = parseParams(args, params); if (result != 0) { @@ -21,80 +25,98 @@ int main(string[] args) { return result; } - BlockCipher cipher = null; - // TODO: Use args to determine block cipher. - writeln("Enter a passphrase:"); - string password = readPassphrase(); - if (password is null) { + string passphrase; + if (params.passphraseFile !is null && exists(params.passphraseFile) && isFile(params.passphraseFile)) { + if (params.verbose) { + writefln!"Reading passphrase from \"%s\""(params.passphraseFile); + } + passphrase = readText(params.passphraseFile).strip(); + } else { + write("Enter passphrase: "); + passphrase = readPassphrase(); + writeln(); + } + if (passphrase is null || passphrase.length == 0) { + stderr.writeln("Invalid or missing passphrase."); return 2; } HashFunction hash = new SHA256(); - auto secureKeyVector = hash.process(password); - cipher = new AES256(); + auto secureKeyVector = hash.process(passphrase); + BlockCipher cipher = new AES256(); cipher.setKey(secureKeyVector); ubyte[] buffer = new ubyte[cipher.blockSize]; if (isDir(params.target)) { if (params.action == Action.ENCRYPT) { - encryptDir(params.target, cipher, buffer, params.recursive); + encryptDir(params.target, cipher, buffer, params.recursive, params.verbose); } else { - decryptDir(params.target, cipher, buffer, params.recursive); + bool success = decryptDir(params.target, cipher, buffer, params.recursive, params.verbose); + if (!success) { + stderr.writeln("Decryption failed."); + return 3; + } } } else if (isFile(params.target)) { if (params.action == Action.ENCRYPT) { - encryptAndRemoveFile(params.target, cipher, buffer); + encryptAndRemoveFile(params.target, cipher, buffer, params.verbose); } else { - decryptAndRemoveFile(params.target, cipher, buffer); + bool success = decryptAndRemoveFile(params.target, cipher, buffer, params.verbose); + if (!success) { + stderr.writeln("Decryption failed."); + return 3; + } } - } else { - stderr.writeln("Target is not a directory or file."); - return 1; } return 0; } -void encryptAndRemoveFile(string filename, BlockCipher cipher, ref ubyte[] buffer) { - string encryptedFilename = filename ~ ".enc"; - encryptFile(filename, encryptedFilename, cipher, buffer); +void encryptAndRemoveFile(string filename, BlockCipher cipher, ref ubyte[] buffer, bool verbose) { + string encryptedFilename = filename ~ ENCRYPTED_SUFFIX; + encryptFile(filename, encryptedFilename, cipher, buffer, verbose); std.file.remove(filename); } -void decryptAndRemoveFile(string filename, BlockCipher cipher, ref ubyte[] buffer) { - string decryptedFilename = filename[0 .. $-4]; - decryptFile(filename, decryptedFilename, cipher, buffer); +bool decryptAndRemoveFile(string filename, BlockCipher cipher, ref ubyte[] buffer, bool verbose) { + string decryptedFilename = filename[0 .. $-ENCRYPTED_SUFFIX.length]; + bool success = decryptFile(filename, decryptedFilename, cipher, buffer, verbose); + if (!success) return false; std.file.remove(filename); + return true; } -void encryptDir(string dirname, BlockCipher cipher, ref ubyte[] buffer, bool recursive) { +void encryptDir(string dirname, BlockCipher cipher, ref ubyte[] buffer, bool recursive, bool verbose) { string[] dirsToTraverse; foreach (DirEntry entry; dirEntries(dirname, SpanMode.shallow, false)) { - if (entry.isFile && !endsWith(entry.name, ".enc")) { - encryptAndRemoveFile(entry.name, cipher, buffer); + if (entry.isFile && !endsWith(entry.name, ENCRYPTED_SUFFIX)) { + encryptAndRemoveFile(entry.name, cipher, buffer, verbose); } else if (entry.isDir && recursive) { dirsToTraverse ~= entry.name; } } if (recursive) { foreach (string childDirname; dirsToTraverse) { - encryptDir(childDirname, cipher, buffer, recursive); + encryptDir(childDirname, cipher, buffer, recursive, verbose); } } } -void decryptDir(string dirname, BlockCipher cipher, ref ubyte[] buffer, bool recursive) { +bool decryptDir(string dirname, BlockCipher cipher, ref ubyte[] buffer, bool recursive, bool verbose) { string[] dirsToTraverse; foreach (DirEntry entry; dirEntries(dirname, SpanMode.shallow, false)) { - if (entry.isFile && endsWith(entry.name, ".enc")) { - decryptAndRemoveFile(entry.name, cipher, buffer); + if (entry.isFile && endsWith(entry.name, ENCRYPTED_SUFFIX)) { + bool success = decryptAndRemoveFile(entry.name, cipher, buffer, verbose); + if (!success) return false; } else if (entry.isDir && recursive) { dirsToTraverse ~= entry.name; } } if (recursive) { foreach (string childDirname; dirsToTraverse) { - decryptDir(childDirname, cipher, buffer, recursive); + bool success = decryptDir(childDirname, cipher, buffer, recursive, verbose); + if (!success) return false; } } + return true; } diff --git a/source/cipher_utils.d b/source/cipher_utils.d index da20ddc..a4e3c4c 100644 --- a/source/cipher_utils.d +++ b/source/cipher_utils.d @@ -2,13 +2,31 @@ module cipher_utils; import botan.block.block_cipher : BlockCipher; import std.stdio; +import std.file; -public void encryptFile(string filename, string outputFilename, BlockCipher cipher, ref ubyte[] buffer) { +public const string ENCRYPTED_SUFFIX = ".enc"; + +public void encryptFile(string filename, string outputFilename, BlockCipher cipher, ref ubyte[] buffer, bool verbose) { assert(buffer.length == cipher.blockSize, "Buffer length must match cipher block size."); + if (verbose) { + writefln!"Encrypting file \"%s\" of %d bytes to \"%s\" using cipher %s."( + filename, + getSize(filename), + outputFilename, + cipher.name + ); + } File fIn = File(filename, "rb"); File fOut = File(outputFilename, "wb"); // First, write one block containing the file's size. writeSizeBytes(buffer, fIn.size); + // Fill the rest of the block with an incrementing series of bytes, so we can easily validate decryption. + if (buffer.length > 8) { + ubyte marker = 1; + for (size_t i = 8; i < buffer.length; i++) { + buffer[i] = marker++; + } + } cipher.encrypt(buffer); fOut.rawWrite(buffer); // Then write the rest of the file. @@ -18,17 +36,49 @@ public void encryptFile(string filename, string outputFilename, BlockCipher ciph } fIn.close(); fOut.close(); + if (verbose) { + writefln!" Encrypted file has a size of %d bytes."(getSize(outputFilename)); + } } -public void decryptFile(string filename, string outputFilename, BlockCipher cipher, ref ubyte[] buffer) { +public bool decryptFile(string filename, string outputFilename, BlockCipher cipher, ref ubyte[] buffer, bool verbose) { assert(buffer.length == cipher.blockSize, "Buffer length must match cipher block size."); + if (verbose) { + writefln!"Decrypting file \"%s\" of %d bytes to \"%s\" using cipher %s."( + filename, + getSize(filename), + outputFilename, + cipher.name + ); + } File fIn = File(filename, "rb"); - File fOut = File(outputFilename, "wb"); // First, read one block containing the file's size. fIn.rawRead(buffer); cipher.decrypt(buffer); + // Verify the sequence of values to ensure decryption was successful. + if (buffer.length > 8) { + ubyte expectedMarker = 1; + for (size_t i = 8; i < buffer.length; i++) { + if (buffer[i] != expectedMarker) { + if (verbose) { + writefln!" Decryption validation failed. Expected byte at index %d to be %d, but got %d."( + i, + expectedMarker, + buffer[i] + ); + } + fIn.close(); + return false; + } + expectedMarker++; + } + } ulong size = readSizeBytes(buffer); + if (verbose) { + writefln!" Original file had size of %d bytes."(size); + } ulong bytesWritten = 0; + File fOut = File(outputFilename, "wb"); // Then read the rest of the file. foreach (ubyte[] chunk; fIn.byChunk(buffer)) { cipher.decrypt(buffer); @@ -41,27 +91,48 @@ public void decryptFile(string filename, string outputFilename, BlockCipher ciph } fIn.close(); fOut.close(); + if (verbose) { + writefln!" Decrypted file has a size of %d bytes."(getSize(outputFilename)); + } + return true; +} + +union LongByteArrayUnion { + ulong longValue; + ubyte[8] bytes; } private void writeSizeBytes(ref ubyte[] bytes, ulong size) { - assert(bytes.length >= 4, "Array length must be at least 4."); - bytes[0] = size & 0xFF; - bytes[1] = (size << 8) & 0xFF; - bytes[2] = (size << 16) & 0xFF; - bytes[3] = (size << 24) & 0xFF; - if (bytes.length > 4) { - for (size_t i = 4; i < bytes.length; i++) { + assert(bytes.length >= 8, "Array length must be at least 8."); + LongByteArrayUnion u; + u.longValue = size; + for (size_t i = 0; i < 8; i++) bytes[i] = u.bytes[i]; + if (bytes.length > 8) { + for (size_t i = 8; i < bytes.length; i++) { bytes[i] = 0; } } } private ulong readSizeBytes(ref ubyte[] bytes) { - assert(bytes.length >= 4, "Array length must be at least 4."); - ulong size = 0; - size += bytes[0]; - size += bytes[1] << 8; - size += bytes[2] << 16; - size += bytes[3] << 24; - return size; + assert(bytes.length >= 8, "Array length must be at least 8."); + LongByteArrayUnion u; + for (size_t i = 0; i < 8; i++) u.bytes[i] = bytes[i]; + return u.longValue; +} + +unittest { + ubyte[] buffer = new ubyte[16]; + + void doAssert(ulong size) { + import std.format; + writeSizeBytes(buffer, size); + ulong r = readSizeBytes(buffer); + assert(r == size, format!"Value read: %d does not match expected: %d."(r, size)); + } + + doAssert(0); + doAssert(1); + doAssert(42); + doAssert(74_092_382_742_030); } \ No newline at end of file diff --git a/source/cli_utils.d b/source/cli_utils.d index 4dd5b39..13fc4fa 100644 --- a/source/cli_utils.d +++ b/source/cli_utils.d @@ -1,5 +1,7 @@ module cli_utils; +import cipher_utils : ENCRYPTED_SUFFIX; + enum Action { ENCRYPT, DECRYPT @@ -10,6 +12,7 @@ struct Params { string target = "."; bool recursive = false; bool verbose = false; + string passphraseFile = null; } int parseParams(string[] args, ref Params params) { @@ -24,19 +27,12 @@ int parseParams(string[] args, ref Params params) { "recursive|r", ¶ms.recursive, "verbose|v", ¶ms.verbose, "encrypt|e", &isEncrypting, - "decrypt|d", &isDecrypting + "decrypt|d", &isDecrypting, + "passphrase-file|p", ¶ms.passphraseFile ); if (isEncrypting && isDecrypting) { stderr.writeln("Invalid arguments: Cannot specify both the --encrypt and --decrypt flags."); } - if (isEncrypting) { - params.action = Action.ENCRYPT; - } else if (isDecrypting) { - params.action = Action.DECRYPT; - } else { - writeln("Determining if we should encrypt or decrypt"); - params.action = Action.ENCRYPT; - } if (args.length > 1) { params.target = args[1]; @@ -45,9 +41,33 @@ int parseParams(string[] args, ref Params params) { return 1; } } + + if (isEncrypting) { + params.action = Action.ENCRYPT; + } else if (isDecrypting) { + params.action = Action.DECRYPT; + } else { + params.action = determineBestAction(params.target); + } return 0; } +Action determineBestAction(string target) { + import std.file; + import std.algorithm : endsWith; + + if (isFile(target)) { + return endsWith(target, ENCRYPTED_SUFFIX) ? Action.DECRYPT : Action.ENCRYPT; + } else if (isDir(target)) { + foreach (DirEntry entry; dirEntries(target, SpanMode.breadth, false)) { + if (entry.isFile && endsWith(entry.name, ENCRYPTED_SUFFIX)) return Action.DECRYPT; + } + return Action.ENCRYPT; + } else { + return Action.ENCRYPT; + } +} + void printUsage() { import std.stdio : writeln; writeln(q"HELP @@ -60,6 +80,9 @@ The following options are available: directory. -d | --decrypt Do a decryption operation on the target file or directory. + -p | --passphrase-file A file to read the passphrase from, instead of + prompting the user for a passphrase in the command + line. -r | --recursive Recursively encrypt/decrypt nested directories. -v | --verbose Show verbose output during runtime. -s | --no-suffix Do not add the ".enc" suffix to files. @@ -67,8 +90,7 @@ The following options are available: Encrypted files are suffixed with ".enc" to indicate that they're encrypted and cannot be read as usual. If neither --encrypt nor --decrypt flags are provided, Scrambler will try to determine which operation to do based on the presence of -".enc" file(s) at the target location. It's recommended that you always do -provide an explicit --encrypt or --decrypt flag. +".enc" file(s) at the target location. HELP"); } diff --git a/test-dir-tpl/1024px-Soccerball.png b/test-dir-tpl/1024px-Soccerball.png new file mode 100644 index 0000000..73923f1 Binary files /dev/null and b/test-dir-tpl/1024px-Soccerball.png differ diff --git a/test-dir/1024px-Soccerball.png b/test-dir/1024px-Soccerball.png new file mode 100644 index 0000000..73923f1 Binary files /dev/null and b/test-dir/1024px-Soccerball.png differ