Building an Encrypted File Copy in C
AES-256 and HMAC over netcat, with verify-then-decrypt.
Contents
This is a small scp-style program in C that copies a file from one machine to another over an encrypted channel. The point was to build that channel by hand with OpenSSL’s libcrypto, instead of leaning on SSH and to see what an authenticated encryption pipeline actually involves from one end to the other.
Background: encryption alone isn’t enough#
It’s tempting to think that once data is encrypted, it’s safe. Encryption only gives you confidentiality: an attacker can’t read the data. It doesn’t stop them from changing it.
With a mode like AES-CBC, flipping a bit in the ciphertext causes a predictable change in the decrypted output. An attacker who can’t read a file can still corrupt it, sometimes in controlled ways and the receiver would happily decrypt the result and write it to disk.
The fix is to add integrity with a MAC (message authentication code). A MAC is a short tag computed from the data and a secret key. Without the key, you can’t produce a valid tag for modified data. HMAC-SHA256 is a standard choice.
There are three ways to combine encryption with a MAC and only one is recommended:
| Construction | What gets MACed | Verdict |
|---|---|---|
| MAC-then-encrypt | The plaintext, then the plaintext and tag are encrypted together | Has led to real attacks (padding oracles in older TLS) |
| Encrypt-and-MAC | The plaintext, with the tag sent alongside the ciphertext | The tag can leak information about the plaintext |
| Encrypt-then-MAC | The ciphertext | The recommended choice |
This tool uses encrypt-then-MAC: encrypt first, then compute the HMAC over the ciphertext. The receiver can check the tag before decrypting anything and reject tampered data without ever running it through the decryption code.
The pipeline#
On each side, the work is split across two threads that pass data to each other through a shared buffer. On the client, one thread reads the file and does the cryptography while the other handles the network. The server runs the same split in reverse:
- Client crypto thread: reads the file, encrypts it with AES-256-CBC using a random IV and computes an HMAC-SHA256 over the result.
- Client network thread: base64-encodes the ciphertext and sends it to the server, together with the IV and the HMAC.
- Server network thread: reads the incoming stream and hands it over.
- Server crypto thread: verifies the HMAC. Only if it’s valid does it decrypt the payload and write the file to disk.
Splitting it this way means reading and encrypting can overlap with sending, instead of each step waiting for the previous one to finish.
Keys, IVs and the HMAC#
The key#
Both ends share a pre-shared key. There’s no key exchange: the key has to be put on both machines ahead of time. That keeps the program focused on the encryption and authentication, which was the point.
The IV#
CBC mode needs an initialization vector (IV), a random block that makes sure encrypting the same file twice produces completely different ciphertext. Without it, an observer could tell when the same file was sent twice.
A fresh IV is generated for every transfer by reading from /dev/urandom. The IV isn’t secret. It has to be unpredictable, but the receiver needs it to decrypt, so it travels openly alongside the ciphertext.
The check#
The HMAC is keyed from the shared secret and covers the encrypted bytes, so the server can detect any tampering before it touches the plaintext.
That check is the most important line in the program. If even one bit of the ciphertext is flipped in transit, HMAC verification fails and the file is rejected. Nothing is written to disk. The server never decrypts data it hasn’t authenticated.
netcat as the transport#
The transport is deliberately simple: the bytes travel over netcat. The client pipes its base64 payload into an ncat process and the server reads it back from ncat’s output and parses it.
That’s why everything is base64-encoded first. Ciphertext is arbitrary binary data and encoding it as text makes it safe to push through a plain text pipe. Handing the socket work to netcat kept the program focused on encryption and authentication, not connection handling.
Worth knowing if you build something similar#
A few details matter a lot in designs like this:
- Authenticate the IV too. If the HMAC covers only the ciphertext, the IV is sent unprotected. In CBC mode, changing the IV changes the first decrypted block in a predictable way. Computing the HMAC over the IV and the ciphertext closes that gap.
- Use separate keys for encryption and the MAC. The standard practice is to derive two independent keys from the shared secret (with HKDF, for example) instead of using one key for both jobs.
- Compare tags in constant time. A normal
memcmpreturns as soon as it finds a difference, which leaks timing information. OpenSSL providesCRYPTO_memcmpfor this. - Or use an AEAD mode. AES-GCM and ChaCha20-Poly1305 do encryption and authentication in one construction and remove most of these pitfalls.
What I took from it#
The lasting lesson was the discipline of verify, then decrypt. The HMAC has to be checked before the ciphertext is trusted and the file must not be written if the check fails. This is a hobby reimplementation of things scp and TLS give you for free, but building the IV handling, the keyed MAC and the failure path myself made it obvious why each of those pieces exists.