The most surprising thing about cryptographic agility is that it’s not about choosing the best algorithm, but about making sure you can switch algorithms without breaking everything.
Let’s see this in action. Imagine we have a system that encrypts user data. Right now, it’s using AES-256 in CBC mode.
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
import os
def encrypt_aes_cbc(data: bytes, password: str) -> dict:
salt = os.urandom(16)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32, # AES-256 key size
salt=salt,
iterations=100000,
backend=default_backend()
)
key = kdf.derive(password.encode())
iv = os.urandom(16) # AES block size is 16 bytes
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
encryptor = cipher.encryptor()
padder = modes.PKCS7(algorithms.AES.block_size).padder()
padded_data = padder.update(data) + padder.update(b"") + padder.finalize()
ciphertext = encryptor.update(padded_data) + encryptor.update(b"") + encryptor.finalize()
return {"salt": salt, "iv": iv, "ciphertext": ciphertext}
def decrypt_aes_cbc(salt: bytes, iv: bytes, ciphertext: bytes, password: str) -> bytes:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
backend=default_backend()
)
key = kdf.derive(password.encode())
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
decryptor = cipher.decryptor()
decrypted_padded_data = decryptor.update(ciphertext) + decryptor.update(b"") + decryptor.finalize()
unpadder = modes.PKCS7(algorithms.AES.block_size).unpadder()
data = unpadder.update(decrypted_padded_data) + unpadder.update(b"") + unpadder.finalize()
return data
# Example Usage
password = "mysecretpassword"
original_data = b"This is sensitive information."
encrypted_data = encrypt_aes_cbc(original_data, password)
print("Encrypted:", encrypted_data)
decrypted_data = decrypt_aes_cbc(encrypted_data["salt"], encrypted_data["iv"], encrypted_data["ciphertext"], password)
print("Decrypted:", decrypted_data.decode())
This works fine for now. But what if a new vulnerability is found in CBC mode, or a stronger algorithm like ChaCha20-Poly1305 becomes preferred for performance and security? If your entire system is hardcoded to use AES-CBC, you’re in for a massive rewrite.
Cryptographic agility means designing your system so that the choice of algorithm and mode is a configuration parameter, not a hardcoded fact. This involves abstracting the cryptographic operations behind interfaces.
Consider how you store the encrypted data. Instead of just storing ciphertext, you’d store a structure that includes metadata about the encryption:
{
"version": "1.0",
"algorithm": "AES-256-CBC",
"key_derivation": {
"function": "PBKDF2",
"iterations": 100000,
"hash": "SHA256"
},
"parameters": {
"salt": "...",
"iv": "..."
},
"ciphertext": "..."
}
When you need to decrypt, you read this JSON. The algorithm field tells you which decryption function to call. The key_derivation and parameters fields tell you how to derive the key and what specific parameters (like salt and IV) to use.
To achieve this, you’d have a registry of cryptographic primitives.
class CryptoPrimitive:
def encrypt(self, plaintext: bytes, config: dict) -> dict:
raise NotImplementedError
def decrypt(self, encrypted_data: dict, config: dict) -> bytes:
raise NotImplementedError
class AES256CBCPrimitive(CryptoPrimitive):
def encrypt(self, plaintext: bytes, config: dict) -> dict:
password = config["password"]
salt = os.urandom(16)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=config.get("iterations", 100000),
backend=default_backend()
)
key = kdf.derive(password.encode())
iv = os.urandom(16)
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
padder = modes.PKCS7(algorithms.AES.block_size).padder()
padded_data = padder.update(plaintext) + padder.update(b"") + padder.finalize()
ciphertext = cipher.encryptor().update(padded_data) + cipher.encryptor().update(b"") + cipher.encryptor().finalize()
return {"salt": salt, "iv": iv, "ciphertext": ciphertext}
def decrypt(self, encrypted_data: dict, config: dict) -> bytes:
password = config["password"]
salt = encrypted_data["salt"]
iv = encrypted_data["iv"]
ciphertext = encrypted_data["ciphertext"]
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=config.get("iterations", 100000),
backend=default_backend()
)
key = kdf.derive(password.encode())
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
decrypted_padded_data = cipher.decryptor().update(ciphertext) + cipher.decryptor().update(b"") + cipher.decryptor().finalize()
unpadder = modes.PKCS7(algorithms.AES.block_size).unpadder()
data = unpadder.update(decrypted_padded_data) + unpadder.update(b"") + unpadder.finalize()
return data
class CryptoManager:
def __init__(self):
self.primitives = {
"AES-256-CBC": AES256CBCPrimitive()
# Add other primitives here, e.g., ChaCha20Poly1305Primitive()
}
def encrypt(self, plaintext: bytes, algorithm_name: str, config: dict) -> dict:
primitive = self.primitives.get(algorithm_name)
if not primitive:
raise ValueError(f"Unsupported algorithm: {algorithm_name}")
encrypted_parts = primitive.encrypt(plaintext, config)
# Store the metadata along with the ciphertext
return {
"version": "1.0",
"algorithm": algorithm_name,
"key_derivation": config.get("key_derivation", {}), # Store KDF config if applicable
"parameters": {k: v.hex() if isinstance(v, bytes) else v for k, v in encrypted_parts.items() if k != "ciphertext"},
"ciphertext": encrypted_parts["ciphertext"].hex() # Store ciphertext as hex string
}
def decrypt(self, encrypted_payload: dict, config: dict) -> bytes:
algorithm_name = encrypted_payload["algorithm"]
primitive = self.primitives.get(algorithm_name)
if not primitive:
raise ValueError(f"Unsupported algorithm: {algorithm_name}")
# Reconstruct the encrypted_data dict for the primitive
encrypted_data_for_primitive = {
"salt": bytes.fromhex(encrypted_payload["parameters"]["salt"]),
"iv": bytes.fromhex(encrypted_payload["parameters"]["iv"]),
"ciphertext": bytes.fromhex(encrypted_payload["ciphertext"])
}
# Merge config for password and KDF iterations, etc.
primitive_config = config.copy()
primitive_config.update(encrypted_payload.get("key_derivation", {})) # Merge KDF params from payload
primitive_config.update({k: v for k, v in encrypted_payload["parameters"].items() if k not in ["salt", "iv"]}) # Add other params if needed
return primitive.decrypt(encrypted_data_for_primitive, primitive_config)
# Example Usage with CryptoManager
manager = CryptoManager()
password = "mysecretpassword"
original_data = b"This is sensitive information."
# Encrypt using AES-256-CBC
config = {
"password": password,
"key_derivation": {"function": "PBKDF2", "iterations": 100000, "hash": "SHA256"}
}
encrypted_payload = manager.encrypt(original_data, "AES-256-CBC", config)
print("Encrypted Payload:", encrypted_payload)
# Decrypt
decrypt_config = {"password": password} # Only need password for decryption
decrypted_data = manager.decrypt(encrypted_payload, decrypt_config)
print("Decrypted Data:", decrypted_data.decode())
# Now, imagine we want to switch to ChaCha20-Poly1305.
# If we had implemented ChaCha20Poly1305Primitive and registered it,
# we could simply change the algorithm name during encryption/decryption
# without touching the core logic of the CryptoManager or the data structure.
The key takeaway is that your system logic should depend on an interface (like CryptoPrimitive), not on a concrete implementation (like AES256CBCPrimitive). The specific algorithm and its parameters are data, managed and chosen at runtime.
When you encrypt, you are essentially creating a self-describing blob of data. The blob tells you how it was encrypted. This is crucial for decryption. If you later decide to upgrade to a stronger encryption, you can migrate data by re-encrypting it with the new algorithm. For a period, you might even need to support both old and new formats, decrypting based on the algorithm field in the payload.
The most overlooked aspect of this is how you handle key management and algorithm evolution. It’s not just about swapping out AES for ChaCha20; it’s about ensuring your entire ecosystem, from data storage to authentication, can gracefully transition. This often means designing for forward and backward compatibility, a process that requires careful planning of metadata and versioning within your encrypted data structures.
The next step is to consider how this applies to authenticated encryption modes and how to manage algorithm deprecation.