diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index d8909d8..0000000 --- a/app/__init__.py +++ /dev/null @@ -1,155 +0,0 @@ -from flask import Flask, render_template -from flask_sqlalchemy import SQLAlchemy -from flask_migrate import Migrate -from flask_login import LoginManager -from flask_wtf.csrf import CSRFProtect -from flask_limiter import Limiter -from flask_limiter.util import get_remote_address -from flask_cors import CORS - -from .config import config - -db = SQLAlchemy() -migrate = Migrate() -login_manager = LoginManager() -csrf = CSRFProtect() -limiter = Limiter(key_func=get_remote_address) - -# APScheduler is used for the background token-blacklist cleanup job. -# Imported here so it is available at module level; started inside create_app(). -try: - from apscheduler.schedulers.background import BackgroundScheduler - _scheduler_available = True -except ImportError: # pragma: no cover — optional dependency - _scheduler_available = False - - -def create_app(config_name: str = 'development') -> Flask: - app = Flask(__name__) - app.config.from_object(config[config_name]) - - # Extensions - db.init_app(app) - migrate.init_app(app, db) - login_manager.init_app(app) - csrf.init_app(app) - limiter.init_app(app) - - # Restrict CORS to the configured origin (locked to production domain in prod) - cors_origins = app.config.get('CORS_ORIGINS', '*') - CORS(app, resources={r'/api/*': {'origins': cors_origins}}) - - # Inject static asset version into every template for cache-busting. - # Usage in templates: {{ url_for('static', filename='css/app.css') }}?v={{ sv }} - app.jinja_env.globals['sv'] = app.config.get('STATIC_VERSION', '1') - - # Attach security headers to every response - @app.after_request - def set_security_headers(response): - # Strict-Transport-Security: enforce HTTPS for 1 year, include subdomains - response.headers['Strict-Transport-Security'] = ( - 'max-age=31536000; includeSubDomains' - ) - # Prevent clickjacking - response.headers['X-Frame-Options'] = 'DENY' - # Prevent MIME-type sniffing - response.headers['X-Content-Type-Options'] = 'nosniff' - # Control referrer information leakage - response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' - # Permissions policy — disable features the app does not use - response.headers['Permissions-Policy'] = ( - 'geolocation=(), camera=(), microphone=()' - ) - # CSP via HTTP header (authoritative — overrides the meta tag for all resources) - response.headers['Content-Security-Policy'] = ( - "default-src 'self'; " - "script-src 'self'; " - "style-src 'self'; " - "img-src 'self' data:; " - "font-src 'self'; " - "connect-src 'self' https://api.pwnedpasswords.com; " - "frame-ancestors 'none';" - ) - return response - - # Ensure all models are imported so SQLAlchemy knows about them - from .models.user import User - from .models.folder import Folder - from .models.vault_item import VaultItem - from .models.token_blacklist import TokenBlacklist - from .models.shared_item import SharedItem - from .models.emergency_access import EmergencyAccess - from .models.audit_log import AuditLog - - @login_manager.user_loader - def load_user(user_id): - return User.query.get(int(user_id)) - - # Blueprints - from .routes.auth import auth_bp - from .routes.vault import vault_bp - from .routes.folders import folders_bp - from .routes.sharing import sharing_bp - from .routes.emergency import emergency_bp - - app.register_blueprint(auth_bp, url_prefix='/api/auth') - app.register_blueprint(vault_bp, url_prefix='/api/vault') - app.register_blueprint(folders_bp, url_prefix='/api/folders') - app.register_blueprint(sharing_bp, url_prefix='/api/sharing') - app.register_blueprint(emergency_bp, url_prefix='/api/emergency') - - # Exempt all API blueprints from CSRF — JWT bearer tokens make CSRF irrelevant - csrf.exempt(auth_bp) - csrf.exempt(vault_bp) - csrf.exempt(folders_bp) - csrf.exempt(sharing_bp) - csrf.exempt(emergency_bp) - - # Page-serving routes - @app.route('/') - @app.route('/login') - def login_page(): - return render_template('auth/login.html') - - @app.route('/register') - def register_page(): - return render_template('auth/register.html') - - @app.route('/vault') - def vault_page(): - return render_template('vault/index.html') - - @app.route('/recover') - def recover_page(): - return render_template('auth/recover.html') - - # ── Background scheduler — token blacklist cleanup ───────────────────────── - # Runs cleanup_expired() every hour so the token_blacklist table never - # accumulates unbounded rows. Runs in a daemon thread — no request context. - if _scheduler_available: - def _cleanup_expired_tokens(): - with app.app_context(): - try: - from app.models.token_blacklist import TokenBlacklist - TokenBlacklist.cleanup_expired() - import logging - logging.getLogger(__name__).debug( - '[PassKeeper] token_blacklist cleanup completed' - ) - except Exception as exc: # pragma: no cover - import logging - logging.getLogger(__name__).warning( - '[PassKeeper] token_blacklist cleanup failed: %s', exc - ) - - scheduler = BackgroundScheduler(daemon=True) - scheduler.add_job( - _cleanup_expired_tokens, - trigger='interval', - hours=1, - id='token_blacklist_cleanup', - replace_existing=True, - ) - scheduler.start() - - return app \ No newline at end of file diff --git a/app/build.sh b/app/build.sh new file mode 100644 index 0000000..c071c7a --- /dev/null +++ b/app/build.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# build.sh — Build PassKeeper extension for Chrome (MV3) and Firefox (MV2). +# +# Usage: +# ./build.sh # build both targets +# ./build.sh chrome # Chrome only +# ./build.sh firefox # Firefox only +# +# Output: +# dist/passkeeper-chrome.zip +# dist/passkeeper-firefox.zip +# +# Requirements: zip (standard on macOS/Linux) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXT_DIR="$SCRIPT_DIR/extension" +DIST_DIR="$SCRIPT_DIR/dist" + +# Files and directories included in every build (relative to extension/). +COMMON_FILES=( + "content" + "popup" + "bridge" + "shared" + "icons" +) + +TARGET="${1:-both}" + +mkdir -p "$DIST_DIR" + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +build_chrome() { + local out="$DIST_DIR/passkeeper-chrome.zip" + echo "Building Chrome (MV3) → $out" + rm -f "$out" + + ( + cd "$EXT_DIR" + zip -r "$out" manifest.json background.js "${COMMON_FILES[@]}" \ + --exclude "*.DS_Store" --exclude "**/__pycache__/*" --exclude "*.py" + ) + + echo " ✓ Chrome build complete: $out ($(du -sh "$out" | cut -f1))" +} + +build_firefox() { + local out="$DIST_DIR/passkeeper-firefox.zip" + echo "Building Firefox (MV2) → $out" + rm -f "$out" + + # Firefox uses a different manifest and background script. + # We build into a temp directory so we can swap those files cleanly. + local tmp + tmp="$(mktemp -d)" + trap "rm -rf '$tmp'" EXIT + + # Copy common files into temp dir. + for item in "${COMMON_FILES[@]}"; do + cp -r "$EXT_DIR/$item" "$tmp/" + done + + # Swap in Firefox-specific manifest and background. + cp "$EXT_DIR/manifest.firefox.json" "$tmp/manifest.json" + cp "$EXT_DIR/background.firefox.js" "$tmp/background.js" + + ( + cd "$tmp" + zip -r "$out" . \ + --exclude "*.DS_Store" --exclude "**/__pycache__/*" --exclude "*.py" + ) + + echo " ✓ Firefox build complete: $out ($(du -sh "$out" | cut -f1))" +} + +# ── Main ────────────────────────────────────────────────────────────────────── + +case "$TARGET" in + chrome) build_chrome ;; + firefox) build_firefox ;; + both) build_chrome; build_firefox ;; + *) + echo "Usage: $0 [chrome|firefox|both]" >&2 + exit 1 + ;; +esac + +echo "Done. Packages are in $DIST_DIR/" diff --git a/app/models/audit_log.py b/app/models/audit_log.py index bd4957e..4ee28c5 100644 --- a/app/models/audit_log.py +++ b/app/models/audit_log.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy.dialects.mysql import INTEGER @@ -21,7 +21,7 @@ class AuditLog(db.Model): resource_id = db.Column(INTEGER(unsigned=True), nullable=True) # FK to the affected row detail = db.Column(db.String(512), nullable=True) # human-readable summary (no secrets) ip_address = db.Column(db.String(45), nullable=True) # IPv4 or IPv6 - created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False, index=True) @classmethod def log(cls, user_id: int, action: str, resource_type: str, diff --git a/app/models/emergency_access.py b/app/models/emergency_access.py index d874f74..a6ce9e9 100644 --- a/app/models/emergency_access.py +++ b/app/models/emergency_access.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timezone, timedelta from sqlalchemy.dialects.mysql import INTEGER @@ -39,14 +39,14 @@ class EmergencyAccess(db.Model): request_initiated_at = db.Column(db.DateTime, nullable=True) # JSON string: [{ id, name, item_type, enc_data, iv }, ...] enc_vault = db.Column(db.Text, nullable=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False) @property def wait_elapsed(self): """True if the wait period has passed since the access request.""" if self.status != 'pending' or not self.request_initiated_at: return False - return datetime.utcnow() >= self.request_initiated_at + timedelta(days=self.wait_days) + return datetime.now(timezone.utc).replace(tzinfo=None) >= self.request_initiated_at + timedelta(days=self.wait_days) def to_dict(self, grantor_email=None): return { diff --git a/app/models/shared_item.py b/app/models/shared_item.py index a5bb470..1dfcad8 100644 --- a/app/models/shared_item.py +++ b/app/models/shared_item.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy.dialects.mysql import INTEGER @@ -40,7 +40,7 @@ class SharedItem(db.Model): iv = db.Column(db.String(64), nullable=False) accepted = db.Column(db.Boolean, default=False, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False) def to_dict(self): return { diff --git a/app/models/token_blacklist.py b/app/models/token_blacklist.py index cfcca23..da5fed6 100644 --- a/app/models/token_blacklist.py +++ b/app/models/token_blacklist.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy.dialects.mysql import INTEGER @@ -20,10 +20,10 @@ class TokenBlacklist(db.Model): if not entry: return False # Automatically ignore expired entries (they can be cleaned up later) - return entry.expires_at > datetime.utcnow() + return entry.expires_at > datetime.now(timezone.utc).replace(tzinfo=None) @classmethod def cleanup_expired(cls): """Delete entries that have already expired — call occasionally to keep table small.""" - cls.query.filter(cls.expires_at <= datetime.utcnow()).delete() + cls.query.filter(cls.expires_at <= datetime.now(timezone.utc).replace(tzinfo=None)).delete() db.session.commit() diff --git a/app/models/user.py b/app/models/user.py index 8fc9fd8..57a6e20 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from flask_login import UserMixin from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError @@ -19,7 +19,7 @@ class User(db.Model, UserMixin): # Returned to the client on login so it can re-derive the AES-256-GCM vault key. # The server never uses this for decryption — it is opaque to us. enc_key_salt = db.Column(db.String(64), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False) last_login = db.Column(db.DateTime, nullable=True) # TOTP / MFA # totp_secret: AES-256-GCM ciphertext of the base32 TOTP secret, base64-encoded. @@ -60,4 +60,4 @@ class User(db.Model, UserMixin): return False def __repr__(self): - return f'' \ No newline at end of file + return f'' diff --git a/app/models/vault_item.py b/app/models/vault_item.py index 8abfd88..af6ceb2 100644 --- a/app/models/vault_item.py +++ b/app/models/vault_item.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone import enum from sqlalchemy.dialects.mysql import INTEGER @@ -34,8 +34,8 @@ class VaultItem(db.Model): # the plaintext 'name' column when enc_name is absent. enc_name = db.Column(db.Text, nullable=True) iv_name = db.Column(db.String(64), nullable=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False) + updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), onupdate=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False) def to_dict(self): # item_type is stored as a plain string; handle both str and enum safely diff --git a/app/routes/__init__.py b/app/routes/__init__.py index e69de29..e61b80c 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -0,0 +1,171 @@ +from flask import Flask, render_template +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager +from flask_wtf.csrf import CSRFProtect +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_cors import CORS + +from .config import config + +db = SQLAlchemy() +migrate = Migrate() +login_manager = LoginManager() +csrf = CSRFProtect() +limiter = Limiter(key_func=get_remote_address) + +# APScheduler is used for the background token-blacklist cleanup job. +# Imported here so it is available at module level; started inside create_app(). +try: + from apscheduler.schedulers.background import BackgroundScheduler + _scheduler_available = True +except ImportError: # pragma: no cover — optional dependency + _scheduler_available = False + + +def create_app(config_name: str = 'development') -> Flask: + app = Flask(__name__) + app.config.from_object(config[config_name]) + + # Extensions + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + csrf.init_app(app) + limiter.init_app(app) + + # Restrict CORS to the configured origin (locked to production domain in prod) + cors_origins = app.config.get('CORS_ORIGINS', '*') + CORS(app, resources={r'/api/*': {'origins': cors_origins}}) + + # Inject static asset version into every template for cache-busting. + # Usage in templates: {{ url_for('static', filename='css/app.css') }}?v={{ sv }} + app.jinja_env.globals['sv'] = app.config.get('STATIC_VERSION', '1') + + # Attach security headers to every response + @app.after_request + def set_security_headers(response): + # Strict-Transport-Security: enforce HTTPS for 1 year, include subdomains + response.headers['Strict-Transport-Security'] = ( + 'max-age=31536000; includeSubDomains' + ) + # Prevent clickjacking + response.headers['X-Frame-Options'] = 'DENY' + # Prevent MIME-type sniffing + response.headers['X-Content-Type-Options'] = 'nosniff' + # Control referrer information leakage + response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' + # Permissions policy — disable features the app does not use + response.headers['Permissions-Policy'] = ( + 'geolocation=(), camera=(), microphone=()' + ) + # CSP via HTTP header (authoritative — overrides the meta tag for all resources) + response.headers['Content-Security-Policy'] = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self'; " + "img-src 'self' data:; " + "font-src 'self'; " + "connect-src 'self' https://api.pwnedpasswords.com; " + "frame-ancestors 'none';" + ) + return response + + # Ensure all models are imported so SQLAlchemy knows about them + from .models.user import User + from .models.folder import Folder + from .models.vault_item import VaultItem + from .models.token_blacklist import TokenBlacklist + from .models.shared_item import SharedItem + from .models.emergency_access import EmergencyAccess + from .models.audit_log import AuditLog + + @login_manager.user_loader + def load_user(user_id): + return User.query.get(int(user_id)) + + # Blueprints + from .routes.auth import auth_bp + from .routes.vault import vault_bp + from .routes.folders import folders_bp + from .routes.sharing import sharing_bp + from .routes.emergency import emergency_bp + + app.register_blueprint(auth_bp, url_prefix='/api/auth') + app.register_blueprint(vault_bp, url_prefix='/api/vault') + app.register_blueprint(folders_bp, url_prefix='/api/folders') + app.register_blueprint(sharing_bp, url_prefix='/api/sharing') + app.register_blueprint(emergency_bp, url_prefix='/api/emergency') + + # Exempt all API blueprints from CSRF — JWT bearer tokens make CSRF irrelevant + csrf.exempt(auth_bp) + csrf.exempt(vault_bp) + csrf.exempt(folders_bp) + csrf.exempt(sharing_bp) + csrf.exempt(emergency_bp) + + # Page-serving routes + @app.route('/') + @app.route('/login') + def login_page(): + return render_template('auth/login.html') + + @app.route('/register') + def register_page(): + return render_template('auth/register.html') + + @app.route('/vault') + def vault_page(): + return render_template('vault/index.html') + + @app.route('/recover') + def recover_page(): + return render_template('auth/recover.html') + + # ── Background scheduler — token blacklist cleanup ───────────────────────── + # Runs cleanup_expired() every hour so the token_blacklist table never + # accumulates unbounded rows. Runs in a daemon thread — no request context. + if _scheduler_available: + def _cleanup_expired_tokens(): + with app.app_context(): + try: + from app.models.token_blacklist import TokenBlacklist + TokenBlacklist.cleanup_expired() + import logging + logging.getLogger(__name__).debug( + '[PassKeeper] token_blacklist cleanup completed' + ) + except Exception as exc: # pragma: no cover + import logging + logging.getLogger(__name__).warning( + '[PassKeeper] token_blacklist cleanup failed: %s', exc + ) + + scheduler = BackgroundScheduler(daemon=True) + scheduler.add_job( + _cleanup_expired_tokens, + trigger='interval', + hours=1, + id='token_blacklist_cleanup', + replace_existing=True, + ) + scheduler.start() + + # ── Production safety checks ─────────────────────────────────────────────── + # Warn loudly at startup when running in production with settings that are + # only appropriate for development. + if not app.config.get('DEBUG', False): + import logging + _log = logging.getLogger(__name__) + storage_uri = app.config.get('RATELIMIT_STORAGE_URI', 'memory://') + if storage_uri.startswith('memory://'): + _log.warning( + '[PassKeeper] WARNING: RATELIMIT_STORAGE_URI is set to "memory://" ' + 'in a production environment. Rate limits are tracked per-worker ' + 'and will not be shared across Gunicorn processes. ' + 'Set RATELIMIT_STORAGE_URI to a Redis URL (e.g. redis://localhost:6379) ' + 'in your production .env to enforce global rate limits.' + ) + + return app \ No newline at end of file diff --git a/app/routes/auth.py b/app/routes/auth.py index b97ee2b..72e7291 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -115,7 +115,7 @@ def login(): user.failed_login_count = (user.failed_login_count or 0) + 1 if user.failed_login_count >= MAX_FAILED_LOGINS: from datetime import timedelta - user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_MINUTES) + user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=LOCKOUT_MINUTES) AuditLog.log( user_id=user.id, action='auth.account_locked', @@ -139,7 +139,7 @@ def login(): # Successful authentication — reset lockout state. user.failed_login_count = 0 user.locked_until = None - user.last_login = datetime.utcnow() + user.last_login = datetime.now(timezone.utc).replace(tzinfo=None) AuditLog.log( user_id=user.id, @@ -455,17 +455,58 @@ def mfa_backup_codes_regenerate(): @require_jwt def me(): """Return basic profile info for the authenticated user.""" + import json user = db.session.get(User, g.current_user_id) + stored_codes = json.loads(user.mfa_backup_codes or '[]') return jsonify({ 'id': user.id, 'email': user.email, 'created_at': user.created_at.isoformat() if user.created_at else None, 'last_login': user.last_login.isoformat() if user.last_login else None, 'totp_enabled': user.totp_enabled, + 'backup_codes_remaining': len(stored_codes), 'recovery_configured': bool(user.recovery_enc_salt), }), 200 +@auth_bp.route('/audit-log', methods=['GET']) +@require_jwt +@limiter.limit('30 per minute') +def audit_log(): + """ + Return the authenticated user's recent audit log entries. + + Query params: + limit — max entries to return (default 50, max 200) + offset — pagination offset (default 0) + + Sensitive field values are never logged — entries contain only action + types, resource IDs, timestamps, and IP addresses. + """ + try: + limit = min(int(request.args.get('limit', 50)), 200) + offset = max(int(request.args.get('offset', 0)), 0) + except (ValueError, TypeError): + return jsonify({'error': 'limit and offset must be integers'}), 400 + + entries = ( + AuditLog.query + .filter_by(user_id=g.current_user_id) + .order_by(AuditLog.created_at.desc()) + .limit(limit) + .offset(offset) + .all() + ) + total = AuditLog.query.filter_by(user_id=g.current_user_id).count() + + return jsonify({ + 'total': total, + 'limit': limit, + 'offset': offset, + 'entries': [e.to_dict() for e in entries], + }), 200 + + # ── Account management ──────────────────────────────────────────────────────── @auth_bp.route('/change-password', methods=['POST']) @@ -864,4 +905,5 @@ def recovery_items(): {'id': item.id, 'enc_data': item.enc_data, 'iv': item.iv} for item in items ] - }), 200 \ No newline at end of file + }), 200 + diff --git a/app/routes/emergency.py b/app/routes/emergency.py index f2bb3a6..c2cd2be 100644 --- a/app/routes/emergency.py +++ b/app/routes/emergency.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from flask import Blueprint, request, jsonify, g from app import db @@ -210,7 +210,7 @@ def request_access(ea_id): return jsonify({'error': 'Not found or not in ready state'}), 404 ea.status = 'pending' - ea.request_initiated_at = datetime.utcnow() + ea.request_initiated_at = datetime.now(timezone.utc).replace(tzinfo=None) AuditLog.log( user_id=g.current_user_id, @@ -271,7 +271,7 @@ def get_emergency_vault(ea_id): return jsonify({'error': 'Not found'}), 404 if not ea.wait_elapsed: if ea.request_initiated_at: - elapsed_secs = (datetime.utcnow() - ea.request_initiated_at).total_seconds() + elapsed_secs = (datetime.now(timezone.utc).replace(tzinfo=None) - ea.request_initiated_at).total_seconds() days_left = max(0, ea.wait_days - elapsed_secs / 86400) else: days_left = ea.wait_days diff --git a/app/services/auth_service.py b/app/services/auth_service.py index a280f29..1218780 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -4,7 +4,7 @@ import hmac import os import uuid import time -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from functools import wraps import jwt @@ -72,7 +72,7 @@ def decrypt_totp_secret(ciphertext_b64: str, iv_b64: str) -> str: def generate_tokens(user_id: int) -> dict: """Return access_token and refresh_token JWTs, each with a unique jti.""" - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) secret = current_app.config['JWT_SECRET_KEY'] access_payload = { 'sub': str(user_id), @@ -96,7 +96,7 @@ def generate_tokens(user_id: int) -> dict: def generate_mfa_token(user_id: int) -> str: """Short-lived (5-min) single-use token issued after password but before TOTP.""" - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) payload = { 'sub': str(user_id), 'type': 'mfa', @@ -129,7 +129,7 @@ def blacklist_token(token: str, token_type: str) -> None: if not jti: return exp = payload.get('exp') - expires_at = datetime.utcfromtimestamp(exp) if exp else datetime.utcnow() + timedelta(days=7) + expires_at = datetime.fromtimestamp(exp) if exp else datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(days=7, tz=timezone.utc).replace(tzinfo=None) from app.models.token_blacklist import TokenBlacklist from app import db # Avoid duplicate if already blacklisted @@ -279,4 +279,4 @@ def verify_and_consume_backup_code(hashed_codes: list[str], candidate: str) -> t return False, hashed_codes remaining = [h for i, h in enumerate(hashed_codes) if i != matched_index] - return True, remaining \ No newline at end of file + return True, remaining diff --git a/app/static/js/content.js b/app/static/js/content.js new file mode 100644 index 0000000..1786b93 --- /dev/null +++ b/app/static/js/content.js @@ -0,0 +1,977 @@ +/** + * extension/content/content.js — PassKeeper content script. + * + * 1. Detects login forms → notifies background (badge count). + * 2. Injects a PassKeeper icon button OUTSIDE the DOM (position:fixed, tracked + * to the field via scroll/resize) into username AND password fields. + * This avoids breaking site layouts (flex/grid parents, React-controlled inputs). + * 3. Clicking the icon OR focusing a decorated field shows a suggestion dropdown. + * 4. "More options…" shows a second panel with vault/generator actions. + * 5. Listens for DO_AUTOFILL from the popup → fills fields. + * 6. Watches form submissions → shows save-credentials banner. + */ +(() => { + 'use strict'; + + const PK_ATTR = 'data-pk-decorated'; + const PK_BTN_CLASS = '__pk_btn__'; + const PK_DROPDOWN_ID = '__pk_dropdown__'; + const VAULT_URL = 'https://pwkeeper.ngodanguyen.tech/vault'; + + let _bannerEl = null; + let _hasNotifiedForm = false; + let _formObserver = null; + let _matchingItems = []; + // Map from field element → its fixed-position icon button element + const _fieldBtnMap = new WeakMap(); + + // ── Helpers ────────────────────────────────────────────────────────────────── + + function escHtml(str) { + return String(str ?? '').replace(/&/g, '&').replace(//g, '>'); + } + + /** + * More robust visibility check than offsetParent (which fails for + * position:fixed elements and some modern layouts). + */ + function isVisible(el) { + if (!el || !el.getBoundingClientRect) return false; + if (el.disabled) return false; + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; + return true; + } + + /** + * Returns a debounced version of `fn` that waits `ms` milliseconds after + * the last call before firing. Used to avoid re-rendering the dropdown on + * every keystroke. + */ + function _debounce(fn, ms) { + var timer; + return function () { + var args = arguments; + var ctx = this; + clearTimeout(timer); + timer = setTimeout(function () { fn.apply(ctx, args); }, ms); + }; + } + + function visiblePasswordFields() { + return Array.from(document.querySelectorAll('input[type="password"]')) + .filter(el => isVisible(el) && !el.disabled); + } + + /** + * Returns true only if the input field carries signals suggesting it + * collects a credential (username / email / phone) — not a generic + * text field such as a search box, full-name field, or address field. + * + * Scoring precedence: + * 1. autocomplete="username"|"email"|"tel" → definite YES + * 2. Non-credential autocomplete value → definite NO + * 3. name / id / placeholder / aria-label contain a credential keyword → YES + * 4. Otherwise → NO (do not decorate) + */ + function _isLikelyUsernameField(el) { + const CRED_HINTS = /user|email|mail|login|phone|tel|mobile|account/i; + const ac = (el.getAttribute('autocomplete') || '').toLowerCase().trim(); + + // Strongest positive signal. + if (['username', 'email', 'tel'].includes(ac)) return true; + + // Definite negative signals (Chrome's autocomplete token set). + const NON_CRED_AC = /^(name|given-name|family-name|additional-name|honorific-prefix|honorific-suffix|organization|street-address|address-line[123]|address-level[1234]|country|country-name|postal-code|cc-|transaction-|language|bday|sex|url|photo|search|new-password|current-password|one-time-code|off)$/i; + if (ac && NON_CRED_AC.test(ac)) return false; + + // Check name, id, placeholder, and aria-label for credential keywords. + const attrs = [ + el.getAttribute('name') || '', + el.getAttribute('id') || '', + el.getAttribute('placeholder') || '', + el.getAttribute('aria-label') || '', + ].join(' '); + + return CRED_HINTS.test(attrs); + } + + function findUsernameField(pwField) { + // Helper: accept email/tel inputs unconditionally; text inputs only when + // they look like a genuine credential field. + function isCredentialType(el) { + if (el.type === 'email' || el.type === 'tel') return true; + if (el.type === 'text') return _isLikelyUsernameField(el); + return false; + } + + // 1. Walk backwards through all inputs in DOM order. + const all = Array.from(document.querySelectorAll('input')); + const idx = all.indexOf(pwField); + for (let i = idx - 1; i >= 0; i--) { + const el = all[i]; + if (!isVisible(el) || el.disabled) continue; + if (isCredentialType(el)) return el; + } + + // 2. Fallback: search within the same form / ancestor container. + // Prefer email inputs first, then scored text/tel inputs. + const container = pwField.closest('form') || pwField.closest('[role="form"]') || pwField.parentElement; + if (container) { + const emailCandidate = container.querySelector('input[type="email"]:not([disabled])'); + if (emailCandidate && isVisible(emailCandidate)) return emailCandidate; + + const textTelInputs = Array.from( + container.querySelectorAll('input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])') + ); + const scored = textTelInputs.filter(el => isVisible(el) && _isLikelyUsernameField(el)); + if (scored.length) return scored[0]; + } + + return null; + } + + // ── Framework-compatible fill ───────────────────────────────────────────────── + + function fillField(el, value) { + const nativeSet = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + if (nativeSet) nativeSet.call(el, value); + else el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } + + function doAutofill(username, password) { + const pwFields = visiblePasswordFields(); + if (!pwFields.length) return; + const pwField = pwFields[0]; + const usernameField = findUsernameField(pwField); + if (usernameField && username) fillField(usernameField, username); + if (password) fillField(pwField, password); + [usernameField, pwField].filter(Boolean).forEach(el => { + el.style.outline = '2px solid #c0392b'; + setTimeout(() => { el.style.outline = ''; }, 1500); + }); + } + + // ── Icon button (fixed-position, outside the DOM tree of the field) ─────────── + + /** + * Position the icon button over the right edge of `field` using fixed coords. + * This never touches the field's parent, so it can't break any layout. + */ + function positionBtn(btn, field) { + const rect = field.getBoundingClientRect(); + if (rect.width === 0) { btn.style.display = 'none'; return; } + btn.style.display = 'flex'; + btn.style.top = (rect.top + rect.height / 2 - 13) + 'px'; + btn.style.left = (rect.right - 30) + 'px'; + } + + // createIconBtn is defined in the Field decoration section below. + + // ── Suggestion dropdown ─────────────────────────────────────────────────────── + + function removeDropdown() { + const el = document.getElementById(PK_DROPDOWN_ID); + if (el) el.remove(); + } + + /** + * Build the dropdown anchored below `anchorField`. + * Uses _matchingItems which is kept fresh via storage.onChanged listener. + * `panel` is either 'credentials' (main list) or 'more' (options menu). + */ + async function showDropdown(anchorField, pwField, filterText, panel) { + removeDropdown(); + + // Use module-level _matchingItems (kept fresh by storage.onChanged). + // If still empty, try a direct storage read as last resort. + var freshItems = _matchingItems; + if (!freshItems.length) { + try { + var result = await chrome.storage.session.get('vault_items_cs'); + var all = (result && result.vault_items_cs) || []; + freshItems = _filterForHost(all); + if (freshItems.length) _matchingItems = freshItems; + } catch (e) { } + } + + const rect = anchorField.getBoundingClientRect(); + const dropWidth = Math.max(260, rect.width); + + const dropdown = document.createElement('div'); + dropdown.id = PK_DROPDOWN_ID; + Object.assign(dropdown.style, { + position: 'fixed', + top: (rect.bottom + 4) + 'px', + left: rect.left + 'px', + width: dropWidth + 'px', + background: '#fff', + border: '1px solid #dadce0', + borderRadius: '10px', + boxShadow: '0 6px 24px rgba(0,0,0,0.18)', + zIndex: '2147483647', + fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", + fontSize: '13px', + overflow: 'hidden', + }); + + if (panel === 'more') { + buildMorePanel(dropdown, anchorField, pwField, freshItems, filterText); + } else { + buildCredentialsPanel(dropdown, anchorField, pwField, freshItems, filterText); + } + + document.body.appendChild(dropdown); + + // Reposition on scroll/resize so it stays under the field. + function reposition() { + const r = anchorField.getBoundingClientRect(); + dropdown.style.top = (r.bottom + 4) + 'px'; + dropdown.style.left = r.left + 'px'; + } + window.addEventListener('scroll', reposition, { passive: true, capture: true }); + window.addEventListener('resize', reposition, { passive: true }); + + // Close on outside mousedown or keyboard navigation. + function onOutside(e) { + const btn = _fieldBtnMap.get(anchorField); + if (dropdown.contains(e.target) || e.target === anchorField || (btn && btn.contains(e.target))) return; + removeDropdown(); + document.removeEventListener('mousedown', onOutside, true); + document.removeEventListener('keydown', onKeydown, true); + } + + // Keyboard navigation: Arrow keys move focus between rows; Enter selects; Escape closes. + function onKeydown(e) { + if (e.key === 'Escape') { + removeDropdown(); + document.removeEventListener('mousedown', onOutside, true); + document.removeEventListener('keydown', onKeydown, true); + return; + } + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Enter') return; + + // Only navigate credential rows (divs with data-pk-row attribute). + var rows = Array.from(dropdown.querySelectorAll('[data-pk-row]')); + if (!rows.length) return; + + e.preventDefault(); // prevent the field from scrolling the page + + if (e.key === 'Enter') { + var focused = dropdown.querySelector('[data-pk-row].pk-row-focused'); + if (focused && focused._pkFill) focused._pkFill(); + return; + } + + var currentIdx = rows.findIndex(function (r) { return r.classList.contains('pk-row-focused'); }); + var nextIdx; + if (e.key === 'ArrowDown') { + nextIdx = currentIdx < rows.length - 1 ? currentIdx + 1 : 0; + } else { + nextIdx = currentIdx > 0 ? currentIdx - 1 : rows.length - 1; + } + + rows.forEach(function (r) { + r.classList.remove('pk-row-focused'); + r.style.background = ''; + }); + rows[nextIdx].classList.add('pk-row-focused'); + rows[nextIdx].style.background = '#e8f0fe'; + rows[nextIdx].scrollIntoView({ block: 'nearest' }); + } + + setTimeout(function () { + document.addEventListener('mousedown', onOutside, true); + document.addEventListener('keydown', onKeydown, true); + }, 0); + } + + // ── Credentials panel (main list) ──────────────────────────────────────────── + + function buildCredentialsPanel(dropdown, anchorField, pwField, items, filterText) { + const q = (filterText || '').trim().toLowerCase(); + const filtered = q + ? items.filter(function (item) { + return ((item.plain && item.plain.username) || '').toLowerCase().includes(q) || + item.name.toLowerCase().includes(q); + }) + : items; + + const usernameField = anchorField.type === 'password' ? findUsernameField(anchorField) : anchorField; + + if (filtered.length === 0) { + // No saved passwords — show a minimal "no items" row + More options. + const empty = document.createElement('div'); + Object.assign(empty.style, { + padding: '12px 14px', + color: '#5f6368', + fontSize: '12px', + }); + empty.textContent = q ? 'No matches found.' : 'No saved passwords for this site.'; + dropdown.appendChild(empty); + } else { + filtered.forEach(function (item) { + const row = document.createElement('div'); + row.setAttribute('data-pk-row', '1'); // enables keyboard navigation + Object.assign(row.style, { + display: 'flex', + alignItems: 'center', + gap: '10px', + padding: '10px 14px', + cursor: 'pointer', + transition: 'background 0.1s', + }); + row.onmouseenter = function () { + if (!row.classList.contains('pk-row-focused')) row.style.background = '#f1f3f4'; + }; + row.onmouseleave = function () { + if (!row.classList.contains('pk-row-focused')) row.style.background = ''; + }; + + // Derive display hostname. + var siteHost = item.name; + if (item.plain && item.plain.url) { + try { siteHost = new URL(item.plain.url).hostname.replace(/^www\./, ''); } catch (e) { } + } + + var username = escHtml((item.plain && item.plain.username) || ''); + var site = escHtml(siteHost); + + // Lock icon avatar — filled dark circle like the screenshot. + var avatar = document.createElement('div'); + Object.assign(avatar.style, { + width: '34px', + height: '34px', + borderRadius: '50%', + background: '#1a1a2e', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: '0', + }); + avatar.innerHTML = + '' + + '' + + '' + + ''; + + // Text. + var text = document.createElement('div'); + text.style.cssText = 'flex:1;min-width:0;'; + text.innerHTML = + '
' + site + '
' + + (username ? '
' + username + '
' : ''); + + // Edit pencil. + var editBtn = document.createElement('button'); + Object.assign(editBtn.style, { + background: 'none', + border: 'none', + cursor: 'pointer', + padding: '5px', + color: '#1a73e8', + display: 'flex', + alignItems: 'center', + flexShrink: '0', + borderRadius: '4px', + }); + editBtn.title = 'Edit in PassKeeper'; + editBtn.innerHTML = + '' + + '' + + '' + + ''; + editBtn.addEventListener('mousedown', function (e) { + e.preventDefault(); + e.stopPropagation(); + chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); + removeDropdown(); + }); + + row.appendChild(avatar); + row.appendChild(text); + row.appendChild(editBtn); + + // Shared fill action — used by both mousedown and keyboard Enter. + function doFill() { + if (usernameField && item.plain && item.plain.username) fillField(usernameField, item.plain.username); + if (pwField && item.plain && item.plain.password) fillField(pwField, item.plain.password); + + // ✓ Filled flash: replace row content briefly before closing. + row.innerHTML = + '
' + + '' + + '' + + 'Filled
'; + row.style.background = '#f0fdf4'; + + setTimeout(function () { + removeDropdown(); + if (pwField && anchorField !== pwField) pwField.focus(); + }, 600); + } + + row.addEventListener('mousedown', function (e) { + if (e.target === editBtn || editBtn.contains(e.target)) return; + e.preventDefault(); + doFill(); + }); + + // Expose doFill for the keyboard Enter handler via a custom property. + row._pkFill = doFill; + + dropdown.appendChild(row); + }); + } + + // Divider + "More options…" footer — always shown. + var divider = document.createElement('div'); + divider.style.cssText = 'height:1px;background:#e8eaed;'; + dropdown.appendChild(divider); + + var more = document.createElement('div'); + Object.assign(more.style, { + display: 'flex', + alignItems: 'center', + gap: '10px', + padding: '10px 14px', + cursor: 'pointer', + color: '#202124', + fontSize: '13px', + transition: 'background 0.1s', + }); + more.onmouseenter = function () { more.style.background = '#f1f3f4'; }; + more.onmouseleave = function () { more.style.background = ''; }; + more.innerHTML = + '' + + '' + + '' + + '' + + '' + + 'More options\u2026'; + more.addEventListener('mousedown', function (e) { + e.preventDefault(); + showDropdown(anchorField, pwField, filterText, 'more'); + }); + dropdown.appendChild(more); + } + + // ── More options panel ──────────────────────────────────────────────────────── + + function buildMorePanel(dropdown, anchorField, pwField, items, filterText) { + // Back header. + var backRow = document.createElement('div'); + Object.assign(backRow.style, { + display: 'flex', + alignItems: 'center', + gap: '6px', + padding: '10px 14px', + cursor: 'pointer', + color: '#1a73e8', + fontSize: '13px', + fontWeight: '600', + borderBottom: '1px solid #e8eaed', + transition: 'background 0.1s', + }); + backRow.onmouseenter = function () { backRow.style.background = '#f1f3f4'; }; + backRow.onmouseleave = function () { backRow.style.background = ''; }; + backRow.innerHTML = + '' + + '' + + ' Back'; + backRow.addEventListener('mousedown', function (e) { + e.preventDefault(); + showDropdown(anchorField, pwField, filterText, 'credentials'); + }); + dropdown.appendChild(backRow); + + // Menu items matching the screenshot. + var menuItems = [ + { + icon: '', + label: 'Report a problem', + action: function () { chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); }, + }, + { + icon: '', + label: 'Generate a password', + chevron: true, + action: function () { chrome.runtime.sendMessage({ type: 'OPEN_GENERATOR' }).catch(function () { }); removeDropdown(); }, + }, + { + icon: '', + label: 'Open my vault', + action: function () { chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); }, + }, + ]; + + menuItems.forEach(function (item) { + var row = document.createElement('div'); + Object.assign(row.style, { + display: 'flex', + alignItems: 'center', + gap: '12px', + padding: '11px 14px', + cursor: 'pointer', + color: '#202124', + fontSize: '13px', + transition: 'background 0.1s', + borderBottom: '1px solid #f3f4f6', + }); + row.onmouseenter = function () { row.style.background = '#f1f3f4'; }; + row.onmouseleave = function () { row.style.background = ''; }; + + var iconWrap = document.createElement('div'); + iconWrap.style.cssText = 'width:18px;height:18px;display:flex;align-items:center;justify-content:center;flex-shrink:0;'; + iconWrap.innerHTML = '' + item.icon + ''; + + var label = document.createElement('span'); + label.style.cssText = 'flex:1;'; + label.textContent = item.label; + + row.appendChild(iconWrap); + row.appendChild(label); + + if (item.chevron) { + var chev = document.createElement('div'); + chev.innerHTML = + '' + + '' + + ''; + row.appendChild(chev); + } else { + var extIcon = document.createElement('div'); + extIcon.innerHTML = + '' + + '' + + '' + + ''; + row.appendChild(extIcon); + } + + row.addEventListener('mousedown', function (e) { + e.preventDefault(); + item.action(); + }); + + dropdown.appendChild(row); + }); + } + + // ── Field decoration ────────────────────────────────────────────────────────── + + // Map from field element → AbortController so we can cancel its listeners on re-decoration. + const _fieldAbortMap = new WeakMap(); + + function decorateField(field, pwField) { + if (field.getAttribute(PK_ATTR)) return; + field.setAttribute(PK_ATTR, '1'); + + // Cancel any previous listeners on this field. + const prevAC = _fieldAbortMap.get(field); + if (prevAC) prevAC.abort(); + const ac = new AbortController(); + _fieldAbortMap.set(field, ac); + const sig = ac.signal; + + createIconBtn(field, pwField, sig); + } + + function createIconBtn(field, pwField, abortSignal) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = PK_BTN_CLASS; + btn.title = 'PassKeeper autofill'; + btn.setAttribute('aria-label', 'Autofill with PassKeeper'); + btn.style.cssText = [ + 'position:fixed', + 'width:26px', + 'height:26px', + 'background:#c0392b', + 'border:none', + 'border-radius:5px', + 'cursor:pointer', + 'display:flex', + 'align-items:center', + 'justify-content:center', + 'z-index:2147483646', + 'padding:0', + 'box-shadow:0 1px 4px rgba(0,0,0,0.3)', + 'transition:background 0.15s', + ].join(';'); + + btn.innerHTML = + '' + + '' + + '' + + '' + + ''; + + btn.addEventListener('mouseenter', function () { btn.style.background = '#a93226'; }); + btn.addEventListener('mouseleave', function () { btn.style.background = '#c0392b'; }); + + positionBtn(btn, field); + document.body.appendChild(btn); + _fieldBtnMap.set(field, btn); + + // Remove the button when the AbortController fires (re-decoration). + abortSignal.addEventListener('abort', function () { + btn.remove(); + _fieldBtnMap.delete(field); + }); + + // Keep button tracked as page scrolls/resizes. + function reposition() { if (document.body.contains(btn)) positionBtn(btn, field); } + window.addEventListener('scroll', reposition, { passive: true, signal: abortSignal }); + window.addEventListener('resize', reposition, { passive: true, signal: abortSignal }); + + // ── All event handlers read _matchingItems at call time, never from closure ── + + // Show dropdown on focus — reads vault_items fresh from storage each time. + field.addEventListener('focus', function () { + showDropdown(field, pwField, field.value, 'credentials'); + }, { signal: abortSignal }); + + // Re-filter as user types — debounced to avoid rebuilding the dropdown + // on every single keystroke (noticeable on large vaults or slow machines). + var _debouncedShow = _debounce(function () { + showDropdown(field, pwField, field.value, 'credentials'); + }, 150); + field.addEventListener('input', _debouncedShow, { signal: abortSignal }); + + // Dim button when field loses focus and no dropdown is open. + field.addEventListener('blur', function () { + setTimeout(function () { + if (!document.getElementById(PK_DROPDOWN_ID)) btn.style.opacity = '0.4'; + }, 150); + }, { signal: abortSignal }); + + field.addEventListener('focus', function () { + btn.style.opacity = '1'; + }, { signal: abortSignal }); + + // Icon click: toggle dropdown. + btn.addEventListener('mousedown', function (e) { + e.preventDefault(); + e.stopPropagation(); + if (document.getElementById(PK_DROPDOWN_ID)) { removeDropdown(); } + else { showDropdown(field, pwField, field.value, 'credentials'); } + }); + + return btn; + } + + /** + * Decorate all visible password (and paired username) fields with the PassKeeper + * icon button. + * + * @param {Array|null} knownItems When the caller already holds the correct + * filtered item list (e.g. from a storage.onChanged newValue or a VAULT_UPDATED + * message payload), pass it here to skip the redundant storage read. + * Pass null/undefined to let this function read storage itself. + */ + async function decorateFields(knownItems) { + if (knownItems != null) { + // Caller supplied items — trust them and skip the storage round-trip. + _matchingItems = knownItems; + console.log('[PassKeeper] decorateFields (inline): host=' + location.hostname.replace(/^www\./, '') + + ', matched=' + _matchingItems.length); + } else { + // Read from chrome.storage.session — memory-only, cleared on browser close. + // Decrypted vault data must never be written to persistent (local) storage. + var all = []; + try { + var result = await chrome.storage.session.get('vault_items_cs'); + all = (result && result.vault_items_cs) || []; + } catch (e) { } + _matchingItems = _filterForHost(all); + console.log('[PassKeeper] decorateFields (storage): host=' + location.hostname.replace(/^www\./, '') + + ', matched=' + _matchingItems.length + ' of ' + all.length + ' items'); + } + + // Decorate every visible password field and its paired username field. + visiblePasswordFields().forEach(function (pwField) { + var usernameField = findUsernameField(pwField); + if (usernameField) decorateField(usernameField, pwField); + decorateField(pwField, pwField); + }); + } + + // ── Vault item helpers ──────────────────────────────────────────────────────── + + /** + * Normalise a stored URL string so it is always parseable by `new URL()`. + * Handles bare domains ("github.com"), protocol-relative ("//github.com"), + * and fully-formed URLs ("https://github.com") identically. + */ + function _normaliseUrl(raw) { + if (!raw) return null; + var s = raw.trim(); + if (/^https?:\/\//i.test(s)) return s; // already has a scheme + if (s.startsWith('//')) return 'https:' + s; // protocol-relative + return 'https://' + s; // bare domain or path + } + + function _filterForHost(items) { + var host = location.hostname.replace(/^www\./, ''); + return (items || []).filter(function (item) { + if (item.item_type !== 'password' || !(item.plain && item.plain.url)) return false; + try { + var normalised = _normaliseUrl(item.plain.url); + if (!normalised) return false; + var h = new URL(normalised).hostname.replace(/^www\./, ''); + // Match exact domain or any subdomain relationship. + return h === host || h.endsWith('.' + host) || host.endsWith('.' + h); + } catch (e) { + console.warn('[PassKeeper] _filterForHost: could not parse URL:', item.plain.url, e.message); + return false; + } + }); + } + + // ── Form detection ──────────────────────────────────────────────────────────── + + function notifyFormDetected() { + if (_hasNotifiedForm) return; + if (!visiblePasswordFields().length) return; + _hasNotifiedForm = true; + chrome.runtime.sendMessage({ type: 'FORMS_DETECTED' }).catch(function () { }); + } + + // ── Duplicate detection ─────────────────────────────────────────────────────── + + async function classifyCredentials(username, password) { + var all = []; + try { + var result = await chrome.storage.session.get('vault_items_cs'); + all = (result && result.vault_items_cs) || []; + } catch (e) { return 'new'; } + if (!all.length) return 'new'; + + var siteItems = _filterForHost(all); + if (!siteItems.length) return 'new'; + var exactMatch = siteItems.some(function (item) { + return item.plain && item.plain.username === username && item.plain.password === password; + }); + return exactMatch ? 'same' : 'updated'; + } + + // ── Save blocklist ──────────────────────────────────────────────────────────── + + const BLOCKLIST_KEY = 'save_blocklist'; + + async function isBlocked(hostname) { + try { + var result = await chrome.storage.local.get(BLOCKLIST_KEY); + var list = (result && result[BLOCKLIST_KEY]) || []; + return list.indexOf(hostname) !== -1; + } catch (e) { return false; } + } + + async function addToBlocklist(hostname) { + try { + var result = await chrome.storage.local.get(BLOCKLIST_KEY); + var list = (result && result[BLOCKLIST_KEY]) || []; + if (list.indexOf(hostname) === -1) { + list.push(hostname); + await chrome.storage.local.set({ [BLOCKLIST_KEY]: list }); + console.log('[PassKeeper] Added to save blocklist:', hostname); + } + } catch (e) { } + } + + // ── Auto-save banner ────────────────────────────────────────────────────────── + + function showSaveBanner(username, password, credentialState) { + if (_bannerEl) _bannerEl.remove(); + + var banner = document.createElement('div'); + banner.id = '__pk_save_banner__'; + Object.assign(banner.style, { + position: 'fixed', + top: '12px', + right: '12px', + zIndex: '2147483647', + background: '#ffffff', + border: '1px solid #e2e8f0', + borderRadius: '10px', + boxShadow: '0 8px 30px rgba(0,0,0,0.15)', + padding: '14px 16px 12px', + fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", + fontSize: '13px', + color: '#1a1a2e', + maxWidth: '300px', + minWidth: '240px', + }); + + var site = escHtml(location.hostname); + var user = escHtml(username); + // Default site name: prefer page title (trimmed), fall back to hostname. + var defaultSiteName = (document.title || '').trim().slice(0, 60) || location.hostname; + var title = credentialState === 'updated' ? 'Update in PassKeeper?' : 'Save to PassKeeper?'; + + banner.innerHTML = + '
' + + '' + + '' + escHtml(title) + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '

