Encryption Decryption Tool
Encryption Decryption Tool
In this project I am going to create a custom Encryption Decryption Tool. I will use docker environment to deploy it.
So first I create the requirements.txt containing this line
cryptography==42.0.5
This lockdown the library version for my project.
Then we should create a docker file but it not necessary at first but anyway.
# Use a lightweight Python
FROM python:3.11-slim
# Prevent Python from writing .pyc files and enable unbuffered logging
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Create a non-root user
RUN useradd --create-home appuser
# Set the working directory
WORKDIR /home/appuser/app
# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy your Python scripts
COPY <I will update this section later>
...
...
# Switch to the non-root user
USER appuser
# Set the default executable
ENTRYPOINT ["python", "app.py"]
Docker is enough for now. Lets go back to the real thing.
AES Algorithm
Now lets create aes_handle.py for handle the symmetric-key block cipher algorithm adopted as a global standard for data encryption.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
os: We need this to generate cryptographically secure random numbers.
AESGCM: This is the specific cryptographic primitive from the cryptography library that handles the heavy lifting.
Now lets create a function to generate a key. It will generates a secure 256-bit AES key and saves it to a file.
def generate_key(key_path="secret.key"):
# Generate 32 bytes (256 bits) of random data
key = AESGCM.generate_key(bit_length=256)
with open(key_path, "wb") as key_file:
key_file.write(key)
print(f"[+] 256-bit AES key successfully generated and saved to '{key_path}'")
return key
The "wb" mode stands for write binary.
Next, this function will load the AES key from the file.
def load_key(key_path="secret.key"):
with open(key_path, "rb") as key_file:
return key_file.read()
The "rb" mode, It stands for read binary.
To encrypt data securely, AES requires a Nonce (Number Used Once). This is where os library will shine. And also The nonce is not a secret. It must be saved alongside the encrypted file so the decryption function knows how to reverse the process.
Now lets create the function of encryption.
def encrypt_file(file_path, key):
aes_object = AESGCM(key)
nonce = os.urandom(12)
with open(file_path, "rb") as file:
plaintext = file.read()
ciphertext = aes_object.encrypt(nonce, plaintext, None)
encrypted_file_path = file_path + ".enc"
with open(encrypted_file_path, "wb") as file:
file.write(nonce + ciphertext)
print(f"[+] Successfully encrypted '{file_path}' to '{encrypted_file_path}'")
aes_object = AESGCM(key) => This is a AESGCM (Advanced Encryption Standard with Galois/Counter Mode) object which is needed to do the encryption as shown below.
ciphertext = aes_object.encrypt(nonce, plaintext, None)
At the lowest level, AES is a mathematical engine that takes a 128-bit (16-byte) block of data and scrambles it using your 256-bit secret key. For a 256-bit key, AES executes 14 consecutive cycles called "rounds".
Instead of being a single algorithm, it is a combination of two distinct cryptographic processes running simultaneously: CTR (Counter Mode) handles the encryption, and GMAC (Galois Message Authentication Code) calculates a digital fingerprint to prevent tampering.
Next lets create a function to decrypt the data.
def decrypt_file(encrypted_file_path, key):
aes_object = AESGCM(key)
with open(encrypted_file_path, "rb") as file:
file_data = file.read()
nonce = file_data[:12]
ciphertext = file_data[12:]
plaintext = aes_object.decrypt(nonce, ciphertext, None)
decrypted_file_path = encrypted_file_path.replace(".enc", ".dec")
with open(decrypted_file_path, "wb") as file:
file.write(plaintext)
print(f"[+] Successfully decrypted '{encrypted_file_path}' to '{decrypted_file_path}'")
Here also, aes_object is the one who do the decryption.
decrypted_file_path = encrypted_file_path.replace(".enc", ".dec")
This formulates a new file name for the output. It takes the original encrypted file path and uses the string .replace() method to swap ".enc" with ".dec".
Next update the app.py to see the magic.
import aes_handle
import os
import sys
def main():
print("=== AES-GCM Encryption/Decryption Tool ===")
while True:
print("\nOptions:")
print("1. Generate a new key")
print("2. Encrypt a file")
print("3. Decrypt a file")
print("4. Exit")
choice = input("Select an option (1-4): ").strip()
if choice == '1':
key_path = input("Enter path to save key (default: secret.key): ").strip() or "secret.key"
try:
aes_handle.generate_key(key_path)
except Exception as e:
print(f"[-] Error generating key: {e}")
elif choice == '2':
file_path = input("Enter path of the file to encrypt: ").strip()
if not os.path.exists(file_path):
print(f"[-] File not found: '{file_path}'")
continue
key_path = input("Enter path to the key file (default: secret.key): ").strip() or "secret.key"
if not os.path.exists(key_path):
print(f"[-] Key file not found: '{key_path}'")
continue
try:
key = aes_handle.load_key(key_path)
aes_handle.encrypt_file(file_path, key)
except Exception as e:
print(f"[-] Encryption failed: {e}")
elif choice == '3':
file_path = input("Enter path of the file to decrypt: ").strip()
if not os.path.exists(file_path):
print(f"[-] File not found: '{file_path}'")
continue
key_path = input("Enter path to the key file (default: secret.key): ").strip() or "secret.key"
if not os.path.exists(key_path):
print(f"[-] Key file not found: '{key_path}'")
continue
try:
key = aes_handle.load_key(key_path)
aes_handle.decrypt_file(file_path, key)
except Exception as e:
print(f"[-] Decryption failed. Did you use the wrong key/file? Error: {e}")
elif choice == '4':
print("Exiting tool...")
sys.exit(0)
else:
print("[-] Invalid choice. Please enter 1, 2, 3, or 4.")
if __name__ == "__main__":
main()
Now update the Dockerfile:
# Copy your Python scripts
COPY aes_handle.py .
COPY app.py .
Build & run the docker file.
docker build -t crypto-tool .
docker run -it --rm -v "$(pwd):/home/appuser/app" crypto-tool
First part is done. Now lets create asymmetric encryption method, widely know as the Rivest-Shamir-Adleman (RSA) asymmetric cryptographic algorithm.
RSA Algorithm
First lets create rsa_handle.py and create a generate_keypair() method.
RSA relies on the mathematical difficulty of factoring large prime numbers. In practical terms, this means RSA is highly secure for sharing secrets but too slow to encrypt large files directly.
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import serialization, hashes
import os
The cryptography library organizes its core, low-level algorithms under a module called hazmat (Hazardous Materials). This name is an inside joke in the cybersecurity industry: it is a warning that using these primitive mathematical functions directly can be dangerous if you don't know exactly what you are doing, as a slight misconfiguration can ruin the encryption.
rsa: This module contains the mathematical engine needed to generate the massive prime numbers that make up an RSA Private and Public key pair.
padding: RSA math by itself is actually deterministic. To make it secure, we must add "padding". This module provides the tools to inject structured randomness into your data before the math happens, confusing attackers.
serialization: Cryptographic keys exist as raw binary objects in your computer's RAM. To save a key to your hard drive (like a secret.pem file) or send it over the internet, you must "serialize" it. This module translates the raw key into standard formats (like PEM or DER) so it can be safely written to a file and loaded back later.
hashes: A hash function is a one-way mathematical algorithm that creates a unique fingerprint of data. The RSA padding mentioned above actually relies heavily on hash functions to verify that the padding hasn't been tampered with.
We all know what os for. : )
Now, generate_keypair() function will create a 2048-bit private key and then extract the corresponding public key.
- 2048-bit is the standard minimum length for modern security.
- 65537 is the standard public exponent used in almost all RSA implementations because it provides a good balance of security and performance.
def generate_keypair(
private_key_path='private_key.pem',
public_key_path='public_key.pem'
):
# Generate the Private Key mathematically
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048
)
# Extract the Public Key from Private Key
public_key = private_key.public_key()
# Serialize and save the Private Key (.pem format)
with open(private_key_path, 'wb') as file:
file.write(
private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
)
# Serialize and save the Public Key
with open(public_key_path, 'wb') as file:
file.write(
public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
)
print(f"[+] RSA Key pair generated: '{private_key_path}' and '{public_key_path}'")
return private_key, public_key
Next, let's create functions to load these keys back from their files into memory so we can use them.
def load_private_key(private_key_path="private_key.pem"):
if not os.path.exists(private_key_path):
raise FileNotFoundError(f"[-] Private key not found at {private_key_path}")
with open(private_key_path, "rb") as file:
return serialization.load_pem_private_key(
file.read(),
password=None
)
def load_public_key(public_key_path="public_key.pem"):
if not os.path.exists(public_key_path):
raise FileNotFoundError(f"[-] Public key not found at {public_key_path}")
with open(public_key_path, "rb") as file:
return serialization.load_pem_public_key(file.read())
password=None: Since we didn't use a password to encrypt our private key during generation, we tell the serialization module not to look for one here.
Now, let's create the function for encryption. We will use OAEP (Optimal Asymmetric Encryption Padding) to inject randomness and secure our data.
def encrypt_data(data, public_key):
if isinstance(data, str):
data = data.encode('utf-8')
ciphertext = public_key.encrypt(
data,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
return ciphertext
padding.OAEP: As mentioned before, this adds the necessary structured randomness to our data before the RSA math happens.
hashes.SHA256(): We use the SHA-256 hash function as part of the padding process to ensure maximum integrity and prevent tampering.
Next, let's write the decryption function to reverse this process using our private key.
def decrypt_data(encrypted_data, private_key):
plaintext = private_key.decrypt(
encrypted_data,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
return plaintext
Notice that the decryption function uses the exact same padding and hashing algorithms as the encryption function. If these don't match exactly, the decryption will fail!
Next update the app.py to add the RSA algorithm into an interactive menu. You can check the code base from App.py
Now let's update our Dockerfile. Make sure appuser also gets ownership of our new script when it gets copied over.
# Copy Python scripts
COPY --chown=appuser:appuser aes_handle.py .
COPY --chown=appuser:appuser rsa_handle.py .
COPY --chown=appuser:appuser app.py .
Re-build & run the docker file again to see the fully completed tool in action!
docker build -t crypto-tool .
docker run -it --rm -v "$(pwd):/home/appuser/app" crypto-tool
And that's it! We have successfully created our custom dual-algorithm encryption tool.
But There Is a Catch ... : (
Here is a summary of the issue:
| Algorithm | Speed | Data Size Limit | Key Exchange |
|---|---|---|---|
| AES (symmetric) | Very fast | Unlimited | Hard to share key securely |
| RSA (asymmetric) | Slow | ~190 bytes max | Easy via public key |
So we are gonna use a hybrid method. Hybrid encryption is the industry-standard approach used in TLS, PGP, and most real-world secure systems.
So basically what it does is use the AES method to encrypt the data and use the RSA method to encrypt the AES key. This way we get unlimited data size for encryption and very high security for key exchange.
import os
import struct
import rsa_handle
import aes_handle
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def hybrid_encrypt(file_path, public_key_path="public_key.pem"):
aes_key = AESGCM.generate_key(bit_length=256)
with open(file_path, "rb") as f:
plaintext = f.read()
aes_object = AESGCM(aes_key)
nonce = os.urandom(12)
aes_ciphertext = aes_object.encrypt(nonce, plaintext, None)
public_key = rsa_handle.load_public_key(public_key_path)
encrypted_aes_key = rsa_handle.encrypt_data(aes_key, public_key)
encrypted_file_path = file_path + ".henc"
rsa_block_size = len(encrypted_aes_key)
with open(encrypted_file_path, "wb") as f:
f.write(struct.pack(">H", rsa_block_size)) # :) (big-endian)
f.write(encrypted_aes_key)
f.write(nonce)
f.write(aes_ciphertext)
print(f"[+] Hybrid encrypted '{file_path}' → '{encrypted_file_path}'")
def hybrid_decrypt(encrypted_file_path, private_key_path="private_key.pem"):
with open(encrypted_file_path, "rb") as f:
rsa_block_size = struct.unpack(">H", f.read(2))[0]
encrypted_aes_key = f.read(rsa_block_size)
nonce = f.read(12)
aes_ciphertext = f.read()
private_key = rsa_handle.load_private_key(private_key_path)
aes_key = rsa_handle.decrypt_data(encrypted_aes_key, private_key)
aes_object = AESGCM(aes_key)
plaintext = aes_object.decrypt(nonce, aes_ciphertext, None)
decrypted_file_path = encrypted_file_path.replace(".henc", ".hdec")
with open(decrypted_file_path, "wb") as f:
f.write(plaintext)
print(f"[+] Hybrid decrypted '{encrypted_file_path}' → '{decrypted_file_path}'")
Nothing much to explain here and I also added this functionality to App.py feel free to check it out too.