A small command-line tool for encrypting and decrypting individual files. It has two commands and no plugin system, sync feature, or cloud component. The point of this project is to demonstrate correct use of authenticated encryption on files of arbitrary size, not to be a full-featured tool.
cryptfile encrypt myphotos.tar
cryptfile decrypt myphotos.tar.enc
pip install -e .
This installs a cryptfile command backed by the cryptography library.
cryptfile encrypt <file> [-o output] [--iterations N] [--shred]
cryptfile decrypt <file> [-o output]
Both commands prompt for the passphrase interactively (via getpass).
The passphrase is never accepted as a command-line argument, because
arguments are visible in shell history and in ps output on shared
machines.
encryptwrites<file>.encby default, or a path given with-o.decryptwrites<file>with.encstripped by default, or a path given with-o.--shredoverwrites and deletes the original plaintext file after a successful encrypt. See "Shred limitations" below before relying on this for anything sensitive.
The encryption key is derived from the user's passphrase with
PBKDF2-HMAC-SHA256. PBKDF2 is not the newest password-hashing
construction available (Argon2id would be a reasonable alternative),
but it is well understood, has an implementation directly available in
cryptography's hazmat layer without pulling in another dependency,
and is still an accepted choice under current OWASP guidance provided
the iteration count is high enough.
310,000 iterations is OWASP's published baseline for PBKDF2-HMAC-SHA256 as of this writing. It is not hardcoded as an unchangeable constant: the iteration count actually used is stored in the file's header, so a file encrypted today can still be decrypted correctly even if a future version of this tool raises the default for new files. Old files keep working; they just remain protected at whatever iteration count they were written with, which is a case for re-encrypting old sensitive files periodically rather than assuming they get stronger for free.
The salt is 16 random bytes generated fresh for every file with
os.urandom and stored alongside the ciphertext. It is never fixed or
reused across files. Without a per-file salt, an attacker who
precomputes a table of derived keys for common passphrases could apply
that table to every file encrypted with this tool at once; a fresh
salt forces the attacker to redo the expensive derivation work per
file.
AES-GCM was chosen over AES-CBC because GCM is an authenticated mode: decryption fails loudly if the ciphertext or the associated data has been modified, instead of silently producing corrupted plaintext that looks superficially valid. CBC on its own gives no integrity guarantee at all; using it safely means bolting on a separate MAC and getting the encrypt-then-MAC ordering right, which is exactly the kind of detail that is easy to get wrong by hand. GCM avoids that whole category of mistake by folding authentication into the mode itself.
The one hard rule for GCM is that a (key, nonce) pair must never be reused. Reusing a nonce with the same key breaks GCM's confidentiality and, in some cases, allows an attacker to forge ciphertexts. This tool handles that as follows:
- Every file gets a fresh, unique 256-bit key, because every file gets
a fresh random salt and the key is derived from
(passphrase, salt). - Within a file, the nonce for chunk
iis a random 4-byte per-file prefix concatenated with an 8-byte big-endian counter starting at 0. Since the key is already unique to the file, a monotonically increasing counter is sufficient on its own to guarantee no nonce is reused within that file. The random prefix is added on top as a second layer of protection, so that even a bug which reset the counter to 0 across two different files would still need the same key to matter, and the key already differs because the salt does. - The counter is 8 bytes, so a single file would need over 2^64 chunks before it could wrap around. At a 64 KiB chunk size that is far beyond any file this tool will ever see in practice.
Encrypting a whole file as a single AES-GCM operation would require holding the entire plaintext (or the entire ciphertext) in memory at once, which does not scale to large files. It also concentrates all of a file's data behind a single nonce and a single 128-bit authentication tag, so any single bit error anywhere in a multi-gigabyte file fails the whole file at once with no ability to process it incrementally.
Splitting the file into fixed-size chunks (64 KiB by default) and encrypting each chunk as its own AES-GCM operation solves the memory problem: the tool only ever needs one chunk resident in memory during encryption or decryption, so file size is effectively unbounded on memory grounds. Each chunk gets a unique nonce as described above, and its own tag, so tampering can be localized to a specific chunk index instead of merely "somewhere in the file."
Chunking does introduce a new problem that a single whole-file nonce does not have: an attacker can drop, duplicate, or reorder chunks in a way that AES-GCM's per-chunk authentication alone does not catch, since each chunk is authenticated in isolation. This is addressed by binding each chunk's position and finality into its associated data (AAD):
AAD = chunk_index (8 bytes, big-endian) || is_last (1 byte)
Decryption is done in file order and checks the chunk index it expects
against the index baked into the ciphertext's AAD implicitly, by only
succeeding when it decrypts with the AAD for that exact index. An
attacker who reorders chunks changes which AAD is needed to decrypt
each one, so decryption of the misplaced chunk fails. An attacker who
truncates the file after a non-final chunk causes decryption to finish
without ever reaching a chunk whose AAD says is_last = true, which
this tool treats as an error rather than a successful decrypt of a
shorter file.
MAGIC 8 bytes b"CRYPTFIL"
VERSION 1 byte currently 1
KDF_ITERATIONS 4 bytes big-endian uint32
SALT 16 bytes PBKDF2 salt
NONCE_PREFIX 4 bytes random per-file nonce prefix
CHUNK_SIZE 4 bytes big-endian uint32, plaintext bytes per chunk
---- header ends, one or more chunks follow ----
IS_LAST 1 byte 0x00 or 0x01, plaintext framing hint
CHUNK_LEN 4 bytes big-endian uint32, length of ciphertext+tag
CHUNK_DATA CHUNK_LEN ciphertext followed by the 16-byte GCM tag
IS_LAST is stored in plaintext so a reader does not need to guess
which AAD to try, but it is not trusted on its own: it is also part of
the AAD used for that chunk's GCM tag, so if it is altered on disk the
chunk simply fails authentication instead of being silently
misinterpreted.
The format is versioned from the first release. A future version that
changes the header layout, the KDF, or the chunking scheme can bump
VERSION and branch on it during reads, without losing the ability to
decrypt files written by this version.
This tool uses cryptography's hazmat AESGCM and PBKDF2HMAC
classes directly rather than implementing AES or PBKDF2 from scratch.
Implementing AES-GCM correctly by hand, including getting the nonce
construction, tag verification, and constant-time comparisons right,
is a well-known source of real vulnerabilities even for careful
engineers, and there is no benefit to redoing that work here. The part
of this project that is actually interesting is the key management,
nonce handling, and chunking design described above, not a
reimplementation of primitives that a well-vetted library already
provides.
This tool protects file contents at rest against someone who obtains
the .enc file without the passphrase, assuming the passphrase itself
is strong and kept secret. It does not protect against everything, and
some of the gaps are worth stating plainly rather than glossing over:
-
No protection against a compromised machine while the tool is running. If the OS, another process, or a keylogger can observe what you type or read process memory while
cryptfileis running, the passphrase and plaintext are exposed regardless of what the crypto does. This tool has no defense against that; it assumes the machine it runs on is not already compromised at the time of use. -
No secure memory wiping. Python does not give a program reliable control over when memory is copied, moved by the garbage collector, or paged to disk. This tool does not attempt to zero out passphrases or plaintext buffers in memory, and doing so in CPython would be partial at best. Treat anything decrypted during a run as potentially recoverable from memory or swap after the fact, and don't consider this suitable for adversaries who can capture memory images of the machine.
-
Passphrase strength is the user's responsibility. PBKDF2 at 310,000 iterations raises the cost of brute-forcing a weak passphrase, but it does not turn a weak passphrase into a strong one. A short or common passphrase is still crackable, just more slowly.
-
--shredis best-effort, not a guarantee. It overwrites the original file's bytes in place and deletes it. On a traditional spinning disk with a filesystem that overwrites data in place, this is reasonably effective. On modern SSDs, wear leveling means the physical cells holding the old data are frequently not the ones that get overwritten by a logical "overwrite the same file" operation, so a copy of the original data can persist on the drive regardless of what this tool does at the filesystem level. Journaling and copy-on-write filesystems (and filesystem snapshots, and backups) have similar problems. If a file absolutely must not be recoverable, full-disk encryption from the start is a much stronger guarantee than after-the-fact shredding of a plaintext copy. -
No protection for metadata. File size (rounded up to the nearest chunk boundary) is visible from the size of the
.encfile. This tool does not pad files to hide their approximate size, and it does not encrypt filenames. -
Not a substitute for transport security. This tool encrypts files at rest. It has no opinion on how the resulting
.encfile gets from one machine to another; use a secure channel for that separately.