Problem
Most file encryption tutorials stop at "here's how to call AES-CBC." They don't cover key derivation, authenticated encryption, memory safety, or what happens when you try to encrypt a 10 GB file. I wanted to build something that actually got all of that right.
StreamSeal is an attempt to write a correct, auditable CLI encryption tool in C99 — one I could read, explain, and defend line by line.
Cryptographic Design
StreamSeal uses a two-layer construction:
-
Key derivation: Argon2id (memory-hard KDF) derives a 32-byte encryption key from a user passphrase and a random 16-byte salt. Memory hardness makes offline brute-force attacks expensive even with specialized hardware.
-
Streaming encryption: libsodium's
secretstreamAPI (ChaCha20-Poly1305) encrypts data in fixed-size chunks. Each chunk is authenticated before being written to disk — corruption or truncation is detected before decryption proceeds.
Passphrase + Salt
↓
Argon2id
↓
32-byte key
↓
secretstream_init_push
↓
chunk → encrypt → AEAD tag → write
chunk → encrypt → AEAD tag → write
...
TAG_FINAL → encrypt → write
Header binding as AAD. The file header (magic bytes, version, salt, algorithm identifiers) is bound to the first secretstream message as Additional Authenticated Data. This means any tampering with the header — including downgrade attacks that swap in different algorithm identifiers — will fail authentication. The ciphertext is inseparable from its header.
Implementation Details
Constant-memory streaming. Files are processed in fixed-size chunks regardless of file size. A 4 KB buffer handles both a 100-byte config file and a 100 GB archive identically. Memory usage is bounded and predictable.
Atomic credential writes. When StreamSeal generates or stores credentials, it writes to a temporary file with mode 0600, then uses rename(2) for an atomic swap. A power failure during a write cannot produce a corrupt credential file.
Folder encryption. Directory mode creates a temporary tar archive, encrypts the archive, and removes the intermediate file. The archive creation and cleanup are handled with explicit error paths to avoid orphaning sensitive data.
Testing & Quality
- 200 encrypt/decrypt test cases covering normal files, empty files, large files, and edge cases
- Corruption tests that flip individual bytes in the ciphertext and verify detection
- Fuzz smoke tests feeding random input to the decrypt path
- Sanitizer-clean: AddressSanitizer + UndefinedBehaviorSanitizer pass with zero findings
- Static analysis: cppcheck reports zero warnings
- CI: GitHub Actions runs the full test suite on every push
Performance
Measured on Apple M3, single thread, local SSD, 1 GB test files:
| Operation | Throughput | |---|---| | Encryption | 15.6 GB/min | | Decryption | 15.4 GB/min |
Streaming throughput is primarily bounded by memory bandwidth, not CPU — the ChaCha20-Poly1305 cipher is hardware-accelerated on modern ARM and x86_64.
Challenges
The header commitment problem. Early versions of the tool had a subtle bug: the salt was written to the header but not included in the authenticated data stream. An attacker could swap the salt in the header, causing the recipient to derive a different key and fail decryption — but not detect why. The fix was to bind the entire header to the first AAD chunk, making any modification detectable.
Avoiding partial decryption. secretstream authenticates each chunk independently, which means a corrupt file could decrypt a few chunks successfully before failing mid-way. StreamSeal's decrypt path reads the entire file once to verify all authentication tags, then rewinds and writes plaintext only if every chunk passes. This prevents writing a partially-decrypted file to disk.
Lessons Learned
C99 is a surprisingly good language for security tooling — the lack of implicit allocations makes data flow easy to audit. The hard part isn't the cryptography (libsodium makes the primitives safe); it's the I/O plumbing, error handling, and getting the authenticated data binding right.
StreamSeal is MIT licensed and available on GitHub.