quantumvault-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
controllers/README.md ADDED
@@ -0,0 +1 @@
1
+ # controllers
File without changes
controllers/auth.py ADDED
@@ -0,0 +1,426 @@
1
+ """Zero-knowledge authentication controller for QuantumVault.
2
+
3
+ Registration accepts cryptographic material that the browser generated
4
+ and stores it verbatim: the SRP salt and verifier, the user's public
5
+ key, the password-encrypted private key blob, and the KDF salt. The
6
+ server never sees the password or any private key. Login is a Secure
7
+ Remote Password (SRP-6a) challenge/response (see :mod:`utils.srp6a`)
8
+ so the password is never transmitted.
9
+
10
+ Phone verification codes, MFA codes, and recovery codes are never
11
+ stored in plaintext. They are hashed with a server-side pepper via
12
+ :func:`utils.security.hash_secret` so a database leak does not hand
13
+ the attacker ready-to-use codes.
14
+ """
15
+
16
+ import os
17
+ import secrets
18
+ from datetime import datetime, timedelta, timezone
19
+ from typing import Optional, Tuple
20
+
21
+ import pytz
22
+ from flask import current_app, flash
23
+ from flask_mail import Mail
24
+
25
+ import clicksend_client
26
+ from clicksend_client import SmsMessage
27
+ from clicksend_client.rest import ApiException
28
+
29
+ from models.user import UserModel, UserDB
30
+ from models.plans import PlanDB
31
+ from utils import srp6a
32
+ from utils.mailer import external_url, mail_is_configured, send_transactional_email
33
+ from utils.utils import database_path
34
+ from utils.security import (
35
+ audit_event,
36
+ constant_time_compare,
37
+ hash_secret,
38
+ new_one_time_code,
39
+ verify_secret,
40
+ )
41
+
42
+ DISALLOWED_USERNAME_CHARS = [';', '|', '&', '$', '`', '\'', '"', '\\', '<', '>']
43
+
44
+ configuration = clicksend_client.Configuration()
45
+ configuration.username = os.environ.get('CLICKSEND_USERNAME', '')
46
+ configuration.password = os.environ.get('CLICKSEND_API_KEY', '')
47
+
48
+
49
+ def _now_utc() -> datetime:
50
+ """Return the current time as a timezone-aware UTC datetime.
51
+
52
+ Avoids the deprecated ``datetime.utcnow()`` which returns a naive
53
+ value and triggers a DeprecationWarning in Python 3.12+.
54
+ """
55
+ return datetime.now(timezone.utc)
56
+
57
+
58
+ class AuthController:
59
+ """Handles zero-knowledge registration and SRP-6a authentication."""
60
+
61
+ def __init__(self, db_path: str, mail: Mail, storage_uri: str):
62
+ """Initialize the controller.
63
+
64
+ Args:
65
+ db_path: Path to the SQLite user database.
66
+ mail: Configured Flask-Mail instance for transactional email.
67
+ storage_uri: Redis URI backing the ephemeral SRP session store.
68
+ """
69
+ self.user_db = UserDB(db_path)
70
+ self.mail = mail
71
+ self.srp_store = srp6a.SRPSessionStore(storage_uri)
72
+
73
+ # ------------------------------------------------------------------
74
+ # Registration
75
+ # ------------------------------------------------------------------
76
+ def register(self, username: str, srp_salt: str, srp_verifier: str, public_key: str,
77
+ encrypted_private_key: str, kdf_salt: str, email: str, phone: str,
78
+ first_name: str, last_name: str, recovery_salt: Optional[str] = None,
79
+ encrypted_private_key_recovery: Optional[str] = None) -> bool:
80
+ """Register a user from client-generated zero-knowledge credentials.
81
+
82
+ Args:
83
+ username: The desired unique username.
84
+ srp_salt: SRP salt (hex) generated on the client.
85
+ srp_verifier: SRP verifier (hex) generated on the client.
86
+ public_key: The user's hybrid public key blob (opaque).
87
+ encrypted_private_key: The password-encrypted private key blob (opaque).
88
+ kdf_salt: Salt (hex) for the client-side key derivation function.
89
+ email: The user's email address.
90
+ phone: The user's phone number.
91
+ first_name: The user's first name.
92
+ last_name: The user's last name.
93
+ recovery_salt: Optional QV-RECOVERY-1 PBKDF2 salt (hex), generated on the client.
94
+ encrypted_private_key_recovery: Optional QV-RECOVERY-1 AES-256-GCM
95
+ wrapping of the same private key blob, keyed by a
96
+ client-generated recovery code instead of the password.
97
+
98
+ Returns:
99
+ True on success, False if validation fails or persistence errors.
100
+ """
101
+ try:
102
+ if any(char in username for char in DISALLOWED_USERNAME_CHARS):
103
+ flash('The username contains disallowed characters.')
104
+ return False
105
+
106
+ required = (username, srp_salt, srp_verifier, public_key, encrypted_private_key, kdf_salt)
107
+ if not all(required):
108
+ flash('Missing required cryptographic material.')
109
+ return False
110
+
111
+ confirmation_token = secrets.token_urlsafe(32)
112
+ phone_verification_code = new_one_time_code(6)
113
+ phone_code_expires = _now_utc() + timedelta(minutes=30)
114
+
115
+ plan_db = PlanDB(database_path())
116
+ plan = plan_db.get_plan("free")
117
+ trial_start = _now_utc()
118
+ trial_end = trial_start + timedelta(days=plan["trial_days"])
119
+
120
+ self.user_db.create_user(
121
+ username=username,
122
+ srp_salt=srp_salt,
123
+ srp_verifier=srp_verifier,
124
+ public_key=public_key,
125
+ encrypted_private_key=encrypted_private_key,
126
+ kdf_salt=kdf_salt,
127
+ email=email,
128
+ phone=phone,
129
+ first_name=first_name,
130
+ last_name=last_name,
131
+ role="free",
132
+ storage_quota=plan["storage_quota"],
133
+ trial_start=trial_start,
134
+ trial_end=trial_end,
135
+ subscription_status="active",
136
+ email_verified=False,
137
+ confirmation_token=confirmation_token,
138
+ phone_verified=False,
139
+ phone_verification_code_hash=hash_secret(phone_verification_code),
140
+ phone_code_expires=phone_code_expires,
141
+ mfa_enabled=False,
142
+ recovery_salt=recovery_salt,
143
+ encrypted_private_key_recovery=encrypted_private_key_recovery,
144
+ )
145
+
146
+ audit_event("register_success", username=username, email=email)
147
+ # Dispatch the email and SMS after the DB commit so we never
148
+ # advertise a link or code that the caller cannot subsequently use.
149
+ self.send_confirmation_email(email, username, confirmation_token)
150
+ self.send_sms_verification(phone, phone_verification_code, username)
151
+ return True
152
+ except Exception as e:
153
+ audit_event("register_failure", username=username, reason=type(e).__name__)
154
+ return False
155
+
156
+ def send_confirmation_email(self, email: str, username: str, token: str) -> bool:
157
+ """Email the account-confirmation link to a freshly registered user.
158
+
159
+ The link targets :func:`views.auth.confirm_email`. When SMTP is not
160
+ configured, or the send fails, the link is logged at WARNING so an
161
+ operator can still verify the account from the server logs; this keeps
162
+ local and bare-VPS deployments usable before mail credentials exist.
163
+
164
+ Args:
165
+ email: The recipient address.
166
+ username: The account username (used for the greeting).
167
+ token: The single-use confirmation token stored on the account.
168
+
169
+ Returns:
170
+ True if the mail server accepted the message, False otherwise.
171
+ """
172
+ confirm_url = external_url(f"/confirm/{token}")
173
+ body = (
174
+ f"Hi {username},\n\n"
175
+ f"Confirm your QuantumVault email address by visiting:\n"
176
+ f"{confirm_url}\n\n"
177
+ f"If you did not create this account, you can ignore this message."
178
+ )
179
+
180
+ if not mail_is_configured():
181
+ audit_event("confirmation_email_unconfigured", username=username)
182
+ current_app.logger.warning(
183
+ "Mail is not configured; confirmation link for %s: %s",
184
+ username,
185
+ confirm_url,
186
+ )
187
+ return False
188
+
189
+ sent = send_transactional_email(
190
+ "Confirm your QuantumVault account", [email], body
191
+ )
192
+ audit_event("confirmation_email", username=username, sent=sent)
193
+ if not sent:
194
+ current_app.logger.warning(
195
+ "Confirmation email failed; link for %s: %s", username, confirm_url
196
+ )
197
+ return sent
198
+
199
+ # ------------------------------------------------------------------
200
+ # SRP login
201
+ # ------------------------------------------------------------------
202
+ def srp_hello(self, username: str, client_a_hex: str) -> Optional[Tuple[str, str]]:
203
+ """Begin an SRP-6a login and return the salt and server challenge B."""
204
+ user_data = self.user_db.get_user(username)
205
+ if not user_data or not user_data.get('srp_salt') or not user_data.get('srp_verifier'):
206
+ audit_event("srp_hello_unknown_user", username=username)
207
+ return None
208
+ server_b_hex = srp6a.hello(
209
+ self.srp_store,
210
+ username=username,
211
+ client_a_hex=client_a_hex,
212
+ salt_hex=user_data['srp_salt'],
213
+ verifier_hex=user_data['srp_verifier'],
214
+ )
215
+ if server_b_hex is None:
216
+ audit_event("srp_hello_invalid_A", username=username)
217
+ return None
218
+ audit_event("srp_hello_success", username=username)
219
+ return user_data['srp_salt'], server_b_hex
220
+
221
+ def srp_verify(self, username: str, client_m1_hex: str) -> Optional[Tuple[UserModel, str]]:
222
+ """Complete an SRP-6a login and return the authenticated user and proof."""
223
+ server_m2_hex = srp6a.verify(self.srp_store, username, client_m1_hex)
224
+ if server_m2_hex is None:
225
+ audit_event("srp_verify_failure", username=username)
226
+ return None
227
+
228
+ user_data = self.user_db.get_user(username)
229
+ if not user_data:
230
+ return None
231
+
232
+ audit_event("srp_verify_success", username=username)
233
+ user = UserModel(
234
+ id=user_data["id"],
235
+ username=user_data["username"],
236
+ role=user_data["role"],
237
+ email=user_data["email"],
238
+ phone=user_data["phone"],
239
+ first_name=user_data["first_name"],
240
+ last_name=user_data["last_name"],
241
+ storage_quota=user_data["storage_quota"],
242
+ trial_start=user_data["trial_start"],
243
+ trial_end=user_data["trial_end"],
244
+ subscription_status=user_data["subscription_status"],
245
+ email_verified=user_data["email_verified"],
246
+ mfa_enabled=user_data["mfa_enabled"]
247
+ )
248
+ return user, server_m2_hex
249
+
250
+ # ------------------------------------------------------------------
251
+ # SMS provider
252
+ # ------------------------------------------------------------------
253
+ def send_sms_verification(self, phone: str, code: str, username: str = "") -> bool:
254
+ """Send a verification code via SMS."""
255
+ if not configuration.username or not configuration.password:
256
+ return False
257
+ try:
258
+ api_instance = clicksend_client.SMSApi(clicksend_client.ApiClient(configuration))
259
+ message = f"Your QuantumVault verification code is: {code}"
260
+ if username:
261
+ message += f" https://quantumvault.pro/verify_phone?username={username}"
262
+
263
+ sms_message = SmsMessage(source="QuantumVault", body=message, to=phone)
264
+ sms_messages = clicksend_client.SmsMessageCollection(messages=[sms_message])
265
+ api_instance.sms_send_post(sms_messages)
266
+ return True
267
+ except (ApiException, Exception) as e:
268
+ audit_event("sms_send_failure", reason=str(e))
269
+ return False
270
+
271
+ # ------------------------------------------------------------------
272
+ # Phone verification
273
+ # ------------------------------------------------------------------
274
+ def verify_phone_code(self, username: str, code: str) -> bool:
275
+ """Verify a phone verification code for a user.
276
+
277
+ The stored value is the peppered hash; the supplied code is
278
+ hashed in the same way and the two digests are compared in
279
+ constant time.
280
+ """
281
+ user_data = self.user_db.get_user(username)
282
+ if not user_data:
283
+ audit_event("phone_verify_unknown_user", username=username)
284
+ return False
285
+
286
+ stored_hash = user_data.get("phone_verification_code_hash") or user_data.get("phone_verification_code")
287
+ expires_at = user_data.get("phone_code_expires")
288
+
289
+ if not verify_secret(code, stored_hash):
290
+ audit_event("phone_verify_bad_code", username=username)
291
+ return False
292
+
293
+ if not self._is_code_valid(expires_at):
294
+ audit_event("phone_verify_expired", username=username)
295
+ return False
296
+
297
+ try:
298
+ self.user_db.update_user_phone_status(
299
+ username=username,
300
+ phone_verified=True,
301
+ phone_verification_code_hash=None,
302
+ phone_code_expires=None
303
+ )
304
+ audit_event("phone_verify_success", username=username)
305
+ return True
306
+ except Exception as e:
307
+ audit_event("phone_verify_db_error", username=username, reason=type(e).__name__)
308
+ return False
309
+
310
+ def resend_phone_code(self, username: str) -> bool:
311
+ """Issue and send a fresh phone verification code.
312
+
313
+ Generates a new one-time code, stores its peppered hash with a
314
+ 30-minute expiry (overwriting any previous pending code), and
315
+ dispatches it by SMS. Returns False when the account is unknown,
316
+ has no phone number, is already verified, or the SMS provider is
317
+ not configured, so the caller can surface an honest result.
318
+
319
+ Args:
320
+ username: The account requesting a new code.
321
+
322
+ Returns:
323
+ True if a new code was generated, stored, and accepted by the
324
+ SMS provider; False otherwise.
325
+ """
326
+ user_data = self.user_db.get_user(username)
327
+ if not user_data or not user_data.get("phone"):
328
+ audit_event("phone_resend_unknown_user", username=username)
329
+ return False
330
+ if user_data.get("phone_verified"):
331
+ audit_event("phone_resend_already_verified", username=username)
332
+ return False
333
+
334
+ code = new_one_time_code(6)
335
+ expires_at = _now_utc() + timedelta(minutes=30)
336
+ try:
337
+ self.user_db.update_user_phone_status(
338
+ username=username,
339
+ phone_verification_code_hash=hash_secret(code),
340
+ phone_code_expires=expires_at,
341
+ )
342
+ sent = self.send_sms_verification(user_data["phone"], code, username)
343
+ audit_event("phone_resend", username=username, sent=sent)
344
+ return sent
345
+ except Exception as e:
346
+ audit_event("phone_resend_error", username=username, reason=type(e).__name__)
347
+ return False
348
+
349
+ def _is_code_valid(self, expires_at) -> bool:
350
+ """Return True if a verification code has not yet expired."""
351
+ if not expires_at:
352
+ return False
353
+ try:
354
+ if isinstance(expires_at, str):
355
+ expires_dt = datetime.fromisoformat(expires_at)
356
+ else:
357
+ expires_dt = expires_at
358
+
359
+ if expires_dt.tzinfo is None:
360
+ expires_dt = pytz.utc.localize(expires_dt)
361
+
362
+ return _now_utc() <= expires_dt
363
+ except (ValueError, TypeError):
364
+ return False
365
+
366
+ # ------------------------------------------------------------------
367
+ # MFA
368
+ # ------------------------------------------------------------------
369
+ def verify_mfa_code(self, username: str, code: str) -> bool:
370
+ """Verify a multi-factor authentication code for a user."""
371
+ user_data = self.user_db.get_user(username)
372
+ if not user_data:
373
+ audit_event("mfa_unknown_user", username=username)
374
+ return False
375
+
376
+ stored_hash = user_data.get("mfa_code_hash") or user_data.get("mfa_code")
377
+ if verify_secret(code, stored_hash) and self._is_code_valid(user_data.get("mfa_code_expires")):
378
+ try:
379
+ self.user_db.update_user_mfa_status(
380
+ username=username,
381
+ mfa_code_hash=None,
382
+ mfa_code_expires=None
383
+ )
384
+ audit_event("mfa_success", username=username)
385
+ return True
386
+ except Exception as e:
387
+ audit_event("mfa_db_error", username=username, reason=type(e).__name__)
388
+ return False
389
+
390
+ audit_event("mfa_failure", username=username)
391
+ return False
392
+
393
+ def send_mfa_code(self, username: str) -> bool:
394
+ """Generate, store, and send an MFA code to the user's phone."""
395
+ user_data = self.user_db.get_user(username)
396
+ if not user_data or not user_data.get("phone_verified"):
397
+ audit_event("mfa_send_unverified", username=username)
398
+ return False
399
+
400
+ mfa_code = new_one_time_code(6)
401
+ expires_at = _now_utc() + timedelta(minutes=10)
402
+
403
+ try:
404
+ self.user_db.update_user_mfa_status(
405
+ username=username,
406
+ mfa_code_hash=hash_secret(mfa_code),
407
+ mfa_code_expires=expires_at
408
+ )
409
+ ok = self.send_sms_verification(user_data["phone"], mfa_code)
410
+ audit_event("mfa_send", username=username, sent=ok)
411
+ return ok
412
+ except Exception as e:
413
+ audit_event("mfa_send_error", username=username, reason=type(e).__name__)
414
+ return False
415
+
416
+ def toggle_mfa(self, username: str, enable: bool) -> None:
417
+ """Enable or disable MFA for a user."""
418
+ try:
419
+ self.user_db.update_user_mfa_status(
420
+ username=username,
421
+ mfa_enabled=enable
422
+ )
423
+ audit_event("mfa_toggle", username=username, enabled=enable)
424
+ except Exception as e:
425
+ audit_event("mfa_toggle_error", username=username, reason=type(e).__name__)
426
+ raise
controllers/contact.py ADDED
@@ -0,0 +1,46 @@
1
+ from models.contact import ContactDB, ContactModel
2
+ from flask import flash
3
+
4
+ class ContactController:
5
+ """Handles logic related to contact messages."""
6
+ def __init__(self, db_path: str):
7
+ """Initialize the ContactController with the database path.
8
+
9
+ Args:
10
+ db_path (str): Path to the SQLite database file.
11
+ """
12
+ self.contact_db = ContactDB(db_path)
13
+
14
+ def create_contact(self, user_id: int, subject: str, message: str) -> bool:
15
+ """Create a new contact message.
16
+
17
+ Args:
18
+ user_id (int): ID of the user sending the message.
19
+ subject (str): Subject of the message.
20
+ message (str): Content of the message.
21
+
22
+ Returns:
23
+ bool: True if the message was created successfully, False otherwise.
24
+ """
25
+ if not subject or not message:
26
+ flash("Subject and message are required.")
27
+ return False
28
+ if len(subject) > 100:
29
+ flash("Subject must not exceed 100 characters.")
30
+ return False
31
+ if len(message) > 1000:
32
+ flash("Message must not exceed 1000 characters.")
33
+ return False
34
+ return self.contact_db.create_contact(user_id, subject, message)
35
+
36
+ def get_user_contacts(self, user_id: int) -> list[ContactModel]:
37
+ """Retrieve all contact messages for a user.
38
+
39
+ Args:
40
+ user_id (int): ID of the user.
41
+
42
+ Returns:
43
+ list[ContactModel]: List of contact messages.
44
+ """
45
+ contacts = self.contact_db.get_user_contacts(user_id)
46
+ return [ContactModel(**contact) for contact in contacts]