This commit is contained in:
Jay Juyung 2026-07-26 19:26:27 +00:00
commit b0307788a5
5 changed files with 315 additions and 0 deletions

107
README.md Normal file
View file

@ -0,0 +1,107 @@
# Post-Quantum Hybrid Kyber-1024 + AES-256-CTR File Encryption
Streaming hybrid file encryption combining ML-KEM-1024 (Kyber-1024) for post-quantum key encapsulation with AES-256-CTR for bulk encryption and HMAC-SHA256 for integrity verification.
Handles files larger than available RAM via chunked streaming (64 MB chunks).
<a href="https://notes.juyung.com/gallery/6nIr0qID0vri0DR4Jy7HCbS6/hJv-1N1hIqFxzXIv85KqR9dq" target="_blank"/><img src='https://notes.juyung.com/uploads/small2x/f9/20/0805d761b05dbbf2046a68268b58.png' style="height: 720px;" /></a>
## Features
- Post-quantum secure key exchange via liboqs Kyber-1024 KEM (~256-bit PQ security)
- AES-256-CTR symmetric encryption with a streaming architecture that handles files larger than RAM (64 MB chunks)
- HMAC-SHA256 authenticity tag to detect tampering or corruption
## Requirements
- `oqs` ([liboqs-python](https://github.com/open-quantum-safe/liboqs-python))
- `cryptography` ([pyca/cryptography](https://github.com/pyca/cryptography))
```bash
python3 -m venv venv && . venv/bin/activate
pip install -r requirements.txt
```
## Usage
1. Generate a Kyber-1024 key pair
```bash
python3 gen_kyber.py
```
Produces:
- `kyber.pub` - public key (1568 bytes)
- `kyber.sec` - secret key (3168 bytes)
2. Encrypt a file
```bash
python3 encrypt.py archive.7z
```
Output: `archive.7z.enc`
The `.enc` file contains:
Section | Description
-|-
Kyber ciphertext|Encapsulated key (ciphertext from KEM)
IV|16-byte AES-CTR nonce
HMAC key|32-byte HMAC key
File ciphertext|AES-256-CTR encrypted chunks
HMAC tag|32-byte SHA-256 MAC of the file ciphertext
3. Decrypt a file
```bash
python3 decrypt.py archive.7z.enc
```
Output: `archive.7z`
The script automatically verifies the HMAC tag. If the file has been tampered with, you'll see:
```
Error: Integrity check failed. File corrupted or tampered
```
and the partial output gets deleted.
## File formats
**Key files (raw binary)**
File|Content|Size (bytes)
-|-|-
`kyber.pub`|Kyber-1024 public key|1568
`kyber.sec`|Kyber-1024 secret key|3168
**Encrypted file structure**
`archive.7z.enc`:
```
┌─────────────────────────┐
│ Kyber CT length (4 B) │ ← big-endian uint32
├─────────────────────────┤
│ Kyber ciphertext │ ← 1568 bytes(Kyber-1024)
├─────────────────────────┤
│ IV (16 B) │ ← AES-CTR nonce
├─────────────────────────┤
│ HMAC key (32 B) │ ← random per file
├─────────────────────────┤
│ AES-256-CTR ciphertext │ ← streamed in 64 MB chunks
├─────────────────────────┤
│ HMAC-SHA256 tag (32 B) │ ← over the AES ciphertext only
└─────────────────────────┘
```
## Security notes
- A new random AES key is generated **per file** via Kyber encapsulation.
- A new random HMAC key is generated **per file**.
- The HMAC tag is computed over the **AES ciphertext** (the encrypted file data), so any tampering is detected before decryption.
- Kyber-1024 is believed to offer security equivalent to ~256-bit classical keys against quantum attackers.
- The secret key file (`kyber.sec`) should be treated like a private SSH key. Store it offline or in a secure vault.

100
decrypt.py Normal file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Hybrid decrypt a file encrypted with AES-256-CTR + HMAC-SHA256 + Kyber-1024.
Streaming handles files larger than RAM.
Usage:
python3 decrypt.py <filename.enc>
Output: original filename (without .enc)
Requires: kyber.sec in the same directory.
"""
import os
import sys
import struct
import oqs
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.hmac import HMAC
from cryptography.hazmat.primitives import hashes
KYBER_KEM = "Kyber1024"
IV_LEN = 16
CHUNK_SIZE = 64 * 1024 * 1024
HMAC_KEY_LEN = 32
HMAC_TAG_LEN = 32
def decrypt_file(input_path: str, output_path: str, secret_key: bytes):
with open(input_path, 'rb') as fin:
# Read header
kyber_ct_len = struct.unpack('!I', fin.read(4))[0]
kyber_ct = fin.read(kyber_ct_len)
iv = fin.read(IV_LEN)
hmac_key = fin.read(HMAC_KEY_LEN)
# Recover AES key
kem = oqs.KeyEncapsulation(KYBER_KEM, secret_key)
aes_key = kem.decap_secret(kyber_ct)
kem.free()
# Decrypt stream
cipher = Cipher(algorithms.AES(aes_key), modes.CTR(iv))
decryptor = cipher.decryptor()
hmac = HMAC(hmac_key, hashes.SHA256())
# File size minus HMAC tag at the end
fin.seek(0, os.SEEK_END)
total_size = fin.tell()
data_end = total_size - HMAC_TAG_LEN
fin.seek(4 + kyber_ct_len + IV_LEN + HMAC_KEY_LEN)
total_read = data_end - fin.tell()
with open(output_path, 'wb') as fout:
read = 0
while fin.tell() < data_end:
chunk_size = min(CHUNK_SIZE, data_end - fin.tell())
chunk = fin.read(chunk_size)
hmac.update(chunk)
fout.write(decryptor.update(chunk))
read += len(chunk)
print(f" Progress: {read}/{total_read} bytes", end='\r')
# Verify integrity
stored_hmac = fin.read(HMAC_TAG_LEN)
computed_hmac = hmac.finalize()
if stored_hmac != computed_hmac:
os.remove(output_path)
print()
raise ValueError("Error: Integrity check failed. File corrupted or tampered")
fout.write(decryptor.finalize())
print(f"\nSuccess: {input_path}{output_path} ({total_read} bytes)")
if __name__ == "__main__":
if len(sys.argv) != 2:
print(__doc__)
sys.exit(1)
infile = sys.argv[1]
if not os.path.exists(infile):
print(f"Error: File not found: {infile}")
sys.exit(1)
if not infile.endswith(".enc"):
print(f"Warning: File doesn't end with .enc. Decrypting anyway")
seckey_path = "kyber.sec"
if not os.path.exists(seckey_path):
print(f"Error: No Kyber secret key found.")
sys.exit(1)
with open(seckey_path, 'rb') as f:
secret_key = f.read()
outfile = infile[:-4] if infile.endswith(".enc") else infile + ".dec"
decrypt_file(infile, outfile, secret_key)

86
encrypt.py Normal file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""
Hybrid encrypt a file using AES-256-CTR + HMAC-SHA256 + Kyber-1024.
Streaming handles files larger than RAM.
Usage:
python3 encrypt.py <filename>
Output: <filename>.enc
Requires: kyber.pub in the same directory.
"""
import os
import sys
import struct
import oqs
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.hmac import HMAC
from cryptography.hazmat.primitives import hashes
KYBER_KEM = "Kyber1024"
IV_LEN = 16
CHUNK_SIZE = 64 * 1024 * 1024 # 64 MB
HMAC_KEY_LEN = 32
def encrypt_file(plaintext_path: str, output_path: str, pub_key: bytes):
iv = os.urandom(IV_LEN)
hmac_key = os.urandom(HMAC_KEY_LEN)
kem = oqs.KeyEncapsulation(KYBER_KEM)
kyber_ct, aes_key = kem.encap_secret(pub_key)
kem.free()
cipher = Cipher(algorithms.AES(aes_key), modes.CTR(iv))
encryptor = cipher.encryptor()
hmac = HMAC(hmac_key, hashes.SHA256())
total_size = os.path.getsize(plaintext_path)
written = 0
with open(output_path, 'wb') as fout:
# Header
fout.write(struct.pack('!I', len(kyber_ct)))
fout.write(kyber_ct)
fout.write(iv)
fout.write(hmac_key)
with open(plaintext_path, 'rb') as fin:
while True:
chunk = fin.read(CHUNK_SIZE)
if not chunk:
break
ct_chunk = encryptor.update(chunk)
hmac.update(ct_chunk)
fout.write(ct_chunk)
written += len(chunk)
print(f" Progress: {written}/{total_size} bytes", end='\r')
fout.write(encryptor.finalize())
fout.write(hmac.finalize())
print(f"\nSuccess: {plaintext_path}{output_path} ({total_size} bytes)")
if __name__ == "__main__":
if len(sys.argv) != 2:
print(__doc__)
sys.exit(1)
infile = sys.argv[1]
if not os.path.exists(infile):
print(f"Error: File not found: {infile}")
sys.exit(1)
pubkey_path = "kyber.pub"
if not os.path.exists(pubkey_path):
print(f"Error: No Kyber public key found. Run gen_kyber.py first.")
sys.exit(1)
with open(pubkey_path, 'rb') as f:
pub_key = f.read()
outfile = infile + ".enc"
encrypt_file(infile, outfile, pub_key)

18
gen_kyber.py Normal file
View file

@ -0,0 +1,18 @@
import oqs
# ── Generate Kyber-1024 keypair ──
# ML-KEM-1024 gives ~256-bit post-quantum security
kem = oqs.KeyEncapsulation("Kyber1024")
public_key = kem.generate_keypair() # bytes
secret_key = kem.export_secret_key() # bytes
kem.free()
# ── Save to disk (PEM-like, but just raw bytes is fine) ──
with open("kyber.pub", "wb") as f:
f.write(public_key)
with open("kyber.sec", "wb") as f:
f.write(secret_key)
print(f"Public key size: {len(public_key)} bytes") # 1568 for Kyber-1024
print(f"Secret key size: {len(secret_key)} bytes") # 3168 for Kyber-1024

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
cffi==2.1.0
cryptography==49.0.0
liboqs-python==0.15.0
pycparser==3.0