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

192 lines
7.1 KiB
Python

#!/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.kyb>
Output: original filename (without .kyb)
Requires: kyber.sec in the same directory.
"""
import os
import sys
import tempfile
import hmac as hmac_mod
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
SIGNATURE = "KYB"
SUPPORTED_VER = "1"
INPUT_SUFFIX = ".kyb"
CHUNK_SIZE = 64 * 1024 * 1024
MAX_HEADER_LEN = 1024 # sane upper bound for the header line
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 parse_header(header_bytes: bytes):
"""Parse the ASCII header line into a dict of fields."""
line = header_bytes.rstrip(b"\n").decode("ascii")
parts = line.split("|")
if parts[0] != SIGNATURE:
raise ValueError("Error: Not a kyber (.kyb) file (bad signature)")
fields = {}
for part in parts[1:]:
if "=" in part:
key, _, value = part.partition("=")
fields[key] = value
return fields
def decrypt_file(input_path: str, output_path: str, secret_key: bytes):
with open(input_path, 'rb') as fin:
# 1. Parse the self-describing header line
header_bytes = fin.readline(MAX_HEADER_LEN)
if not header_bytes.endswith(b"\n"):
raise ValueError("Error: File truncated or corrupt header")
fields = parse_header(header_bytes)
# 2. Version gate: refuse files this tool cannot parse
version = fields.get("VER")
if version != SUPPORTED_VER:
raise ValueError(f"Error: Unsupported format version: {version}")
# 3. 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") != "AES256CTR":
raise ValueError(f"Error: Unsupported cipher: {fields.get('CIPHER')}")
if fields.get("MAC") != "HMACSHA256":
raise ValueError(f"Error: Unsupported MAC: {fields.get('MAC')}")
try:
kyber_ct_len = int(fields["KYBERCT"])
iv_len = int(fields["IV"])
tag_len = int(fields["TAG"])
except (KeyError, ValueError):
raise ValueError("Error: Corrupt header (missing or invalid length fields)")
# Cross-checks: ML-KEM-1024 CT is always 1568 B; HMAC-SHA256 tag always 32 B
if kyber_ct_len != 1568:
raise ValueError("Error: Header inconsistent: MLKEM1024 implies a 1568-byte ciphertext")
if tag_len != 32:
raise ValueError("Error: Header inconsistent: HMACSHA256 implies a 32-byte tag")
# 4. Read the Kyber ciphertext + IV
kyber_ct = fin.read(kyber_ct_len)
if len(kyber_ct) != kyber_ct_len:
raise ValueError("Error: File truncated: incomplete Kyber ciphertext")
iv = fin.read(iv_len)
if len(iv) != iv_len:
raise ValueError("Error: File truncated: incomplete header")
# 5. Recover the shared secret, then derive AES + HMAC keys
kem = oqs.KeyEncapsulation("Kyber1024", secret_key)
try:
shared_secret = kem.decap_secret(kyber_ct)
except Exception:
raise ValueError("Error: Failed to decapsulate key - wrong secret key or corrupt header")
finally:
kem.free()
aes_key, hmac_key = derive_keys(shared_secret, iv)
# 6. Stream-decrypt, authenticating the whole preamble + ciphertext
cipher = Cipher(algorithms.AES(aes_key), modes.CTR(iv))
decryptor = cipher.decryptor()
hmac = HMAC(hmac_key, hashes.SHA256())
hmac.update(header_bytes)
hmac.update(kyber_ct)
hmac.update(iv)
# File size minus the MAC tag at the end
fin.seek(0, os.SEEK_END)
total_size = fin.tell()
if total_size < len(header_bytes) + kyber_ct_len + iv_len + tag_len:
raise ValueError("Error: File too small to be a valid .kyb file")
data_end = total_size - tag_len
body_start = len(header_bytes) + kyber_ct_len + iv_len
fin.seek(body_start)
total_read = data_end - body_start
# Write to a temp file; only rename into place after the HMAC 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)
hmac.update(chunk)
fout.write(decryptor.update(chunk))
read += len(chunk)
print(f" Progress: {read}/{total_read} bytes", end='\r')
# Verify integrity (constant-time comparison)
stored_hmac = fin.read(tag_len)
computed_hmac = hmac.finalize()
if not hmac_mod.compare_digest(stored_hmac, computed_hmac):
print()
raise ValueError("Error: Integrity check failed. File corrupted or tampered")
fout.write(decryptor.finalize())
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 = "kyber.sec"
if not os.path.exists(seckey_path):
print("Error: No Kyber 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)