Cryptographic Agility
debt(d5/e7/b7/t7)
Closest to 'specialist tool catches' (d5). Semgrep and phpstan patterns can flag hardcoded weak algorithms like md5/sha1/des, but the deeper absence of agility (no version prefix on ciphertext, no policy centralisation) is invisible to linters and only surfaces during a forced migration.
Closest to 'cross-cutting refactor across the codebase' (e7). The quick_fix requires prefixing every ciphertext/hash/token with a version identifier, centralising algorithm policy, AND adding a background re-encryption job — this touches every call site and every stored record, not one component.
Closest to 'strong gravitational pull' (b7). Applies across web, cli, queue-worker, and library contexts; every crypto operation must go through the policy layer, and stored data formats carry the versioning commitment forward indefinitely.
Closest to 'serious trap' (t7). The misconception is explicit: developers think agility means 'let callers pick an algorithm', which is the exact pattern that produces JWT alg:none and algorithm confusion attacks — the intuitive reading is the dangerous one.
Also Known As
TL;DR
Explanation
Cryptographic agility is a design property: your system can migrate from one algorithm to another (AES-GCM to ChaCha20-Poly1305, SHA-256 to SHA-3, RSA to post-quantum KEMs like ML-KEM) without a rewrite. History makes this non-negotiable — MD5, SHA-1, DES, 3DES, RC4, and PKCS#1 v1.5 padding were all standard once and are now broken or deprecated. NIST's post-quantum standardization (FIPS 203/204/205 in 2024) means every asymmetric cryptosystem currently in production will eventually need replacement.
Agility has three concrete requirements. First, algorithm identifiers travel with ciphertext, hashes, and tokens. Password hashes prefix the algorithm (`$argon2id$...`, `$2y$...`), JWTs declare `alg` in the header, TLS negotiates cipher suites, and encrypted payloads carry a version byte or algorithm OID. Second, the code path branches on that identifier rather than hardcoding a call. Third, key material is versioned so you can decrypt old data with the old algorithm while encrypting new data with the new one — envelope encryption with per-record key IDs makes this manageable.
Operationally, agility means dual-write during migration: read supports old and new, write emits only new, then a background job re-encrypts or re-hashes lazy-loaded records. `password_needs_rehash()` in PHP is the canonical example — on successful login you check whether the stored hash uses current parameters and upgrade transparently.
What agility is not: it is not writing your own crypto abstraction over libsodium, nor exposing algorithm choice to callers, nor supporting every algorithm forever. Deprecated algorithms should be readable but never writable, and eventually removed after full migration. The goal is that when SHA-3, Argon2, or ML-KEM is mandated by policy, you ship a config change and a migration job — not a rewrite.
Common Misconception
Why It Matters
Common Mistakes
- Hardcoding the algorithm in function calls (`hash('sha256', ...)`, `openssl_encrypt($data, 'aes-256-cbc', ...)`) with no way to change it without editing every call site.
- Storing ciphertext or hashes without an algorithm identifier or version prefix, making it impossible to know how to decrypt old records after migrating.
- Treating agility as 'accept any algorithm the client requests' — this is how JWT `alg: none` and algorithm confusion attacks happen.
- Forgetting to plan the write cutover: reading both old and new but never actually re-encrypting old data, leaving the deprecated algorithm live forever.
- Building a custom crypto wrapper for agility instead of using vetted libraries (libsodium, JOSE, PASETO) that already handle versioning.
Code Examples
<?php
// Hardcoded algorithm — no path to migrate off SHA-256 or AES-CBC.
function storePassword(string $password): string {
// Direct sha256 — broken for passwords AND unversioned.
return hash('sha256', $password);
}
function encryptData(string $plaintext, string $key): string {
$iv = random_bytes(16);
$ct = openssl_encrypt($plaintext, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
// No algorithm tag, no version — future migration requires
// decrypting every row to figure out what algorithm it used.
return base64_encode($iv . $ct);
}
function verifyPassword(string $password, string $stored): bool {
return hash_equals($stored, hash('sha256', $password));
// No upgrade path when we move to Argon2.
}
<?php
// Agile design: algorithm identifier travels with the data,
// current policy is centralised, old formats stay readable.
final class PasswordHasher {
// Policy lives in one place; change here to migrate everyone.
private const CURRENT_ALGO = PASSWORD_ARGON2ID;
private const CURRENT_OPTS = ['memory_cost' => 65536, 'time_cost' => 4];
public function hash(string $password): string {
// password_hash() prefixes the algorithm: $argon2id$..., $2y$...
return password_hash($password, self::CURRENT_ALGO, self::CURRENT_OPTS);
}
public function verify(string $password, string $stored): bool {
// Reads any supported legacy format (bcrypt, argon2i, argon2id).
return password_verify($password, $stored);
}
public function needsRehash(string $stored): bool {
// Transparent upgrade on next successful login.
return password_needs_rehash($stored, self::CURRENT_ALGO, self::CURRENT_OPTS);
}
}
final class VersionedCipher {
// Version byte selects the algorithm. Add v2 without touching v1 readers.
public function encrypt(string $plaintext, string $key): string {
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$ct = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
$plaintext, '', $nonce, $key
);
return "\x02" . $nonce . $ct; // 0x02 = XChaCha20-Poly1305
}
public function decrypt(string $blob, string $key): string {
$version = ord($blob[0]);
return match ($version) {
0x01 => $this->decryptV1AesGcm(substr($blob, 1), $key), // legacy
0x02 => $this->decryptV2XChaCha(substr($blob, 1), $key), // current
default => throw new RuntimeException("Unknown cipher version: $version"),
};
}
// ... version-specific decrypt methods
}