kyb/encrypt.py
2026-08-06 20:31:30 +00:00

136 lines
4.1 KiB
Python

#!/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>.kyb
Requires: kyber.pub in the same directory.
"""
import os
import sys
import oqs
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.hmac import HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
KYBER_KEM = "Kyber1024"
SIGNATURE = "KYB"
VERSION = "1"
OUT_SUFFIX = ".kyb"
IV_LEN = 16
TAG_LEN = 32
CHUNK_SIZE = 64 * 1024 * 1024 # 64 MB
def build_header(kyber_ct_len: int, iv_len: int, tag_len: int) -> bytes:
"""Build the self-describing ASCII header line (ends with a newline).
Human-readable and labelled, so a decrypt script (or a person) can learn
every format parameter straight from the file with no hardcoded sizes.
"""
fields = [
SIGNATURE,
f"VER={VERSION}",
"KEM=MLKEM1024",
"CIPHER=AES256CTR",
"MAC=HMACSHA256",
f"KYBERCT={kyber_ct_len}",
f"IV={iv_len}",
f"TAG={tag_len}",
]
return "|".join(fields).encode("ascii") + b"\n"
def derive_keys(shared_secret: bytes, salt: bytes):
"""Derive independent AES and HMAC keys from the Kyber shared secret.
The IV is used as the HKDF salt, binding the keys to this specific
encryption (defense-in-depth: key/nonce mismatches fail loudly).
"""
aes_key = HKDF(
algorithm=hashes.SHA256(), length=32, salt=salt,
info=b"kyber-aes-key"
).derive(shared_secret)
hmac_key = HKDF(
algorithm=hashes.SHA256(), length=32, salt=salt,
info=b"kyber-hmac-key"
).derive(shared_secret)
return aes_key, hmac_key
def encrypt_file(plaintext_path: str, output_path: str, pub_key: bytes):
iv = os.urandom(IV_LEN)
# Fresh encapsulation per file -> fresh shared secret -> fresh AES/HMAC keys.
# Never reuse or cache a shared secret across files: the per-file keys are
# exactly what make accidental IV reuse harmless.
kem = oqs.KeyEncapsulation(KYBER_KEM)
kyber_ct, shared_secret = kem.encap_secret(pub_key)
kem.free()
aes_key, hmac_key = derive_keys(shared_secret, iv)
header = build_header(len(kyber_ct), IV_LEN, TAG_LEN)
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:
# Preamble (all authenticated): ASCII header + Kyber CT + IV
preamble = header + kyber_ct + iv
fout.write(preamble)
hmac.update(preamble)
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')
final_ct = encryptor.finalize()
hmac.update(final_ct) # cover every ciphertext byte written (no-op for CTR)
fout.write(final_ct)
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 + OUT_SUFFIX
if os.path.exists(outfile):
print(f"Error: Output already exists: {outfile}")
sys.exit(1)
encrypt_file(infile, outfile, pub_key)