' + + '' + user + ' on ' + site + '' + + '

' + + '
' + + '' + + '' + + '
' + + ''; + + document.body.appendChild(banner); + _bannerEl = banner; + + var dismiss = function () { if (_bannerEl === banner) { banner.remove(); _bannerEl = null; } }; + banner.querySelector('#__pk_close__').addEventListener('click', dismiss); + banner.querySelector('#__pk_skip__').addEventListener('click', dismiss); + banner.querySelector('#__pk_save__').addEventListener('click', function () { + var siteName = (banner.querySelector('#__pk_site_name__').value || '').trim() || location.hostname; + console.log('[PassKeeper] User chose to save credentials for', location.hostname, '— site name:', siteName); + chrome.runtime.sendMessage({ + type: 'SAVE_CREDENTIALS', + data: { url: location.href, siteName: siteName, username: username, password: password }, + }).catch(function () { }); + dismiss(); + }); + banner.querySelector('#__pk_never__').addEventListener('click', function () { + addToBlocklist(location.hostname); + dismiss(); + }); + } + + // ── Form submission watch ───────────────────────────────────────────────────── + + function watchSubmissions() { + document.addEventListener('submit', async function (e) { + var form = e.target; + var pwField = form.querySelector('input[type="password"]:not([disabled])'); + if (!pwField || !pwField.value) return; + + var userField = findUsernameField(pwField) + || form.querySelector('input[type="email"]:not([disabled])') + || form.querySelector('input[type="text"]:not([disabled])'); + + var username = (userField && userField.value && userField.value.trim()) || ''; + var password = pwField.value; + if (!username || !password) return; + + removeDropdown(); + + // Check blocklist before doing anything else. + if (await isBlocked(location.hostname)) { + console.log('[PassKeeper] Site is blocklisted, skipping save banner:', location.hostname); + return; + } + + var credentialState = await classifyCredentials(username, password); + console.log('[PassKeeper] Credential state for', location.hostname, '\u2192', credentialState); + if (credentialState === 'same') return; + + setTimeout(function () { showSaveBanner(username, password, credentialState); }, 500); + }, true); + } + + // ── Message listener ────────────────────────────────────────────────────────── + + chrome.runtime.onMessage.addListener(function (msg, _sender, sendResponse) { + if (msg.type === 'DO_AUTOFILL') { + doAutofill(msg.username, msg.password); + sendResponse({ ok: true }); + } + if (msg.type === 'VAULT_UPDATED') { + // Items may arrive in the message payload (best-effort), but the source + // of truth is now chrome.storage.session which was already written by the popup. + var allItems = msg.vault_items || []; + var matched = allItems.length ? _filterForHost(allItems) : null; + if (matched !== null) { + _matchingItems = matched; + console.log('[PassKeeper] VAULT_UPDATED (message): matched=' + _matchingItems.length + ' of ' + allItems.length); + } + // Re-decorate. Pass matched items so decorateFields skips the storage read + // when the message payload was non-empty; fall back to storage otherwise. + document.querySelectorAll('[' + PK_ATTR + ']').forEach(function (el) { + var ac = _fieldAbortMap.get(el); + if (ac) ac.abort(); + el.removeAttribute(PK_ATTR); + }); + document.querySelectorAll('.' + PK_BTN_CLASS).forEach(function (el) { el.remove(); }); + removeDropdown(); + decorateFields(matched); + } + return false; + }); + + // ── Init ────────────────────────────────────────────────────────────────────── + + function init() { + notifyFormDetected(); + decorateFields(); + watchSubmissions(); + + // React instantly when the popup writes fresh vault data to local storage. + // This fires in the same tick as the write — no message delivery required. + chrome.storage.onChanged.addListener(function (changes, area) { + if (area === 'session' && changes.vault_items_cs) { + var allItems = (changes.vault_items_cs.newValue) || []; + var matched = _filterForHost(allItems); + _matchingItems = matched; + console.log('[PassKeeper] storage.onChanged: matched=' + matched.length + ' of ' + allItems.length + ' items'); + // Re-decorate, passing the already-filtered list to avoid a redundant storage read. + document.querySelectorAll('[' + PK_ATTR + ']').forEach(function (el) { + var ac = _fieldAbortMap.get(el); + if (ac) ac.abort(); + el.removeAttribute(PK_ATTR); + }); + document.querySelectorAll('.' + PK_BTN_CLASS).forEach(function (el) { el.remove(); }); + removeDropdown(); + decorateFields(matched); + } + }); + + _formObserver = new MutationObserver(function (mutations) { + // Ignore mutations caused by the extension's own injected elements + // (dropdown, icon buttons, save banner) to prevent re-decoration loops + // on SPAs that react to every DOM change. + var ownMutation = mutations.every(function (m) { + return Array.from(m.addedNodes).concat(Array.from(m.removedNodes)).every(function (node) { + if (!node || node.nodeType !== 1) return true; + var cls = (node.className || ''); + var id = (node.id || ''); + return cls.indexOf('__pk') !== -1 || + id.indexOf('__pk') !== -1 || + node.querySelector && ( + node.querySelector('.' + PK_BTN_CLASS) || + node.querySelector('#' + PK_DROPDOWN_ID) + ); + }); + }); + if (ownMutation) return; + _hasNotifiedForm = false; + notifyFormDetected(); + decorateFields(); + }); + _formObserver.observe(document.body, { childList: true, subtree: true }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); \ No newline at end of file diff --git a/app/static/js/vault.js b/app/static/js/vault.js index c003680..e162464 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -919,6 +919,44 @@ const Vault = (() => { document.getElementById('btn-export-csv')?.addEventListener('click', async () => { const vaultKey = VaultSession.getKey(); if (!vaultKey) { showUnlockOverlay(); return; } + + // Warn the user that this export contains plaintext passwords before proceeding. + const confirmed = await new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.style.cssText = [ + 'position:fixed', 'inset:0', 'background:rgba(0,0,0,0.55)', + 'z-index:9999', 'display:flex', 'align-items:center', 'justify-content:center', + ].join(';'); + overlay.innerHTML = ` +
+
+ + + + + + Export plaintext passwords? +
+

+ The CSV file will contain all your passwords in plaintext. + Anyone with access to the file can read them. +

+

+ Store the file in a secure location and delete it when you no longer need it. +

+
+ + +
+
`; + document.body.appendChild(overlay); + overlay.querySelector('#_csv_cancel').addEventListener('click', () => { overlay.remove(); resolve(false); }); + overlay.querySelector('#_csv_confirm').addEventListener('click', () => { overlay.remove(); resolve(true); }); + overlay.addEventListener('click', (e) => { if (e.target === overlay) { overlay.remove(); resolve(false); } }); + }); + + if (!confirmed) return; + try { const res = await apiFetch('/api/vault'); if (!res) return; diff --git a/requirements.txt b/requirements.txt index 8c0ec65..2c5b327 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ pyotp>=2.9.0 qrcode[pil]>=7.4.2 cryptography>=42.0 # AES-256-GCM server-side TOTP secret encryption redis>=5.0 # Shared rate-limit storage across Gunicorn workers +APScheduler