Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions pysnippets/encryption/encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,24 @@ def unpad(self, data: bytes) -> bytes:

Returns:
bytes: The unpadded data.

Raises:
ValueError: If padding is invalid.
"""
pad_length = data[-1]
if pad_length < 1 or pad_length > AES.block_size:
raise ValueError("Invalid padding length")
if data[-pad_length:] != bytes([pad_length] * pad_length):
raise ValueError("Invalid padding bytes")
return data[:-pad_length]
Comment on lines 75 to 80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject empty and non-block-aligned padded data.

Line 75 raises IndexError for empty input, and b"\x01" is accepted despite not being an AES block. Raise ValueError before indexing unless data is non-empty and block-aligned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pysnippets/encryption/encryption.py` around lines 75 - 80, Update the
unpadding logic to validate that data is non-empty and its length is AES
block-aligned before accessing data[-1]. Raise ValueError for empty or
non-block-aligned input, while preserving the existing padding-length,
padding-byte, and successful unpadding behavior.


def encrypt(self, data: bytes) -> str:
def encrypt(self, data: bytes, salt: bytes = b"") -> str:
"""
Encrypts the given data using AES encryption.

Args:
data (bytes): The data to encrypt.
salt (bytes): Optional salt to prepend for key derivation.

Returns:
str: The encrypted data encoded in base64.
Expand All @@ -86,22 +94,23 @@ def encrypt(self, data: bytes) -> str:
iv = get_random_bytes(AES.block_size)
cipher = AES.new(self.key, AES.MODE_CBC, iv)
encrypted_data = cipher.encrypt(data)
return base64.b64encode(iv + encrypted_data).decode("utf-8")
return base64.b64encode(salt + iv + encrypted_data).decode("utf-8")

def decrypt(self, enc_data: str) -> str:
def decrypt(self, enc_data: str, salt_len: int = 0) -> str:
"""
Decrypts the given encrypted data using AES decryption.

Args:
enc_data (str): The encrypted data encoded in base64.
salt_len (int): Length of the salt prepended to the data.

Returns:
str: The decrypted data.
"""
enc_data = base64.b64decode(enc_data)
iv = enc_data[:AES.block_size]
iv = enc_data[salt_len:salt_len + AES.block_size]
cipher = AES.new(self.key, AES.MODE_CBC, iv)
data = cipher.decrypt(enc_data[AES.block_size:])
data = cipher.decrypt(enc_data[salt_len + AES.block_size:])
return self.unpad(data).decode("utf-8")

def encrypt_file(self, file_path: str) -> EncryptionResult:
Expand Down Expand Up @@ -193,20 +202,34 @@ def main() -> None:
The main function that initializes the AESCipher and encrypts/decrypts specified files and folders.
"""
passphrase = input("Enter a passphrase for encryption/decryption: ")
salt = get_random_bytes(16) # This should be securely stored to decrypt later
key = AESCipher.generate_key(passphrase, salt)
cipher = AESCipher(key)

operation = input("Would you like to (e)ncrypt or (d)ecrypt? ").strip().lower()

if operation == 'e':
salt = get_random_bytes(16)
key = AESCipher.generate_key(passphrase, salt)
cipher = AESCipher(key)
file_path = input("Enter the path to the file to encrypt: ")
folder_path = input("Enter the path to the folder to encrypt (or leave blank): ")
cipher.encrypt_file(file_path)
# Save salt alongside encrypted data
salt_path = file_path + ".salt"
with open(salt_path, "wb") as f:
f.write(salt)
Comment on lines 214 to +218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Persist recovery material before replacing plaintext.

encrypt_file() overwrites the source at Line 214 before the salt is written. A termination or write failure between these operations leaves ciphertext whose key cannot be re-derived. Make the ciphertext and sidecar update durable/transactional, at minimum persisting the salt before replacing the file and handling a failed encryption result.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 216-216: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(salt_path, "wb")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pysnippets/encryption/encryption.py` around lines 214 - 218, Update the
encryption flow around encrypt_file so the salt sidecar is durably written
before replacing the plaintext, and handle any failed encryption result without
leaving unrecoverable ciphertext. Ensure the ciphertext and salt updates are
coordinated transactionally, preserving the existing salt_path behavior while
preventing partial completion.

logging.info(f"Salt saved to {salt_path} — keep this file to decrypt later")
if folder_path:
cipher.encrypt_folder(folder_path)
Comment on lines 214 to 221

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not recursively encrypt the selected file or its salt sidecar.

If file_path is inside folder_path, Line 214 encrypts it, Lines 216-218 create <file>.salt, and Line 221 encrypts both again. The sidecar is then ciphertext, so decryption derives a key from invalid salt bytes and cannot recover the files. Make file and folder modes mutually exclusive, or exclude the selected file and all .salt sidecars consistently in both directions.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 216-216: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(salt_path, "wb")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pysnippets/encryption/encryption.py` around lines 214 - 221, Update the
encryption flow around cipher.encrypt_file and cipher.encrypt_folder so file and
folder modes are mutually exclusive: when file_path is provided, encrypt only
that file and its salt sidecar; when folder_path is provided, encrypt only the
folder without recursively re-encrypting the selected file or any .salt
sidecars. Preserve the existing salt-writing behavior and ensure the
corresponding decryption path applies the same exclusion rules.

elif operation == 'd':
file_path = input("Enter the path to the file to decrypt: ")
salt_path = file_path + ".salt"
try:
with open(salt_path, "rb") as f:
salt = f.read()
except FileNotFoundError:
logging.error(f"Salt file {salt_path} not found. Cannot decrypt without the original salt.")
return
key = AESCipher.generate_key(passphrase, salt)
cipher = AESCipher(key)
folder_path = input("Enter the path to the folder to decrypt (or leave blank): ")
cipher.decrypt_file(file_path)
if folder_path:
Expand Down