AES-128-GCM built from first principles · Kyber-512 post-quantum KEM · NIST SP 800-38D validated · 196 tests
A ground-up implementation of hybrid post-quantum file encryption. Every cryptographic primitive — Galois field arithmetic, S-box generation, AES round operations, key expansion, CTR mode, GHASH, and the full GCM authentication tag — is written by hand in pure Python and verified against NIST test vectors. A Kyber-512 KEM layer wraps the AES core into a quantum-resistant hybrid scheme, and a binary file format brings the full stack to disk with a CLI.
Four self-contained layers, each building on the one below:
┌─────────────────────────────────────────────────────────────────────┐
│ CLI (src/cli.py) │
│ keygen · encrypt · decrypt · info │
├─────────────────────────────────────────────────────────────────────┤
│ File Encryption (src/file_crypto.py) │
│ PQC\x01 binary format · AAD-bound metadata · disk I/O │
├─────────────────────────────────────────────────────────────────────┤
│ Hybrid Crypto (src/hybrid_crypto.py) │
│ Kyber-512 key encapsulation + AES-128-GCM payload │
├──────────────────────────────┬──────────────────────────────────────┤
│ AES-128-GCM (src/aes_gcm) │ Kyber-512 KEM (src/pqc_wrapper) │
│ GF(2^8) · S-box · rounds │ keygen · encaps · decaps │
│ CTR mode · GHASH · tag │ 800B pub · 1632B priv · 32B secret │
└──────────────────────────────┴──────────────────────────────────────┘
Encrypting a message for a recipient requires two independent cryptographic operations running in sequence:
SENDER RECIPIENT
────── ─────────
recipient_pk ──────────────────────────────► (holds secret key)
① Generate random AES-128 session key
aes_key = urandom(16)
② Encrypt payload with AES-128-GCM
(aes_ct, auth_tag) = AES_GCM.gcm_encrypt(
plaintext, aes_key, nonce, aad)
③ Encapsulate via Kyber-512
(kyber_ct, kyber_ss) = KyberKEM.encapsulate(recipient_pk)
④ Wrap the session key under the Kyber shared secret
(enc_key, key_tag) = AES_GCM.gcm_encrypt(
aes_key, kyber_ss[:16], kyber_nonce)
⑤ Transmit ──────────────────────────────────────────────►
kyber_ct (768B) | kyber_nonce (12B) | enc_key (32B)
key_tag (16B) | nonce (12B) | aes_ct (var)
auth_tag (16B)
① kyber_ss = KyberKEM.decapsulate(sk, kyber_ct)
② aes_key = AES_GCM.gcm_decrypt(enc_key, kyber_ss, ...)
③ plaintext = AES_GCM.gcm_decrypt(aes_ct, aes_key, ...)
This two-layer wrapping means: quantum computers that could break classical key exchange cannot recover the session key (Kyber is ML-KEM, NIST-selected PQC standard). Any tampering with any field — at either layer — raises ValueError before a single byte of plaintext is returned.
Every component is hand-written against NIST FIPS 197 and SP 800-38D:
galois_multiply(a, b) Polynomial multiplication mod x^8+x^4+x^3+x+1 (0x11b)
gf_inverse(byte) Multiplicative inverse via exhaustive search
byte b
│
▼
┌─────────────────────┐
│ GF(2^8) inverse │ b^(-1) → non-linearity
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Affine transformation│ × M + 0x63 → breaks algebraic structure
└──────────┬──────────┘
│
▼
S-box[b]
The 256-entry S-box and its inverse are computed at runtime from these two steps — no hardcoded lookup tables.
| Operation | Forward | Inverse |
|---|---|---|
| Byte substitution | sub_bytes via S-box |
inv_sub_bytes via inv S-box |
| Row permutation | shift_rows left by row i |
inv_shift_rows right by i |
| Column mixing | mix_columns GF(2^8) matmul |
inv_mix_columns |
| Key mixing | add_round_key XOR |
add_round_key (symmetric) |
128-bit key → 11 round keys (1 initial + 10 rounds) via RotWord, SubWord, and Rcon.
Key K
│
┌────────▼────────┐
│ E(K, 0^128) │ ──► H (hash subkey)
└─────────────────┘
Nonce (96b) ──► J0 = nonce ║ 0x00000001
│
┌─────────▼─────────────────────────────────┐
│ CTR Mode Encryption │
│ J1=J0+1 J2=J0+2 J3=J0+3 ... │
│ E(K,J1) E(K,J2) E(K,J3) │
│ XOR XOR XOR │
│ P[0] P[1] P[2] ... │
└──────────────────┬────────────────────────┘
│ ciphertext C
▼
┌──────────────────────────────────────────┐
│ GHASH │
│ input: pad(AAD) ║ pad(C) ║ len(A)∥len(C) │
│ process: Σ block_i × H^(n-i) in GF(2^128)│
└───────────────────┬──────────────────────┘
│ GHASH result
▼
tag = GHASH ⊕ E(K, J0) ← binds nonce to tag
On-disk binary layout for .pqc encrypted files:
Offset Size Field
────── ────── ──────────────────────────────────────────────────────
0 4 B Magic bytes: PQC\x01 (format identifier + version)
4 2 B filename_len (little-endian uint16)
6 N B filename (UTF-8 encoded original filename)
6+N 8 B timestamp (little-endian uint64 Unix timestamp)
─── fixed from here ──────────────────────────────────────────────────
768 B kyber_ciphertext (KEM ciphertext for key recovery)
12 B kyber_nonce (AES-GCM nonce for session key wrap)
32 B encrypted_aes_key (session key wrapped under Kyber ss)
16 B key_auth_tag (GCM tag authenticating the wrapped key)
12 B nonce (AES-GCM nonce for payload)
M B aes_ciphertext (encrypted file payload, M = original size)
16 B auth_tag (GCM tag authenticating the payload)
──────────────────────────────────────────────────────────────────────
Fixed overhead: 4+2+N+8+768+12+32+16+12+16 = 870+N bytes
(typically 885 B for short filenames)
The filename and timestamp are bound into the AAD for both AES-GCM layers — any modification to the header fields invalidates the payload authentication tag.
Both ciphertext and authentication tags match the official NIST test vectors:
| Test Case | Input | Ciphertext match | Tag match |
|---|---|---|---|
| TC1 | K=0, IV=0, PT=empty | ✓ (empty) | ✓ |
| TC2 | K=0, IV=0, PT=0^128 | ✓ 0388dace... |
✓ ab6e47d4... |
| TC3 | K=feffe992, IV=cafebabe, 64B | ✓ 42831ec2... |
✓ 4d5c2af3... |
Key design decisions that ensure compliance:
- Column-major ordering (
order='F') for all byte ↔ matrix conversions, matchingstate[r][c] = bytes[r + 4c] - Padded GHASH input: AAD and ciphertext each padded to 16-byte boundaries, followed by a 16-byte block encoding
len(AAD) ‖ len(CT)in bits - E(K, J0) XOR: final tag XOR'd with encryption of the initial counter J0, binding the nonce into the tag
Usage: pqcrypt [OPTIONS] COMMAND [ARGS]
Commands:
keygen Generate a new Kyber-512 keypair
encrypt Encrypt a file using the recipient's public key
decrypt Decrypt a file using your private key
info Show metadata stored in an encrypted file
Workflow:
# Generate keypairs
pqcrypt keygen --name alice
pqcrypt keygen --name bob
# Alice encrypts a file for Bob
pqcrypt encrypt report.pdf --recipient bob_public.key
# Bob decrypts it
pqcrypt decrypt report.pdf.enc --key bob_private.key
# Inspect an encrypted file (no key needed)
pqcrypt info report.pdf.encfrom src.aes_gcm import AES_GCM
aes = AES_GCM()
key = AES_GCM.generate_random_key() # 4x4 numpy uint8 array (128-bit)
nonce = AES_GCM.generate_random_nonce() # 12-byte random IV
ciphertext, tag = aes.gcm_encrypt(b"Secret payload", key, nonce, aad=b"metadata")
# Raises ValueError on any authentication failure
plaintext = aes.gcm_decrypt(ciphertext, key, nonce, tag, aad=b"metadata")from src.pqc_wrapper import KyberKEM
kem = KyberKEM()
pk, sk = kem.generate_keypair() # 800 B public, 1632 B secret
ct, ss_sender = kem.encapsulate(pk) # sender: get ciphertext + shared secret
ss_recv = kem.decapsulate(sk, ct) # recipient: recover shared secret
assert ss_sender == ss_recv # both parties hold the same 32-byte keyfrom src.hybrid_crypto import HybridCrypto
from src.pqc_wrapper import KyberKEM
kem = KyberKEM()
pk, sk = kem.generate_keypair()
hc = HybridCrypto()
enc = hc.encrypt(b"Secret message", pk, aad=b"optional context")
plaintext = hc.decrypt(enc, sk, aad=b"optional context")from src.file_crypto import FileEncryptor
from src.pqc_wrapper import KyberKEM
kem = KyberKEM()
pk, sk = kem.generate_keypair()
fe = FileEncryptor()
info = fe.encrypt_file("input.pdf", "output.pqc", pk)
# info: original_size, encrypted_size, overhead, timestamp
info = fe.decrypt_file("output.pqc", "recovered.pdf", sk)
# info: decrypted_size, original_filename, timestamp (ISO), encrypted_size| Suite | Tests | Coverage |
|---|---|---|
| GF(2^128) Multiply | 6 | Commutativity, distributivity, identity (1<<127), zero, bounds |
| NIST Vectors | 5 | TC1 / TC2 / TC3 ciphertext; TC2 / TC3 authentication tags |
| Roundtrip | 16 | 1 B to 64 KB; block-aligned; non-aligned; empty; large AAD |
| Edge Cases | 17 | Single byte; all-zero/all-FF; full byte range; nonce/key independence |
| Tamper Detection | 17 | Ciphertext; AAD; key; nonce; tag truncation/extension/flip |
| Performance | 5 | Throughput benchmarks at 16 B → 16 KB |
| Suite | Tests | Coverage |
|---|---|---|
| KyberKEM | 15 | Key sizes; encaps/decaps roundtrip; implicit rejection; type checks |
| Hybrid Roundtrip | 17 | All sizes; UTF-8/binary AAD; AAD binding; 64 KB |
| Edge Cases | 13 | Empty plaintext; single byte; cross-recipient isolation; 64 KB |
| Tamper Detection | 23 | Every field; swapped tags/nonces; truncated/zeroed key; wrong sk |
| Performance | 5 | Kyber KEM overhead + hybrid throughput at 64 B → 64 KB |
| Suite | Tests | Coverage |
|---|---|---|
| Roundtrip | 16 | Empty; single byte; block-aligned; 1 MB; all-zeros; full byte range |
| Metadata | 12 | All return fields; filename preservation; size tracking; ISO timestamp |
| Format Validation | 8 | Magic bytes; wrong magic; random/empty/truncated/zeroed input |
| Tamper Detection | 13 | auth_tag; aes_ct; key_auth_tag; kyber_ct; kyber_nonce; wrong key |
| Error Handling | 3 | Nonexistent input; plaintext passed to decrypt |
| Performance | 3 | 1 KB / 64 KB / 1 MB encrypt + decrypt timing |
Run the full suite:
source venv/bin/activate
python3 -m unittest discover -s tests -vThis is a learning implementation — pure Python, no hardware acceleration. The bottleneck is gf128_multiply, which iterates 128 times per 16-byte block. OpenSSL uses CLMUL/AES-NI CPU instructions for the same operation.
| Payload | This Implementation | OpenSSL (AES-NI) | Slowdown |
|---|---|---|---|
| 64 B | ~5 ms | ~0.005 ms | ~1,000× |
| 1 KB | ~60 ms | ~0.050 ms | ~1,200× |
| 16 KB | ~910 ms | ~0.023 ms | ~40,000× |
| Operation | Time |
|---|---|
| Key generation | ~1.5 ms |
| Encapsulate | ~2 ms |
| Decapsulate | ~3 ms |
| Full KEM round-trip | ~6 ms |
At payload sizes above ~64 KB, AES-GCM dominates and KEM overhead drops below 0.1% of total time.
src/
aes_gcm.py Full AES-128-GCM implementation (~430 lines)
pqc_wrapper.py KyberKEM — thin wrapper around Kyber-512
hybrid_crypto.py HybridCrypto — two-layer Kyber + AES-GCM scheme
file_crypto.py FileEncryptor — PQC\x01 binary format, disk I/O
cli.py Click CLI — keygen / encrypt / decrypt / info
tests/
test_aes_gcm.py 66 tests: GF math, NIST vectors, tamper detection
test_hybrid_crypto.py 75 tests: KEM, hybrid roundtrip, tamper detection
test_file_encryption.py 55 tests: file I/O, format, tamper detection
examples/
demo.py 10-section walkthrough of the full stack
docs/
DESIGN.md Cryptographic architecture and design decisions
LEARNINGS.md Implementation retrospective and key insights
| Package | Used in | Purpose |
|---|---|---|
numpy |
src/aes_gcm.py |
4×4 state matrix operations |
kyber-py |
src/pqc_wrapper.py |
Pure-Python Kyber-512 implementation |
click |
src/cli.py |
CLI argument parsing |
rich |
src/cli.py |
Terminal output formatting |
cryptography |
examples/demo.py |
OpenSSL baseline for benchmarks only |
pip install -r requirements.txt