05/02/2026 updated code for security 2

This commit is contained in:
2026-05-02 18:44:39 -04:00
parent c7b1806ec8
commit 78feedc5c7
15 changed files with 1347 additions and 182 deletions
-155
View File
@@ -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
+91
View File
@@ -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/"
+2 -2
View File
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timezone
from sqlalchemy.dialects.mysql import INTEGER 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 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) 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 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 @classmethod
def log(cls, user_id: int, action: str, resource_type: str, def log(cls, user_id: int, action: str, resource_type: str,
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta from datetime import datetime, timezone, timedelta
from sqlalchemy.dialects.mysql import INTEGER from sqlalchemy.dialects.mysql import INTEGER
@@ -39,14 +39,14 @@ class EmergencyAccess(db.Model):
request_initiated_at = db.Column(db.DateTime, nullable=True) request_initiated_at = db.Column(db.DateTime, nullable=True)
# JSON string: [{ id, name, item_type, enc_data, iv }, ...] # JSON string: [{ id, name, item_type, enc_data, iv }, ...]
enc_vault = db.Column(db.Text, nullable=True) 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 @property
def wait_elapsed(self): def wait_elapsed(self):
"""True if the wait period has passed since the access request.""" """True if the wait period has passed since the access request."""
if self.status != 'pending' or not self.request_initiated_at: if self.status != 'pending' or not self.request_initiated_at:
return False 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): def to_dict(self, grantor_email=None):
return { return {
+2 -2
View File
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timezone
from sqlalchemy.dialects.mysql import INTEGER from sqlalchemy.dialects.mysql import INTEGER
@@ -40,7 +40,7 @@ class SharedItem(db.Model):
iv = db.Column(db.String(64), nullable=False) iv = db.Column(db.String(64), nullable=False)
accepted = db.Column(db.Boolean, default=False, 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): def to_dict(self):
return { return {
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timezone
from sqlalchemy.dialects.mysql import INTEGER from sqlalchemy.dialects.mysql import INTEGER
@@ -20,10 +20,10 @@ class TokenBlacklist(db.Model):
if not entry: if not entry:
return False return False
# Automatically ignore expired entries (they can be cleaned up later) # 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 @classmethod
def cleanup_expired(cls): def cleanup_expired(cls):
"""Delete entries that have already expired — call occasionally to keep table small.""" """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() db.session.commit()
+2 -2
View File
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timezone
from flask_login import UserMixin from flask_login import UserMixin
from argon2 import PasswordHasher from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError 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. # 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. # The server never uses this for decryption — it is opaque to us.
enc_key_salt = db.Column(db.String(64), nullable=False) 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) last_login = db.Column(db.DateTime, nullable=True)
# TOTP / MFA # TOTP / MFA
# totp_secret: AES-256-GCM ciphertext of the base32 TOTP secret, base64-encoded. # totp_secret: AES-256-GCM ciphertext of the base32 TOTP secret, base64-encoded.
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timezone
import enum import enum
from sqlalchemy.dialects.mysql import INTEGER from sqlalchemy.dialects.mysql import INTEGER
@@ -34,8 +34,8 @@ class VaultItem(db.Model):
# the plaintext 'name' column when enc_name is absent. # the plaintext 'name' column when enc_name is absent.
enc_name = db.Column(db.Text, nullable=True) enc_name = db.Column(db.Text, nullable=True)
iv_name = db.Column(db.String(64), nullable=True) iv_name = db.Column(db.String(64), 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)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, 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): def to_dict(self):
# item_type is stored as a plain string; handle both str and enum safely # item_type is stored as a plain string; handle both str and enum safely
+171
View File
@@ -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
+44 -2
View File
@@ -115,7 +115,7 @@ def login():
user.failed_login_count = (user.failed_login_count or 0) + 1 user.failed_login_count = (user.failed_login_count or 0) + 1
if user.failed_login_count >= MAX_FAILED_LOGINS: if user.failed_login_count >= MAX_FAILED_LOGINS:
from datetime import timedelta 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( AuditLog.log(
user_id=user.id, user_id=user.id,
action='auth.account_locked', action='auth.account_locked',
@@ -139,7 +139,7 @@ def login():
# Successful authentication — reset lockout state. # Successful authentication — reset lockout state.
user.failed_login_count = 0 user.failed_login_count = 0
user.locked_until = None user.locked_until = None
user.last_login = datetime.utcnow() user.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
AuditLog.log( AuditLog.log(
user_id=user.id, user_id=user.id,
@@ -455,17 +455,58 @@ def mfa_backup_codes_regenerate():
@require_jwt @require_jwt
def me(): def me():
"""Return basic profile info for the authenticated user.""" """Return basic profile info for the authenticated user."""
import json
user = db.session.get(User, g.current_user_id) user = db.session.get(User, g.current_user_id)
stored_codes = json.loads(user.mfa_backup_codes or '[]')
return jsonify({ return jsonify({
'id': user.id, 'id': user.id,
'email': user.email, 'email': user.email,
'created_at': user.created_at.isoformat() if user.created_at else None, 'created_at': user.created_at.isoformat() if user.created_at else None,
'last_login': user.last_login.isoformat() if user.last_login else None, 'last_login': user.last_login.isoformat() if user.last_login else None,
'totp_enabled': user.totp_enabled, 'totp_enabled': user.totp_enabled,
'backup_codes_remaining': len(stored_codes),
'recovery_configured': bool(user.recovery_enc_salt), 'recovery_configured': bool(user.recovery_enc_salt),
}), 200 }), 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 ──────────────────────────────────────────────────────── # ── Account management ────────────────────────────────────────────────────────
@auth_bp.route('/change-password', methods=['POST']) @auth_bp.route('/change-password', methods=['POST'])
@@ -865,3 +906,4 @@ def recovery_items():
for item in items for item in items
] ]
}), 200 }), 200
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timezone
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from app import db from app import db
@@ -210,7 +210,7 @@ def request_access(ea_id):
return jsonify({'error': 'Not found or not in ready state'}), 404 return jsonify({'error': 'Not found or not in ready state'}), 404
ea.status = 'pending' ea.status = 'pending'
ea.request_initiated_at = datetime.utcnow() ea.request_initiated_at = datetime.now(timezone.utc).replace(tzinfo=None)
AuditLog.log( AuditLog.log(
user_id=g.current_user_id, user_id=g.current_user_id,
@@ -271,7 +271,7 @@ def get_emergency_vault(ea_id):
return jsonify({'error': 'Not found'}), 404 return jsonify({'error': 'Not found'}), 404
if not ea.wait_elapsed: if not ea.wait_elapsed:
if ea.request_initiated_at: 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) days_left = max(0, ea.wait_days - elapsed_secs / 86400)
else: else:
days_left = ea.wait_days days_left = ea.wait_days
+4 -4
View File
@@ -4,7 +4,7 @@ import hmac
import os import os
import uuid import uuid
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from functools import wraps from functools import wraps
import jwt import jwt
@@ -72,7 +72,7 @@ def decrypt_totp_secret(ciphertext_b64: str, iv_b64: str) -> str:
def generate_tokens(user_id: int) -> dict: def generate_tokens(user_id: int) -> dict:
"""Return access_token and refresh_token JWTs, each with a unique jti.""" """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'] secret = current_app.config['JWT_SECRET_KEY']
access_payload = { access_payload = {
'sub': str(user_id), 'sub': str(user_id),
@@ -96,7 +96,7 @@ def generate_tokens(user_id: int) -> dict:
def generate_mfa_token(user_id: int) -> str: def generate_mfa_token(user_id: int) -> str:
"""Short-lived (5-min) single-use token issued after password but before TOTP.""" """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 = { payload = {
'sub': str(user_id), 'sub': str(user_id),
'type': 'mfa', 'type': 'mfa',
@@ -129,7 +129,7 @@ def blacklist_token(token: str, token_type: str) -> None:
if not jti: if not jti:
return return
exp = payload.get('exp') 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.models.token_blacklist import TokenBlacklist
from app import db from app import db
# Avoid duplicate if already blacklisted # Avoid duplicate if already blacklisted
+977
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* 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 =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none">' +
'<rect x="3" y="10" width="18" height="12" rx="2" fill="#fff"/>' +
'<path d="M8 10V7a4 4 0 018 0v3" stroke="#fff" stroke-width="2" stroke-linecap="round" fill="none"/>' +
'</svg>';
// Text.
var text = document.createElement('div');
text.style.cssText = 'flex:1;min-width:0;';
text.innerHTML =
'<div style="font-size:13px;color:#202124;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + site + '</div>' +
(username ? '<div style="font-size:11px;color:#5f6368;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px;">' + username + '</div>' : '');
// 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 =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none">' +
'<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" stroke="#1a73e8" stroke-width="1.8" stroke-linecap="round"/>' +
'<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" stroke="#1a73e8" stroke-width="1.8" stroke-linejoin="round"/>' +
'</svg>';
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 =
'<div style="display:flex;align-items:center;gap:8px;color:#16a34a;font-size:13px;font-weight:600;padding:0 4px;">' +
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none">' +
'<path d="M5 13l4 4L19 7" stroke="#16a34a" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>Filled</div>';
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 =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none">' +
'<circle cx="5" cy="12" r="1.8" fill="#5f6368"/>' +
'<circle cx="12" cy="12" r="1.8" fill="#5f6368"/>' +
'<circle cx="19" cy="12" r="1.8" fill="#5f6368"/>' +
'</svg>' +
'<span style="flex:1;">More options\u2026</span>';
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 =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none">' +
'<path d="M15 18l-6-6 6-6" stroke="#1a73e8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg> Back';
backRow.addEventListener('mousedown', function (e) {
e.preventDefault();
showDropdown(anchorField, pwField, filterText, 'credentials');
});
dropdown.appendChild(backRow);
// Menu items matching the screenshot.
var menuItems = [
{
icon: '<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="#5f6368" stroke-width="1.8" fill="none"/><line x1="12" y1="9" x2="12" y2="13" stroke="#5f6368" stroke-width="1.8" stroke-linecap="round"/><line x1="12" y1="17" x2="12.01" y2="17" stroke="#5f6368" stroke-width="2" stroke-linecap="round"/>',
label: 'Report a problem',
action: function () { chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); },
},
{
icon: '<rect x="3" y="9" width="18" height="12" rx="2" stroke="#5f6368" stroke-width="1.8" fill="none"/><path d="M8 9V6a4 4 0 018 0v3" stroke="#5f6368" stroke-width="1.8" stroke-linecap="round" fill="none"/><circle cx="12" cy="15" r="1.5" fill="#5f6368"/>',
label: 'Generate a password',
chevron: true,
action: function () { chrome.runtime.sendMessage({ type: 'OPEN_GENERATOR' }).catch(function () { }); removeDropdown(); },
},
{
icon: '<rect x="2" y="3" width="20" height="14" rx="2" stroke="#5f6368" stroke-width="1.8" fill="none"/><path d="M8 21h8M12 17v4" stroke="#5f6368" stroke-width="1.8" stroke-linecap="round"/>',
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 = '<svg width="18" height="18" viewBox="0 0 24 24">' + item.icon + '</svg>';
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 =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none">' +
'<path d="M9 18l6-6-6-6" stroke="#5f6368" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>';
row.appendChild(chev);
} else {
var extIcon = document.createElement('div');
extIcon.innerHTML =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none">' +
'<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" stroke="#5f6368" stroke-width="1.8" stroke-linecap="round"/>' +
'<path d="M15 3h6v6M10 14L21 3" stroke="#5f6368" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>';
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 =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none">' +
'<rect x="3" y="9" width="18" height="12" rx="2" stroke="#fff" stroke-width="2"/>' +
'<path d="M8 9V6a4 4 0 018 0v3" stroke="#fff" stroke-width="2" stroke-linecap="round"/>' +
'<circle cx="12" cy="15" r="1.5" fill="#fff"/>' +
'</svg>';
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 =
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">' +
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none"><rect x="3" y="9" width="18" height="12" rx="2" stroke="#c0392b" stroke-width="1.8"/><path d="M8 9V6a4 4 0 018 0v3" stroke="#c0392b" stroke-width="1.8" stroke-linecap="round"/></svg>' +
'<strong style="flex:1;font-size:13px;color:#111827;">' + escHtml(title) + '</strong>' +
'<button id="__pk_close__" style="background:none;border:none;cursor:pointer;font-size:18px;color:#9ca3af;line-height:1;padding:0;">\xd7</button>' +
'</div>' +
'<div style="margin-bottom:8px;">' +
'<label style="display:block;font-size:11px;color:#6b7280;margin-bottom:3px;">Site name</label>' +
'<input id="__pk_site_name__" type="text" value="' + escHtml(defaultSiteName) + '"' +
' style="width:100%;box-sizing:border-box;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px;font-size:12px;color:#111827;outline:none;">' +
'</div>' +
'<p style="color:#6b7280;font-size:12px;margin-bottom:10px;">' +
'<strong style="color:#111827;">' + user + '</strong> on <strong style="color:#111827;">' + site + '</strong>' +
'</p>' +
'<div style="display:flex;gap:8px;margin-bottom:8px;">' +
'<button id="__pk_save__" style="flex:1;padding:7px 0;background:#c0392b;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;">Save</button>' +
'<button id="__pk_skip__" style="flex:1;padding:7px 0;background:transparent;color:#374151;border:1px solid #d1d5db;border-radius:6px;cursor:pointer;font-size:12px;">Not now</button>' +
'</div>' +
'<button id="__pk_never__" style="width:100%;padding:5px 0;background:transparent;color:#9ca3af;border:none;cursor:pointer;font-size:11px;text-align:center;">Never ask for ' + site + '</button>';
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();
}
})();
+38
View File
@@ -919,6 +919,44 @@ const Vault = (() => {
document.getElementById('btn-export-csv')?.addEventListener('click', async () => { document.getElementById('btn-export-csv')?.addEventListener('click', async () => {
const vaultKey = VaultSession.getKey(); const vaultKey = VaultSession.getKey();
if (!vaultKey) { showUnlockOverlay(); return; } 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 = `
<div style="background:#fff;border-radius:12px;padding:28px 24px;max-width:380px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.22);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="#d97706" stroke-width="1.8" fill="none"/>
<line x1="12" y1="9" x2="12" y2="13" stroke="#d97706" stroke-width="1.8" stroke-linecap="round"/>
<line x1="12" y1="17" x2="12.01" y2="17" stroke="#d97706" stroke-width="2" stroke-linecap="round"/>
</svg>
<strong style="font-size:15px;color:#111827;">Export plaintext passwords?</strong>
</div>
<p style="font-size:13px;color:#374151;margin-bottom:6px;">
The CSV file will contain <strong>all your passwords in plaintext</strong>.
Anyone with access to the file can read them.
</p>
<p style="font-size:13px;color:#374151;margin-bottom:20px;">
Store the file in a secure location and delete it when you no longer need it.
</p>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button id="_csv_cancel" style="padding:8px 18px;border:1px solid #d1d5db;border-radius:7px;background:transparent;cursor:pointer;font-size:13px;color:#374151;">Cancel</button>
<button id="_csv_confirm" style="padding:8px 18px;border:none;border-radius:7px;background:#c0392b;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">Export anyway</button>
</div>
</div>`;
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 { try {
const res = await apiFetch('/api/vault'); const res = await apiFetch('/api/vault');
if (!res) return; if (!res) return;
+1
View File
@@ -14,3 +14,4 @@ pyotp>=2.9.0
qrcode[pil]>=7.4.2 qrcode[pil]>=7.4.2
cryptography>=42.0 # AES-256-GCM server-side TOTP secret encryption cryptography>=42.0 # AES-256-GCM server-side TOTP secret encryption
redis>=5.0 # Shared rate-limit storage across Gunicorn workers redis>=5.0 # Shared rate-limit storage across Gunicorn workers
APScheduler