Root cause
Other / insufficient public mechanism detail introduced
Implement Post-Quantum Cryptography with secure memory and resilient design
How AI contributed: Direct introductionPublished Feb 10, 2026 in
jahlives/openssl_encryptAll 20 findings in this repository
The decrypt path accepts the encapsulated KEM ciphertext as input and tries real decapsulation. If both supported decapsulation APIs fail, it suppresses the KEM failure and substitutes SHA-256(encapsulated_key || first_16_bytes_of_private_key) as the shared secret, then hashes that into the AEAD key. Because encapsulated_key is carried with the ciphertext, anyone with those 16 private-key bytes can compute the fallback key and construct ciphertext that the recipient accepts after an intentionally failing KEM encapsulation; legacy simulation-mode ciphertexts use the same weak deterministic construction. Ordinary successfully encapsulated ciphertext still uses its real KEM secret, so the direct code evidence does not by itself show that the fallback decrypts every ordinary ciphertext.
When key decapsulation failed, decryption suppressed the error and built an encryption key from attacker-controlled encapsulated data plus a short private-key prefix. This converted a cryptographic failure into a predictable acceptance path.
Root cause
Implement Post-Quantum Cryptography with secure memory and resilient design
Fix
security: Remove simulation mode from PQC encryption path (CRIT-3)
Case facts
@@ -10,28 +10,96 @@ import secrets import hashlib import os+import json+import time+import random+import ctypes from enum import Enum from typing import Tuple, Optional, Union -from .secure_memory import SecureBytes, secure_memzero+from .secure_memory import SecureBytes, secure_memzero, secure_string++def public_key_part(private_key: bytes) -> bytes:+ """+ Extract a deterministic public-key-like value from a private key+ for use in simulation mode.+ + Args:+ private_key: The private key bytes+ + Returns:+ bytes: A deterministic identifier derived from the private key+ """+ # Use secure memory for operations with private key data+ with SecureBytes(private_key) as secure_private_key:+ # Take the first 16 bytes (or all if smaller) to act as an identifier+ # This is only used for simulation mode+ if len(secure_private_key) <= 16:+ # Create a copy to ensure the original is not shared+ return bytes(secure_private_key)+ else:+ # Use first 16 bytes which should be enough to uniquely identify the key+ # but not enough to reveal the entire key+ return bytes(secure_private_key[:16]) # Try to import PQC libraries, provide fallbacks if not available+LIBOQS_AVAILABLE = False+oqs = None+ try: import oqs- LIBOQS_AVAILABLE = True-except ImportError:+ # Check essential methods that we need to verify compatibility+ kem_methods_available = (hasattr(oqs, 'get_enabled_kem_mechanisms') or + hasattr(oqs, 'get_supported_kem_mechanisms'))+ + if kem_methods_available:+ LIBOQS_AVAILABLE = True+ # Testing KeyEncapsulation creation+ try:+ test_mechanisms = oqs.get_enabled_kem_mechanisms()+ if test_mechanisms:+ test_kem = oqs.KeyEncapsulation(test_mechanisms[0])+ if not hasattr(test_kem, 'generate_keypair'):+ print(f"Warning: oqs KeyEncapsulation has non-standard API: {dir(test_kem)}")+ # Clean up test object+ test_kem = None+ except Exception as e:+ print(f"Warning: oqs KEM initialization test failed: {e}")+ else:+ print("Warning: oqs module found but KEM mechanisms not available")+ print(f"Available methods: {dir(oqs)}")+ LIBOQS_AVAILABLE = False+except ImportError as e:+ print(f"Post-quantum cryptography import failed: {e}")+ print("To use post-quantum features, install: pip install liboqs-python")+ LIBOQS_AVAILABLE = False+except Exception as e:+ print(f"Error initializing oqs module: {e}")+ print("Continuing without post-quantum cryptography support") LIBOQS_AVAILABLE = False- oqs = None # Define supported PQC algorithms class PQCAlgorithm(Enum): # NIST Round 3 Finalists and Selected Algorithms- KYBER512 = "Kyber-512"- KYBER768 = "Kyber-768"- KYBER1024 = "Kyber-1024"- DILITHIUM2 = "Dilithium-2"- DILITHIUM3 = "Dilithium-3"- DILITHIUM5 = "Dilithium-5"+ # The original Kyber naming scheme+ KYBER512 = "Kyber512"+ KYBER768 = "Kyber768"+ KYBER1024 = "Kyber1024"+ @@ -991,12 +991,20 @@ def signal_handler(signum, frame): args.argon2_preset = None # Check for post-quantum cryptography availability if needed- if args.algorithm in ['kyber512-hybrid', 'kyber768-hybrid', 'kyber1024-hybrid'] and not PQC_AVAILABLE:- if not args.quiet:- print("Warning: liboqs-python module not found. Post-quantum cryptography will not be available.")- print("Install with: pip install liboqs-python")- print("Falling back to aes-gcm algorithm.")- args.algorithm = 'aes-gcm'+ if args.algorithm in ['kyber512-hybrid', 'kyber768-hybrid', 'kyber1024-hybrid']:+ try:+ # Attempt direct import to ensure module is truly available+ import oqs+ pqc_available = True+ except ImportError:+ pqc_available = False+ + if not pqc_available:+ if not args.quiet:+ print("Warning: liboqs-python module not found. Post-quantum cryptography will not be available.")+ print("Install with: pip install liboqs-python")+ print("Falling back to aes-gcm algorithm.")+ args.algorithm = 'aes-gcm' # Validate random password parameter if args.random is not None:@@ -1192,11 +1200,27 @@ def signal_handler(signum, frame): if args.pqc_gen_key and args.pqc_keyfile: from .pqc import PQCipher, PQCAlgorithm - # Map algorithm name to PQCAlgorithm+ # Map algorithm name to PQCAlgorithm with fallbacks+ pqc_algorithms = check_pqc_support()[2]+ + # Determine which variants are available+ kyber512_options = [alg for alg in pqc_algorithms if alg.lower().replace('-', '').replace('_', '') in ["kyber512", "mlkem512"]]+ kyber768_options = [alg for alg in pqc_algorithms if alg.lower().replace('-', '').replace('_', '') in ["kyber768", "mlkem768"]]+ kyber1024_options = [alg for alg in pqc_algorithms if alg.lower().replace('-', '').replace('_', '') in ["kyber1024", "mlkem1024"]]+ + # Choose first available or fall back to default name+ kyber512_algo = kyber512_options[0] if kyber512_options else "Kyber512"+ kyber768_algo = kyber768_options[0] if kyber768_options else "Kyber768"+ kyber1024_algo = kyber1024_options[0] if kyber1024_options else "Kyber1024"+ + if not args.quiet:+ print(f"Using algorithm mappings: kyber512-hybrid → {kyber512_algo}, kyber768-hybrid → {kyber768_algo}, kyber1024-hybrid → {kyber1024_algo}")+ + # Create direct string mapping instead of using enum algo_map = {- 'kyber512-hybrid': PQCAlgorithm.KYBER512,- 'kyber768-hybrid': PQCAlgorithm.KYBER768,- 'kyber1024-hybrid': PQCAlgorithm.KYBER1024+ 'kyber512-hybrid': kyber512_algo,+ 'kyber768-hybrid': kyber768_algo,+ 'kyber1024-hybrid': kyber1024_algo } # Generate key pair@@ -1237,6 +1261,35 @@ def signal_handler(signum, frame): if not args.quiet: print(f"Loaded post-quantum key pair from {args.pqc_keyfile}") + # For PQC algorithms, we may need to generate a keypair if not specified+ if args.algorithm in ['kyber512-hybrid', 'kyber768-hybrid', 'kyber1024-hybrid'] and not pqc_keypair:+ # No keypair provided, generate an ephemeral one+ from .pqc import PQCipher, check_pqc_support+ + # Map algorithm name to available algorithms+ pqc_algorithms = check_pqc_support()[2]+ kyber512_options = [alg for alg in pqc_algorithms if alg.lower().replace('-', '').replace('_', '') in ["kyber512", "mlkem512"]]+ kyber768_options = [alg for alg in pqc_algorithms if alg.lower().replace('-', '').replace('_', '') in ["kyber768", "mlkem768"]]+ kyber1024_options = [alg for alg in pqc_algorithms if alg.lower().replace('-', '').replace('_', '') in ["kyber1024", "mlkem1024"]]+ + # Choose first available algorithm+ algo_map = {+ 'kyber512-hybrid': kyber512_options[0] if kyber512_options else "Kyber512",+ 'kyber768-hybrid': kyber768_options[0] if kyber768_options else "Kyber768",+ 'kyber1024-hybrid': kyber1024_options[0] if kyber1024_options else "Kyber1024"+ }+ + if not args.quiet:+ print(f"Generating ephemeral post-quantum key pair for {args.algorithm}")+ if args.pqc_store_key:+ print("Private key will be stored in the encrypted file for self-decryption")+ else:+ print("WARNING: Private key will NOT be stored - you must use a key file for decryption")+ + cipher = PQCipher(algo_map[args.algorithm])+ public_key, private_key = cipher.generate_keypair()@@ -1206,7 +1206,7 @@ def show_progress(algorithm, current, total): def encrypt_file(input_file, output_file, password, hash_config=None, pbkdf2_iterations=100000, quiet=False, algorithm=EncryptionAlgorithm.FERNET, progress=False, verbose=False,- pqc_keypair=None):+ pqc_keypair=None, pqc_store_private_key=False): """ Encrypt a file with a password using the specified algorithm. @@ -1221,6 +1221,7 @@ def encrypt_file(input_file, output_file, password, hash_config=None, verbose (bool): Whether to show verbose output algorithm (EncryptionAlgorithm): Encryption algorithm to use (default: Fernet) pqc_keypair (tuple, optional): Post-quantum keypair (public_key, private_key) for hybrid encryption+ pqc_store_private_key (bool): Whether to store the private key in the metadata for self-decryption Returns: bool: True if encryption was successful@@ -1410,14 +1411,25 @@ def do_encrypt(): EncryptionAlgorithm.KYBER768_HYBRID, EncryptionAlgorithm.KYBER1024_HYBRID]: if pqc_keypair:- # If keypair was provided externally, store the public key only+ # Always store the public key metadata['pqc_public_key'] = base64.b64encode(pqc_keypair[0]).decode('utf-8')- # We don't store the private key in metadata for security+ + # Store private key only if requested (for self-decryption)+ if pqc_store_private_key and len(pqc_keypair) > 1:+ if not quiet:+ print("Storing post-quantum private key in encrypted file for self-decryption")+ metadata['pqc_private_key'] = base64.b64encode(pqc_keypair[1]).decode('utf-8')+ elif not quiet:+ print("Post-quantum private key NOT stored - you'll need the key file for decryption") elif 'private_key' in locals(): # If we generated a keypair internally, store both keys metadata['pqc_public_key'] = base64.b64encode(public_key).decode('utf-8')- # Store the private key securely (will be needed for decryption)- metadata['pqc_private_key'] = base64.b64encode(private_key).decode('utf-8')+ + # Store the private key if requested+ if pqc_store_private_key:+ if not quiet:+ print("Storing post-quantum private key in encrypted file for self-decryption")+ metadata['pqc_private_key'] = base64.b64encode(private_key).decode('utf-8') # If scrypt is used, add rounds to hash_config # Serialize and encode the metadata metadata_json = json.dumps(metadata).encode('utf-8')@@ -1752,11 +1764,28 @@ def do_decrypt(): # Initialize PQC cipher and decrypt cipher = PQCipher(pqc_algo_map[algorithm]) try:- return cipher.decrypt(encrypted_data, pqc_private_key)+ # Pass the full file contents for recovery if needed+ # This allows the PQCipher to try to recover the original content+ # if the standard decryption approach fails+ if 'input_file' in locals() and input_file and os.path.exists(input_file):+ # Read the original encrypted file for content recovery+ with open(input_file, 'rb') as f:+ original_file_contents = f.read()+ # Now decrypt with both the encrypted data and original file+ return cipher.decrypt(encrypted_data, pqc_private_key, + file_contents=original_file_contents)+ else:+ # Standard approach without file contents+ return cipher.decrypt(encrypted_data, pqc_private_key) except Exception as e: # Use generic error message to prevent oracle attacks if os.environ.get('PYTEST_CURRENT_TEST') is not None: raise e+ # Try to show more information if available+ if hasattr(e, 'args') and len(e.args) > 0:+ err_msg = str(e.args[0])+ if "integrity" in err_msg.lower():+ print(f"PQC integrity verification failed: {err_msg}") raise ValueError("Decryption failed: post-quantum decryption error") else: raise ValueError(f"Unsupported encryption algorithm: {algorithm}")@@ -1777,18 +1806,44 @@ def do_decrypt(): if original_hash: if not quiet: print("Verifying decrypted content integrity", end=" ")- computed_hash = calculate_hash(decrypted_data)- # Use constant-time comparison to prevent timing attacks- if not secrets.compare_digest(computed_hash, original_hash):- print("❌") # Red X symbol- # In test mode, use the original message for compatibility with tests- if os.environ.get('PYTEST_CURRENT_TEST') is not None:- raise ValueError("Decryption failed: data integrity check failed")- else:- # In production mode, use a generic message to avoid leaking specifics@@ -12,7 +12,6 @@ import json import logging import os-import random import secrets import sys import time@@ -32,8 +31,12 @@ def public_key_part(private_key: bytes) -> bytes: """- Extract a deterministic public-key-like value from a private key- for use in simulation mode.+ Extract a deterministic public-key-like value from a private key.++ .. deprecated::+ This function is only retained for backward-compatible decryption of+ files encrypted with the legacy simulation mode. It MUST NOT be used+ for new encryptions (CRIT-3). Args: private_key: The private key bytes@@ -43,20 +46,12 @@ def public_key_part(private_key: bytes) -> bytes: """ # Use secure memory for operations with private key data with SecureBytes(private_key) as secure_private_key:- # Take the first 16 bytes (or all if smaller) to act as an identifier- # This is only used for simulation mode if len(secure_private_key) <= 16:- # Create a copy to ensure the original is not shared return bytes(secure_private_key) else:- # Use first 16 bytes which should be enough to uniquely identify the key- # but not enough to reveal the entire key return bytes(secure_private_key[:16]) -# Environment variable to control PQC initialization messages-import os- # Try to import PQC libraries, provide fallbacks if not available LIBOQS_AVAILABLE = False oqs = None@@ -1042,99 +1037,70 @@ def decrypt( # No need for debug output on nonce - # Check if this is a simulated ciphertext (created during encryption)+ # Check if this is a simulated ciphertext (legacy format) sim_header = b"SIMULATED_PQC_v1"- simulation_mode = False+ simulation_detected = False if ( len(encapsulated_key) >= len(sim_header) and encapsulated_key[: len(sim_header)] == sim_header ):- # Detected simulation header- simulation_mode = True- if not self.quiet:- print(- "Detected simulated ciphertext, using matching simulation for decryption"- )- elif len(encapsulated_key) > 0 and encapsulated_key[0] == ord(b"S"):- # Detected marker byte for short simulation- simulation_mode = True- if not self.quiet:- print(- "Detected simulation marker, using matching simulation for decryption"- )+ simulation_detected = True # Initialize the shared secret with None to detect success shared_secret = None- simulation_detected = False-- # Check if this is a simulated encryption from the encrypt method- if simulation_mode:- # This was detected as a simulation mode ciphertext- simulation_detected = True- else:- # Check the first few bytes for a marker- # Even if not detected via header, it could still be simulation mode- sim_marker = b"S"- if len(encapsulated_key) > 0 and encapsulated_key[0] == ord(sim_marker):- simulation_detected = True- if not self.quiet:- print("Detected simulation marker byte") AI-assisted change f8a554cd2bf34e8045aa186a600507cb7c95f8b24383830fae160b0a3caf7156 · Fix 8952cc882a44048f09dc1bd39f6540b51149bcbc8415e808760879969812ed23