04/30 Web Checker web app ver. 1.0

This commit is contained in:
2026-04-30 17:15:56 -04:00
parent feb8e5051c
commit cd63d5a020
39 changed files with 8031 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
# utils package
+142
View File
@@ -0,0 +1,142 @@
"""
utils/crypto.py — Fernet symmetric encryption for website credentials.
IMPORTANT: This module is intentionally byte-for-byte compatible with the
desktop application's utils/crypto.py so that both apps can share the same
MySQL database and read/write each other's encrypted credential values.
Key derivation (must not change without re-encrypting all stored values):
- APP_SECRET : b"WebsiteChecker-v1-CredentialKey" (fixed, same as desktop)
- Salt : 32 random bytes, stored base64-encoded in app_settings
under key "crypto.salt" (same as desktop)
- KDF : PBKDF2-HMAC-SHA256, 100,000 iterations (same as desktop)
- Ciphertext : prefixed with "enc:" so plaintext legacy values are
distinguishable without attempting decryption (same as desktop)
Salt storage format:
- Stored as base64 (NOT hex) in app_settings.value for "crypto.salt".
- The desktop generates 32 bytes; we do the same to stay consistent.
Compatibility:
- decrypt() returns the raw value unchanged for strings that are NOT
prefixed with "enc:" — this handles legacy plaintext credentials
written before encryption was introduced, identical to desktop behaviour.
"""
import base64
import logging
import os
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
logger = logging.getLogger("utils.crypto")
# ── Must match desktop exactly ────────────────────────────────────────────────
_APP_SECRET = b"WebsiteChecker-v1-CredentialKey"
_ITERATIONS = 100_000
_SETTING_KEY = "crypto.salt"
_fernet: "Fernet | None" = None
# ── Key bootstrap ─────────────────────────────────────────────────────────────
def _get_or_create_salt() -> bytes:
"""
Read the 32-byte salt from app_settings (base64-encoded), creating and
persisting a fresh one if absent. The base64 format matches the desktop.
"""
from config import get_setting, set_setting
raw = get_setting(_SETTING_KEY, "")
if raw:
try:
return base64.b64decode(raw)
except Exception as e:
logger.warning(f"Crypto: could not decode stored salt: {e} — generating new salt.")
# Generate a fresh 32-byte salt (same size as desktop)
salt = os.urandom(32)
b64_salt = base64.b64encode(salt).decode("ascii")
try:
set_setting(_SETTING_KEY, b64_salt)
logger.info("Crypto: generated and persisted new salt to app_settings.")
except Exception as e:
logger.warning(f"Crypto: could not persist new salt: {e}")
return salt
def _build_fernet() -> Fernet:
salt = _get_or_create_salt()
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=_ITERATIONS,
)
key = base64.urlsafe_b64encode(kdf.derive(_APP_SECRET))
return Fernet(key)
def _get_fernet() -> Fernet:
global _fernet
if _fernet is None:
_fernet = _build_fernet()
return _fernet
def reset_fernet():
"""Force key reload — call if the salt in app_settings is ever rotated."""
global _fernet
_fernet = None
# ── Public API ────────────────────────────────────────────────────────────────
def encrypt(plaintext: str) -> str:
"""
Encrypt a plaintext string.
Returns a UTF-8-safe ciphertext string prefixed with 'enc:' so callers
can distinguish encrypted values from legacy plaintext ones.
Returns the original string unchanged if plaintext is empty.
Compatible with desktop encrypt().
"""
if not plaintext:
return plaintext
try:
token = _get_fernet().encrypt(plaintext.encode("utf-8"))
return "enc:" + token.decode("ascii")
except Exception as e:
logger.error(f"Credential encryption failed: {e}")
return plaintext # safe fallback — don't lose data
def decrypt(ciphertext: str) -> str:
"""
Decrypt a ciphertext string produced by encrypt().
- If ciphertext starts with 'enc:', decrypts with Fernet.
- Otherwise returns the value as-is (plaintext legacy credential).
Returns empty string on decryption failure.
Compatible with desktop decrypt().
"""
if not ciphertext:
return ciphertext
if not ciphertext.startswith("enc:"):
# Legacy plaintext credential — return as-is, identical to desktop
return ciphertext
try:
token = ciphertext[4:].encode("ascii")
return _get_fernet().decrypt(token).decode("utf-8")
except InvalidToken:
logger.error("Credential decryption failed — wrong key or corrupted data.")
return ""
except Exception as e:
logger.error(f"Credential decryption error: {e}")
return ""
def is_encrypted(value: str) -> bool:
"""Return True if the value was produced by encrypt()."""
return isinstance(value, str) and value.startswith("enc:")
+30
View File
@@ -0,0 +1,30 @@
"""
utils/decorators.py — Route protection decorators for the Flask web app.
"""
import functools
from flask import session, redirect, url_for, flash, abort
def login_required(f):
"""Redirect to login if the user is not authenticated."""
@functools.wraps(f)
def decorated(*args, **kwargs):
if "user" not in session:
flash("Please log in to access this page.", "warning")
return redirect(url_for("auth.login"))
return f(*args, **kwargs)
return decorated
def admin_required(f):
"""Abort 403 if the authenticated user is not an admin."""
@functools.wraps(f)
def decorated(*args, **kwargs):
if "user" not in session:
flash("Please log in to access this page.", "warning")
return redirect(url_for("auth.login"))
if session["user"].get("role") != "admin":
abort(403)
return f(*args, **kwargs)
return decorated