Skip to content
Merged
Show file tree
Hide file tree
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
3 changes: 2 additions & 1 deletion backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ archive.zip
# macOS
.DS_Store

.env
.env
data/
File renamed without changes.
File renamed without changes.
250 changes: 250 additions & 0 deletions backend/encrypted_notes/crypto.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions backend/encrypted_notes/errors.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions backend/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Empty file added backend/tests/__init__.py
Empty file.
82 changes: 82 additions & 0 deletions backend/tests/test_crypto.py
Original file line number Diff line number Diff line change
@@ -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