pranit144's picture
Upload 23 files
f88a7bc verified
# --- START OF FILE app.py ---
import os
import json
import hashlib
import time
import re
import base64
import traceback # For detailed error logging
import secrets # Use secrets for cryptographic randomness
import string # For character sets
import random # For shuffling (secrets doesn't have shuffle)
from datetime import timedelta # ** IMPORT timedelta **
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, session
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
from flask_bcrypt import Bcrypt
from supabase import create_client, Client # Use v2 import style
# Note: cryptography library parts are only needed for derive_key now
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from dotenv import load_dotenv
import google.generativeai as genai
from zxcvbn import zxcvbn
load_dotenv()
app = Flask(__name__)
# Make sure FLASK_SECRET_KEY is set strong in your .env!
app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'default_secret_key_please_change')
app.config['ENV'] = os.environ.get('FLASK_ENV', 'production')
app.config['DEBUG'] = app.config['ENV'] == 'development'
# ** SET PERMANENT SESSION LIFETIME (e.g., 7 days) **
# This applies *only* when 'Remember Me' is checked.
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
# --- Supabase Configuration ---
supabase_url = os.environ.get("SUPABASE_URL")
supabase_key = os.environ.get("SUPABASE_KEY") # Anon key
if not supabase_url or not supabase_key:
raise ValueError("Supabase URL and Key must be set in .env file")
supabase: Client = create_client(supabase_url, supabase_key)
print("Supabase client initialized.")
# --- Authentication Setup ---
bcrypt = Bcrypt(app)
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'login'
login_manager.login_message_category = 'info'
print("Flask-Login initialized.")
# --- User Model for Flask-Login ---
class User(UserMixin):
def __init__(self, id, email):
self.id = id
self.email = email
@login_manager.user_loader
def load_user(user_id):
user_data = session.get('user_data')
if user_data and user_data['id'] == user_id:
return User(id=user_data['id'], email=user_data['email'])
# print(f"User {user_id} not found in session.") # Reduced verbosity
return None
# --- Gemini LLM Configuration ---
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")
genai_available = False
if GEMINI_API_KEY and GEMINI_API_KEY.startswith("AIza"):
try:
genai.configure(api_key=GEMINI_API_KEY)
genai_available = True
print("Gemini API configured successfully.")
except Exception as e:
print(f"Warning: Failed to configure Gemini API: {e}")
else:
print("Warning: Gemini API key not found or looks invalid. LLM features will use mock/basic analysis.")
# --- E2EE Helper Functions ---
def derive_key(master_password: str, salt_or_email: str) -> bytes:
"""
Derives a 32-byte key suitable for AES using PBKDF2HMAC.
Uses email (lowercase) as salt for simplicity - *replace with stored unique salt in production*.
Returns the key BASE64 URL-SAFE ENCODED (suitable for session/storage).
"""
salt = salt_or_email.lower().encode('utf-8')
iterations = 390000 # Match JS side
kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=salt, iterations=iterations )
key_bytes = kdf.derive(master_password.encode('utf-8')) # Raw 32 bytes
# --- ADDED LOGGING ---
# Log the standard Base64 representation for easier comparison with JS console log
try:
raw_key_standard_b64 = base64.b64encode(key_bytes).decode('utf-8')
# print(f"DEBUG: PY Derived Key (Raw -> Standard Base64): {raw_key_standard_b64}") # Keep commented unless debugging
except Exception as log_e:
print(f"DEBUG: Error logging derived key: {log_e}")
# --- END LOGGING ---
# Return URL-safe Base64 encoded key for session storage
key_b64url_encoded = base64.urlsafe_b64encode(key_bytes)
return key_b64url_encoded
# --- Password Analysis Functions ---
# (get_character_composition, get_llm_password_insights, get_basic_password_analysis_from_chars, get_mock_llm_insights_from_chars)
def get_character_composition(password):
"""Generates character composition dict."""
composition = { 'lowercase': 0, 'uppercase': 0, 'digits': 0, 'special': 0 }
if not password: return composition # Handle None or empty string
for char in password:
if char.islower(): composition['lowercase'] += 1
elif char.isupper(): composition['uppercase'] += 1
elif char.isdigit(): composition['digits'] += 1
elif not char.isalnum() and not char.isspace(): composition['special'] += 1 # More specific special char check
return composition
def get_llm_password_insights(password_characteristics: dict):
""" Get password analysis insights from Gemini API based on characteristics."""
global genai_available
try:
use_mock = app.config['DEBUG'] or not genai_available
if use_mock:
return get_mock_llm_insights_from_chars(password_characteristics)
generation_config = { "temperature": 0.2, "top_p": 0.8, "top_k": 40, "max_output_tokens": 512 }
safety_settings = [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
]
model = genai.GenerativeModel('gemini-1.5-flash', generation_config=generation_config, safety_settings=safety_settings)
prompt = f"""
Analyze a password based *only* on the following characteristics. Provide a security assessment.
DO NOT attempt to guess the password or ask for it.
Return ONLY a valid JSON object with the exact structure below, no other text or explanations.
{{
"strength_score": 1-5 (integer, 1=very weak, 5=very strong),
"assessment": "Very Weak/Weak/Medium/Strong/Very Strong",
"insights": ["Specific observations based on characteristics provided"],
"suggestions": ["Actionable improvement tips based on characteristics"]
}}
Password Characteristics:
Length: {password_characteristics.get('length', 0)}
Contains Lowercase: {'Yes' if password_characteristics.get('composition', {}).get('lowercase', 0) > 0 else 'No'}
Contains Uppercase: {'Yes' if password_characteristics.get('composition', {}).get('uppercase', 0) > 0 else 'No'}
Contains Digits: {'Yes' if password_characteristics.get('composition', {}).get('digits', 0) > 0 else 'No'}
Contains Special Chars: {'Yes' if password_characteristics.get('composition', {}).get('special', 0) > 0 else 'No'}
"""
response = model.generate_content(prompt)
if not response.candidates or not hasattr(response.candidates[0], 'content') or not response.candidates[0].content.parts:
print(f"Gemini response blocked or empty. Reason: {response.prompt_feedback}")
return get_basic_password_analysis_from_chars(password_characteristics)
response_text = response.text
json_match = re.search(r'```(?:json)?\s*({.*?})\s*```', response_text, re.DOTALL | re.IGNORECASE)
if not json_match: json_match = re.search(r'({.*})', response_text, re.DOTALL)
if json_match:
try:
insights_json = json.loads(json_match.group(1))
if all(k in insights_json for k in ['strength_score', 'assessment', 'insights', 'suggestions']):
return { 'strength': insights_json.get('strength_score', 1), 'assessment': insights_json.get('assessment', 'Weak'),
'insights': insights_json.get('insights', []), 'suggestions': insights_json.get('suggestions', []) }
else: raise ValueError("Missing keys in JSON response")
except (json.JSONDecodeError, ValueError) as json_err:
print(f"LLM JSON processing Error: {json_err}. Falling back.")
return get_basic_password_analysis_from_chars(password_characteristics)
else:
print("LLM JSON pattern not found. Falling back.")
return get_basic_password_analysis_from_chars(password_characteristics)
except Exception as e:
print(f"Error getting Gemini insights: {type(e).__name__} - {e}")
return get_basic_password_analysis_from_chars(password_characteristics)
def get_basic_password_analysis_from_chars(characteristics: dict):
""" Basic password analysis based on characteristics. Returns same structure as LLM."""
strength = 0; insights = []; suggestions = []
length = characteristics.get('length', 0)
composition = characteristics.get('composition', {})
# Score Calculation (simple example)
if length < 8: insights.append("Short (< 8 chars)"); suggestions.append("Use 12+ chars.")
elif length < 12: strength += 1; insights.append("Okay length (8-11)") ; suggestions.append("Use 12+ chars.")
else: strength += 2; insights.append("Good length (12+)")
if composition.get('uppercase', 0) > 0: strength += 1
else: insights.append("No uppercase"); suggestions.append("Add A-Z.")
if composition.get('lowercase', 0) > 0: strength += 1
else: insights.append("No lowercase"); suggestions.append("Add a-z.")
if composition.get('digits', 0) > 0: strength += 1
else: insights.append("No numbers"); suggestions.append("Add 0-9.")
if composition.get('special', 0) > 0: strength += 1
else: insights.append("No special chars"); suggestions.append("Add !@#$.")
# Map score (0-6) to 1-5 rating
score_map = {0: 1, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 5}
final_score = score_map.get(strength, 1)
assessment_map = {1: "Very Weak", 2: "Weak", 3: "Medium", 4: "Strong", 5: "Very Strong"}
assessment = assessment_map.get(final_score, "Weak")
if final_score == 5 and not insights: insights.append("Meets basic complexity.")
return {'strength': final_score, 'assessment': assessment, 'insights': insights, 'suggestions': suggestions}
def get_mock_llm_insights_from_chars(characteristics: dict):
""" Generates mock LLM insights based on characteristics."""
basic_analysis = get_basic_password_analysis_from_chars(characteristics)
if basic_analysis['strength'] < 4: basic_analysis['suggestions'].append("Consider passphrase.")
if not basic_analysis['insights'] and basic_analysis['strength'] >= 4: basic_analysis['insights'].append("Good length/variety.")
return basic_analysis
# --- NEW: Password Generation Function ---
def generate_secure_password(length=16, use_lowercase=True, use_uppercase=True, use_digits=True, use_symbols=True):
"""Generates a secure password ensuring character set inclusion."""
character_pool = []
required_chars = []
if use_lowercase:
character_pool.extend(string.ascii_lowercase)
required_chars.append(secrets.choice(string.ascii_lowercase))
if use_uppercase:
character_pool.extend(string.ascii_uppercase)
required_chars.append(secrets.choice(string.ascii_uppercase))
if use_digits:
character_pool.extend(string.digits)
required_chars.append(secrets.choice(string.digits))
if use_symbols:
# Define a standard set of symbols, avoid potentially problematic ones like `'"\
symbols = '!@#$%^&*()_-+={}[]|:;<>,.?/~'
character_pool.extend(symbols)
required_chars.append(secrets.choice(symbols))
if not character_pool:
raise ValueError("No character types selected for password generation.")
if length < len(required_chars):
raise ValueError(f"Length ({length}) is too short to include all required character types ({len(required_chars)}).")
# Fill the rest of the password length
remaining_length = length - len(required_chars)
password_chars = required_chars + [secrets.choice(character_pool) for _ in range(remaining_length)]
# Shuffle the list to ensure randomness
random.SystemRandom().shuffle(password_chars) # Use system random for better shuffling
return "".join(password_chars)
# --- Routes ---
@app.route('/')
def home():
if current_user.is_authenticated: return redirect(url_for('add_password_page'))
else: return redirect(url_for('login'))
@app.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated: return redirect(url_for('add_password_page'))
if request.method == 'POST':
email = request.form.get('email', '').strip(); password = request.form.get('password', '')
# ** GET REMEMBER ME VALUE **
remember_me = 'remember' in request.form # True if checkbox was ticked
if not email or not password:
flash('Email and Master Password are required.', 'danger'); return render_template('login.html'), 400
try:
response = supabase.table('users').select("id, email, password_hash").eq('email', email).maybe_single().execute()
if hasattr(response, 'data') and response.data:
user_data = response.data; stored_hash = user_data['password_hash']
if bcrypt.check_password_hash(stored_hash, password):
user_obj = User(id=user_data['id'], email=user_data['email'])
# ** PASS REMEMBER ME TO login_user **
login_user(user_obj, remember=remember_me)
# Flask-Login sets session.permanent based on 'remember' flag
session['user_data'] = {'id': user_data['id'], 'email': user_data['email']}
# Derive key and store URL-safe Base64 version in session
derived_key_b64url = derive_key(password, user_data['email'])
session['encryption_key'] = derived_key_b64url.decode('utf-8')
flash('Logged in successfully!', 'success')
next_page = request.args.get('next')
return redirect(next_page or url_for('add_password_page'))
else: flash('Login failed. Invalid email or password.', 'danger')
else: flash('Login failed. Invalid email or password.', 'danger')
return render_template('login.html'), 401
except Exception as e:
flash(f'An error occurred during login. Please try again.', 'danger')
print(f"Login Exception for {email}: {type(e).__name__} - {e}"); traceback.print_exc()
return render_template('login.html'), 500
return render_template('login.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated: return redirect(url_for('add_password_page'))
if request.method == 'POST':
email = request.form.get('email', '').strip()
password = request.form.get('password', ''); confirm_password = request.form.get('confirm_password', '')
if not email or not password or not confirm_password: flash('All fields are required.', 'danger'); return render_template('register.html'), 400
if password != confirm_password: flash('Passwords do not match.', 'danger'); return render_template('register.html'), 400
try:
check_response = supabase.table('users').select("id", count='exact').eq('email', email).execute()
if hasattr(check_response, 'count') and check_response.count is not None and check_response.count > 0:
flash('Email address is already registered. Please login.', 'warning'); return redirect(url_for('login'))
elif not hasattr(check_response, 'count'): print(f"WARNING: Supabase check for {email} lacked 'count': {check_response}")
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
insert_response = supabase.table('users').insert({'email': email, 'password_hash': hashed_password}).execute()
if hasattr(insert_response, 'data') and insert_response.data:
flash('Registration successful! Please log in.', 'success'); return redirect(url_for('login'))
else:
print(f"ERROR: Registration failed for {email}. Response: {insert_response}")
flash("Registration failed due to a server error.", 'danger')
return render_template('register.html'), 500
except Exception as e:
flash(f'An error occurred during registration. Please try again.', 'danger')
print(f"Registration Exception for {email}: {type(e).__name__} - {e}"); traceback.print_exc()
return render_template('register.html'), 500
return render_template('register.html')
@app.route('/logout')
@login_required
def logout():
session.pop('encryption_key', None); session.pop('user_data', None)
logout_user()
flash('You have been logged out successfully.', 'success')
return redirect(url_for('login'))
# --- Main Application Pages (Require Login) ---
@app.route('/add')
@login_required
def add_password_page(): return render_template('index.html')
@app.route('/storage')
@login_required
def storage(): return render_template('storage.html')
@app.route('/analyse')
@login_required
def analyse(): return render_template('analyse.html')
# --- API Endpoints (Require Login) ---
@app.route('/api/credentials', methods=['GET'])
@login_required
def get_credentials():
user_id = current_user.id
try:
response = supabase.table('credentials').select("id, encrypted_data, service_hint, created_at").eq('user_id', user_id).order('created_at', desc=True).execute()
# Check response structure for Supabase v2 (data attribute is primary)
if hasattr(response, 'data'):
return jsonify(response.data)
else: # Fallback or error logging if structure changes
print(f"Supabase get credentials unexpected response for user {user_id}. Response: {response}")
return jsonify({'error': 'Failed to retrieve credentials'}), 500
except Exception as e:
print(f"Error in get_credentials for user {user_id}: {e}"); traceback.print_exc()
return jsonify({'error': 'Server error retrieving credentials'}), 500
@app.route('/api/credentials', methods=['POST'])
@login_required
def add_credential():
data = request.json; user_id = current_user.id
encrypted_data = data.get('encrypted_data'); service_hint = data.get('service_hint')
if not encrypted_data: return jsonify({'success': False, 'message': 'Encrypted data payload is required.'}), 400
try:
insert_response = supabase.table('credentials').insert({'user_id': user_id, 'encrypted_data': encrypted_data, 'service_hint': service_hint}).execute()
# Check response structure for Supabase v2
if hasattr(insert_response, 'data') and insert_response.data:
return jsonify({'success': True, 'message': 'Credential saved successfully.'})
else:
print(f"Supabase insert credentials error for user {user_id}. Response: {insert_response}")
# Attempt to get more specific error if available (structure might vary)
error_msg = "Failed to save credential"
if hasattr(insert_response, 'error') and insert_response.error and hasattr(insert_response.error, 'message'):
error_msg += f": {insert_response.error.message}"
return jsonify({'success': False, 'message': error_msg}), 500
except Exception as e:
print(f"Error in add_credential for user {user_id}: {e}"); traceback.print_exc()
return jsonify({'success': False, 'message': 'Server error saving credential'}), 500
@app.route('/api/analyse_password', methods=['POST'])
@login_required
def analyse_password_api():
data = request.json; characteristics = data.get('characteristics')
if not characteristics or not isinstance(characteristics, dict):
return jsonify({'error': 'Password characteristics payload is required.'}), 400
analysis = get_llm_password_insights(characteristics)
feedback = []
if 'insights' in analysis: feedback.extend([f"Issue: {ins}" for ins in analysis['insights'] if ins])
if 'suggestions' in analysis: feedback.extend([f"Tip: {sug}" for sug in analysis['suggestions'] if sug])
if not feedback:
if analysis.get('strength', 0) == 5: feedback.append("Tip: Looks good based on characteristics!")
else: feedback.append("Issue: Review password based on assessment.")
return jsonify({ 'strength': analysis.get('strength', 1), 'assessment': analysis.get('assessment', 'Weak'), 'feedback': feedback })
# --- NEW: Password Generation API Endpoint ---
@app.route('/api/generate_password', methods=['POST']) # Using POST for consistency
@login_required
def generate_password_api():
try:
data = request.get_json(silent=True)
if data is None:
data = request.args.to_dict()
if not data:
data = {}
# Safely get length
try:
length_str = data.get('length', '16') # Default to string '16'
length = int(length_str)
if not (4 <= length <= 128):
return jsonify({'error': 'Length must be between 4 and 128.'}), 400
except (ValueError, TypeError):
return jsonify({'error': f'Invalid length parameter: "{length_str}". Must be an integer.'}), 400
# Helper for robust boolean parsing from various request inputs
def parse_bool(key, default_value):
value = data.get(key, default_value) # Get value or default
if isinstance(value, bool): return value
if isinstance(value, str):
low_val = value.lower()
if low_val in ['true', '1', 'yes', 'y']: return True
if low_val in ['false', '0', 'no', 'n']: return False
# Check for integer 1/0 as well
if isinstance(value, int) and value in [0, 1]:
return bool(value)
# If it's none of the above, return the original default
# print(f"Warning: Unexpected type for boolean parse '{key}': {type(value)}, using default: {default_value}")
return default_value
use_lowercase = parse_bool('use_lowercase', True)
use_uppercase = parse_bool('use_uppercase', True)
use_digits = parse_bool('use_digits', True)
use_symbols = parse_bool('use_symbols', True)
if not any([use_lowercase, use_uppercase, use_digits, use_symbols]):
return jsonify({'error': 'At least one character type must be selected.'}), 400
password = generate_secure_password(length, use_lowercase, use_uppercase, use_digits, use_symbols)
return jsonify({'password': password})
except ValueError as ve:
print(f"Password generation validation error: {ve}")
return jsonify({'error': str(ve)}), 400
except Exception as e:
print(f"Unexpected error in generate_password_api: {type(e).__name__} - {e}")
traceback.print_exc()
return jsonify({'error': 'Server error generating password.'}), 500
# --- Helper for zxcvbn analysis ---
# (Keep the import from zxcvbn at the top)
# from zxcvbn import zxcvbn # Already should be there
def get_zxcvbn_feedback(password):
"""Analyzes password using zxcvbn and formats feedback."""
results = zxcvbn(password)
score = results['score'] # Score 0-4
feedback_items = []
# Map score to assessment
assessment_map = {
0: "Very Weak", 1: "Weak", 2: "Medium", 3: "Strong", 4: "Very Strong"
}
assessment = assessment_map.get(score, "Unknown")
# Add warning if present
if results['feedback']['warning']:
feedback_items.append(f"Warning: {results['feedback']['warning']}")
# Add suggestions
if results['feedback']['suggestions']:
feedback_items.extend([f"Suggestion: {s}" for s in results['feedback']['suggestions']])
# Add a generic suggestion if feedback is empty but score is low
if not feedback_items and score < 3:
feedback_items.append("Suggestion: Add more variety (uppercase, numbers, symbols) or increase length.")
elif not feedback_items and score >= 3:
feedback_items.append("Suggestion: Looks reasonably strong!")
# Return without calc_time which is not JSON serializable
return {
'score': score, # 0-4
'assessment': assessment,
'feedback': feedback_items,
'guesses': results['guesses'], # Optional: include estimated guesses if needed
}
@app.route('/api/strength_check', methods=['POST'])
@login_required
def strength_check_api():
"""Receives a password and returns its zxcvbn strength analysis."""
data = request.get_json()
if not data or 'password' not in data:
return jsonify({'error': 'Password key missing in request body.'}), 400
password = data['password']
if not password: # Handle empty password case
return jsonify({'score': 0, 'assessment': 'Very Weak', 'feedback': ['Suggestion: Please enter a password.']})
try:
analysis = get_zxcvbn_feedback(password)
return jsonify(analysis)
except Exception as e:
print(f"Error during zxcvbn strength check: {e}")
traceback.print_exc()
# Return a generic low score on error, rather than crashing
return jsonify({'score': 0, 'assessment': 'Error', 'feedback': ['Error analyzing password strength.']}), 500
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
app.run(host='0.0.0.0', port=port, debug=app.config['DEBUG'])
# --- END OF FILE app.py ---