Three Ciphers Walk Into a C++ CLI

A Caesar cipher, a One-Time Pad, and AES-256-GCM walk into a terminal. Only one of them leaves with its dignity intact.
For a cryptography lab at school, I built a C++ CLI that implements three encryption algorithms side by side: Caesar cipher, One-Time Pad, and AES-256-GCM. The idea was to compare them directly, from the hilariously broken to the actually trustworthy.
The repo is here. It uses CMake and OpenSSL, outputs hex for binary data, and has a little interactive menu so you can play with all three without touching the code. Let me walk through what I built and why each algorithm tells a very different story.
Caesar Cipher: the one we keep around to humble ourselves
The Caesar cipher shifts every letter in the plaintext by a fixed number of positions in the alphabet. That’s it. That’s the whole thing.
Where is the character position and is the key, an integer from 0 to 25.
So "hello" with becomes "khoor". Julius Caesar used this to communicate with his generals and probably felt very clever about it.
Here’s the thing: there are only 25 possible keys (key 0 is just the original text). A brute force attack takes less than a second. Even without brute force, a frequency analysis attack works because the cipher preserves letter frequencies. In English, E is the most common letter, so the most common ciphertext character is almost certainly the encrypted E. From there, the key falls out immediately.
std::string caesar_encrypt(const std::string& text, int shift) {
std::string result = text;
for (char& c : result) {
if (std::isalpha(c)) {
char base = std::isupper(c) ? 'A' : 'a';
c = (c - base + shift) % 26 + base;
}
}
return result;
} I included it in the CLI because every cryptography conversation needs a starting point, and Caesar is the perfect “this is where we came from” anchor. It’s also genuinely fun to play with.
One-Time Pad: theoretically perfect, practically a nightmare
The One-Time Pad is the only encryption scheme that is provably, mathematically unbreakable when used correctly. Claude Shannon proved this in 1949. The math is elegant: you XOR every byte of plaintext with a truly random key byte.
For decryption:
Because is uniformly random and used only once, the ciphertext reveals absolutely nothing about the plaintext. Every possible plaintext of that length is equally likely given the ciphertext. An attacker with infinite compute time and all the patience in the world still learns nothing.
std::string otp_encrypt(const std::string& plaintext, const std::string& key) {
std::string ciphertext = plaintext;
for (size_t i = 0; i < plaintext.size(); i++) {
ciphertext[i] = plaintext[i] ^ key[i];
}
return ciphertext;
} So why isn’t everyone using this? The key has to be truly random (not pseudo-random), at least as long as the message, never reused ever, and kept perfectly secret. That last point is a trap. If you can securely share a key as long as the message, why not just share the message through that same secure channel? The OTP basically assumes you already solved the key distribution problem, which is the hard part of cryptography in the first place.
Theoretically unbreakable, operationally unforgiving. That pretty much sums it up.
AES-256-GCM: the adult in the room
AES-256-GCM is what you actually use when you need to encrypt something today. It shows up in TLS 1.3, Signal, file encryption, VPNs, pretty much anywhere serious confidentiality is required.
It combines two things: AES in GCM mode gives you both encryption and authentication in a single operation. That second part matters a lot.
The encryption uses a 256-bit key and a 96-bit nonce (initialization vector). GCM produces a ciphertext and a 128-bit authentication tag. To decrypt, you supply the key, nonce, ciphertext, and tag. OpenSSL verifies the tag before returning the plaintext. If anything in the ciphertext was tampered with, authentication fails and you get nothing.
Where is the key, is the nonce, is the plaintext, is the ciphertext, and is the authentication tag.
EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr);
EVP_EncryptInit_ex(ctx, nullptr, nullptr, key, iv);
EVP_EncryptUpdate(ctx, ciphertext, &len, plaintext, plaintext_len);
EVP_EncryptFinal_ex(ctx, ciphertext + len, &len);
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, tag); One of the features I built into the CLI is a tamper test: after encrypting with AES-GCM, you can flip a bit in the ciphertext and try to decrypt it. Authentication fails with an explicit error. This is the whole point of authenticated encryption. You can detect if data was modified in transit, and you find out before you do anything with it.
Running it
Build and run is straightforward. You need CMake 3.16+, a C++17 compiler, and OpenSSL. On most Linux systems that is just apt install libssl-dev and you are good.
cmake -S . -B build
cmake --build build
./build/cripto The menu lets you pick any of the three algorithms and run encrypt or decrypt interactively. Binary output shows up in hex so it is actually readable in a terminal. Encrypting the same string three ways and watching the output side by side is surprisingly educational.
What I actually took away from this
The comparison format was the whole point of the assignment, and it delivered. Caesar, OTP, and AES-GCM in the same codebase makes the evolution of cryptographic thinking very concrete. Caesar is just arithmetic on characters. OTP is information-theoretically perfect but falls apart at scale. AES-GCM is the result of decades of work on symmetric primitives, modes of operation, and authenticated encryption, and it shows.
Building the tamper detection test was my favorite part. Watching OpenSSL reject a modified ciphertext is one of those small things that just feels right. Security working as intended is a good feeling, and that feeling never really gets old.
The repo is sarahsec/cripto, Apache-2.0.