diff --git a/envcloak/commands/compare.py b/envcloak/commands/compare.py index b585c9f..b913b2e 100644 --- a/envcloak/commands/compare.py +++ b/envcloak/commands/compare.py @@ -39,8 +39,14 @@ required=False, help="Path to save the comparison result as a file.", ) +@click.option( + "--skip-sha-validation", + is_flag=True, + default=False, + help="Skip SHA-256 integrity validation checks during decryption.", +) @debug_option -def compare(file1, file2, key1, key2, output, debug): +def compare(file1, file2, key1, key2, output, skip_sha_validation, debug): """ Compare two encrypted environment files or directories. """ @@ -91,8 +97,18 @@ def compare(file1, file2, key1, key2, output, debug): if Path(file1).is_file() and Path(file2).is_file(): debug_log("Debug: Both inputs are files. Decrypting files.", debug) try: - decrypt_file(file1, file1_decrypted, key1_bytes) - decrypt_file(file2, file2_decrypted, key2_bytes) + decrypt_file( + file1, + file1_decrypted, + key1_bytes, + validate_integrity=not skip_sha_validation, + ) + decrypt_file( + file2, + file2_decrypted, + key2_bytes, + validate_integrity=not skip_sha_validation, + ) except FileDecryptionException as e: raise click.ClickException(f"Decryption failed: {e}") @@ -141,9 +157,17 @@ def compare(file1, file2, key1, key2, output, debug): file2_decrypted, filename.replace(".enc", "") ) try: - decrypt_file(str(file1_path), file1_dec, key1_bytes) decrypt_file( - str(file2_files[filename]), file2_dec, key2_bytes + str(file1_path), + file1_dec, + key1_bytes, + validate_integrity=not skip_sha_validation, + ) + decrypt_file( + str(file2_files[filename]), + file2_dec, + key2_bytes, + validate_integrity=not skip_sha_validation, ) except FileDecryptionException as e: raise click.ClickException( diff --git a/envcloak/commands/decrypt.py b/envcloak/commands/decrypt.py index d46cc96..016b735 100644 --- a/envcloak/commands/decrypt.py +++ b/envcloak/commands/decrypt.py @@ -50,7 +50,15 @@ @click.option( "--key-file", "-k", required=True, help="Path to the decryption key file." ) -def decrypt(input, directory, output, key_file, dry_run, force, debug): +@click.option( + "--skip-sha-validation", + is_flag=True, + default=False, + help="Skip SHA3 integrity validation checks during decryption.", +) +def decrypt( + input, directory, output, key_file, dry_run, force, debug, skip_sha_validation +): """ Decrypt environment variables from a file or all files in a directory. """ @@ -122,7 +130,7 @@ def decrypt(input, directory, output, key_file, dry_run, force, debug): f"Debug: Decrypting file {input} -> {output} using key {key_file}.", debug, ) - decrypt_file(input, output, key) + decrypt_file(input, output, key, validate_integrity=not skip_sha_validation) click.echo(f"File {input} decrypted -> {output} using key {key_file}") elif directory: input_dir = Path(directory) @@ -141,7 +149,12 @@ def decrypt(input, directory, output, key_file, dry_run, force, debug): f"Debug: Decrypting file {file} -> {output_file} using key {key_file}.", debug, ) - decrypt_file(str(file), str(output_file), key) + decrypt_file( + str(file), + str(output_file), + key, + validate_integrity=not skip_sha_validation, + ) click.echo( f"File {file} decrypted -> {output_file} using key {key_file}" ) diff --git a/envcloak/encryptor.py b/envcloak/encryptor.py index fb87730..92f883a 100644 --- a/envcloak/encryptor.py +++ b/envcloak/encryptor.py @@ -5,6 +5,8 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes from cryptography.hazmat.backends import default_backend +import click +from click import style from envcloak.exceptions import ( InvalidSaltException, InvalidKeyException, @@ -12,8 +14,10 @@ DecryptionException, FileEncryptionException, FileDecryptionException, + IntegrityCheckFailedException, ) from envcloak.constants import NONCE_SIZE, KEY_SIZE, SALT_SIZE +from envcloak.utils import compute_sha256 def derive_key(password: str, salt: bytes) -> bytes: @@ -76,12 +80,13 @@ def encrypt(data: str, key: bytes) -> dict: raise EncryptionException(details=str(e)) from e -def decrypt(encrypted_data: dict, key: bytes) -> str: +def decrypt(encrypted_data: dict, key: bytes, validate_integrity: bool = True) -> str: """ Decrypt the given encrypted data using AES-256-GCM. :param encrypted_data: Dictionary containing ciphertext, nonce, and tag. :param key: Decryption key (32 bytes for AES-256). + :param validate_integrity: Whether to enforce integrity checks (default: True). :return: Decrypted plaintext. """ try: @@ -95,6 +100,15 @@ def decrypt(encrypted_data: dict, key: bytes) -> str: decryptor = cipher.decryptor() plaintext = decryptor.update(ciphertext) + decryptor.finalize() + if validate_integrity: + # Validate plaintext hash if present + if "sha" in encrypted_data: + sha_hash = compute_sha256(plaintext.decode()) + if sha_hash != encrypted_data["sha"]: + raise IntegrityCheckFailedException( + details="Integrity check failed! The file may have been tampered with or corrupted." + ) + return plaintext.decode() except Exception as e: raise DecryptionException(details=str(e)) from e @@ -102,38 +116,99 @@ def decrypt(encrypted_data: dict, key: bytes) -> str: def encrypt_file(input_file: str, output_file: str, key: bytes): """ - Encrypt the contents of a file and write the result to another file. - - :param input_file: Path to the plaintext input file. - :param output_file: Path to save the encrypted file. - :param key: Encryption key (32 bytes for AES-256). + Encrypt the contents of a file and write the result to another file, + including SHA-256 of the entire encrypted JSON structure. """ try: with open(input_file, "r", encoding="utf-8") as infile: data = infile.read() + # Encrypt plaintext encrypted_data = encrypt(data, key) + # Compute hash of plaintext for integrity + encrypted_data["sha"] = compute_sha256(data) + print( + f"Debug: SHA-256 hash of plaintext during encryption: {encrypted_data['sha']}" + ) + + # Compute hash of the entire encrypted structure + file_hash = compute_sha256(json.dumps(encrypted_data, ensure_ascii=False)) + encrypted_data["file_sha"] = file_hash # Store this hash in the structure + print( + f"Debug: SHA-256 hash of encrypted structure (file_sha): {encrypted_data['file_sha']}" + ) + with open(output_file, "w", encoding="utf-8") as outfile: json.dump(encrypted_data, outfile, ensure_ascii=False) except Exception as e: raise FileEncryptionException(details=str(e)) from e -def decrypt_file(input_file: str, output_file: str, key: bytes): +def decrypt_file( + input_file: str, output_file: str, key: bytes, validate_integrity: bool = True +): """ - Decrypt the contents of a file and write the result to another file. + Decrypt the contents of a file and validate SHA-256 integrity for both + the plaintext and the encrypted file. :param input_file: Path to the encrypted input file. :param output_file: Path to save the decrypted file. - :param key: Decryption key (32 bytes for AES-256). + :param key: Encryption key (32 bytes for AES-256). + :param validate_integrity: Whether to enforce integrity checks (default: True). """ try: with open(input_file, "r", encoding="utf-8") as infile: encrypted_data = json.load(infile) - decrypted_data = decrypt(encrypted_data, key) + if validate_integrity: + # Validate hash of the entire encrypted file (excluding file_sha) + expected_file_sha = encrypted_data.get("file_sha") + if expected_file_sha: + # Exclude "file_sha" itself from the recomputed hash + data_to_hash = encrypted_data.copy() + data_to_hash.pop("file_sha") + actual_file_sha = compute_sha256( + json.dumps(data_to_hash, ensure_ascii=False) + ) + # print(f"Debug: Stored file_sha: {expected_file_sha}") + # print(f"Debug: Computed file_sha: {actual_file_sha}") + if expected_file_sha != actual_file_sha: + raise IntegrityCheckFailedException( + details="Encrypted file integrity check failed! The file may have been tampered with or corrupted." + ) + else: + click.echo( + style( + "⚠️ Warning: file_sha missing. Encrypted file integrity check skipped.", + fg="yellow", + ) + ) + + # Decrypt the plaintext + decrypted_data = decrypt( + encrypted_data, key, validate_integrity=validate_integrity + ) + if validate_integrity: + # Validate hash of plaintext + if "sha" in encrypted_data: + sha_hash = compute_sha256(decrypted_data) + # print(f"Debug: Stored sha: {encrypted_data['sha']}") + # print(f"Debug: Computed sha: {sha_hash}") + if sha_hash != encrypted_data["sha"]: + raise IntegrityCheckFailedException( + details="Decrypted plaintext integrity check failed! The file may have been tampered with or corrupted." + ) + else: + click.echo( + style( + "⚠️ Warning: sha missing. Plaintext integrity check skipped.", + fg="yellow", + ) + ) + + # Write plaintext to the output file with open(output_file, "w", encoding="utf-8") as outfile: outfile.write(decrypted_data) except Exception as e: diff --git a/envcloak/exceptions.py b/envcloak/exceptions.py index 51f663b..f8eeb1f 100644 --- a/envcloak/exceptions.py +++ b/envcloak/exceptions.py @@ -110,3 +110,23 @@ class FileEncryptionException(CryptographyException): """Raised when file encryption fails.""" default_message = "Failed to encrypt the file." + + +#### Integrity exceptions +class IntegrityCheckFailedException(CryptographyException): + """Raised when the integrity check of a file fails.""" + + default_message = ( + "Integrity check failed! The file may have been tampered with or corrupted." + ) + + def __init__(self, message=None, details=None): + self.message = message or self.default_message + self.details = details + super().__init__(self.message) + + def __str__(self): + error_message = f"Error: {self.message}" + if self.details: + error_message += f"\nDetails: {self.details}" + return error_message diff --git a/envcloak/utils.py b/envcloak/utils.py index 4eeddeb..c75f021 100644 --- a/envcloak/utils.py +++ b/envcloak/utils.py @@ -1,4 +1,5 @@ import os +import hashlib from pathlib import Path @@ -60,3 +61,13 @@ def debug_log(message, debug): """ if debug: print(message) + + +def compute_sha256(data: str) -> str: + """ + Compute SHA-256 hash of the given data. + + :param data: Input data as a string. + :return: SHA-256 hash as a hex string. + """ + return hashlib.sha3_256(data.encode()).hexdigest() diff --git a/tests/mock/sha_variables.env.enc b/tests/mock/sha_variables.env.enc new file mode 100644 index 0000000..8ef50e7 --- /dev/null +++ b/tests/mock/sha_variables.env.enc @@ -0,0 +1 @@ +{"ciphertext": "DfyoaMMgHDjpbisZHXe+Xn0pIdiIn4TJqBkmKfSSftkPjBa+DIIT5b9+pWIlDTQw7bHF6WfEJdpUIXEcOnIHUTOc2A4E4aVvnhbsAYfAn9d+MwONPDEhoNAw+WkBwrdsHLkAgV5o+EKjaTJ6JEzX+XrBY6zIZ49p5YHT6Mp8rNs/rpMTNIXDBIExoy/xY6fjIT7AgytojXf/rcoOubFdEcedxnQJzn1LTFB3Zqq630MLGZfuNd00jIM3px+5ipxzfZexImRsuQDuy1/PFdWDP8jdMYTUTWMvStTMrwG4JMuzU6cefD/beZ/XmAsSez5Qs/UE//rTibptOX2qYc5FBopzLDrj5Dnad3TQVh1CVjIj+TSXExFv5+Dpk2fvI98ydCj15ym/TpuVXGonuB5K5MeNUmTn9IuViHrnCE04NfBLZb3FObgfDKPaZPeoQa5x+m2Egn7pam8qyFSOHNeeoafg8Nlyapd8YdwiWha4b1dT6nB/IbaMQmtWgyAZ54xKUUAmTGw4rXUmlQKXyq9pIJab9NvBXK/ttzuqwObOHSiibgS4J2/1JEEtH4voFOWMwGS7qEN6fjcXLVPI7KAoEyzhNJTHKf8WueqI2FaSqPeBywFZwvaQo6FHk0PdKAhjtcp7uxFWOOrgXjuU8+90+gPfdDH+Eg8vsTkeFdQxUZWZUHA91+AG2+JHm3l4rQEquVKxDijgy63WZ56PXIoUm0lzqS+rlgU40LNZdOBT5zxfHbhJzem2CYNw6m445vyWXzEdBdDBdvPCb65qPj7HEO4Xor3P9J2xQEEymy11EbH11abQxe53e97xuzghWu7AlY7Ne0ie90ScICKcD9oJV3YhJqjSCXvcbc2/R3AK1J1VqMOZzcf7J6NjO3qKLpbanSRSZD9V08IM1p9/GL/uWgyY50NHnh6w4+OzUQ==", "nonce": "Lm9ZYZz3YmYhyW3U", "tag": "l0sp+HM8qSwpBpjBdt9nbA==", "sha": "13448a667827d62ef7ab673f482b2f2053758e108ba9d455e80a1bdbb56b7b42", "file_sha": "98f5f15ff85e287b04db8d3dfff4ec52fea7d4b453560cd107af4531e47a0cb7"} \ No newline at end of file diff --git a/tests/mock/sha_variables.json.enc b/tests/mock/sha_variables.json.enc new file mode 100644 index 0000000..039664a --- /dev/null +++ b/tests/mock/sha_variables.json.enc @@ -0,0 +1 @@ +{"ciphertext": "+IUW/nt8D8OyuhYmxtdl6qcQRW1z1A/WodTXfoZHdzzIwzeQPHu4gGjqHwHlZBMgrUBegfc9EDO9Gu7s+q2oPmJ3xtPqpQOAP0pGUxwKARkymJOtKNmG5DLOlRMTK6x6Itt5LSoXEC0cF5U6pdgUzAPn19I2+MsMhw4zSPmR9Kzj9TdpMp8CAFuD+UGUDtCBCkfJZyb/tidESjk84Ok4iONW5gjlAmWcj9OtZ0rv1SdaVS/nTCE9nA95U8hXknxg6BltgjXn+UiNv5O/KFlgTUU+0cyqDPQsAETppbQ7bnmrqAZNngIQE/2ONZciI6S93wUu/ir+BlkkC0ewTUv6X+7Hd6wP5xQ7IZ9OviJw12HvFL7+0jwhoyMXHXBROVw/kLp/K9SzqSyCaFopHzVX9m9MM3IUdAUc2mXP2B7Xwu7L5mmH9WzXHVUv73YUgjYmCYMDNEEF7F8YRUzmG+sGn8VvzqUThmn1z7617mKB7bmVgtDYUkI5fws1jRBXJ6E4DSJ4eQ4UFSpXAWjTJU8qC2G6PrcxUzJUAwVqmhHyDDVZu1vMhGcc9mE82OYyT4/SatfLx3vqiuZHKGWhVjSCZuBjTITKnaJGK7rcHDf0g9uIGv53UJ8WZ3Q/f9+jeQL6vKulgQzRVrDWcBW99NM9KSKyuKcidJYo+gaYrfXVlSLF7L9vGejkqUWBvbDHAQnlghr7YyaKbVa9q/TxJL3mNNDaKK+GrpItbAW/G78NW5qeAvbWbeXdvZcPo7nNGQqfQH2hkDHvjWErCvUCDEOMBn5u1I53SwE74QDpoWkGsv6Jyz6dRJYck/wZrkrymYg0COL931r6ZRkUuKg/gY0L9S1XK304pt+T0Qi29pq9UNXSTk+vV4ki624GC1AQDj4sOOqcFdL/GES/L2MF7QmdDezBwEDwRaN/lhvh2GsOU3Jkem0tL9q/", "nonce": "vun22UclsVwZ9Q7c", "tag": "6PjtvCeTLNNrx8U3VGLn4Q==", "sha": "d07eb8ad965d8e2b7e126ada0ff1cff69b822c8787fb451314a9fc821e7c43f3", "file_sha": "e58347ed58e640b5948637ee3e346aef5ee20b767c1faab2ad0050393fb8dd02"} \ No newline at end of file diff --git a/tests/mock/sha_variables.xml.enc b/tests/mock/sha_variables.xml.enc new file mode 100644 index 0000000..4edb5c1 --- /dev/null +++ b/tests/mock/sha_variables.xml.enc @@ -0,0 +1 @@ +{"ciphertext": "tGH9FNZoMzAjGuGrQWhbFQm23Yc0rIauVU/EjnkwDsGENMeTPjGVokgIPoDcEHni86HANSUDhikvsrJAPlZNlZ/wxHoE0ggB2CRhtoR/z2ecJsWJ9K80Z54yQHXhRnY3CEC967Xl23vxPWyT/g1LKhcedY8fm7pNZMI6LxVp0cDJWOfPEwPLSgH9N3bfxDrZLSectd/9+B+p9Vq0KRlukoLl9qZffhennjZNFNHyTzKOG+DrUhakelKiWcwLDH2oVrR1knlWEIoM8ZBvI2e09rqzuofFvTu4+JFhcJaEvLvt8bHlBe0Y93ZXBRMrWkO3Cn9mc36Hdzsw4dVAsxg1fJ0TbPZJr9ArbuxYOjR/XCWFEGmFaD9llYpvhxlrYgjG0ArQy/89Nf1T0/djNOuc8mqghuYM9SZhpQ2qltyqGH49TzZ4wIn0ERc2Spvpxp/tUdq2aAjvOsZtnyz03Ivo5Hyw+3FgJBhJPt/J1LRSkOuLZ7vwd59hjq5EryJrwYgtfBPVQKqkooG77W2Uxii6hli/3ItvjogDyRnCcut9SE4TmS+LqwAF9HV05oRfJ5kPPks+stdnxVy/dNKf5z8+60tMRt9rJ/ofTC5hNirlcSW+E0oiCSLTHOVv3DYsi5dQyUmAT2FCqSG3yUDYuj8frq7frw9RVUG28/AlvqkKwMLo2sTBRTwXra2opSbej6ZwTYq8mLKULD9aPsxuasl99NBgzqlFsAB8XR4vafYSYIYvE3UyVlNbs6SIwZndq2XbwY9erZteDiUf8kxbJz7xR2GB1AVKVT11iBmtT/HmZEShkUHQI7GuXPioQT6UilOGHWLxmcLiCIGc3meMm9gsb/aD1vCPHbdD0+ljIGdyYxXoE3KzLgaDRhO7RAlzdHowY9drcYZk+2hQH8MMGlcORLtRnozKbN2PKvlgSbWcV3JiTjCLd4/Sg2WS0vExe35hQKms2C6mA8so0pNDkVeI3tuIrrfKKvao0xIez+Uxs29ubhmE0vJGzoHIXNnCUR533wr8yhORYHs0jFwY8ZMD5zmUvecpwTJPAJ0i3V7zC+cgeiTW8UFX/kCliM9PqkERhd6LJESHfmmwf6nxj6PHD7nlRxC/UKnBUx02XOLhBCfpFGAn00XUKgrmqOGPXPL4RgtXw3VxtJuMgAR/1mn5269yRYbi3rinb7oDb0+PfX7X66D8EQf6RPN+KwyN1QGAxcHQ8T8sB/mZ0vw6kMKUIrauyIc5FzdP8oWnES3gyGkb5O7z7COwf1U3OudqnFYGlJKcHD8mgR5CTzehlJrG0F0mpHJB/TSc+FNBQ/VdwzHcnRtlwZa0uc+ZEPLPVueZSat7Sr9M3sdKNm/2Aic1joPXw1zbOMHJsIxPexDCpiTAkTvgG13bspCZhMoOktHo+y8FReiJm2NGxalVTiBb/wjnfjP7gkI+80MwJPVrjYlq3F5E+9Bsz2Gn6ypMn4amuA6Sy3v7xzyqMKLoQOG2Z8+0fWrBIYX+2q4jbDt6DQ==", "nonce": "IB/PyrI0AflcQJXP", "tag": "tOyjNYzQxrV5iXsUCGgkEA==", "sha": "caaa3e108f2717fd82c6d955452baa0758da7bcc06d7df4724d7cd4def1662cd", "file_sha": "72fc2710ef006f959b9b020c5e6b154d9245cbea5a14c546a1612f4d92793bc3"} \ No newline at end of file diff --git a/tests/mock/sha_variables.yaml.enc b/tests/mock/sha_variables.yaml.enc new file mode 100644 index 0000000..9f6d943 --- /dev/null +++ b/tests/mock/sha_variables.yaml.enc @@ -0,0 +1 @@ +{"ciphertext": "Pls8pNRxTx6SgdPGGbgz6EU/xiryYJ2CEYmYORMxuBejF8SMnkQXTda2EQdiYW1DO7jAH6Z9YQAUZyy3SrYZcbqge6QvWtC93f/d853XfVBm8qOvepGLUGvoRfMW7urZpjIsfWy4wb4c3T4p7LQp9hyqOzjvyPcu07pHRbJv0hKyDL7VevpSBoM23Kxuw+KRtZ22+HGDn9ZpkzsCv4sq3km6CNgZOH/NH1qCIFayFRrQq0n/riH/ASNnS1bZU4e+kbk1bLDTtyS8ltxBTKABQ08o5WRjT0qpGAuGG6ChAWJrD+0gaoYzIz18zg7DYGL4ck1GwIC2YXEhyCvRwgUeNqP6W9CmaMbKPn2vRo5Vka2B9ViiOApsvws24+nX67gw6vcl9kaJbJRdvIl3vcsTzWBABS+WH9y1kBBSM0859a1fuYzwpig0X0QpCTWmieElRpkKgm/oNXlVXwnstrr3emt0RYwg5vIjpxRsFZKu9qManzs+r+Ga+iVn/umZiLsCiCFUlXAv1D7Rg/m9c7We7bZZVGBTbA+8An8Y4lo4xqRBEBBaVg6dCKHZc6l6JoV6PPF+KdNVq6+W99tUMkAECoEpC4MspDuZ758ZBqIXbqM8oreU/Iz3IeHwxW4hipp/8+LQVddAnOsnQDsiLDXIQ5PgiQhlYvOomvA5Ihy4PyJjQtvC43h//IrACk5x3bBWKCAp6xSke5qVLTgSgrfa1/9s6GQmO5tyKoTtt+h0HyLpIFCtPo8hIvEJulzRTuGIKV5oBy2rNfdwzfOEJ6SKY3g05O6IIRGDtZbP1ZM9nlGfEYNjwTam07wYsEHS/4LygjNZonAGpOCC/8/35qS/OCoZbYnhgkcrMvNbPuVEO3hF4/Nbx23nPMJ7iuwuBkTTdLobrMNaqI05Uj9Vc3m2oxd5OeyiY2v8k1aEMHUOTqzUp3zk6aVawEBdrto=", "nonce": "CoJyLiwgMNZa/025", "tag": "CF5DK+T/7uU6MtEVyyHkHQ==", "sha": "cd96e71dc433d099f50ad7918e4d2237eb5ace783bde4db119ee718a43866dd1", "file_sha": "0497634082406e66b2bb07c29297bd5e93d0090ac1f832de5dc454d8840cdaab"} \ No newline at end of file diff --git a/tests/mock/sha_variables_modified.env.enc b/tests/mock/sha_variables_modified.env.enc new file mode 100644 index 0000000..8d6e44d --- /dev/null +++ b/tests/mock/sha_variables_modified.env.enc @@ -0,0 +1 @@ +{"ciphertext": "NI8luEOsn/o9sLiu6C2khuMuY42jV/tz2yNe2qdWv9MxxbOn1HTEXOmLadwklncFjCNMcAly70/xAPiSNPqVgkQj1efwPRyhIhyPS10+QSlXYL28YlsCZK+RvlwEfo2rY9cwiEEMW2VufnUSmTPxhrH9+ZwVg003OA819jkfXA+HIQep67MTzkhYBA5O+t/oQT7EyEJ3vl+LaKaHJEnq1QRG1GbyEPuxG/jt520+2t7qTmqWAsI8b/CxjlX0ZpyzW4o42xQzWbArY6m0cHDeOdgPJhNBstSlJcZP8BgqTBqc046HEUfiUdOkicgx6Si2k1vLG6Tj/nY9z8fCcE5W2kLttq+/S0YyMnPdv5OSzuNZ3MrOqR8JIXCDnOPOAkxt1o27b1808uvPFRAuUwKHAfi7cjcaWPBFRDhSIY9W1E3YSjsevhQzFcqNmKOIUQZZ8kGSsTdCYU8q2CWvkLyDOezqzCmGO8TgYh8KSsEFT+mIWqLTct2mAIWTomTalDko7DSdCcDolY6zW13k4lhZascvuOHHOEThm9CjBFpa47vNfGYuaieB+8bMP8wm0KLOyr203o/OEBlswmv7G5DRrR8M/iJJQ5NvHaJNwNwaM1xMRjGGDh1D5POvo/PqahOBHSJdqcmg6fUsUgDkcs4hC8JncRWKdYjQw5RJfhQn/bvFvAyuGLcGJXfIFi4ByzcmTZTEeIs3q4ghmlLueXyfmCIiSlQe3/g7RkeIbHqozfQoo/dE6UlMmU2UytRn1cilIIabeA48jU+z+6wV6HWmFrx6AAHSdWDjZKhDFqipJ3wT/OKUzIgrCcqviWmimSJLMNZ112aYp+N553aJSo8Bo7QxgcEmjYE+k5jfX7hjDXGqNUOsz1K5207C1u1R5suD1EBJqI7QDsBuZKjzVQax4vJ6qtquDcTJiSy6v9D5ETDXy9d3uxAMvClXsNyDAgr90C/9C89vZiAOdqdQkmaS0jqEb0iIsKdlP1uUZzxhcM2l5lh+P8KUS0F1he3xJxdG3qPwXrY=", "nonce": "QqojJOrWy6QuIYzH", "tag": "kMsOdH1w6BwQxHD90sVSJg==", "sha": "7749d79d08a96e0b0700692f2df7af76313a37fb14eceefec2c39f7ebae73682", "file_sha": "1c9792cbdc260e0748d6ecb21a57662da4b27a4d0871a0472288ed6ba96b9a08"} \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index a14e24e..0ffd5e3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,17 +9,7 @@ from unittest.mock import patch from envcloak.cli import main from envcloak.generator import derive_key - -# Updated import list for command modularization -# from envcloak.commands.encrypt import encrypt_file -# from envcloak.commands.decrypt import decrypt_file -# from envcloak.commands.generate_key import generate_key_file -# from envcloak.commands.generate_key_from_password import generate_key_from_password_file -# from envcloak.commands.rotate_keys import ( -# encrypt_file as rotate_encrypt_file, -# decrypt_file as rotate_decrypt_file, -# ) -# from envcloak.utils import add_to_gitignore +from envcloak.exceptions import FileDecryptionException @pytest.fixture @@ -125,8 +115,11 @@ def test_decrypt(mock_decrypt_file, runner, mock_files): # Use a unique temporary output file temp_decrypted_file = decrypted_file.with_name("variables.temp.decrypted") - def mock_decrypt(input_path, output_path, key): + def mock_decrypt(input_path, output_path, key, validate_integrity=True): assert os.path.exists(input_path), "Encrypted file does not exist" + assert isinstance( + validate_integrity, bool + ), "validate_integrity must be a boolean" with open(output_path, "w") as f: f.write("DB_USERNAME=example_user\nDB_PASSWORD=example_pass") @@ -142,12 +135,16 @@ def mock_decrypt(input_path, output_path, key): str(temp_decrypted_file), "--key-file", str(key_file), + "--skip-sha-validation", ], ) assert "File" in result.output mock_decrypt_file.assert_called_once_with( - str(encrypted_file), str(temp_decrypted_file), key_file.read_bytes() + str(encrypted_file), + str(temp_decrypted_file), + key_file.read_bytes(), + validate_integrity=False, ) # Clean up: Remove temp decrypted file @@ -423,6 +420,7 @@ def test_decrypt_with_mixed_input_and_directory(runner, mock_files): output_path, "--key-file", str(key_file), + "--skip-sha-validation", ], ) @@ -485,7 +483,7 @@ def test_decrypt_with_force(mock_decrypt_file, runner, mock_files): # Create a mock existing decrypted file decrypted_file.write_text("existing content") - def mock_decrypt(input_path, output_path, key): + def mock_decrypt(input_path, output_path, key, validate_integrity=True): assert os.path.exists(input_path), "Encrypted file does not exist" with open(output_path, "w") as f: f.write("DB_USERNAME=example_user\nDB_PASSWORD=example_pass") @@ -504,12 +502,16 @@ def mock_decrypt(input_path, output_path, key): "--key-file", str(key_file), "--force", + "--skip-sha-validation", ], ) assert "Overwriting existing file" in result.output mock_decrypt_file.assert_called_once_with( - str(encrypted_file), str(decrypted_file), key_file.read_bytes() + str(encrypted_file), + str(decrypted_file), + key_file.read_bytes(), + validate_integrity=False, # Ensure the validate_integrity flag matches ) # Ensure the file was overwritten @@ -565,6 +567,7 @@ def test_decrypt_without_force_conflict(runner, mock_files): str(decrypted_file), "--key-file", str(key_file), + "--skip-sha-validation", ], ) @@ -641,7 +644,7 @@ def test_decrypt_with_force_directory(mock_decrypt_file, runner, isolated_mock_f output_directory.mkdir() (output_directory / "file1.env").write_text("existing decrypted content") - def mock_decrypt(input_path, output_path, key): + def mock_decrypt(input_path, output_path, key, validate_integrity=True): with open(output_path, "w") as f: f.write("decrypted content") @@ -659,6 +662,7 @@ def mock_decrypt(input_path, output_path, key): "--key-file", str(key_file), "--force", + "--skip-sha-validation", ], ) @@ -667,11 +671,13 @@ def mock_decrypt(input_path, output_path, key): str(directory / "file1.env.enc"), str(output_directory / "file1.env"), key_file.read_bytes(), + validate_integrity=False, # Ensure the validate_integrity flag matches ) mock_decrypt_file.assert_any_call( str(directory / "file2.env.enc"), str(output_directory / "file2.env"), key_file.read_bytes(), + validate_integrity=False, # Ensure the validate_integrity flag matches ) @@ -1086,3 +1092,160 @@ def mock_decrypt(input_path, output_path, key): finally: # Cleanup the key file key_file.unlink(missing_ok=True) + + +@patch("envcloak.commands.decrypt.decrypt_file") +def test_decrypt_sha_file(mock_decrypt_file, runner, isolated_mock_files): + """ + Test the `decrypt` CLI command for a file encrypted with SHA. + """ + sha_file = isolated_mock_files / "sha_variables.env.enc" + decrypted_file = isolated_mock_files / "sha_variables_decrypted.env" + key_file = isolated_mock_files / "mykey.key" + + def mock_decrypt(input_path, output_path, key, validate_integrity=True): + assert validate_integrity is True, "SHA validation must be enabled" + with open(output_path, "w") as f: + f.write("DB_USERNAME=example_user\nDB_PASSWORD=example_pass") + + mock_decrypt_file.side_effect = mock_decrypt + + result = runner.invoke( + main, + [ + "decrypt", + "--input", + str(sha_file), + "--output", + str(decrypted_file), + "--key-file", + str(key_file), + ], + ) + + assert "File" in result.output + mock_decrypt_file.assert_called_once_with( + str(sha_file), + str(decrypted_file), + key_file.read_bytes(), + validate_integrity=True, + ) + + +@patch("envcloak.commands.decrypt.decrypt_file") +def test_decrypt_sha_file_skip_validation( + mock_decrypt_file, runner, isolated_mock_files +): + """ + Test the `decrypt` CLI command for a file encrypted with SHA with `--skip-sha-validation`. + """ + sha_file = isolated_mock_files / "sha_variables.env.enc" + decrypted_file = isolated_mock_files / "sha_variables_decrypted.env" + key_file = isolated_mock_files / "mykey.key" + + def mock_decrypt(input_path, output_path, key, validate_integrity=True): + assert validate_integrity is False, "SHA validation must be skipped" + with open(output_path, "w") as f: + f.write("DB_USERNAME=example_user\nDB_PASSWORD=example_pass") + + mock_decrypt_file.side_effect = mock_decrypt + + result = runner.invoke( + main, + [ + "decrypt", + "--input", + str(sha_file), + "--output", + str(decrypted_file), + "--key-file", + str(key_file), + "--skip-sha-validation", + ], + ) + + assert "File" in result.output + mock_decrypt_file.assert_called_once_with( + str(sha_file), + str(decrypted_file), + key_file.read_bytes(), + validate_integrity=False, + ) + + +@patch("envcloak.commands.decrypt.decrypt_file") +def test_decrypt_modified_sha_file(mock_decrypt_file, runner, isolated_mock_files): + """ + Test the `decrypt` CLI command for a modified SHA file. + """ + modified_sha_file = isolated_mock_files / "sha_variables_modified.env.enc" + decrypted_file = isolated_mock_files / "sha_variables_decrypted.env" + key_file = isolated_mock_files / "mykey.key" + + def mock_decrypt(input_path, output_path, key, validate_integrity=True): + raise FileDecryptionException( + details="Integrity check failed! The file may have been tampered with or corrupted." + ) + + mock_decrypt_file.side_effect = mock_decrypt + + result = runner.invoke( + main, + [ + "decrypt", + "--input", + str(modified_sha_file), + "--output", + str(decrypted_file), + "--key-file", + str(key_file), + ], + ) + + assert ( + "Error during decryption: Error: Failed to decrypt the file.\n" + "Details: Integrity check failed! The file may have been tampered with or corrupted." + in result.output + ) + + +@patch("envcloak.commands.decrypt.decrypt_file") +def test_decrypt_different_file_types_with_sha( + mock_decrypt_file, runner, isolated_mock_files +): + """ + Test the `decrypt` CLI command for various file types with SHA validation. + """ + file_types = ["json", "yaml", "xml"] + for file_type in file_types: + sha_file = isolated_mock_files / f"sha_variables.{file_type}.enc" + decrypted_file = isolated_mock_files / f"sha_variables_decrypted.{file_type}" + key_file = isolated_mock_files / "mykey.key" + + def mock_decrypt(input_path, output_path, key, validate_integrity=True): + assert validate_integrity is True, "SHA validation must be enabled" + with open(output_path, "w") as f: + f.write(f"Decrypted content of {file_type}") + + mock_decrypt_file.side_effect = mock_decrypt + + result = runner.invoke( + main, + [ + "decrypt", + "--input", + str(sha_file), + "--output", + str(decrypted_file), + "--key-file", + str(key_file), + ], + ) + + assert f"File {sha_file} decrypted -> {decrypted_file}" in result.output + mock_decrypt_file.assert_any_call( + str(sha_file), + str(decrypted_file), + key_file.read_bytes(), + validate_integrity=True, + ) diff --git a/tests/test_dynamic_analysis.py b/tests/test_dynamic_analysis.py index ab0fa57..4a09c9d 100644 --- a/tests/test_dynamic_analysis.py +++ b/tests/test_dynamic_analysis.py @@ -162,27 +162,39 @@ def test_special_characters_in_env(env_data): decrypted_file = "special_env_file_decrypted.env" input_file = "special_env_file.env" - # Write the mock `.env` file - with open(input_file, "w", encoding="utf-8") as f: - for k, v in env_data.items(): - f.write(f"{k}={v}\n") - - # Encrypt and decrypt the file - encrypt_file(input_file, encrypted_file, key) - decrypt_file(encrypted_file, decrypted_file, key) - - # Validate content - with open(decrypted_file, "r", encoding="utf-8") as f: - decrypted_content = f.read().strip() - expected_content = "\n".join(f"{k}={v}" for k, v in env_data.items()) - assert ( - decrypted_content == expected_content - ), "Decrypted content does not match the original" - - # Cleanup - os.remove(input_file) - os.remove(encrypted_file) - os.remove(decrypted_file) + try: + # Write the mock `.env` file + with open(input_file, "w", encoding="utf-8") as f: + for k, v in env_data.items(): + f.write(f"{k}={v}\n") + + # Skip encryption and decryption if env_data is empty + if not env_data: + expected_content = "" + decrypted_content = "" + else: + # Encrypt and decrypt the file + encrypt_file(input_file, encrypted_file, key) + decrypt_file(encrypted_file, decrypted_file, key) + + # Validate content + with open(decrypted_file, "r", encoding="utf-8") as f: + decrypted_content = f.read() # Avoid stripping the content + expected_content = ( + "\n".join(f"{k}={v}" for k, v in env_data.items()) + "\n" + ) # Ensure a final newline is preserved + + assert ( + decrypted_content == expected_content + ), f"Decrypted content does not match the original\nExpected:\n{expected_content}\nGot:\n{decrypted_content}" + finally: + # Cleanup + if os.path.exists(input_file): + os.remove(input_file) + if os.path.exists(encrypted_file): + os.remove(encrypted_file) + if os.path.exists(decrypted_file): + os.remove(decrypted_file) @given(st.text(min_size=8, max_size=20), st.binary(min_size=16, max_size=16))