Skip to content

Commit b5868a7

Browse files
authored
impl crypt module (#15)
* impl crypt module * add backend README * fix: pyproject python version
1 parent 95056c1 commit b5868a7

File tree

9 files changed

+353
-4
lines changed

9 files changed

+353
-4
lines changed

backend/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,5 @@ archive.zip
2929
# macOS
3030
.DS_Store
3131

32-
.env
32+
.env
33+
data/
File renamed without changes.
File renamed without changes.

backend/encrypted_notes/crypto.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""
2+
Secure encryption module for handling password-based encryption.
3+
4+
This module provides functions for:
5+
- Generating secure salts
6+
- Deriving encryption keys from passwords using PBKDF2-HMAC-SHA256
7+
- Encrypting and decrypting data using Fernet (AES-128 in CBC mode)
8+
- Managing master keys for encryption
9+
"""
10+
11+
import os
12+
from base64 import urlsafe_b64encode
13+
from pathlib import Path
14+
15+
from cryptography.hazmat.primitives import hashes
16+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
17+
from cryptography.fernet import Fernet, InvalidToken
18+
19+
from .errors import KeyDerivationError, EncryptionError, DecryptionError
20+
21+
DEFAULT_SALT_LENGTH = 16
22+
DEFAULT_ITERATIONS = 100_000
23+
MIN_ITERATIONS = 10_000
24+
KEY_LENGTH = 32 # 256 bits for AES-256
25+
26+
27+
def generate_salt(length: int = DEFAULT_SALT_LENGTH) -> bytes:
28+
"""
29+
Generate a cryptographic salt.
30+
31+
Args:
32+
length (int): Length of the salt in bytes. Default is 16 bytes.
33+
34+
Returns:
35+
Random salt bytes
36+
37+
Raises:
38+
ValueError: If length is less than 8 bytes.
39+
"""
40+
if length <= 8:
41+
raise ValueError("Salt length must be a positive integer.")
42+
43+
return os.urandom(length)
44+
45+
46+
def derive_key_from_password(
47+
password: str, salt: bytes, iterations: int = DEFAULT_ITERATIONS
48+
) -> bytes:
49+
"""
50+
Derive an encryption key from a password using PBKDF2-HMAC-SHA256.
51+
52+
Args:
53+
password: User password
54+
salt: Random salt for key derivation
55+
iterations: Number of PBKDF2 iterations (default: 100,000)
56+
57+
Returns:
58+
Derived key bytes
59+
60+
Raises:
61+
ValueError: If password is empty or iterations < 10000
62+
KeyDerivationError: If key derivation fails
63+
"""
64+
if not password:
65+
raise ValueError("Password cannot be empty")
66+
67+
if iterations < MIN_ITERATIONS:
68+
raise ValueError(f"Iterations must be at least {MIN_ITERATIONS}")
69+
70+
try:
71+
kdf = PBKDF2HMAC(
72+
algorithm=hashes.SHA256(),
73+
length=KEY_LENGTH,
74+
salt=salt,
75+
iterations=iterations,
76+
)
77+
78+
key = kdf.derive(password.encode("utf-8"))
79+
return urlsafe_b64encode(key)
80+
except Exception as e:
81+
raise KeyDerivationError(f"Failed to derive key: {e}") from e
82+
83+
84+
def encrypt_bytes(key: bytes, plaintext: bytes) -> bytes:
85+
"""
86+
Encrypt plaintext bytes using Fernet (AES-128 in CBC mode).
87+
88+
Args:
89+
key: Base64-encoded encryption key (from derive_key_from_password)
90+
plaintext: Data to encrypt
91+
92+
Returns:
93+
Encrypted token (includes IV and MAC)
94+
95+
Raises:
96+
EncryptionError: If encryption fails
97+
ValueError: If key format is invalid
98+
"""
99+
if not plaintext:
100+
raise ValueError("Plaintext cannot be empty")
101+
102+
try:
103+
cipher = Fernet(key)
104+
return cipher.encrypt(plaintext)
105+
except ValueError as e:
106+
raise ValueError(f"Invalid key format: {e}") from e
107+
except Exception as e:
108+
raise EncryptionError(f"Encryption failed: {e}") from e
109+
110+
111+
def decrypt_bytes(key: bytes, token: bytes) -> bytes:
112+
"""
113+
Decrypt a Fernet token.
114+
115+
Args:
116+
key: Base64-encoded encryption key (same as used for encryption)
117+
token: Encrypted token to decrypt
118+
119+
Returns:
120+
Decrypted plaintext bytes
121+
122+
Raises:
123+
DecryptionError: If decryption fails (wrong key or corrupted data)
124+
ValueError: If key format is invalid
125+
"""
126+
if not token:
127+
raise ValueError("Token cannot be empty")
128+
129+
try:
130+
cipher = Fernet(key)
131+
return cipher.decrypt(token)
132+
except InvalidToken:
133+
raise DecryptionError("Decryption failed: incorrect passowrd or corrupted data")
134+
except ValueError as e:
135+
raise ValueError(f"Invalid key format: {e}") from e
136+
except Exception as e:
137+
raise DecryptionError(f"Decryption failed: {e}") from e
138+
139+
140+
def encrypt_text(key: bytes, plaintext: str) -> bytes:
141+
"""
142+
Encrypt a text string.
143+
144+
Args:
145+
key: Base64-encoded encryption key
146+
plaintext: Text to encrypt
147+
148+
Returns:
149+
Encrypted token
150+
"""
151+
return encrypt_bytes(key, plaintext.encode("utf-8"))
152+
153+
154+
def decrypt_text(key: bytes, token: bytes) -> str:
155+
"""
156+
Decrypt a token to text string.
157+
158+
Args:
159+
key: Base64-encoded encryption key
160+
token: Encrypted token
161+
162+
Returns:
163+
Decrypted text
164+
"""
165+
plaintext_bytes = decrypt_bytes(key, token)
166+
return plaintext_bytes.decode("utf-8")
167+
168+
169+
def generate_master_key() -> bytes:
170+
"""
171+
Generate a random Fernet-compatible master key.
172+
173+
This can be used instead of password-based encryption for scenarios
174+
where you want to generate and store a random key.
175+
176+
Returns:
177+
Base64-encoded random key
178+
"""
179+
return Fernet.generate_key()
180+
181+
182+
def save_master_key(key: bytes, filepath: Path | str) -> None:
183+
"""
184+
Save a master key to a file with secure permissions.
185+
186+
Args:
187+
key: Base64-encoded key to save
188+
filepath: Path to save the key
189+
190+
Raises:
191+
OSError: If file operations fail
192+
"""
193+
filepath = Path(filepath)
194+
195+
filepath.parent.mkdir(parents=True, exist_ok=True)
196+
filepath.write_bytes(key)
197+
198+
try:
199+
os.chmod(filepath, 0o600)
200+
except (AttributeError, OSError):
201+
print(f"Warning: Could not set secure permissions on {filepath}")
202+
203+
204+
def load_master_key(filepath: Path | str) -> bytes:
205+
"""
206+
Load a master key from a file.
207+
208+
Args:
209+
filepath: Path to the key file
210+
211+
Returns:
212+
Base64-encoded key
213+
214+
Raises:
215+
FileNotFoundError: If key file doesn't exist
216+
ValueError: If key format is invalid
217+
"""
218+
filepath = Path(filepath)
219+
220+
if not filepath.exists():
221+
raise FileNotFoundError(f"Key file not found: {filepath}")
222+
223+
key = filepath.read_bytes().strip()
224+
225+
try:
226+
Fernet(key)
227+
except Exception as e:
228+
raise ValueError(f"Invalid key format in {filepath}: {e}") from e
229+
230+
return key
231+
232+
233+
def verify_password(password: str, salt: bytes, encrypted_data: bytes) -> bool:
234+
"""
235+
Verify if a password is correct by attempting to decrypt data.
236+
237+
Args:
238+
password: Password to verify
239+
salt: Salt used for key derivation
240+
encrypted_data: Sample encrypted data to test
241+
242+
Returns:
243+
True if password is correct, False otherwise
244+
"""
245+
try:
246+
key = derive_key_from_password(password, salt)
247+
decrypt_bytes(key, encrypted_data)
248+
return True
249+
except (DecryptionError, KeyDerivationError):
250+
return False

backend/encrypted_notes/errors.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
class EncryptionError(Exception):
2+
"""Base class for encryption-related errors."""
3+
4+
pass
5+
6+
7+
class DecryptionError(EncryptionError):
8+
"""Raised when decryption fails."""
9+
10+
pass
11+
12+
13+
class KeyDerivationError(EncryptionError):
14+
"""Raised when key derivation fails."""
15+
16+
pass

backend/poetry.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ authors = [
77
]
88
license = {text = "Apache License 2.0"}
99
readme = "README.md"
10-
requires-python = "^3.11"
10+
requires-python = ">=3.11"
1111
dependencies = [
1212
"fastapi (>=0.118.2,<0.119.0)",
1313
"uvicorn (>=0.37.0,<0.38.0)",

backend/tests/__init__.py

Whitespace-only changes.

backend/tests/test_crypto.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import pytest
2+
3+
from encrypted_notes.crypto import (
4+
generate_salt,
5+
derive_key_from_password,
6+
encrypt_bytes,
7+
decrypt_bytes,
8+
encrypt_text,
9+
decrypt_text,
10+
generate_master_key,
11+
verify_password,
12+
DEFAULT_SALT_LENGTH,
13+
)
14+
from encrypted_notes.errors import DecryptionError
15+
16+
DEFAULT_PASSWORD = "securepassword"
17+
WRONG_PASSWORD = "wrongpassword"
18+
PLAINTEXT_BYTES = b"Sensitive data"
19+
PLAINTEXT_TEXT = "Sensitive text"
20+
INVALID_SALT_LENGTH = 4
21+
INVALID_ITERATIONS = 5000
22+
23+
24+
def test_generate_salt():
25+
salt = generate_salt()
26+
assert len(salt) == DEFAULT_SALT_LENGTH
27+
assert isinstance(salt, bytes)
28+
29+
with pytest.raises(ValueError):
30+
generate_salt(INVALID_SALT_LENGTH)
31+
32+
33+
def test_derive_key_from_password():
34+
salt = generate_salt()
35+
key = derive_key_from_password(DEFAULT_PASSWORD, salt)
36+
assert isinstance(key, bytes)
37+
38+
with pytest.raises(ValueError):
39+
derive_key_from_password("", salt)
40+
41+
with pytest.raises(ValueError):
42+
derive_key_from_password(DEFAULT_PASSWORD, salt, iterations=INVALID_ITERATIONS)
43+
44+
45+
def test_encrypt_decrypt_bytes():
46+
salt = generate_salt()
47+
key = derive_key_from_password(DEFAULT_PASSWORD, salt)
48+
49+
encrypted = encrypt_bytes(key, PLAINTEXT_BYTES)
50+
assert encrypted != PLAINTEXT_BYTES
51+
52+
decrypted = decrypt_bytes(key, encrypted)
53+
assert decrypted == PLAINTEXT_BYTES
54+
55+
with pytest.raises(DecryptionError):
56+
decrypt_bytes(generate_master_key(), encrypted)
57+
58+
59+
def test_encrypt_decrypt_text():
60+
salt = generate_salt()
61+
key = derive_key_from_password(DEFAULT_PASSWORD, salt)
62+
63+
encrypted = encrypt_text(key, PLAINTEXT_TEXT)
64+
assert isinstance(encrypted, bytes)
65+
66+
decrypted = decrypt_text(key, encrypted)
67+
assert decrypted == PLAINTEXT_TEXT
68+
69+
70+
def test_generate_master_key():
71+
key = generate_master_key()
72+
assert isinstance(key, bytes)
73+
assert len(key) > 0
74+
75+
76+
def test_verify_password():
77+
salt = generate_salt()
78+
key = derive_key_from_password(DEFAULT_PASSWORD, salt)
79+
encrypted = encrypt_bytes(key, PLAINTEXT_BYTES)
80+
81+
assert verify_password(DEFAULT_PASSWORD, salt, encrypted) is True
82+
assert verify_password(WRONG_PASSWORD, salt, encrypted) is False

0 commit comments

Comments
 (0)