diff --git a/backend/.gitignore b/backend/.gitignore index 0f331aa..1aedb1a 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -29,4 +29,5 @@ archive.zip # macOS .DS_Store -.env \ No newline at end of file +.env +data/ \ No newline at end of file diff --git a/backend/encrypted-notes/__init__.py b/backend/README.md similarity index 100% rename from backend/encrypted-notes/__init__.py rename to backend/README.md diff --git a/backend/encrypted-notes/crypto.py b/backend/encrypted_notes/__init__.py similarity index 100% rename from backend/encrypted-notes/crypto.py rename to backend/encrypted_notes/__init__.py diff --git a/backend/encrypted_notes/crypto.py b/backend/encrypted_notes/crypto.py new file mode 100644 index 0000000..3976fd1 --- /dev/null +++ b/backend/encrypted_notes/crypto.py @@ -0,0 +1,250 @@ +""" +Secure encryption module for handling password-based encryption. + +This module provides functions for: +- Generating secure salts +- Deriving encryption keys from passwords using PBKDF2-HMAC-SHA256 +- Encrypting and decrypting data using Fernet (AES-128 in CBC mode) +- Managing master keys for encryption +""" + +import os +from base64 import urlsafe_b64encode +from pathlib import Path + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.fernet import Fernet, InvalidToken + +from .errors import KeyDerivationError, EncryptionError, DecryptionError + +DEFAULT_SALT_LENGTH = 16 +DEFAULT_ITERATIONS = 100_000 +MIN_ITERATIONS = 10_000 +KEY_LENGTH = 32 # 256 bits for AES-256 + + +def generate_salt(length: int = DEFAULT_SALT_LENGTH) -> bytes: + """ + Generate a cryptographic salt. + + Args: + length (int): Length of the salt in bytes. Default is 16 bytes. + + Returns: + Random salt bytes + + Raises: + ValueError: If length is less than 8 bytes. + """ + if length <= 8: + raise ValueError("Salt length must be a positive integer.") + + return os.urandom(length) + + +def derive_key_from_password( + password: str, salt: bytes, iterations: int = DEFAULT_ITERATIONS +) -> bytes: + """ + Derive an encryption key from a password using PBKDF2-HMAC-SHA256. + + Args: + password: User password + salt: Random salt for key derivation + iterations: Number of PBKDF2 iterations (default: 100,000) + + Returns: + Derived key bytes + + Raises: + ValueError: If password is empty or iterations < 10000 + KeyDerivationError: If key derivation fails + """ + if not password: + raise ValueError("Password cannot be empty") + + if iterations < MIN_ITERATIONS: + raise ValueError(f"Iterations must be at least {MIN_ITERATIONS}") + + try: + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=KEY_LENGTH, + salt=salt, + iterations=iterations, + ) + + key = kdf.derive(password.encode("utf-8")) + return urlsafe_b64encode(key) + except Exception as e: + raise KeyDerivationError(f"Failed to derive key: {e}") from e + + +def encrypt_bytes(key: bytes, plaintext: bytes) -> bytes: + """ + Encrypt plaintext bytes using Fernet (AES-128 in CBC mode). + + Args: + key: Base64-encoded encryption key (from derive_key_from_password) + plaintext: Data to encrypt + + Returns: + Encrypted token (includes IV and MAC) + + Raises: + EncryptionError: If encryption fails + ValueError: If key format is invalid + """ + if not plaintext: + raise ValueError("Plaintext cannot be empty") + + try: + cipher = Fernet(key) + return cipher.encrypt(plaintext) + except ValueError as e: + raise ValueError(f"Invalid key format: {e}") from e + except Exception as e: + raise EncryptionError(f"Encryption failed: {e}") from e + + +def decrypt_bytes(key: bytes, token: bytes) -> bytes: + """ + Decrypt a Fernet token. + + Args: + key: Base64-encoded encryption key (same as used for encryption) + token: Encrypted token to decrypt + + Returns: + Decrypted plaintext bytes + + Raises: + DecryptionError: If decryption fails (wrong key or corrupted data) + ValueError: If key format is invalid + """ + if not token: + raise ValueError("Token cannot be empty") + + try: + cipher = Fernet(key) + return cipher.decrypt(token) + except InvalidToken: + raise DecryptionError("Decryption failed: incorrect passowrd or corrupted data") + except ValueError as e: + raise ValueError(f"Invalid key format: {e}") from e + except Exception as e: + raise DecryptionError(f"Decryption failed: {e}") from e + + +def encrypt_text(key: bytes, plaintext: str) -> bytes: + """ + Encrypt a text string. + + Args: + key: Base64-encoded encryption key + plaintext: Text to encrypt + + Returns: + Encrypted token + """ + return encrypt_bytes(key, plaintext.encode("utf-8")) + + +def decrypt_text(key: bytes, token: bytes) -> str: + """ + Decrypt a token to text string. + + Args: + key: Base64-encoded encryption key + token: Encrypted token + + Returns: + Decrypted text + """ + plaintext_bytes = decrypt_bytes(key, token) + return plaintext_bytes.decode("utf-8") + + +def generate_master_key() -> bytes: + """ + Generate a random Fernet-compatible master key. + + This can be used instead of password-based encryption for scenarios + where you want to generate and store a random key. + + Returns: + Base64-encoded random key + """ + return Fernet.generate_key() + + +def save_master_key(key: bytes, filepath: Path | str) -> None: + """ + Save a master key to a file with secure permissions. + + Args: + key: Base64-encoded key to save + filepath: Path to save the key + + Raises: + OSError: If file operations fail + """ + filepath = Path(filepath) + + filepath.parent.mkdir(parents=True, exist_ok=True) + filepath.write_bytes(key) + + try: + os.chmod(filepath, 0o600) + except (AttributeError, OSError): + print(f"Warning: Could not set secure permissions on {filepath}") + + +def load_master_key(filepath: Path | str) -> bytes: + """ + Load a master key from a file. + + Args: + filepath: Path to the key file + + Returns: + Base64-encoded key + + Raises: + FileNotFoundError: If key file doesn't exist + ValueError: If key format is invalid + """ + filepath = Path(filepath) + + if not filepath.exists(): + raise FileNotFoundError(f"Key file not found: {filepath}") + + key = filepath.read_bytes().strip() + + try: + Fernet(key) + except Exception as e: + raise ValueError(f"Invalid key format in {filepath}: {e}") from e + + return key + + +def verify_password(password: str, salt: bytes, encrypted_data: bytes) -> bool: + """ + Verify if a password is correct by attempting to decrypt data. + + Args: + password: Password to verify + salt: Salt used for key derivation + encrypted_data: Sample encrypted data to test + + Returns: + True if password is correct, False otherwise + """ + try: + key = derive_key_from_password(password, salt) + decrypt_bytes(key, encrypted_data) + return True + except (DecryptionError, KeyDerivationError): + return False diff --git a/backend/encrypted_notes/errors.py b/backend/encrypted_notes/errors.py new file mode 100644 index 0000000..6ebf67d --- /dev/null +++ b/backend/encrypted_notes/errors.py @@ -0,0 +1,16 @@ +class EncryptionError(Exception): + """Base class for encryption-related errors.""" + + pass + + +class DecryptionError(EncryptionError): + """Raised when decryption fails.""" + + pass + + +class KeyDerivationError(EncryptionError): + """Raised when key derivation fails.""" + + pass diff --git a/backend/poetry.lock b/backend/poetry.lock index a277d4e..383a119 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1199,5 +1199,5 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" -python-versions = "^3.11" -content-hash = "7b4f382ab273e7526b5551e6acd88c160f24bf063164730b071061d22237ba67" +python-versions = ">=3.11" +content-hash = "25fac6a06962f5cd4a60559ef58a61e6b9d5e41fa40a3011ed111d0c778a4362" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d031f54..863a8f7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -7,7 +7,7 @@ authors = [ ] license = {text = "Apache License 2.0"} readme = "README.md" -requires-python = "^3.11" +requires-python = ">=3.11" dependencies = [ "fastapi (>=0.118.2,<0.119.0)", "uvicorn (>=0.37.0,<0.38.0)", diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_crypto.py b/backend/tests/test_crypto.py index e69de29..139576d 100644 --- a/backend/tests/test_crypto.py +++ b/backend/tests/test_crypto.py @@ -0,0 +1,82 @@ +import pytest + +from encrypted_notes.crypto import ( + generate_salt, + derive_key_from_password, + encrypt_bytes, + decrypt_bytes, + encrypt_text, + decrypt_text, + generate_master_key, + verify_password, + DEFAULT_SALT_LENGTH, +) +from encrypted_notes.errors import DecryptionError + +DEFAULT_PASSWORD = "securepassword" +WRONG_PASSWORD = "wrongpassword" +PLAINTEXT_BYTES = b"Sensitive data" +PLAINTEXT_TEXT = "Sensitive text" +INVALID_SALT_LENGTH = 4 +INVALID_ITERATIONS = 5000 + + +def test_generate_salt(): + salt = generate_salt() + assert len(salt) == DEFAULT_SALT_LENGTH + assert isinstance(salt, bytes) + + with pytest.raises(ValueError): + generate_salt(INVALID_SALT_LENGTH) + + +def test_derive_key_from_password(): + salt = generate_salt() + key = derive_key_from_password(DEFAULT_PASSWORD, salt) + assert isinstance(key, bytes) + + with pytest.raises(ValueError): + derive_key_from_password("", salt) + + with pytest.raises(ValueError): + derive_key_from_password(DEFAULT_PASSWORD, salt, iterations=INVALID_ITERATIONS) + + +def test_encrypt_decrypt_bytes(): + salt = generate_salt() + key = derive_key_from_password(DEFAULT_PASSWORD, salt) + + encrypted = encrypt_bytes(key, PLAINTEXT_BYTES) + assert encrypted != PLAINTEXT_BYTES + + decrypted = decrypt_bytes(key, encrypted) + assert decrypted == PLAINTEXT_BYTES + + with pytest.raises(DecryptionError): + decrypt_bytes(generate_master_key(), encrypted) + + +def test_encrypt_decrypt_text(): + salt = generate_salt() + key = derive_key_from_password(DEFAULT_PASSWORD, salt) + + encrypted = encrypt_text(key, PLAINTEXT_TEXT) + assert isinstance(encrypted, bytes) + + decrypted = decrypt_text(key, encrypted) + assert decrypted == PLAINTEXT_TEXT + + +def test_generate_master_key(): + key = generate_master_key() + assert isinstance(key, bytes) + assert len(key) > 0 + + +def test_verify_password(): + salt = generate_salt() + key = derive_key_from_password(DEFAULT_PASSWORD, salt) + encrypted = encrypt_bytes(key, PLAINTEXT_BYTES) + + assert verify_password(DEFAULT_PASSWORD, salt, encrypted) is True + assert verify_password(WRONG_PASSWORD, salt, encrypted) is False