Initial release

This commit is contained in:
Juyung 2026-08-08 17:27:56 +00:00
commit ccc0b2fed2
5 changed files with 468 additions and 0 deletions

111
README.md Normal file
View file

@ -0,0 +1,111 @@
# Post-Quantum Hybrid ML-KEM-1024 + AES-256-GCM File Encryption
Streaming hybrid file encryption: ML-KEM-1024 handles post-quantum key exchange, and AES-256-GCM handles bulk file encryption with built-in authenticity. The file signature is `KYB`, and the file extension is ".kyb".
Simplified flow (ML-KEM-1024 + AES-256 only, HKDF omitted for clarity):
<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 ML-KEM-1024 KEM (~256-bit PQ security)
- AES-256-GCM authenticated encryption with a streaming architecture that handles files larger than RAM (64 MB chunks)
- AES key derived from the ML-KEM shared secret via HKDF-SHA256 (never stored in the file)
- Unforgeable 128-bit GCM tag over the header + ciphertext to detect tampering or corruption
- Self-describing CBOR header (RFC 8949; file signature `KYB`, version, algorithm and length fields), so each file identifies its own format and unsupported future versions are rejected cleanly
## Requirements
- `oqs` ([liboqs-python](https://github.com/open-quantum-safe/liboqs-python))
- `cryptography` ([pyca/cryptography](https://github.com/pyca/cryptography))
- `cbor2` ([cbor2](https://github.com/agronholm/cbor2))
```bash
python3 -m venv venv && . venv/bin/activate
pip install -r requirements.txt
```
## Usage
1. Generate an ML-KEM-1024 key pair
```bash
python3 gen_keys.py
```
Produces:
- `mlkem1024.pub` - public key (1568 bytes)
- `mlkem1024.sec` - secret key (3168 bytes)
2. Encrypt a file
```bash
python3 encrypt.py archive.7z
```
Output: `archive.7z.kyb`
The `.kyb` file is self-contained: a compact CBOR header at the start describes the exact format, algorithms, and lengths. See [File formats](#file-formats) for the layout.
3. Decrypt a file
```bash
python3 decrypt.py archive.7z.kyb
```
Output: `archive.7z`
The script automatically verifies the GCM tag. If the file has been tampered with, you'll see:
```
Error: Integrity check failed. File corrupted or tampered
```
and the temp file is discarded. No plaintext ever appears at the destination path until the tag has been verified.
## File formats
**Key files (raw binary)**
File|Content|Size (bytes)
-|-|-
`mlkem1024.pub`|ML-KEM-1024 public key|1568
`mlkem1024.sec`|ML-KEM-1024 secret key|3168
**CBOR header**
Layout: 3-byte signature `KYB` + 1-byte version, then a CBOR map (RFC 8949) of field name → value pairs. CBOR is self-delimiting, so the reader knows exactly where the map ends; unknown keys are simply ignored, keeping the format forward-compatible. The `KDF` and `INFO` fields record the key-derivation parameters, making the file fully self-describing: with the secret key alone, a from-scratch decryptor can derive the AES key from the header.
Key | Value (v1)
-|-
`KEM`|`MLKEM1024`
`CIPHER`|`AES256GCM`
`MLKEMCT`|`1568`
`IV`|`12`
`TAG`|`16`
`KDF`|`HKDF-SHA256`
`INFO`|`mlkem-aes-key`
**Encrypted file structure**
`archive.7z.kyb`:
Section | Size | Description
-|-|-
CBOR header|91 bytes (v1)|Signature `KYB` (3 B) + version (1 B) + CBOR map (87 B)
ML-KEM ciphertext|1568 bytes|Encapsulated key (ciphertext from ML-KEM-1024); length read from the header
IV (nonce)|12 bytes|GCM nonce, also the HKDF salt; length read from the header
File ciphertext|same as plaintext|AES-256-GCM encrypted chunks, streamed in 64 MB blocks
GCM auth tag|16 bytes|Tag over the header + ML-KEM CT + IV + all ciphertext; length read from the header
## Security notes
- A fresh 32-byte **ML-KEM shared secret** is generated **per file** by `encap_secret()` and recovered only with `mlkem1024.sec` via `decap_secret()`. It is the root key material: neither the AES key nor the ML-KEM private key.
- The AES key is **derived from that ML-KEM shared secret** with HKDF-SHA256 (domain-separated `info` label, both recorded in the header), so no key material is ever stored in the file.
- Because the AES key is only derivable by the legitimate recipient (ML-KEM decapsulation), the GCM tag is **unforgeable**: an attacker without the secret key cannot modify the file undetectably.
- The GCM tag is computed over the **CBOR header + ML-KEM CT + IV + AES ciphertext** (via additional authenticated data), so any tampering with the encrypted data, the nonce, or the format header is detected before decryption.
- Decrypted plaintext is written to a temporary file and atomically renamed into place **only after** the GCM tag verifies.
- The header carries a format **version**; decrypt checks it up front, so a file written by a newer (or unknown) format is refused with a clear error instead of being mis-parsed.
- ML-KEM-1024 is believed to offer security equivalent to ~256-bit classical keys against quantum attackers.
- The secret key file (`mlkem1024.sec`) is the single point of failure. Losing it means losing every encrypted file. Treat it like a private SSH key: keep it offline, back it up, and restrict access (e.g. `chmod 600`). For stronger protection, consider encrypting the key file with a passphrase or splitting it into pieces (XOR-splitting or Shamir secret sharing) across separate locations.

198
decrypt.py Normal file
View file

@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
Hybrid decrypt a file encrypted with AES-256-GCM + ML-KEM-1024.
Streaming handles files larger than RAM.
Usage:
python3 decrypt.py <filename.kyb>
Output: original filename (without .kyb)
Requires: mlkem1024.sec in the same directory.
"""
import os
import sys
import tempfile
from io import BytesIO
import cbor2
import oqs
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
SIGNATURE = b"KYB"
SUPPORTED_VER = 1
INPUT_SUFFIX = ".kyb"
CHUNK_SIZE = 64 * 1024 * 1024
MAX_HEADER_LEN = 1024 # sane upper bound for the CBOR header map
HEADER_PREFIX_LEN = 4 # signature (3) + version (1)
KDF_NAME = "HKDF-SHA256" # KDF advertised in the header
KDF_INFO = "mlkem-aes-key" # HKDF domain-separation label
def derive_key(shared_secret: bytes, salt: bytes) -> bytes:
"""Derive the single AES-256-GCM key from the ML-KEM shared secret.
The nonce is used as the HKDF salt, binding the key to this specific
encryption (defense-in-depth: key/nonce mismatches fail loudly).
"""
return HKDF(
algorithm=hashes.SHA256(), length=32, salt=salt,
info=KDF_INFO.encode("ascii")
).derive(shared_secret)
def decrypt_file(input_path: str, output_path: str, secret_key: bytes):
with open(input_path, 'rb') as fin:
# 1. Read the fixed prefix: signature (3) + version (1)
prefix = fin.read(HEADER_PREFIX_LEN)
if len(prefix) != HEADER_PREFIX_LEN:
raise ValueError("Error: File truncated or corrupt header")
if prefix[:3] != SIGNATURE:
raise ValueError("Error: Not a .kyb file (bad signature)")
# 2. Version gate: refuse files this tool cannot parse
if prefix[3] != SUPPORTED_VER:
raise ValueError(f"Error: Unsupported format version: {prefix[3]}")
# 3. Read the CBOR header map (bounded read, then locate its end)
chunk = fin.read(MAX_HEADER_LEN)
if not chunk:
raise ValueError("Error: File truncated or corrupt header")
decoder = cbor2.CBORDecoder(BytesIO(chunk))
try:
fields = decoder.decode()
cbor_len = decoder.fp.tell()
except EOFError:
raise ValueError("Error: File truncated or corrupt header")
except Exception:
raise ValueError("Error: Corrupt header (invalid CBOR)")
if not isinstance(fields, dict):
raise ValueError("Error: Corrupt header (CBOR map expected)")
header_bytes = prefix + chunk[:cbor_len]
# Position exactly after the CBOR map so the ML-KEM CT reads correctly
fin.seek(HEADER_PREFIX_LEN + cbor_len)
# 4. Validate the advertised algorithms and derive the lengths
kem_name = fields.get("KEM")
if kem_name not in ("MLKEM1024",):
raise ValueError(f"Error: Unsupported KEM: {kem_name}")
if fields.get("CIPHER") != "AES256GCM":
raise ValueError(f"Error: Unsupported cipher: {fields.get('CIPHER')}")
if fields.get("KDF") != KDF_NAME:
raise ValueError(f"Error: Unsupported KDF: {fields.get('KDF')}")
if fields.get("INFO") != KDF_INFO:
raise ValueError(f"Error: Unsupported KDF info: {fields.get('INFO')}")
try:
mlkem_ct_len = fields["MLKEMCT"]
iv_len = fields["IV"]
tag_len = fields["TAG"]
except (KeyError, TypeError):
raise ValueError("Error: Corrupt header (missing or invalid length fields)")
# Cross-checks: ML-KEM-1024 CT is always 1568 B; GCM nonce 12 B; tag 16 B
if mlkem_ct_len != 1568:
raise ValueError("Error: Header inconsistent: MLKEM1024 implies a 1568-byte ciphertext")
if iv_len != 12:
raise ValueError("Error: Header inconsistent: AES256GCM implies a 12-byte nonce")
if tag_len != 16:
raise ValueError("Error: Header inconsistent: AES256GCM implies a 16-byte tag")
# 5. Read the ML-KEM ciphertext + IV
mlkem_ct = fin.read(mlkem_ct_len)
if len(mlkem_ct) != mlkem_ct_len:
raise ValueError("Error: File truncated: incomplete ML-KEM ciphertext")
iv = fin.read(iv_len)
if len(iv) != iv_len:
raise ValueError("Error: File truncated: incomplete header")
# 6. Recover the shared secret, then derive the AES key
kem = oqs.KeyEncapsulation("ML-KEM-1024", secret_key)
try:
shared_secret = kem.decap_secret(mlkem_ct)
except Exception:
raise ValueError("Error: Failed to decapsulate key - wrong secret key or corrupt header")
finally:
kem.free()
aes_key = derive_key(shared_secret, iv)
# File size minus the GCM tag at the end
fin.seek(0, os.SEEK_END)
total_size = fin.tell()
if total_size < len(header_bytes) + mlkem_ct_len + iv_len + tag_len:
raise ValueError("Error: File too small to be a valid .kyb file")
# The trailing GCM tag must be supplied up front (pyca/cryptography)
fin.seek(total_size - tag_len)
gcm_tag = fin.read(tag_len)
data_end = total_size - tag_len
body_start = len(header_bytes) + mlkem_ct_len + iv_len
fin.seek(body_start)
total_read = data_end - body_start
# 7. Stream-decrypt, authenticating the whole preamble + ciphertext
cipher = Cipher(algorithms.AES(aes_key), modes.GCM(iv, gcm_tag))
decryptor = cipher.decryptor()
decryptor.authenticate_additional_data(header_bytes + mlkem_ct + iv)
# Write to a temp file; only rename into place after the tag verifies,
# so corrupted plaintext never appears at the destination path.
fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(output_path) or ".",
prefix=".dec-", suffix=".tmp")
try:
with os.fdopen(fd, 'wb') as fout:
read = 0
while fin.tell() < data_end:
chunk_size = min(CHUNK_SIZE, data_end - fin.tell())
chunk = fin.read(chunk_size)
fout.write(decryptor.update(chunk))
read += len(chunk)
print(f" Progress: {read}/{total_read} bytes", end='\r')
# Verify integrity: finalize() checks the GCM tag
try:
decryptor.finalize()
except InvalidTag:
print()
raise ValueError("Error: Integrity check failed. File corrupted or tampered")
fout.flush()
os.fsync(fout.fileno())
# Atomic move: only a fully verified file lands at output_path
os.replace(temp_path, output_path)
except BaseException:
if os.path.exists(temp_path):
os.remove(temp_path)
raise
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(INPUT_SUFFIX):
print(f"Warning: File doesn't end with {INPUT_SUFFIX}. Decrypting anyway")
seckey_path = "mlkem1024.sec"
if not os.path.exists(seckey_path):
print("Error: No ML-KEM-1024 secret key found.")
sys.exit(1)
with open(seckey_path, 'rb') as f:
secret_key = f.read()
outfile = infile[:-len(INPUT_SUFFIX)] if infile.endswith(INPUT_SUFFIX) else infile + ".dec"
decrypt_file(infile, outfile, secret_key)

137
encrypt.py Normal file
View file

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Hybrid encrypt a file using AES-256-GCM + ML-KEM-1024.
Streaming handles files larger than RAM.
Usage:
python3 encrypt.py <filename>
Output: <filename>.kyb
Requires: mlkem1024.pub in the same directory.
"""
import os
import sys
import cbor2
import oqs
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
MLKEM_KEM = "ML-KEM-1024"
SIGNATURE = b"KYB" # 3-byte file signature
VERSION = 1 # 1-byte format version
KDF_NAME = "HKDF-SHA256" # KDF advertised in the header
KDF_INFO = "mlkem-aes-key" # HKDF domain-separation label
OUT_SUFFIX = ".kyb"
IV_LEN = 12 # GCM standard nonce length (96 bits)
TAG_LEN = 16 # full GCM auth tag (128 bits)
CHUNK_SIZE = 64 * 1024 * 1024 # 64 MB
def build_header(mlkem_ct_len: int, iv_len: int, tag_len: int) -> bytes:
"""Build the CBOR header: signature + version + CBOR map of fields.
CBOR (RFC 8949) is compact, self-delimiting, and standardized in IETF
crypto formats (e.g. COSE), so the header stays forward-compatible.
"""
header_map = {
"KEM": "MLKEM1024",
"CIPHER": "AES256GCM",
"MLKEMCT": mlkem_ct_len,
"IV": iv_len,
"TAG": tag_len,
"KDF": "HKDF-SHA256",
"INFO": "mlkem-aes-key"
}
return SIGNATURE + bytes([VERSION]) + cbor2.dumps(header_map, canonical=True)
def derive_key(shared_secret: bytes, salt: bytes) -> bytes:
"""Derive the single AES-256-GCM key from the ML-KEM shared secret.
The nonce is used as the HKDF salt, binding the key to this specific
encryption (defense-in-depth: key/nonce mismatches fail loudly).
"""
return HKDF(
algorithm=hashes.SHA256(), length=32, salt=salt,
info=KDF_INFO.encode("ascii")
).derive(shared_secret)
def encrypt_file(plaintext_path: str, output_path: str, pub_key: bytes):
iv = os.urandom(IV_LEN)
# Each file gets a brand-new AES key:
# fresh shared secret (encap_secret) -> fresh derived key (derive_key).
# Since every file has its own unique key, even a repeated nonce between
# files would be safe. Never cache or reuse a shared secret across files -
# doing so would break that guarantee.
kem = oqs.KeyEncapsulation(MLKEM_KEM)
mlkem_ct, shared_secret = kem.encap_secret(pub_key)
kem.free()
aes_key = derive_key(shared_secret, iv)
header = build_header(len(mlkem_ct), IV_LEN, TAG_LEN)
cipher = Cipher(algorithms.AES(aes_key), modes.GCM(iv))
encryptor = cipher.encryptor()
# Preamble (header + ML-KEM CT + nonce) is authenticated as GCM AAD
preamble = header + mlkem_ct + iv
encryptor.authenticate_additional_data(preamble)
total_size = os.path.getsize(plaintext_path)
written = 0
try:
with open(output_path, 'wb') as fout:
fout.write(preamble)
with open(plaintext_path, 'rb') as fin:
while True:
chunk = fin.read(CHUNK_SIZE)
if not chunk:
break
ct_chunk = encryptor.update(chunk)
fout.write(ct_chunk)
written += len(chunk)
print(f" Progress: {written}/{total_size} bytes", end='\r')
# finalize() computes the tag over AAD + all ciphertext
encryptor.finalize()
fout.write(encryptor.tag)
except BaseException:
# Never leave a partial .kyb behind on failure
if os.path.exists(output_path):
os.remove(output_path)
raise
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 = "mlkem1024.pub"
if not os.path.exists(pubkey_path):
print(f"Error: No ML-KEM-1024 public key found. Run gen_keys.py first.")
sys.exit(1)
with open(pubkey_path, 'rb') as f:
pub_key = f.read()
outfile = infile + OUT_SUFFIX
if os.path.exists(outfile):
print(f"Error: Output already exists: {outfile}")
sys.exit(1)
encrypt_file(infile, outfile, pub_key)

17
gen_keys.py Normal file
View file

@ -0,0 +1,17 @@
import oqs
# ── Generate ML-KEM-1024 keypair ──
# ML-KEM-1024 gives ~256-bit post-quantum security
kem = oqs.KeyEncapsulation("ML-KEM-1024")
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("mlkem1024.pub", "wb") as f:
f.write(public_key)
with open("mlkem1024.sec", "wb") as f:
f.write(secret_key)
print(f"Public key size: {len(public_key)} bytes") # 1568 for ML-KEM-1024
print(f"Secret key size: {len(secret_key)} bytes") # 3168 for ML-KEM-1024

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
cbor2==5.6.2
cffi==2.1.0
cryptography==49.0.0
liboqs-python==0.16.0
pycparser==3.0