Skip to content

Commit d910cbd

Browse files
authored
Hash emails (#30)
* Add development container configuration with Dockerfile and VSCode settings * Update Super Linter configuration to use latest version and exclude specific files from validation * Update Super Linter workflow to trigger on pushes to the main branch * Add email encryption and hashing functions; update authentication routes * Restore login endpoint (oopsie) * Add email masking functionality and update person routes to handle encrypted emails * Implement email masking in person routes by decrypting and masking email addresses * Fix typo in utility.py: correct function name from 'decrpyt_email' to 'decrypt_email' * Refactor AES_KEY initialization to use bytes.fromhex for better security * Enhance error handling in app and improve email masking logic in person routes * Improve error message extraction to handle SQL errors more gracefully * Add command-line argument for debug mode in Flask app * Refactor error handling in email decryption and improve SQL error message extraction * Refactor import statements in authentication module and clean up utility exports
1 parent 6575f1f commit d910cbd

File tree

5 files changed

+125
-12
lines changed

5 files changed

+125
-12
lines changed

app.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from flask import Flask, jsonify
1+
import argparse
2+
import traceback
3+
4+
from flask import Flask, jsonify, request
25
from flask_cors import CORS
36

47
from config import Config, limiter
@@ -56,11 +59,29 @@ def ratelimit_error(e):
5659

5760
@app.errorhandler(Exception)
5861
def handle_exception(e):
59-
return (
60-
jsonify(error="Internal Server Error", message=extract_error_message(str(e))),
61-
500,
62-
)
62+
# If the app is in debug mode, return the full traceback
63+
if app.debug:
64+
return (
65+
jsonify(
66+
error="Internal Server Error",
67+
message=str(e),
68+
type=type(e).__name__,
69+
url=request.url,
70+
traceback=traceback.format_exc().splitlines(),
71+
),
72+
500,
73+
)
74+
75+
# Otherwise, return a more user-friendly error message
76+
error_message = extract_error_message(str(e))
77+
return jsonify(error="Internal Server Error", message=error_message), 500
6378

6479

6580
if __name__ == "__main__":
66-
app.run(host="0.0.0.0", port=5000)
81+
parser = argparse.ArgumentParser(description="Run the Flask application.")
82+
parser.add_argument(
83+
"--debug", action="store_true", help="Run the app in debug mode."
84+
)
85+
args = parser.parse_args()
86+
87+
app.run(host="0.0.0.0", port=5000, debug=args.debug)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
argon2-cffi>=23.1.0
2+
cryptography>=44.0.2
23
Flask>=3.0.3
34
Flask-JWT-Extended>=2.8.0
45
Flask-Limiter>=3.7.0

routes/authentication.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from pymysql import MySQLError
44

55
from config import limiter
6-
76
from jwt_helper import (
87
TokenError,
98
extract_token_from_header,
@@ -13,6 +12,8 @@
1312
)
1413
from utility import (
1514
database_cursor,
15+
encrypt_email,
16+
hash_email,
1617
hash_password,
1718
validate_password,
1819
verify_password,
@@ -22,8 +23,10 @@
2223

2324

2425
def login_person_by_email(email):
26+
email_hash = hash_email(email)
27+
2528
with database_cursor() as cursor:
26-
cursor.callproc("login_person_by_email", (email,))
29+
cursor.callproc("login_person_by_email", (email_hash,))
2730
return cursor.fetchone()
2831

2932

@@ -48,11 +51,12 @@ def register():
4851
return jsonify(message="Password does not meet security requirements"), 400
4952

5053
hashed_password = hash_password(password)
54+
email = hash_email(email), encrypt_email(email)
5155

5256
try:
5357
with database_cursor() as cursor:
5458
cursor.callproc(
55-
"register_person", (name, email, hashed_password, language_code)
59+
"register_person", (name, *email, hashed_password, language_code)
5660
)
5761
except MySQLError as e:
5862
if "User name already exists" in str(e):

routes/person.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,49 @@
33

44
from utility import (
55
database_cursor,
6+
decrypt_email,
7+
encrypt_email,
8+
hash_email,
69
hash_password,
10+
mask_email,
711
validate_password,
812
verify_password,
913
)
1014

1115
person_blueprint = Blueprint("person", __name__)
1216

1317

14-
def login_person_by_id(person_id):
18+
def login_person_by_id(person_id: int) -> dict:
1519
with database_cursor() as cursor:
1620
cursor.callproc("login_person_by_id", (person_id,))
1721
return cursor.fetchone()
1822

1923

24+
def mask_person_email(person: dict) -> None:
25+
"""Mask the email address of a person safely."""
26+
encrypted_email = person.get("encrypted_email")
27+
28+
if not encrypted_email:
29+
person["email"] = "Unknown"
30+
return
31+
32+
try:
33+
person["email"] = mask_email(decrypt_email(encrypted_email))
34+
except Exception:
35+
person["email"] = "Decryption Error"
36+
37+
# Remove unreadable fields
38+
person.pop("encrypted_email", None)
39+
person.pop("hashed_password", None)
40+
41+
2042
def update_person_in_db(person_id, name, email, hashed_password, locale_code):
43+
email = hash_email(email), encrypt_email(email)
44+
2145
with database_cursor() as cursor:
2246
cursor.callproc(
2347
"update_person",
24-
(person_id, name, email, hashed_password, locale_code),
48+
(person_id, name, *email, hashed_password, locale_code),
2549
)
2650

2751

@@ -30,6 +54,10 @@ def get_all_persons():
3054
with database_cursor() as cursor:
3155
cursor.callproc("get_all_persons")
3256
persons = cursor.fetchall()
57+
58+
for person in persons:
59+
mask_person_email(person)
60+
3361
return jsonify(persons)
3462

3563

@@ -38,6 +66,8 @@ def get_person_by_id(person_id):
3866
with database_cursor() as cursor:
3967
cursor.callproc("get_person_by_id", (person_id,))
4068
person = cursor.fetchone()
69+
70+
mask_person_email(person)
4171
return jsonify(person)
4272

4373

utility.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1+
import base64
2+
import hashlib
13
import os
4+
import re
25
from contextlib import contextmanager
36
from re import match
47

58
from argon2 import PasswordHasher
9+
from cryptography.hazmat.backends import default_backend
10+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
611
from dotenv import load_dotenv
712

813
from db import get_db_connection
914

1015
load_dotenv()
1116
ph = PasswordHasher()
17+
AES_KEY = bytes.fromhex(os.getenv("AES_SECRET_KEY", os.urandom(32).hex()))
1218
PEPPER = os.getenv("PEPPER", "SuperSecretPepper").encode("utf-8")
1319

1420

@@ -27,18 +33,69 @@ def database_cursor():
2733
db.close()
2834

2935

36+
def decrypt_email(encrypted_email: str) -> str:
37+
"""Decrypts an AES-256 encrypted email."""
38+
encrypted_data = base64.b64decode(encrypted_email)
39+
iv, ciphertext = encrypted_data[:16], encrypted_data[16:]
40+
41+
cipher = Cipher(algorithms.AES(AES_KEY), modes.CBC(iv), backend=default_backend())
42+
decryptor = cipher.decryptor()
43+
decrypted_email = decryptor.update(ciphertext) + decryptor.finalize()
44+
45+
return decrypted_email.strip().decode()
46+
47+
48+
def encrypt_email(email: str) -> str:
49+
"""Encrypts an email using AES-256."""
50+
iv = os.urandom(16) # Generate a random IV
51+
cipher = Cipher(algorithms.AES(AES_KEY), modes.CBC(iv), backend=default_backend())
52+
encryptor = cipher.encryptor()
53+
54+
# Pad email to 16-byte blocks
55+
padded_email = email + (16 - len(email) % 16) * " "
56+
ciphertext = encryptor.update(padded_email.encode()) + encryptor.finalize()
57+
58+
# Store IV + ciphertext (Base64 encoded)
59+
return base64.b64encode(iv + ciphertext).decode()
60+
61+
3062
def extract_error_message(message):
3163
try:
32-
return message.split(", ")[1].strip("()'")
64+
cleaner_message = message.split(", ")[1].strip("()'")
65+
return cleaner_message if "SQL" not in cleaner_message else "Database error"
3366
except IndexError:
3467
return "An unknown error occurred"
3568

3669

70+
def hash_email(email: str) -> str:
71+
"""Generate a SHA-256 hash of the email (used for fast lookup)."""
72+
return hashlib.sha256(email.encode()).hexdigest()
73+
74+
3775
def hash_password(password: str) -> tuple[str, bytes]:
3876
peppered_password = password.encode("utf-8") + PEPPER
3977
return ph.hash(peppered_password) # Argon2 applies salt automatically
4078

4179

80+
def mask_email(email: str) -> str:
81+
"""Masks the email address to protect user privacy."""
82+
match = re.match(r"^([\w.+-]+)@([\w-]+)\.([a-zA-Z]{2,})$", email)
83+
if not match:
84+
raise ValueError("Invalid email format")
85+
86+
local_part, domain_name, domain_extension = match.groups()
87+
88+
# Mask the local part
89+
if len(local_part) > 2:
90+
local_part = local_part[0] + "*" * (len(local_part) - 2) + local_part[-1]
91+
92+
# Mask the domain name
93+
if len(domain_name) > 2:
94+
domain_name = domain_name[0] + "*" * (len(domain_name) - 2) + domain_name[-1]
95+
96+
return f"{local_part}@{domain_name}.{domain_extension}"
97+
98+
4299
def validate_password(password):
43100
"""
44101
Validates a password based on the following criteria:

0 commit comments

Comments
 (0)