diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..766e4c3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(python run.py)", + "Bash(curl -s http://127.0.0.1:5000/login)", + "Bash(node --check e:/NguyenNgo/Projects/PassKeeper/app/static/js/vault.js)", + "Bash(pkill -f \"python run.py\")", + "Bash(.venv/Scripts/pip install:*)", + "Bash(wc -l /e/NguyenNgo/Projects/PassKeeper/app/static/js/*.js /e/NguyenNgo/Projects/PassKeeper/app/routes/*.py /e/NguyenNgo/Projects/PassKeeper/app/models/*.py)", + "Bash(grep -n \"security/score\\\\|/api/security\" e:/NguyenNgo/Projects/PassKeeper/app/routes/*.py)", + "Bash(python reset_db.py)", + "Bash(node -c /e/NguyenNgo/Projects/PassKeeper/app/static/js/auth.js)", + "Bash(node -c /e/NguyenNgo/Projects/PassKeeper/app/static/js/sharing.js)", + "Bash(node -c /e/NguyenNgo/Projects/PassKeeper/app/static/js/vault.js)", + "Bash(python3 -m py_compile /e/NguyenNgo/Projects/PassKeeper/app/routes/auth.py /e/NguyenNgo/Projects/PassKeeper/app/routes/sharing.py /e/NguyenNgo/Projects/PassKeeper/app/routes/emergency.py /e/NguyenNgo/Projects/PassKeeper/app/models/token_blacklist.py)", + "Bash(wc -l /e/NguyenNgo/Projects/PassKeeper/app/routes/*.py /e/NguyenNgo/Projects/PassKeeper/app/models/*.py /e/NguyenNgo/Projects/PassKeeper/app/static/js/*.js)" + ] + } +} diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..a182240 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,77 @@ +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) + + +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) + CORS(app, resources={r'/api/*': {'origins': '*'}}) + + # 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 + + @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') + + return app diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..4a0e2fd --- /dev/null +++ b/app/config.py @@ -0,0 +1,52 @@ +import os +from datetime import timedelta +from dotenv import load_dotenv + +load_dotenv() + + +class BaseConfig: + SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-change-me') + JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-change-me') + JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) + JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7) + + SQLALCHEMY_DATABASE_URI = ( + 'mysql+pymysql://{user}:{password}@{host}:{port}/{db}?charset=utf8mb4'.format( + user=os.environ.get('MYSQL_USER', 'passkeeper'), + password=os.environ.get('MYSQL_PASSWORD', ''), + host=os.environ.get('MYSQL_HOST', '127.0.0.1'), + port=os.environ.get('MYSQL_PORT', '3306'), + db=os.environ.get('MYSQL_DB', 'passkeeper'), + ) + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False + + WTF_CSRF_ENABLED = True + WTF_CSRF_TIME_LIMIT = 3600 + + ARGON2_TIME_COST = int(os.environ.get('ARGON2_TIME_COST', 3)) + ARGON2_MEMORY_COST = int(os.environ.get('ARGON2_MEMORY_COST', 65536)) + ARGON2_PARALLELISM = int(os.environ.get('ARGON2_PARALLELISM', 4)) + + RATELIMIT_STORAGE_URI = 'memory://' + + +class DevelopmentConfig(BaseConfig): + DEBUG = True + RATELIMIT_ENABLED = False + + +class ProductionConfig(BaseConfig): + DEBUG = False + RATELIMIT_ENABLED = True + # Force HTTPS in production + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = 'Lax' + + +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, +} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/emergency_access.py b/app/models/emergency_access.py new file mode 100644 index 0000000..d874f74 --- /dev/null +++ b/app/models/emergency_access.py @@ -0,0 +1,65 @@ +from datetime import datetime, timedelta + +from sqlalchemy.dialects.mysql import INTEGER + +from app import db + + +class EmergencyAccess(db.Model): + """ + Emergency access grant from a vault owner (grantor) to a trusted contact (grantee). + + Status flow: + invited → grantee calls /accept → accepted + accepted → grantor calls /provide → ready (enc_vault stored) + ready → grantee calls /request → pending (wait timer starts) + pending → grantor calls /deny → ready (reset, grantee can request again) + pending (wait_days elapsed) → grantable (grantee fetches vault) + + Zero-knowledge: enc_vault is a JSON array of vault items re-encrypted by the grantor + using the ECDH shared secret (grantor private key + grantee public key). + """ + __tablename__ = 'emergency_access' + + id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True) + grantor_id = db.Column( + INTEGER(unsigned=True), + db.ForeignKey('users.id', ondelete='CASCADE'), + nullable=False, + ) + grantee_email = db.Column(db.String(255), nullable=False) + grantee_id = db.Column( + INTEGER(unsigned=True), + db.ForeignKey('users.id', ondelete='SET NULL'), + nullable=True, + ) + wait_days = db.Column(db.Integer, default=7, nullable=False) + # invited | accepted | ready | pending | denied + status = db.Column(db.String(20), default='invited', nullable=False) + 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) + + @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) + + def to_dict(self, grantor_email=None): + return { + 'id': self.id, + 'grantor_id': self.grantor_id, + 'grantor_email': grantor_email, + 'grantee_email': self.grantee_email, + 'grantee_id': self.grantee_id, + 'wait_days': self.wait_days, + 'status': self.status, + 'wait_elapsed': self.wait_elapsed, + 'request_initiated_at': ( + self.request_initiated_at.isoformat() if self.request_initiated_at else None + ), + 'created_at': self.created_at.isoformat() if self.created_at else None, + } diff --git a/app/models/folder.py b/app/models/folder.py new file mode 100644 index 0000000..3b6ae35 --- /dev/null +++ b/app/models/folder.py @@ -0,0 +1,19 @@ +from sqlalchemy.dialects.mysql import INTEGER + +from app import db + + +class Folder(db.Model): + __tablename__ = 'folders' + + id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True) + user_id = db.Column(INTEGER(unsigned=True), db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + name = db.Column(db.String(128), nullable=False) + + vault_items = db.relationship('VaultItem', backref='folder', lazy='dynamic') + + def to_dict(self): + return {'id': self.id, 'name': self.name} + + def __repr__(self): + return f'' diff --git a/app/models/shared_item.py b/app/models/shared_item.py new file mode 100644 index 0000000..a5bb470 --- /dev/null +++ b/app/models/shared_item.py @@ -0,0 +1,58 @@ +from datetime import datetime + +from sqlalchemy.dialects.mysql import INTEGER + +from app import db + + +class SharedItem(db.Model): + """ + A vault item shared by one user with another. + + Zero-knowledge: enc_data is re-encrypted by the SENDER using the ECDH shared + secret derived from sender's private key + recipient's P-256 public key. + The server stores only the ciphertext — it cannot decrypt it. + """ + __tablename__ = 'shared_items' + + id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True) + item_id = db.Column( + INTEGER(unsigned=True), + db.ForeignKey('vault_items.id', ondelete='CASCADE'), + nullable=False, + ) + owner_id = db.Column( + INTEGER(unsigned=True), + db.ForeignKey('users.id', ondelete='CASCADE'), + nullable=False, + ) + recipient_email = db.Column(db.String(255), nullable=False) + recipient_id = db.Column( + INTEGER(unsigned=True), + db.ForeignKey('users.id', ondelete='SET NULL'), + nullable=True, + ) + # Plaintext metadata — not sensitive, used for display before decryption + item_name = db.Column(db.String(255), nullable=False) + item_type = db.Column(db.String(20), nullable=False, default='password') + # ECDH-encrypted payload + enc_data = db.Column(db.Text, nullable=False) + 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) + + def to_dict(self): + return { + 'id': self.id, + 'item_id': self.item_id, + 'owner_id': self.owner_id, + 'recipient_email': self.recipient_email, + 'recipient_id': self.recipient_id, + 'item_name': self.item_name, + 'item_type': self.item_type, + 'enc_data': self.enc_data, + 'iv': self.iv, + 'accepted': self.accepted, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } diff --git a/app/models/token_blacklist.py b/app/models/token_blacklist.py new file mode 100644 index 0000000..cfcca23 --- /dev/null +++ b/app/models/token_blacklist.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from sqlalchemy.dialects.mysql import INTEGER + +from app import db + + +class TokenBlacklist(db.Model): + """Revoked JWT identifiers. Access and refresh tokens are added on logout.""" + __tablename__ = 'token_blacklist' + + id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True) + jti = db.Column(db.String(36), unique=True, nullable=False, index=True) + user_id = db.Column(INTEGER(unsigned=True), nullable=False) + expires_at = db.Column(db.DateTime, nullable=False) + + @classmethod + def is_blacklisted(cls, jti: str) -> bool: + entry = cls.query.filter_by(jti=jti).first() + if not entry: + return False + # Automatically ignore expired entries (they can be cleaned up later) + return entry.expires_at > datetime.utcnow() + + @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() + db.session.commit() diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..58808fb --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,45 @@ +from datetime import datetime +from flask_login import UserMixin +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError +from sqlalchemy.dialects.mysql import INTEGER + +from app import db + + +class User(db.Model, UserMixin): + __tablename__ = 'users' + + id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True) + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + # master_hash: Argon2id hash of the client-derived PBKDF2 auth_hash + # The raw master password is NEVER sent to or stored on the server. + master_hash = db.Column(db.String(255), nullable=False) + # enc_key_salt: random 16-byte salt (base64) generated at registration. + # 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) + last_login = db.Column(db.DateTime, nullable=True) + # TOTP / MFA + totp_secret = db.Column(db.String(64), nullable=True) + totp_enabled = db.Column(db.Boolean, default=False, nullable=False) + # ECDH P-256 sharing keypair + # Public key: raw uncompressed point (65 bytes), base64-encoded, stored plaintext + sharing_public_key = db.Column(db.Text, nullable=True) + # Private key: JWK, AES-256-GCM encrypted with the user's vault key + sharing_private_key_enc = db.Column(db.Text, nullable=True) + sharing_private_key_iv = db.Column(db.String(64), nullable=True) + + folders = db.relationship('Folder', backref='owner', lazy='dynamic', cascade='all, delete-orphan') + vault_items = db.relationship('VaultItem', backref='owner', lazy='dynamic', cascade='all, delete-orphan') + + def check_password(self, auth_hash: str) -> bool: + ph = PasswordHasher() + try: + return ph.verify(self.master_hash, auth_hash) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + def __repr__(self): + return f'' diff --git a/app/models/vault_item.py b/app/models/vault_item.py new file mode 100644 index 0000000..e22ff7d --- /dev/null +++ b/app/models/vault_item.py @@ -0,0 +1,52 @@ +from datetime import datetime +import enum + +from sqlalchemy.dialects.mysql import INTEGER + +from app import db + + +class ItemType(str, enum.Enum): + PASSWORD = 'password' + NOTE = 'note' + ADDRESS = 'address' + CARD = 'card' + BANK = 'bank' + SSN = 'ssn' + PASSKEY = 'passkey' + + +class VaultItem(db.Model): + __tablename__ = 'vault_items' + + id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True) + user_id = db.Column(INTEGER(unsigned=True), db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + folder_id = db.Column(INTEGER(unsigned=True), db.ForeignKey('folders.id', ondelete='SET NULL'), nullable=True) + item_type = db.Column(db.String(20), nullable=False, default=ItemType.PASSWORD.value) + # name is stored in plaintext for display in the vault list. + # All other sensitive fields (username, password, URL, notes, etc.) + # are inside enc_data and are encrypted client-side with AES-256-GCM. + name = db.Column(db.String(255), nullable=False) + enc_data = db.Column(db.Text, nullable=False) # base64-encoded AES-256-GCM ciphertext + iv = db.Column(db.String(64), nullable=False) # base64-encoded 12-byte GCM nonce + 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) + + def to_dict(self): + # item_type is stored as a plain string; handle both str and enum safely + item_type = self.item_type + if isinstance(item_type, ItemType): + item_type = item_type.value + return { + 'id': self.id, + 'item_type': item_type, + 'name': self.name, + 'folder_id': self.folder_id, + 'enc_data': self.enc_data, + 'iv': self.iv, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + + def __repr__(self): + return f'' diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..6dbd301 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,241 @@ +import re +import time + +from flask import Blueprint, request, jsonify, g +from app import db, limiter +from app.models.user import User +from app.services.auth_service import ( + hash_auth_token, + verify_auth_token, + generate_tokens, + generate_mfa_token, + decode_token, + blacklist_token, + require_jwt, +) + +auth_bp = Blueprint('auth', __name__) + +EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') + + +@auth_bp.route('/register', methods=['POST']) +@limiter.limit('10 per minute') +def register(): + data = request.get_json(silent=True) or {} + email = (data.get('email') or '').strip().lower() + auth_hash = data.get('auth_hash', '') + enc_key_salt = data.get('enc_key_salt', '') + + if not email or not EMAIL_RE.match(email): + return jsonify({'error': 'Invalid email address'}), 400 + if not auth_hash: + return jsonify({'error': 'auth_hash is required'}), 400 + if not enc_key_salt: + return jsonify({'error': 'enc_key_salt is required'}), 400 + + if User.query.filter_by(email=email).first(): + return jsonify({'error': 'Email already registered'}), 409 + + master_hash = hash_auth_token(auth_hash) + user = User(email=email, master_hash=master_hash, enc_key_salt=enc_key_salt) + db.session.add(user) + db.session.commit() + + return jsonify({'message': 'Account created successfully'}), 201 + + +@auth_bp.route('/login', methods=['POST']) +@limiter.limit('10 per minute') +def login(): + data = request.get_json(silent=True) or {} + email = (data.get('email') or '').strip().lower() + auth_hash = data.get('auth_hash', '') + + time.sleep(0.1) # mitigate timing-based user enumeration + + if not email or not auth_hash: + return jsonify({'error': 'Email and auth_hash are required'}), 400 + + user = User.query.filter_by(email=email).first() + if not user or not verify_auth_token(auth_hash, user.master_hash): + return jsonify({'error': 'Invalid email or password'}), 401 + + from datetime import datetime + user.last_login = datetime.utcnow() + db.session.commit() + + # MFA gate: if enabled, issue a short-lived mfa_token instead of full tokens + if user.totp_enabled: + mfa_token = generate_mfa_token(user.id) + return jsonify({ + 'mfa_required': True, + 'mfa_token': mfa_token, + 'enc_key_salt': user.enc_key_salt, + }), 200 + + tokens = generate_tokens(user.id) + return jsonify({ + 'access_token': tokens['access_token'], + 'refresh_token': tokens['refresh_token'], + 'enc_key_salt': user.enc_key_salt, + }), 200 + + +@auth_bp.route('/logout', methods=['POST']) +def logout(): + """Blacklist both the access token (from header) and refresh token (from body).""" + auth_header = request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + blacklist_token(auth_header[7:], 'access') + + data = request.get_json(silent=True) or {} + refresh_token = data.get('refresh_token', '') + if refresh_token: + blacklist_token(refresh_token, 'refresh') + + return jsonify({'message': 'Logged out'}), 200 + + +@auth_bp.route('/refresh', methods=['POST']) +@limiter.limit('30 per minute') +def refresh(): + data = request.get_json(silent=True) or {} + refresh_token = data.get('refresh_token', '') + if not refresh_token: + return jsonify({'error': 'refresh_token is required'}), 400 + + try: + payload = decode_token(refresh_token, expected_type='refresh') + except Exception: + return jsonify({'error': 'Invalid or expired refresh token'}), 401 + + # Rotate: blacklist old refresh token and issue fresh pair + blacklist_token(refresh_token, 'refresh') + tokens = generate_tokens(int(payload['sub'])) + return jsonify({ + 'access_token': tokens['access_token'], + 'refresh_token': tokens['refresh_token'], + }), 200 + + +# ── MFA / TOTP endpoints ───────────────────────────────────────────────────── + +@auth_bp.route('/mfa/setup', methods=['GET']) +@require_jwt +def mfa_setup(): + """Generate a new TOTP secret and return QR code (as base64 PNG data URI).""" + user = User.query.get(g.current_user_id) + if user.totp_enabled: + return jsonify({'error': 'MFA is already enabled'}), 400 + + import pyotp + import qrcode + import io + import base64 + + secret = pyotp.random_base32() + uri = pyotp.TOTP(secret).provisioning_uri( + name=user.email, + issuer_name='PassKeeper', + ) + + img = qrcode.make(uri) + buf = io.BytesIO() + img.save(buf, format='PNG') + qr_b64 = base64.b64encode(buf.getvalue()).decode() + + return jsonify({ + 'secret': secret, + 'qr_code': f'data:image/png;base64,{qr_b64}', + 'uri': uri, + }), 200 + + +@auth_bp.route('/mfa/enable', methods=['POST']) +@require_jwt +def mfa_enable(): + """Enable MFA after verifying the first TOTP code.""" + user = User.query.get(g.current_user_id) + if user.totp_enabled: + return jsonify({'error': 'MFA is already enabled'}), 400 + + data = request.get_json(silent=True) or {} + secret = (data.get('secret') or '').strip() + totp_code = (data.get('totp_code') or '').strip() + + if not secret or not totp_code: + return jsonify({'error': 'secret and totp_code are required'}), 400 + + import pyotp + if not pyotp.TOTP(secret).verify(totp_code, valid_window=1): + return jsonify({'error': 'Invalid verification code'}), 400 + + user.totp_secret = secret + user.totp_enabled = True + db.session.commit() + + return jsonify({'message': 'MFA enabled successfully'}), 200 + + +@auth_bp.route('/mfa/disable', methods=['POST']) +@require_jwt +def mfa_disable(): + """Disable MFA after verifying the current TOTP code.""" + user = User.query.get(g.current_user_id) + if not user.totp_enabled: + return jsonify({'error': 'MFA is not enabled'}), 400 + + data = request.get_json(silent=True) or {} + totp_code = (data.get('totp_code') or '').strip() + + import pyotp + if not pyotp.TOTP(user.totp_secret).verify(totp_code, valid_window=1): + return jsonify({'error': 'Invalid verification code'}), 400 + + user.totp_secret = None + user.totp_enabled = False + db.session.commit() + + return jsonify({'message': 'MFA disabled'}), 200 + + +@auth_bp.route('/mfa/verify', methods=['POST']) +@limiter.limit('10 per minute') +def mfa_verify(): + """Complete MFA login: verify TOTP code and exchange mfa_token for real tokens.""" + data = request.get_json(silent=True) or {} + mfa_token = data.get('mfa_token', '') + totp_code = (data.get('totp_code') or '').strip() + + if not mfa_token or not totp_code: + return jsonify({'error': 'mfa_token and totp_code are required'}), 400 + + try: + payload = decode_token(mfa_token, expected_type='mfa', check_blacklist=True) + except Exception: + return jsonify({'error': 'Invalid or expired MFA token'}), 401 + + user = User.query.get(int(payload['sub'])) + if not user or not user.totp_enabled: + return jsonify({'error': 'MFA not configured for this account'}), 400 + + import pyotp + if not pyotp.TOTP(user.totp_secret).verify(totp_code, valid_window=1): + return jsonify({'error': 'Invalid verification code'}), 400 + + # One-time use: blacklist the mfa_token + blacklist_token(mfa_token, 'mfa') + + tokens = generate_tokens(user.id) + return jsonify({ + 'access_token': tokens['access_token'], + 'refresh_token': tokens['refresh_token'], + }), 200 + + +@auth_bp.route('/mfa/status', methods=['GET']) +@require_jwt +def mfa_status(): + user = User.query.get(g.current_user_id) + return jsonify({'totp_enabled': user.totp_enabled}), 200 diff --git a/app/routes/emergency.py b/app/routes/emergency.py new file mode 100644 index 0000000..e86d41d --- /dev/null +++ b/app/routes/emergency.py @@ -0,0 +1,216 @@ +from datetime import datetime + +from flask import Blueprint, request, jsonify, g +from app import db +from app.models.user import User +from app.models.emergency_access import EmergencyAccess +from app.services.auth_service import require_jwt + +emergency_bp = Blueprint('emergency', __name__) + + +@emergency_bp.route('', methods=['GET']) +@require_jwt +def list_emergency(): + """Return emergency access records both as grantor and as grantee.""" + user = User.query.get(g.current_user_id) + + grants = EmergencyAccess.query.filter_by(grantor_id=user.id).order_by( + EmergencyAccess.created_at.desc() + ).all() + + access = EmergencyAccess.query.filter( + db.or_( + EmergencyAccess.grantee_email == user.email, + EmergencyAccess.grantee_id == user.id, + ) + ).order_by(EmergencyAccess.created_at.desc()).all() + + return jsonify({ + 'grants': [ea.to_dict(grantor_email=user.email) for ea in grants], + 'access': [_ea_as_grantee(ea) for ea in access], + }), 200 + + +def _ea_as_grantee(ea: EmergencyAccess) -> dict: + grantor = User.query.get(ea.grantor_id) + d = ea.to_dict(grantor_email=grantor.email if grantor else None) + d['grantor_public_key'] = grantor.sharing_public_key if grantor else None + return d + + +@emergency_bp.route('', methods=['POST']) +@require_jwt +def create_emergency(): + """Grantor creates an emergency access invitation for a trusted contact.""" + data = request.get_json(silent=True) or {} + grantee_email = (data.get('grantee_email') or '').strip().lower() + wait_days = int(data.get('wait_days', 7)) + + if not grantee_email: + return jsonify({'error': 'grantee_email is required'}), 400 + if not (1 <= wait_days <= 90): + return jsonify({'error': 'wait_days must be between 1 and 90'}), 400 + + owner = User.query.get(g.current_user_id) + if owner.email == grantee_email: + return jsonify({'error': 'Cannot designate yourself as emergency contact'}), 400 + + # No duplicate active grants + existing = EmergencyAccess.query.filter( + EmergencyAccess.grantor_id == g.current_user_id, + EmergencyAccess.grantee_email == grantee_email, + EmergencyAccess.status != 'denied', + ).first() + if existing: + return jsonify({'error': 'Emergency access already set up for this contact'}), 409 + + grantee = User.query.filter_by(email=grantee_email).first() + ea = EmergencyAccess( + grantor_id=g.current_user_id, + grantee_email=grantee_email, + grantee_id=grantee.id if grantee else None, + wait_days=wait_days, + ) + db.session.add(ea) + db.session.commit() + + return jsonify(ea.to_dict(grantor_email=owner.email)), 201 + + +@emergency_bp.route('/', methods=['DELETE']) +@require_jwt +def delete_emergency(ea_id): + """Grantor removes an emergency access grant.""" + ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first() + if not ea: + return jsonify({'error': 'Not found'}), 404 + db.session.delete(ea) + db.session.commit() + return jsonify({'message': 'Emergency access removed'}), 200 + + +@emergency_bp.route('//accept', methods=['POST']) +@require_jwt +def accept_emergency(ea_id): + """Grantee accepts an emergency access invitation.""" + user = User.query.get(g.current_user_id) + ea = EmergencyAccess.query.filter( + EmergencyAccess.id == ea_id, + EmergencyAccess.status == 'invited', + db.or_( + EmergencyAccess.grantee_email == user.email, + EmergencyAccess.grantee_id == user.id, + ), + ).first() + if not ea: + return jsonify({'error': 'Not found or not in invited state'}), 404 + + ea.status = 'accepted' + ea.grantee_id = user.id + db.session.commit() + + return jsonify(_ea_as_grantee(ea)), 200 + + +@emergency_bp.route('//provide', methods=['POST']) +@require_jwt +def provide_vault(ea_id): + """ + Grantor provides the ECDH-encrypted vault snapshot for emergency recovery. + + The client re-encrypts each vault item's plaintext with the ECDH shared secret + (grantor private key + grantee public key) and sends the JSON array as enc_vault. + """ + ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first() + if not ea: + return jsonify({'error': 'Not found'}), 404 + if ea.status not in ('accepted', 'ready'): + return jsonify({'error': 'Emergency access must be in accepted or ready state'}), 400 + + data = request.get_json(silent=True) or {} + enc_vault = data.get('enc_vault', '') + if not enc_vault: + return jsonify({'error': 'enc_vault (JSON array) is required'}), 400 + + ea.enc_vault = enc_vault + ea.status = 'ready' + db.session.commit() + + grantor = User.query.get(g.current_user_id) + return jsonify(ea.to_dict(grantor_email=grantor.email)), 200 + + +@emergency_bp.route('//request', methods=['POST']) +@require_jwt +def request_access(ea_id): + """Grantee initiates an access request, starting the wait-period clock.""" + user = User.query.get(g.current_user_id) + ea = EmergencyAccess.query.filter( + EmergencyAccess.id == ea_id, + EmergencyAccess.status == 'ready', + db.or_( + EmergencyAccess.grantee_email == user.email, + EmergencyAccess.grantee_id == user.id, + ), + ).first() + if not ea: + return jsonify({'error': 'Not found or not in ready state'}), 404 + + ea.status = 'pending' + ea.request_initiated_at = datetime.utcnow() + db.session.commit() + + return jsonify(_ea_as_grantee(ea)), 200 + + +@emergency_bp.route('//deny', methods=['POST']) +@require_jwt +def deny_access(ea_id): + """Grantor denies a pending access request (resets to ready).""" + ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first() + if not ea: + return jsonify({'error': 'Not found'}), 404 + if ea.status != 'pending': + return jsonify({'error': 'No pending request to deny'}), 400 + + ea.status = 'ready' + ea.request_initiated_at = None + db.session.commit() + + grantor = User.query.get(g.current_user_id) + return jsonify(ea.to_dict(grantor_email=grantor.email)), 200 + + +@emergency_bp.route('//vault', methods=['GET']) +@require_jwt +def get_emergency_vault(ea_id): + """ + Grantee retrieves the encrypted vault snapshot after the wait period has elapsed. + Also returns the grantor's public key so the client can derive the ECDH secret. + """ + user = User.query.get(g.current_user_id) + ea = EmergencyAccess.query.filter( + EmergencyAccess.id == ea_id, + db.or_( + EmergencyAccess.grantee_email == user.email, + EmergencyAccess.grantee_id == user.id, + ), + ).first() + if not ea: + 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() + days_left = max(0, ea.wait_days - elapsed_secs / 86400) + else: + days_left = ea.wait_days + return jsonify({ + 'error': f'Wait period not yet elapsed ({days_left:.1f} day(s) remaining)' + }), 403 + + grantor = User.query.get(ea.grantor_id) + return jsonify({ + 'enc_vault': ea.enc_vault, + 'grantor_public_key': grantor.sharing_public_key if grantor else None, + }), 200 diff --git a/app/routes/folders.py b/app/routes/folders.py new file mode 100644 index 0000000..735728b --- /dev/null +++ b/app/routes/folders.py @@ -0,0 +1,54 @@ +from flask import Blueprint, request, jsonify, g +from app import db +from app.models.folder import Folder +from app.services.auth_service import require_jwt + +folders_bp = Blueprint('folders', __name__) + + +@folders_bp.route('', methods=['GET']) +@require_jwt +def list_folders(): + folders = Folder.query.filter_by(user_id=g.current_user_id).order_by(Folder.name.asc()).all() + return jsonify([f.to_dict() for f in folders]), 200 + + +@folders_bp.route('', methods=['POST']) +@require_jwt +def create_folder(): + data = request.get_json(silent=True) or {} + name = (data.get('name') or '').strip() + if not name: + return jsonify({'error': 'name is required'}), 400 + + folder = Folder(user_id=g.current_user_id, name=name) + db.session.add(folder) + db.session.commit() + return jsonify(folder.to_dict()), 201 + + +@folders_bp.route('/', methods=['PUT']) +@require_jwt +def update_folder(folder_id): + folder = Folder.query.filter_by(id=folder_id, user_id=g.current_user_id).first() + if not folder: + return jsonify({'error': 'Folder not found'}), 404 + + data = request.get_json(silent=True) or {} + name = (data.get('name') or '').strip() + if not name: + return jsonify({'error': 'name is required'}), 400 + folder.name = name + db.session.commit() + return jsonify(folder.to_dict()), 200 + + +@folders_bp.route('/', methods=['DELETE']) +@require_jwt +def delete_folder(folder_id): + folder = Folder.query.filter_by(id=folder_id, user_id=g.current_user_id).first() + if not folder: + return jsonify({'error': 'Folder not found'}), 404 + db.session.delete(folder) + db.session.commit() + return jsonify({'message': 'Folder deleted'}), 200 diff --git a/app/routes/sharing.py b/app/routes/sharing.py new file mode 100644 index 0000000..44e3284 --- /dev/null +++ b/app/routes/sharing.py @@ -0,0 +1,203 @@ +from flask import Blueprint, request, jsonify, g +from app import db +from app.models.user import User +from app.models.shared_item import SharedItem +from app.services.auth_service import require_jwt + +sharing_bp = Blueprint('sharing', __name__) + + +# ── Sharing keypair management ──────────────────────────────────────────────── + +@sharing_bp.route('/keys', methods=['GET']) +@require_jwt +def get_my_keys(): + """Return current user's encrypted sharing private key (to decrypt client-side).""" + user = User.query.get(g.current_user_id) + if not user.sharing_public_key: + return jsonify({'keys_setup': False}), 200 + return jsonify({ + 'keys_setup': True, + 'public_key': user.sharing_public_key, + 'private_key_enc': user.sharing_private_key_enc, + 'private_key_iv': user.sharing_private_key_iv, + }), 200 + + +@sharing_bp.route('/keys', methods=['POST']) +@require_jwt +def store_my_keys(): + """Store ECDH keypair. Public key plaintext; private key encrypted with vault key.""" + data = request.get_json(silent=True) or {} + public_key = data.get('public_key', '').strip() + private_key_enc = data.get('private_key_enc', '').strip() + private_key_iv = data.get('private_key_iv', '').strip() + + if not public_key or not private_key_enc or not private_key_iv: + return jsonify({'error': 'public_key, private_key_enc, and private_key_iv are required'}), 400 + + user = User.query.get(g.current_user_id) + user.sharing_public_key = public_key + user.sharing_private_key_enc = private_key_enc + user.sharing_private_key_iv = private_key_iv + db.session.commit() + + return jsonify({'message': 'Sharing keys stored'}), 200 + + +@sharing_bp.route('/public-key', methods=['GET']) +@require_jwt +def get_public_key(): + """Look up another user's ECDH public key by email (needed to create a share).""" + email = (request.args.get('email') or '').strip().lower() + if not email: + return jsonify({'error': 'email query param is required'}), 400 + + user = User.query.filter_by(email=email).first() + if not user: + return jsonify({'error': 'User not found'}), 404 + if not user.sharing_public_key: + return jsonify({'error': 'User has not set up sharing keys yet'}), 404 + + return jsonify({ + 'user_id': user.id, + 'email': user.email, + 'public_key': user.sharing_public_key, + }), 200 + + +# ── Outgoing shares ─────────────────────────────────────────────────────────── + +@sharing_bp.route('', methods=['GET']) +@require_jwt +def list_outgoing(): + """List all items the current user has shared with others.""" + shares = ( + SharedItem.query + .filter_by(owner_id=g.current_user_id) + .order_by(SharedItem.created_at.desc()) + .all() + ) + result = [] + for s in shares: + d = s.to_dict() + recipient = User.query.get(s.recipient_id) if s.recipient_id else None + d['recipient_name'] = recipient.email if recipient else s.recipient_email + result.append(d) + return jsonify(result), 200 + + +@sharing_bp.route('', methods=['POST']) +@require_jwt +def create_share(): + """ + Share a vault item with another user. + + The caller must already have: + 1. Fetched the recipient's public key via GET /api/sharing/public-key?email=... + 2. Loaded their own ECDH private key (decrypted client-side with vault key) + 3. Derived the ECDH shared secret + 4. Re-encrypted the item's plaintext with that shared secret → enc_data, iv + """ + data = request.get_json(silent=True) or {} + item_id = data.get('item_id') + recipient_email = (data.get('recipient_email') or '').strip().lower() + enc_data = data.get('enc_data', '') + iv = data.get('iv', '') + item_name = (data.get('item_name') or '').strip() + item_type = data.get('item_type', 'password') + + if not all([item_id, recipient_email, enc_data, iv, item_name]): + return jsonify({'error': 'item_id, recipient_email, enc_data, iv, item_name are required'}), 400 + + owner = User.query.get(g.current_user_id) + if owner.email == recipient_email: + return jsonify({'error': 'Cannot share an item with yourself'}), 400 + + # Verify the item belongs to the current user + from app.models.vault_item import VaultItem + item = VaultItem.query.filter_by(id=item_id, user_id=g.current_user_id).first() + if not item: + return jsonify({'error': 'Item not found'}), 404 + + recipient = User.query.filter_by(email=recipient_email).first() + + share = SharedItem( + item_id=item_id, + owner_id=g.current_user_id, + recipient_email=recipient_email, + recipient_id=recipient.id if recipient else None, + item_name=item_name, + item_type=item_type, + enc_data=enc_data, + iv=iv, + ) + db.session.add(share) + db.session.commit() + + return jsonify(share.to_dict()), 201 + + +@sharing_bp.route('/', methods=['DELETE']) +@require_jwt +def delete_share(share_id): + share = SharedItem.query.filter_by(id=share_id, owner_id=g.current_user_id).first() + if not share: + return jsonify({'error': 'Share not found'}), 404 + db.session.delete(share) + db.session.commit() + return jsonify({'message': 'Share removed'}), 200 + + +# ── Inbox (received shares) ─────────────────────────────────────────────────── + +@sharing_bp.route('/inbox', methods=['GET']) +@require_jwt +def inbox(): + """List all items shared with the current user.""" + user = User.query.get(g.current_user_id) + shares = ( + SharedItem.query + .filter( + db.or_( + SharedItem.recipient_email == user.email, + SharedItem.recipient_id == user.id, + ) + ) + .order_by(SharedItem.created_at.desc()) + .all() + ) + result = [] + for s in shares: + d = s.to_dict() + owner = User.query.get(s.owner_id) + d['owner_email'] = owner.email if owner else 'Unknown' + d['owner_public_key'] = owner.sharing_public_key if owner else None + result.append(d) + return jsonify(result), 200 + + +@sharing_bp.route('/inbox//accept', methods=['POST']) +@require_jwt +def accept_share(share_id): + """Mark a received share as accepted (links recipient_id if not already set).""" + user = User.query.get(g.current_user_id) + share = SharedItem.query.filter( + SharedItem.id == share_id, + db.or_( + SharedItem.recipient_email == user.email, + SharedItem.recipient_id == user.id, + ), + ).first() + if not share: + return jsonify({'error': 'Share not found'}), 404 + + share.accepted = True + share.recipient_id = user.id + db.session.commit() + + d = share.to_dict() + owner = User.query.get(share.owner_id) + d['owner_email'] = owner.email if owner else 'Unknown' + d['owner_public_key'] = owner.sharing_public_key if owner else None + return jsonify(d), 200 diff --git a/app/routes/vault.py b/app/routes/vault.py new file mode 100644 index 0000000..312d23a --- /dev/null +++ b/app/routes/vault.py @@ -0,0 +1,95 @@ +from flask import Blueprint, request, jsonify, g +from app import db +from app.models.vault_item import VaultItem, ItemType +from app.services.auth_service import require_jwt + +vault_bp = Blueprint('vault', __name__) + +VALID_TYPES = {t.value for t in ItemType} + + +@vault_bp.route('', methods=['GET']) +@require_jwt +def list_items(): + items = VaultItem.query.filter_by(user_id=g.current_user_id).order_by( + VaultItem.name.asc() + ).all() + return jsonify([item.to_dict() for item in items]), 200 + + +@vault_bp.route('', methods=['POST']) +@require_jwt +def create_item(): + data = request.get_json(silent=True) or {} + name = (data.get('name') or '').strip() + item_type = data.get('item_type', 'password') + enc_data = data.get('enc_data', '') + iv = data.get('iv', '') + folder_id = data.get('folder_id') + + if not name: + return jsonify({'error': 'name is required'}), 400 + if item_type not in VALID_TYPES: + return jsonify({'error': f'item_type must be one of {sorted(VALID_TYPES)}'}), 400 + if not enc_data or not iv: + return jsonify({'error': 'enc_data and iv are required'}), 400 + + item = VaultItem( + user_id=g.current_user_id, + folder_id=folder_id, + item_type=item_type, # stored as plain string + name=name, + enc_data=enc_data, + iv=iv, + ) + try: + db.session.add(item) + db.session.commit() + except Exception as e: + db.session.rollback() + return jsonify({'error': f'Database error: {str(e)}'}), 500 + return jsonify(item.to_dict()), 201 + + +@vault_bp.route('/', methods=['GET']) +@require_jwt +def get_item(item_id): + item = VaultItem.query.filter_by(id=item_id, user_id=g.current_user_id).first() + if not item: + return jsonify({'error': 'Item not found'}), 404 + return jsonify(item.to_dict()), 200 + + +@vault_bp.route('/', methods=['PUT']) +@require_jwt +def update_item(item_id): + item = VaultItem.query.filter_by(id=item_id, user_id=g.current_user_id).first() + if not item: + return jsonify({'error': 'Item not found'}), 404 + + data = request.get_json(silent=True) or {} + if 'name' in data: + name = data['name'].strip() + if not name: + return jsonify({'error': 'name cannot be empty'}), 400 + item.name = name + if 'folder_id' in data: + item.folder_id = data['folder_id'] + if 'enc_data' in data: + item.enc_data = data['enc_data'] + if 'iv' in data: + item.iv = data['iv'] + + db.session.commit() + return jsonify(item.to_dict()), 200 + + +@vault_bp.route('/', methods=['DELETE']) +@require_jwt +def delete_item(item_id): + item = VaultItem.query.filter_by(id=item_id, user_id=g.current_user_id).first() + if not item: + return jsonify({'error': 'Item not found'}), 404 + db.session.delete(item) + db.session.commit() + return jsonify({'message': 'Item deleted'}), 200 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..029a6c7 --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,123 @@ +import uuid +import time +from datetime import datetime, timedelta +from functools import wraps + +import jwt +from flask import current_app, request, g, jsonify +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError + + +def hash_auth_token(auth_hash: str) -> str: + """Hash the client-derived PBKDF2 auth_hash with Argon2id before storing.""" + ph = PasswordHasher( + time_cost=current_app.config['ARGON2_TIME_COST'], + memory_cost=current_app.config['ARGON2_MEMORY_COST'], + parallelism=current_app.config['ARGON2_PARALLELISM'], + ) + return ph.hash(auth_hash) + + +def verify_auth_token(auth_hash: str, stored_hash: str) -> bool: + ph = PasswordHasher() + try: + return ph.verify(stored_hash, auth_hash) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + +def generate_tokens(user_id: int) -> dict: + """Return access_token and refresh_token JWTs, each with a unique jti.""" + now = datetime.utcnow() + secret = current_app.config['JWT_SECRET_KEY'] + access_payload = { + 'sub': str(user_id), + 'type': 'access', + 'jti': str(uuid.uuid4()), + 'iat': now, + 'exp': now + current_app.config['JWT_ACCESS_TOKEN_EXPIRES'], + } + refresh_payload = { + 'sub': str(user_id), + 'type': 'refresh', + 'jti': str(uuid.uuid4()), + 'iat': now, + 'exp': now + current_app.config['JWT_REFRESH_TOKEN_EXPIRES'], + } + return { + 'access_token': jwt.encode(access_payload, secret, algorithm='HS256'), + 'refresh_token': jwt.encode(refresh_payload, secret, algorithm='HS256'), + } + + +def generate_mfa_token(user_id: int) -> str: + """Short-lived (5-min) single-use token issued after password but before TOTP.""" + now = datetime.utcnow() + payload = { + 'sub': str(user_id), + 'type': 'mfa', + 'jti': str(uuid.uuid4()), + 'iat': now, + 'exp': now + timedelta(minutes=5), + } + return jwt.encode(payload, current_app.config['JWT_SECRET_KEY'], algorithm='HS256') + + +def decode_token(token: str, expected_type: str = 'access', check_blacklist: bool = True) -> dict: + """Decode and validate a JWT. Raises jwt.PyJWTError on any failure.""" + secret = current_app.config['JWT_SECRET_KEY'] + payload = jwt.decode(token, secret, algorithms=['HS256']) + if payload.get('type') != expected_type: + raise jwt.InvalidTokenError('Wrong token type') + if check_blacklist: + from app.models.token_blacklist import TokenBlacklist + jti = payload.get('jti') + if jti and TokenBlacklist.is_blacklisted(jti): + raise jwt.InvalidTokenError('Token has been revoked') + return payload + + +def blacklist_token(token: str, token_type: str) -> None: + """Add a JWT's jti to the blacklist. Silently ignores invalid tokens.""" + try: + payload = decode_token(token, expected_type=token_type, check_blacklist=False) + jti = payload.get('jti') + if not jti: + return + exp = payload.get('exp') + expires_at = datetime.utcfromtimestamp(exp) if exp else datetime.utcnow() + timedelta(days=7) + from app.models.token_blacklist import TokenBlacklist + from app import db + # Avoid duplicate if already blacklisted + if not TokenBlacklist.query.filter_by(jti=jti).first(): + entry = TokenBlacklist( + jti=jti, + user_id=int(payload.get('sub', 0)), + expires_at=expires_at, + ) + db.session.add(entry) + db.session.commit() + # Opportunistic cleanup — runs in same transaction context + TokenBlacklist.cleanup_expired() + except Exception: + pass # Never let blacklisting errors break the logout flow + + +def require_jwt(f): + """Decorator: validates Bearer token and sets g.current_user_id.""" + @wraps(f) + def decorated(*args, **kwargs): + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return jsonify({'error': 'Missing or invalid Authorization header'}), 401 + token = auth_header[7:] + try: + payload = decode_token(token, expected_type='access') + except jwt.ExpiredSignatureError: + return jsonify({'error': 'Token expired'}), 401 + except jwt.PyJWTError: + return jsonify({'error': 'Invalid token'}), 401 + g.current_user_id = int(payload['sub']) + return f(*args, **kwargs) + return decorated diff --git a/app/static/css/app.css b/app/static/css/app.css new file mode 100644 index 0000000..85c32bb --- /dev/null +++ b/app/static/css/app.css @@ -0,0 +1,555 @@ +/* ── Reset & base ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --color-bg: #f4f5f7; + --color-surface: #ffffff; + --color-primary: #d0021b; + --color-primary-dark: #a80016; + --color-text: #1a1a2e; + --color-text-muted: #6b7280; + --color-border: #e2e4e9; + --color-sidebar-bg: #1e2235; + --color-sidebar-text: #c8ccd8; + --color-sidebar-hover: #2d3250; + --color-sidebar-active: #d0021b; + --radius: 8px; + --shadow: 0 1px 4px rgba(0,0,0,.08), 0 2px 12px rgba(0,0,0,.05); + --transition: 150ms ease; +} + +html, body { height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; color: var(--color-text); background: var(--color-bg); } +a { color: var(--color-primary); text-decoration: none; } +a:hover { text-decoration: underline; } +button { cursor: pointer; font: inherit; border: none; background: none; } +input, select, textarea { font: inherit; } +ul { list-style: none; } + +/* ── Utilities ───────────────────────────────────────────────────── */ +.hidden { display: none !important; } + +/* ── Auth pages ──────────────────────────────────────────────────── */ +.auth-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: var(--color-bg); } + +.auth-container { width: 100%; max-width: 420px; padding: 16px; } + +.auth-card { + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 40px 36px; +} + +.auth-logo { display: flex; align-items: center; gap: 8px; margin-bottom: 28px; } +.auth-logo .logo-icon { font-size: 28px; } +.auth-logo .logo-text { font-size: 20px; font-weight: 700; color: var(--color-text); } + +.auth-title { font-size: 22px; font-weight: 600; margin-bottom: 24px; } + +.auth-footer { margin-top: 20px; text-align: center; color: var(--color-text-muted); } + +/* ── Form elements ──────────────────────────────────────────────── */ +.form-group { margin-bottom: 16px; } +.form-group label { display: block; margin-bottom: 6px; font-weight: 500; color: var(--color-text); } +.form-group label .hint { font-weight: 400; color: var(--color-text-muted); font-size: 12px; } + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 9px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); + color: var(--color-text); + transition: border-color var(--transition); +} +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(208,2,27,.12); +} + +.input-with-toggle { position: relative; } +.input-with-toggle input { padding-right: 40px; } +.btn-show-pass { + position: absolute; right: 10px; top: 50%; transform: translateY(-50%); + background: none; border: none; cursor: pointer; font-size: 16px; opacity: .6; + transition: opacity var(--transition); +} +.btn-show-pass:hover { opacity: 1; } + +.form-error { color: #c00; background: #fff0f0; border: 1px solid #fcc; border-radius: var(--radius); padding: 8px 12px; margin-bottom: 12px; font-size: 13px; } + +/* Buttons */ +.btn-primary { + display: inline-flex; align-items: center; justify-content: center; + background: var(--color-primary); color: #fff; + padding: 10px 20px; border-radius: var(--radius); font-weight: 600; + transition: background var(--transition); +} +.btn-primary:hover { background: var(--color-primary-dark); } +.btn-primary:disabled { opacity: .6; cursor: not-allowed; } +.btn-full { width: 100%; } + +.btn-secondary { + display: inline-flex; align-items: center; justify-content: center; + background: var(--color-border); color: var(--color-text); + padding: 10px 20px; border-radius: var(--radius); font-weight: 500; + transition: background var(--transition); +} +.btn-secondary:hover { background: #d1d5dc; } + +.btn-text { background: none; border: none; color: inherit; padding: 0; } + +/* Notices */ +.notice-success { background: #e8f5e9; border: 1px solid #a5d6a7; color: #2e7d32; border-radius: var(--radius); padding: 10px 14px; margin-bottom: 16px; font-size: 13px; } +.notice-info { background: #e3f2fd; border: 1px solid #90caf9; color: #1565c0; border-radius: var(--radius); padding: 10px 14px; margin-bottom: 16px; font-size: 13px; } + +/* Password strength */ +.password-strength { margin-top: 6px; font-size: 12px; font-weight: 500; } +.strength-1 { color: #c62828; } +.strength-2 { color: #e65100; } +.strength-3 { color: #f57f17; } +.strength-4 { color: #2e7d32; } +.strength-5 { color: #1b5e20; } + +/* ── App layout (vault) ──────────────────────────────────────────── */ +.vault-page { height: 100vh; overflow: hidden; } + +.app-layout { display: flex; height: 100vh; } + +/* ── Sidebar ─────────────────────────────────────────────────────── */ +.sidebar { + width: 220px; + flex-shrink: 0; + background: var(--color-sidebar-bg); + color: var(--color-sidebar-text); + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.sidebar-header { + display: flex; + align-items: center; + gap: 8px; + padding: 18px 16px; + border-bottom: 1px solid rgba(255,255,255,.08); +} +.sidebar-header .logo-icon { font-size: 22px; } +.sidebar-header .logo-text { font-size: 16px; font-weight: 700; color: #fff; } + +.sidebar-nav { flex: 1; padding: 8px 0; } + +.sidebar-section-header { + padding: 12px 16px 4px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: .08em; + color: rgba(255,255,255,.35); +} + +.sidebar-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + border-radius: 0; + color: var(--color-sidebar-text); + cursor: pointer; + transition: background var(--transition); + font-size: 14px; + width: 100%; + text-align: left; +} +.sidebar-item:hover { background: var(--color-sidebar-hover); } +.sidebar-item.active { background: var(--color-sidebar-active); color: #fff; } +.sidebar-icon { font-size: 16px; flex-shrink: 0; } + +.sidebar-footer { padding: 12px 0; border-top: 1px solid rgba(255,255,255,.08); } + +.folder-item { justify-content: space-between; } +.folder-label { display: flex; align-items: center; gap: 10px; flex: 1; cursor: pointer; } +.btn-delete-folder { + background: none; border: none; cursor: pointer; + font-size: 13px; opacity: 0; color: rgba(255,255,255,.6); + transition: opacity var(--transition), color var(--transition); + padding: 2px 4px; border-radius: 4px; flex-shrink: 0; +} +.folder-item:hover .btn-delete-folder { opacity: 1; } +.btn-delete-folder:hover { color: #ff6b6b; } + +.btn-new-folder { + float: right; margin-right: 4px; + background: none; border: none; color: rgba(255,255,255,.5); + font-size: 16px; line-height: 1; cursor: pointer; padding: 0 4px; + transition: color var(--transition); +} +.btn-new-folder:hover { color: #fff; } + +.new-folder-row { padding: 4px 10px 4px 16px; } +.new-folder-row form { display: flex; align-items: center; gap: 4px; } +.new-folder-row input { + flex: 1; padding: 5px 8px; border-radius: 4px; border: 1px solid rgba(255,255,255,.2); + background: rgba(255,255,255,.1); color: #fff; font-size: 13px; +} +.new-folder-row input::placeholder { color: rgba(255,255,255,.4); } +.new-folder-row input:focus { outline: none; border-color: rgba(255,255,255,.5); } +.new-folder-row .btn-icon { color: rgba(255,255,255,.7); font-size: 14px; } +.new-folder-row .btn-icon:hover { color: #fff; background: rgba(255,255,255,.1); } + +/* ── Vault main ──────────────────────────────────────────────────── */ +.vault-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--color-bg); +} + +#view-vault, #view-security { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + min-height: 0; + position: relative; +} +#view-vault.hidden, #view-security.hidden { display: none; } + +.vault-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + gap: 16px; +} + +.vault-title { font-size: 18px; font-weight: 600; white-space: nowrap; } + +.vault-toolbar { display: flex; align-items: center; gap: 12px; flex: 1; justify-content: flex-end; } + +.search-wrapper { + display: flex; align-items: center; + background: var(--color-bg); border: 1px solid var(--color-border); + border-radius: 20px; padding: 6px 14px; gap: 8px; flex: 1; max-width: 360px; +} +.search-icon { color: var(--color-text-muted); } +#search-input { border: none; background: none; outline: none; flex: 1; font-size: 14px; } + +.btn-fab { + width: 40px; height: 40px; border-radius: 50%; + background: var(--color-primary); color: #fff; + font-size: 24px; font-weight: 300; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 2px 8px rgba(208,2,27,.35); + transition: background var(--transition), box-shadow var(--transition); + flex-shrink: 0; +} +.btn-fab:hover { background: var(--color-primary-dark); box-shadow: 0 4px 14px rgba(208,2,27,.45); } + +/* ── Vault list ──────────────────────────────────────────────────── */ +.vault-list { flex: 1; overflow-y: auto; padding: 12px 24px 24px; } + +.vault-group-header { + font-size: 12px; font-weight: 600; text-transform: uppercase; + letter-spacing: .06em; color: var(--color-text-muted); + padding: 16px 0 6px; border-bottom: 1px solid var(--color-border); + margin-bottom: 4px; +} + +.vault-empty { + text-align: center; color: var(--color-text-muted); padding: 60px 0; font-size: 15px; +} + +.vault-item { + display: flex; align-items: center; gap: 12px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 12px 14px; + margin-bottom: 6px; + cursor: pointer; + transition: box-shadow var(--transition), border-color var(--transition); +} +.vault-item:hover { box-shadow: var(--shadow); border-color: #c9ccd4; } + +.item-icon { font-size: 22px; flex-shrink: 0; } + +.item-info { flex: 1; min-width: 0; } +.item-name { display: block; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.item-sub { display: block; font-size: 12px; color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.item-actions { display: flex; gap: 4px; opacity: 0; transition: opacity var(--transition); } +.vault-item:hover .item-actions { opacity: 1; } + +.btn-icon { + width: 30px; height: 30px; border-radius: 6px; + display: flex; align-items: center; justify-content: center; + font-size: 15px; color: var(--color-text-muted); + transition: background var(--transition), color var(--transition); +} +.btn-icon:hover { background: var(--color-bg); color: var(--color-text); } + +/* ── Spinner ─────────────────────────────────────────────────────── */ +.spinner-overlay { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + background: rgba(255,255,255,.7); + z-index: 10; +} +.spinner-overlay.hidden { display: none; } +.spinner { + width: 36px; height: 36px; + border: 3px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin .7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Modal ───────────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,.45); + display: none; align-items: center; justify-content: center; + z-index: 100; + padding: 16px; +} +.modal-overlay.open { display: flex; } + +.modal { + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: 0 8px 40px rgba(0,0,0,.2); + width: 100%; max-width: 480px; + max-height: 90vh; + overflow-y: auto; + padding: 28px; +} + +.modal-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 20px; +} +.modal-header h3 { font-size: 18px; font-weight: 600; } + +.btn-close { font-size: 18px; color: var(--color-text-muted); padding: 4px; border-radius: 4px; } +.btn-close:hover { background: var(--color-bg); color: var(--color-text); } + +.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; } + +.required { color: var(--color-primary); } + +/* ── Toast ───────────────────────────────────────────────────────── */ +.toast { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px); + background: #1a1a2e; color: #fff; + padding: 10px 20px; border-radius: 20px; + font-size: 14px; font-weight: 500; + opacity: 0; transition: opacity .25s, transform .25s; + pointer-events: none; z-index: 200; white-space: nowrap; +} +.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } +.toast.toast-error { background: #c00; } + +/* ── Unlock overlay ──────────────────────────────────────────────── */ +.modal-unlock { max-width: 380px; } +.unlock-logo { margin-bottom: 16px; } +.unlock-title { margin-bottom: 4px; } +.unlock-subtitle { color: var(--color-text-muted); font-size: 13px; margin-bottom: 20px; } +.btn-link { padding: 10px 20px; border-radius: var(--radius); text-decoration: none; } + +/* ── Auth step hint ─────────────────────────────────────────────── */ +.auth-step-hint { color: var(--color-text-muted); font-size: 13px; margin-bottom: 16px; } +#mfa-code { font-size: 22px; letter-spacing: .2em; text-align: center; } + +/* ── Panel body (sharing / emergency) ───────────────────────────── */ +.panel-body { flex: 1; overflow-y: auto; padding: 20px 24px; } + +/* ── Tabs ────────────────────────────────────────────────────────── */ +.panel-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 2px solid var(--color-border); padding-bottom: 0; } +.tab-btn { + padding: 8px 16px; border-radius: 6px 6px 0 0; font-size: 13px; + font-weight: 500; color: var(--color-text-muted); + border-bottom: 2px solid transparent; margin-bottom: -2px; + transition: color var(--transition), border-color var(--transition); +} +.tab-btn:hover { color: var(--color-text); } +.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); } + +/* ── Share / Emergency lists ────────────────────────────────────── */ +.share-list { display: flex; flex-direction: column; gap: 8px; } +.share-item { + display: flex; align-items: center; gap: 12px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); padding: 12px 14px; +} +.share-icon { font-size: 22px; flex-shrink: 0; } +.share-info { flex: 1; min-width: 0; } +.share-name { display: block; font-weight: 500; } +.share-meta { display: block; font-size: 12px; color: var(--color-text-muted); margin-top: 2px; } +.share-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; } + +/* ── Badges ──────────────────────────────────────────────────────── */ +.badge { display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 12px; font-weight: 500; } +.badge-warn { background: #fff8e1; color: #e65100; border: 1px solid #ffb74d; } +.badge-info { background: #e3f2fd; color: #1565c0; border: 1px solid #90caf9; } + +/* ── Settings modal ──────────────────────────────────────────────── */ +.modal-settings { max-width: 520px; } +.settings-section { padding: 16px 0; border-bottom: 1px solid var(--color-border); } +.settings-section:last-child { border-bottom: none; } +.settings-section-title { font-size: 15px; font-weight: 600; margin-bottom: 8px; } +.settings-desc { font-size: 13px; color: var(--color-text-muted); margin-bottom: 10px; line-height: 1.5; } +.mfa-qr { display: block; margin: 12px auto; max-width: 180px; border: 1px solid var(--color-border); border-radius: 4px; } +.mfa-secret-text { font-family: monospace; font-size: 12px; } +.mfa-secret-text code { background: var(--color-bg); padding: 2px 6px; border-radius: 4px; user-select: all; } + +/* ── Sort control ────────────────────────────────────────────────── */ +.sort-wrapper select { + padding: 6px 10px; + border: 1px solid var(--color-border); + border-radius: 20px; + background: var(--color-bg); + color: var(--color-text); + font-size: 13px; + cursor: pointer; + outline: none; +} +.sort-wrapper select:focus { border-color: var(--color-primary); } + +/* ── Modal item (wider for address/card forms) ───────────────────── */ +.modal-item { max-width: 540px; } + +/* ── Multi-column form rows ──────────────────────────────────────── */ +.form-row { display: flex; gap: 12px; } +.form-row .form-group { flex: 1; } + +/* ── Btn small ───────────────────────────────────────────────────── */ +.btn-sm { padding: 5px 12px; font-size: 12px; border-radius: 6px; } + +/* ── Security dashboard ──────────────────────────────────────────── */ +.security-dashboard { + flex: 1; overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.sec-notice { + text-align: center; color: var(--color-text-muted); + padding: 40px 0; font-size: 15px; +} + +.security-summary { + display: flex; + gap: 20px; + align-items: center; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 24px; + flex-wrap: wrap; +} + +.sec-score-card { text-align: center; flex-shrink: 0; } +.sec-score { + font-size: 56px; font-weight: 700; line-height: 1; +} +.sec-score.score-good { color: #2e7d32; } +.sec-score.score-fair { color: #e65100; } +.sec-score.score-poor { color: #c62828; } +.sec-score-label { font-size: 16px; font-weight: 600; margin-top: 4px; } +.sec-score-desc { font-size: 12px; color: var(--color-text-muted); margin-top: 2px; } + +.sec-stats { display: flex; gap: 16px; flex-wrap: wrap; } +.sec-stat { + text-align: center; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 12px 20px; + min-width: 80px; +} +.sec-stat-warn { border-color: #ffb74d; background: #fff8e1; } +.sec-stat-info { border-color: #90caf9; background: #e3f2fd; } +.sec-stat-ok { border-color: #a5d6a7; background: #e8f5e9; } +.sec-stat-num { display: block; font-size: 28px; font-weight: 700; } +.sec-stat-label { display: block; font-size: 12px; color: var(--color-text-muted); margin-top: 2px; } + +.sec-section { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + overflow: hidden; +} +.sec-section-header { + display: flex; align-items: flex-start; gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); +} +.sec-section-icon { font-size: 20px; flex-shrink: 0; } +.sec-section-title { font-weight: 600; font-size: 15px; } +.sec-section-desc { font-size: 12px; color: var(--color-text-muted); margin-top: 2px; } + +.sec-item-list { padding: 0; } +.sec-item { + display: flex; align-items: center; gap: 12px; + padding: 10px 20px; + border-bottom: 1px solid var(--color-border); +} +.sec-item:last-child { border-bottom: none; } +.sec-item-name { font-weight: 500; flex: 1; } +.sec-item-sub { font-size: 12px; color: var(--color-text-muted); flex: 1; } + +/* ── Item Detail Modal ───────────────────────────────────────────── */ +.modal-detail { max-height: 80vh; display: flex; flex-direction: column; } +.detail-body { flex: 1; overflow-y: auto; padding: 4px 0; } +.detail-field { display: flex; align-items: flex-start; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--color-border); } +.detail-field:last-child { border-bottom: none; } +.detail-label { font-size: 12px; color: var(--color-text-muted); width: 140px; flex-shrink: 0; padding-top: 4px; } +.detail-val-row { display: flex; align-items: center; gap: 6px; flex: 1; flex-wrap: wrap; } +.detail-val { font-size: 14px; word-break: break-all; } +.detail-sensitive { display: inline-flex; align-items: center; font-family: monospace; font-size: 14px; } +.detail-masked { letter-spacing: 2px; } +.detail-actual { word-break: break-all; } +.btn-reveal, .btn-copy-field { background: none; border: none; cursor: pointer; padding: 2px 4px; font-size: 14px; color: var(--color-text-muted); flex-shrink: 0; } +.btn-reveal:hover, .btn-copy-field:hover { color: var(--color-primary); } + +/* ── Emergency Vault Panel ───────────────────────────────────────── */ +.em-vault-panel { padding: 8px 0; } +.em-vault-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 0 16px; border-bottom: 2px solid var(--color-border); margin-bottom: 8px; } +.em-vault-header h4 { margin: 0; font-size: 15px; font-weight: 600; } +.em-vault-list { list-style: none; padding: 0; margin: 0; } +.em-vault-item { border-bottom: 1px solid var(--color-border); } +.em-vault-item:last-child { border-bottom: none; } +.em-vault-item-header { display: flex; align-items: center; gap: 10px; width: 100%; background: none; border: none; padding: 12px 4px; cursor: pointer; text-align: left; } +.em-vault-item-header:hover { background: var(--color-sidebar-hover); border-radius: var(--radius); } +.em-vault-icon { font-size: 18px; flex-shrink: 0; } +.em-vault-name { font-weight: 500; flex: 1; } +.em-vault-type { font-size: 12px; color: var(--color-text-muted); } +.em-vault-chevron { font-size: 12px; color: var(--color-text-muted); flex-shrink: 0; } +.em-vault-item-body { padding: 0 8px 12px 36px; } +.em-detail-body .detail-field { padding: 8px 0; } + +/* ── Responsive ──────────────────────────────────────────────────── */ +@media (max-width: 640px) { + .sidebar { width: 60px; } + .sidebar-header .logo-text, + .sidebar-label, + .sidebar-section-header { display: none; } + .sidebar-item { justify-content: center; padding: 12px 0; } + .vault-header { padding: 12px 16px; } + .vault-list { padding: 8px 16px 16px; } + .sort-wrapper { display: none; } + .form-row { flex-direction: column; gap: 0; } +} diff --git a/app/static/js/auth.js b/app/static/js/auth.js new file mode 100644 index 0000000..38cbfd4 --- /dev/null +++ b/app/static/js/auth.js @@ -0,0 +1,250 @@ +/** + * auth.js — Register and Login flows (including TOTP MFA step) + * + * Master password never leaves the browser. Only the PBKDF2-derived authHash + * is sent to the server for authentication. + */ + +const Auth = (() => { + // ── Helpers ────────────────────────────────────────────────────────────── + + function csrfToken() { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.content : ''; + } + + function showError(formEl, message) { + let el = formEl.querySelector('.form-error'); + if (!el) { + el = document.createElement('p'); + el.className = 'form-error'; + formEl.prepend(el); + } + el.textContent = message; + el.classList.remove('hidden'); + } + + function setLoading(btn, loading) { + btn.disabled = loading; + btn.textContent = loading ? btn.dataset.loadingText || 'Please wait…' : btn.dataset.originalText; + } + + // ── Register ───────────────────────────────────────────────────────────── + + async function handleRegister(e) { + e.preventDefault(); + const form = e.target; + const btn = form.querySelector('[type="submit"]'); + btn.dataset.originalText = btn.textContent; + + const email = form.email.value.trim().toLowerCase(); + const password = form.password.value; + const confirm = form.confirm_password.value; + + if (password !== confirm) { showError(form, 'Passwords do not match.'); return; } + if (password.length < 12) { showError(form, 'Master password must be at least 12 characters.'); return; } + + setLoading(btn, true); + try { + const authHash = await Crypto.deriveAuthHash(password, email); + const enc_key_salt = Crypto.generateSalt(16); + + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() }, + body: JSON.stringify({ email, auth_hash: authHash, enc_key_salt }), + }); + const data = await res.json(); + if (!res.ok) { showError(form, data.error || 'Registration failed.'); return; } + window.location.href = '/login?registered=1'; + } catch (err) { + showError(form, 'An unexpected error occurred. Please try again.'); + console.error(err); + } finally { + setLoading(btn, false); + } + } + + // ── Login ───────────────────────────────────────────────────────────────── + + // Temporarily held between Step 1 and Step 2 + let _pendingMfaToken = null; + let _pendingEncKeySalt = null; + let _pendingPassword = null; + + async function handleLogin(e) { + e.preventDefault(); + const form = e.target; + const btn = form.querySelector('[type="submit"]'); + btn.dataset.originalText = btn.textContent; + + const email = form.email.value.trim().toLowerCase(); + const password = form.password.value; + + setLoading(btn, true); + try { + const authHash = await Crypto.deriveAuthHash(password, email); + + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() }, + body: JSON.stringify({ email, auth_hash: authHash }), + }); + const data = await res.json(); + if (!res.ok) { showError(form, data.error || 'Invalid email or password.'); return; } + + if (data.mfa_required) { + // Step 2: collect TOTP code + _pendingMfaToken = data.mfa_token; + _pendingEncKeySalt = data.enc_key_salt; + _pendingPassword = password; + sessionStorage.setItem('enc_key_salt', data.enc_key_salt); + showMfaStep(); + return; + } + + await completeLogin(password, data); + } catch (err) { + showError(form, 'An unexpected error occurred. Please try again.'); + console.error(err); + } finally { + setLoading(btn, false); + } + } + + async function handleMfaVerify(e) { + e.preventDefault(); + const form = e.target; + const btn = form.querySelector('[type="submit"]'); + btn.dataset.originalText = btn.textContent; + const errEl = document.getElementById('mfa-error'); + errEl.classList.add('hidden'); + + const totp_code = document.getElementById('mfa-code').value.trim(); + if (!totp_code) return; + + setLoading(btn, true); + try { + const res = await fetch('/api/auth/mfa/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() }, + body: JSON.stringify({ mfa_token: _pendingMfaToken, totp_code }), + }); + const data = await res.json(); + if (!res.ok) { errEl.classList.remove('hidden'); return; } + + await completeLogin(_pendingPassword, { + access_token: data.access_token, + refresh_token: data.refresh_token, + enc_key_salt: _pendingEncKeySalt, + }); + } catch (err) { + errEl.classList.remove('hidden'); + console.error(err); + } finally { + setLoading(btn, false); + } + } + + async function completeLogin(password, data) { + sessionStorage.setItem('access_token', data.access_token); + localStorage.setItem('refresh_token', data.refresh_token); + sessionStorage.setItem('enc_key_salt', data.enc_key_salt); + + // Notify the browser extension (if installed) so it can share the session + window.dispatchEvent(new CustomEvent('passkeeper:session', { + detail: { + access_token: data.access_token, + refresh_token: data.refresh_token, + enc_key_salt: data.enc_key_salt, + }, + })); + + const vaultKey = await Crypto.deriveVaultKey(password, data.enc_key_salt); + VaultSession.setKey(vaultKey); + + window.location.href = '/vault'; + } + + function showMfaStep() { + document.getElementById('login-step-1').classList.add('hidden'); + document.getElementById('login-step-2').classList.remove('hidden'); + document.getElementById('mfa-code').focus(); + } + + function hideMfaStep() { + document.getElementById('login-step-2').classList.add('hidden'); + document.getElementById('login-step-1').classList.remove('hidden'); + document.getElementById('mfa-code').value = ''; + document.getElementById('mfa-error').classList.add('hidden'); + _pendingMfaToken = null; + _pendingEncKeySalt = null; + _pendingPassword = null; + } + + // ── Init ────────────────────────────────────────────────────────────────── + + function initPasswordToggle(btnId, inputId) { + const btn = document.getElementById(btnId); + const input = document.getElementById(inputId); + if (btn && input) { + btn.addEventListener('click', () => { + input.type = input.type === 'password' ? 'text' : 'password'; + }); + } + } + + function initStrengthMeter() { + const input = document.getElementById('password'); + const bar = document.getElementById('strength-bar'); + if (!input || !bar) return; + input.addEventListener('input', function () { + const v = this.value; + let score = 0; + if (v.length >= 12) score++; + if (v.length >= 16) score++; + if (/[A-Z]/.test(v)) score++; + if (/[0-9]/.test(v)) score++; + if (/[^A-Za-z0-9]/.test(v)) score++; + const labels = ['', 'Very weak', 'Weak', 'Fair', 'Strong', 'Very strong']; + const classes = ['', 'strength-1', 'strength-2', 'strength-3', 'strength-4', 'strength-5']; + bar.textContent = v ? labels[score] : ''; + bar.className = 'password-strength ' + (v ? classes[score] : ''); + }); + } + + function init() { + const registerForm = document.getElementById('register-form'); + if (registerForm) registerForm.addEventListener('submit', handleRegister); + + const loginForm = document.getElementById('login-form'); + if (loginForm) loginForm.addEventListener('submit', handleLogin); + + const mfaForm = document.getElementById('mfa-form'); + if (mfaForm) mfaForm.addEventListener('submit', handleMfaVerify); + + document.getElementById('btn-back-to-password')?.addEventListener('click', hideMfaStep); + + initPasswordToggle('toggle-login-pass', 'password'); + initPasswordToggle('toggle-reg-pass', 'password'); + initStrengthMeter(); + + if (window.location.search.includes('registered=1')) { + const notice = document.getElementById('register-notice'); + if (notice) notice.classList.remove('hidden'); + } + } + + return { init }; +})(); + +document.addEventListener('DOMContentLoaded', Auth.init); + +// ── VaultSession — holds the vault key for the lifetime of the browser tab ── +const VaultSession = (() => { + let _vaultKey = null; + function setKey(key) { _vaultKey = key; } + function getKey() { return _vaultKey; } + function clear() { _vaultKey = null; } + return { setKey, getKey, clear }; +})(); diff --git a/app/static/js/crypto.js b/app/static/js/crypto.js new file mode 100644 index 0000000..77b3cfd --- /dev/null +++ b/app/static/js/crypto.js @@ -0,0 +1,137 @@ +/** + * crypto.js — Zero-knowledge cryptography layer + * + * All encryption/decryption runs in the browser using the Web Crypto API. + * The vault key (AES-256-GCM) is derived from the master password client-side + * and is NEVER sent to the server. The server only stores encrypted blobs. + * + * Key derivation chain: + * authHash = PBKDF2(masterPassword, email, 100_000 iter, SHA-256) → sent to server for auth + * vaultKey = PBKDF2(masterPassword, enc_key_salt, 600_000 iter, SHA-256) → stays in memory only + */ + +const Crypto = (() => { + const subtle = window.crypto.subtle; + + // ── Helpers ────────────────────────────────────────────────────────────── + + function strToBytes(str) { + return new TextEncoder().encode(str); + } + + function base64ToBytes(b64) { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; + } + + function bytesToBase64(bytes) { + let bin = ''; + bytes.forEach(b => (bin += String.fromCharCode(b))); + return btoa(bin); + } + + // ── PBKDF2 key import ──────────────────────────────────────────────────── + + async function importPbkdf2Key(masterPassword) { + return subtle.importKey( + 'raw', + strToBytes(masterPassword), + 'PBKDF2', + false, // not extractable + ['deriveBits', 'deriveKey'] + ); + } + + // ── Auth hash (sent to server) ─────────────────────────────────────────── + + /** + * Derive an auth token from the master password. + * Used ONLY for server-side authentication — never for encryption. + * Returns a base64 string safe to POST to /api/auth/login|register. + */ + async function deriveAuthHash(masterPassword, email) { + const baseKey = await importPbkdf2Key(masterPassword); + const bits = await subtle.deriveBits( + { + name: 'PBKDF2', + salt: strToBytes(email.toLowerCase()), + iterations: 100_000, + hash: 'SHA-256', + }, + baseKey, + 256 + ); + return bytesToBase64(new Uint8Array(bits)); + } + + // ── Vault key (stays in memory) ────────────────────────────────────────── + + /** + * Derive the AES-256-GCM vault key from the master password. + * enc_key_salt is the base64-encoded 16-byte salt returned by the server on login. + * The returned CryptoKey is marked extractable:false — raw bytes cannot be read back. + */ + async function deriveVaultKey(masterPassword, enc_key_salt) { + const baseKey = await importPbkdf2Key(masterPassword); + return subtle.deriveKey( + { + name: 'PBKDF2', + salt: base64ToBytes(enc_key_salt), + iterations: 600_000, + hash: 'SHA-256', + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, // extractable:false — key material cannot be exported + ['encrypt', 'decrypt'] + ); + } + + // ── Encrypt / Decrypt ──────────────────────────────────────────────────── + + /** + * Encrypt a plain JS object with the vault key. + * A fresh random 12-byte IV is generated for every call (required for GCM). + * Returns { enc_data: base64, iv: base64 } + */ + async function encryptItem(vaultKey, plaintextObject) { + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const plaintext = strToBytes(JSON.stringify(plaintextObject)); + const ciphertext = await subtle.encrypt( + { name: 'AES-GCM', iv }, + vaultKey, + plaintext + ); + return { + enc_data: bytesToBase64(new Uint8Array(ciphertext)), + iv: bytesToBase64(iv), + }; + } + + /** + * Decrypt an encrypted vault item back to a JS object. + * enc_data and iv must be the base64 strings stored on the server. + */ + async function decryptItem(vaultKey, enc_data, iv) { + const plaintext = await subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBytes(iv) }, + vaultKey, + base64ToBytes(enc_data) + ); + return JSON.parse(new TextDecoder().decode(plaintext)); + } + + /** + * Generate a cryptographically random base64 string (for enc_key_salt). + * byteLength defaults to 16 (128-bit salt). + */ + function generateSalt(byteLength = 16) { + return bytesToBase64(window.crypto.getRandomValues(new Uint8Array(byteLength))); + } + + // ── Public API ─────────────────────────────────────────────────────────── + + return { deriveAuthHash, deriveVaultKey, encryptItem, decryptItem, generateSalt }; +})(); diff --git a/app/static/js/sharing.js b/app/static/js/sharing.js new file mode 100644 index 0000000..55e6a4d --- /dev/null +++ b/app/static/js/sharing.js @@ -0,0 +1,181 @@ +/** + * sharing.js — ECDH P-256 cryptography for zero-knowledge item sharing + * + * Each user has a P-256 keypair: + * - Public key : stored on server as base64 raw bytes (65-byte uncompressed point) + * - Private key : stored on server as JWK, AES-256-GCM encrypted with the user's vault key + * + * When Alice shares with Bob: + * 1. Alice fetches Bob's public key from the server + * 2. Alice derives an ECDH shared secret: ECDH(Alice_priv, Bob_pub) + * 3. Alice imports that shared secret as an AES-256-GCM key + * 4. Alice encrypts the item's plaintext → (enc_data, iv) + * 5. Alice POST /api/sharing with the ciphertext — server stores the blob + * + * When Bob decrypts: + * 1. Bob fetches Alice's public key from the server (returned in inbox response) + * 2. Bob derives the same ECDH shared secret: ECDH(Bob_priv, Alice_pub) ← commutative! + * 3. Bob decrypts enc_data with the derived key + * + * The server only ever sees ciphertext. Zero-knowledge. + */ + +const SharingCrypto = (() => { + const subtle = window.crypto.subtle; + + // ── Helpers (same encoding as crypto.js) ───────────────────────────────── + + function base64ToBytes(b64) { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; + } + + function bytesToBase64(bytes) { + let bin = ''; + bytes.forEach(b => (bin += String.fromCharCode(b))); + return btoa(bin); + } + + // ── Key generation ──────────────────────────────────────────────────────── + + /** + * Generate a fresh ECDH P-256 keypair. + * Both keys are extractable so they can be exported/stored. + */ + async function generateKeyPair() { + return subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'], + ); + } + + /** + * Export the public key as raw bytes (uncompressed point, 65 bytes) → base64. + * This is what gets stored on the server and shared with other users. + */ + async function exportPublicKey(publicKey) { + const raw = await subtle.exportKey('raw', publicKey); + return bytesToBase64(new Uint8Array(raw)); + } + + /** + * Encrypt the private key JWK with the user's vault key (AES-256-GCM). + * The resulting ciphertext is stored on the server — only the user can decrypt it. + */ + async function encryptPrivateKey(vaultKey, privateKey) { + const jwk = await subtle.exportKey('jwk', privateKey); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(JSON.stringify(jwk)); + const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, vaultKey, plaintext); + return { + private_key_enc: bytesToBase64(new Uint8Array(ciphertext)), + private_key_iv: bytesToBase64(iv), + }; + } + + /** + * Decrypt the private key JWK (fetched from server) using the user's vault key. + * Returns a CryptoKey usable for ECDH deriveBits. + */ + async function decryptPrivateKey(vaultKey, private_key_enc, private_key_iv) { + const plaintext = await subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBytes(private_key_iv) }, + vaultKey, + base64ToBytes(private_key_enc), + ); + const jwk = JSON.parse(new TextDecoder().decode(plaintext)); + return subtle.importKey( + 'jwk', + jwk, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + ['deriveBits'], + ); + } + + /** + * Import a remote user's public key from its base64 raw representation. + */ + async function importPublicKey(base64Raw) { + return subtle.importKey( + 'raw', + base64ToBytes(base64Raw), + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [], // public keys have no usages in WebCrypto ECDH + ); + } + + // ── Shared-secret derivation ────────────────────────────────────────────── + + /** + * Derive an AES-256-GCM CryptoKey from the ECDH shared secret. + * ECDH is commutative: ECDH(A_priv, B_pub) === ECDH(B_priv, A_pub). + */ + async function deriveSharedKey(myPrivateKey, theirPublicKey) { + const bits = await subtle.deriveBits( + { name: 'ECDH', public: theirPublicKey }, + myPrivateKey, + 256, + ); + return subtle.importKey( + 'raw', + bits, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'], + ); + } + + // ── Encrypt / Decrypt with shared key ──────────────────────────────────── + + async function encryptForShare(sharedKey, plaintextObject) { + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(JSON.stringify(plaintextObject)); + const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, plaintext); + return { + enc_data: bytesToBase64(new Uint8Array(ciphertext)), + iv: bytesToBase64(iv), + }; + } + + async function decryptShare(sharedKey, enc_data, iv) { + const plaintext = await subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBytes(iv) }, + sharedKey, + base64ToBytes(enc_data), + ); + return JSON.parse(new TextDecoder().decode(plaintext)); + } + + // ── Public API ──────────────────────────────────────────────────────────── + + return { + generateKeyPair, + exportPublicKey, + encryptPrivateKey, + decryptPrivateKey, + importPublicKey, + deriveSharedKey, + encryptForShare, + decryptShare, + }; +})(); + +/** + * SharingSession — holds the decrypted ECDH private key for the tab lifetime. + * Similar to VaultSession; cleared on sign-out. + */ +const SharingSession = (() => { + let _privateKey = null; + + function setKey(key) { _privateKey = key; } + function getKey() { return _privateKey; } + function clear() { _privateKey = null; } + function isReady() { return _privateKey !== null; } + + return { setKey, getKey, clear, isReady }; +})(); diff --git a/app/static/js/vault.js b/app/static/js/vault.js new file mode 100644 index 0000000..988fbc5 --- /dev/null +++ b/app/static/js/vault.js @@ -0,0 +1,1406 @@ +/** + * vault.js — Vault UI: all views (vault, security, sharing, emergency, settings) + * + * All sensitive data is decrypted client-side. The server only ever stores + * encrypted blobs (zero-knowledge architecture). + */ + +const Vault = (() => { + let _items = []; + let _folders = []; + let _currentView = 'vault'; + let _activeFilter = null; + let _sortOrder = 'name-asc'; + + const FIELD_LABELS = { + url: 'Website URL', username: 'Username', password: 'Password', notes: 'Notes', + note_body: 'Note', cardholder_name: 'Cardholder Name', card_number: 'Card Number', + expiry_month: 'Expiry Month', expiry_year: 'Expiry Year', cvv: 'CVV', + bank_name: 'Bank Name', account_type: 'Account Type', routing_number: 'Routing Number', + account_number: 'Account Number', first_name: 'First Name', last_name: 'Last Name', + company: 'Company', address_line: 'Street Address', city: 'City', state: 'State', + zip: 'ZIP', country: 'Country', phone: 'Phone', email: 'Email', + ssn_number: 'Social Security Number', + }; + const SENSITIVE_FIELDS = new Set(['password', 'cvv', 'card_number', 'account_number', 'routing_number', 'ssn_number']); + + // ── API helpers ─────────────────────────────────────────────────────────── + + async function apiFetch(path, options = {}) { + let token = sessionStorage.getItem('access_token'); + if (!token) { redirectToLogin(); return null; } + + const defaults = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + 'X-CSRFToken': csrfToken(), + }, + }; + const merged = { ...defaults, ...options }; + merged.headers = { ...defaults.headers, ...(options.headers || {}) }; + + let res = await fetch(path, merged); + + if (res.status === 401) { + const refreshed = await tryRefreshToken(); + if (!refreshed) { redirectToLogin(); return null; } + merged.headers['Authorization'] = `Bearer ${sessionStorage.getItem('access_token')}`; + res = await fetch(path, merged); + } + + if (!res.ok && res.status !== 404) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `HTTP ${res.status}`); + } + return res; + } + + let _refreshPromise = null; + async function tryRefreshToken() { + // Singleton: if a refresh is already in flight, reuse it so concurrent 401s + // don't each fire a separate rotation (which would blacklist each other's tokens). + if (_refreshPromise) return _refreshPromise; + _refreshPromise = (async () => { + const refreshToken = localStorage.getItem('refresh_token'); + if (!refreshToken) return false; + try { + const res = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + if (!res.ok) return false; + const data = await res.json(); + sessionStorage.setItem('access_token', data.access_token); + if (data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token); + return true; + } catch { return false; } + })().finally(() => { _refreshPromise = null; }); + return _refreshPromise; + } + + function csrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.content || ''; + } + + function redirectToLogin() { + VaultSession.clear(); + SharingSession.clear(); + sessionStorage.removeItem('access_token'); + window.dispatchEvent(new CustomEvent('passkeeper:logout')); + window.location.href = '/login'; + } + + // ── Load ────────────────────────────────────────────────────────────────── + + async function loadVault() { + const vaultKey = VaultSession.getKey(); + if (!vaultKey) { showUnlockOverlay(); return; } + showLoadingState(true); + try { + const [itemsRes, foldersRes] = await Promise.all([ + apiFetch('/api/vault'), apiFetch('/api/folders'), + ]); + if (!itemsRes || !foldersRes) { showUnlockOverlay(); return; } + + const rawItems = await itemsRes.json(); + _folders = await foldersRes.json(); + + _items = await Promise.all(rawItems.map(async item => { + try { + const plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv); + return { ...item, plain }; + } catch { + return { ...item, plain: null, decryptError: true }; + } + })); + + renderFolderList(); + applyCurrentFilter(); + } catch (err) { + showToast('Failed to load vault: ' + err.message, 'error'); + } finally { + showLoadingState(false); + } + } + + async function loadFolders() { + try { + const res = await apiFetch('/api/folders'); + if (!res) return; + _folders = await res.json(); + renderFolderList(); + } catch (err) { + showToast('Could not refresh folders: ' + err.message, 'error'); + } + } + + // ── View switching ──────────────────────────────────────────────────────── + + function switchView(view) { + _currentView = view; + ['vault', 'security', 'sharing', 'emergency'].forEach(v => { + document.getElementById(`view-${v}`)?.classList.toggle('hidden', v !== view); + }); + document.querySelectorAll('.sidebar-item[data-view]').forEach(el => { + el.classList.toggle('active', el.dataset.view === view); + }); + if (view !== 'vault') { + document.querySelectorAll('.sidebar-item[data-type-filter], .sidebar-item[data-folder-id]') + .forEach(el => el.classList.remove('active')); + } + + if (view === 'security') renderSecurityDashboard(); + if (view === 'sharing') loadSharingView(); + if (view === 'emergency') loadEmergencyView(); + } + + // ── Vault render ────────────────────────────────────────────────────────── + + function renderFolderList() { + const ul = document.getElementById('sidebar-folders'); + if (!ul) return; + ul.innerHTML = ''; + _folders.forEach(folder => { + const li = document.createElement('li'); + li.className = 'sidebar-item folder-item'; + li.dataset.folderId = folder.id; + + const label = document.createElement('span'); + label.className = 'folder-label'; + label.innerHTML = `📁 ${escHtml(folder.name)}`; + label.addEventListener('click', () => filterByFolder(folder.id, folder.name)); + + const delBtn = document.createElement('button'); + delBtn.className = 'btn-delete-folder'; + delBtn.title = 'Delete folder'; + delBtn.textContent = '🗑'; + delBtn.addEventListener('click', e => { e.stopPropagation(); confirmDeleteFolder(folder); }); + + li.appendChild(label); + li.appendChild(delBtn); + ul.appendChild(li); + }); + } + + function getSortedItems(items) { + const sorted = [...items]; + switch (_sortOrder) { + case 'name-asc': sorted.sort((a,b) => a.name.localeCompare(b.name)); break; + case 'name-desc': sorted.sort((a,b) => b.name.localeCompare(a.name)); break; + case 'date-desc': sorted.sort((a,b) => new Date(b.created_at) - new Date(a.created_at)); break; + case 'date-asc': sorted.sort((a,b) => new Date(a.created_at) - new Date(b.created_at)); break; + case 'folder': sorted.sort((a,b) => folderName(a.folder_id).localeCompare(folderName(b.folder_id)) || a.name.localeCompare(b.name)); break; + } + return sorted; + } + + function folderName(id) { + if (!id) return '(No folder)'; + return _folders.find(f => f.id === id)?.name || 'Unknown'; + } + + function renderItemList(items) { + const list = document.getElementById('vault-list'); + if (!list) return; + list.innerHTML = ''; + if (items.length === 0) { + list.innerHTML = '
  • No items found. Click + to add one.
  • '; + return; + } + + const sorted = getSortedItems(items); + const groups = {}; + sorted.forEach(item => { + const key = folderName(item.folder_id); + if (!groups[key]) groups[key] = []; + groups[key].push(item); + }); + + const keys = _sortOrder === 'folder' + ? Object.keys(groups).sort() + : ['(No folder)', ..._folders.map(f => f.name)].filter(k => groups[k]); + Object.keys(groups).forEach(k => { if (!keys.includes(k)) keys.push(k); }); + + keys.forEach(groupName => { + if (!groups[groupName]) return; + const header = document.createElement('li'); + header.className = 'vault-group-header'; + header.textContent = groupName; + list.appendChild(header); + groups[groupName].forEach(item => list.appendChild(createItemElement(item))); + }); + } + + function createItemElement(item) { + const li = document.createElement('li'); + li.className = 'vault-item'; + li.dataset.id = item.id; + + const iconMap = { password:'🔑', note:'📝', card:'💳', bank:'🏦', address:'🏠', ssn:'🪪', passkey:'🔐' }; + const icon = iconMap[item.item_type] || '🔑'; + + let subText = ''; + if (item.plain) { + switch (item.item_type) { + case 'password': subText = item.plain.username || item.plain.url || ''; break; + case 'note': subText = (item.plain.note_body || '').slice(0, 60); break; + case 'card': subText = item.plain.card_number ? '•••• ' + String(item.plain.card_number).replace(/\s/g,'').slice(-4) : ''; break; + case 'bank': subText = item.plain.bank_name || ''; break; + case 'address': subText = [item.plain.first_name, item.plain.last_name].filter(Boolean).join(' '); break; + case 'ssn': subText = '•••-••-••••'; break; + } + } + + const showCopyUser = item.item_type === 'password'; + const showCopyPass = ['password','card','bank','ssn'].includes(item.item_type); + + li.innerHTML = ` +
    ${icon}
    +
    + ${escHtml(item.name)} + ${escHtml(subText)} +
    +
    + ${showCopyUser ? `` : ''} + ${showCopyPass ? `` : ''} + + +
    `; + + if (showCopyUser) { + li.querySelector('[data-action="copy-user"]').addEventListener('click', e => { + e.stopPropagation(); copyToClipboard(item.plain?.username || '', 'Username copied'); + }); + } + if (showCopyPass) { + li.querySelector('[data-action="copy-pass"]').addEventListener('click', e => { + e.stopPropagation(); + const s = item.item_type === 'password' ? item.plain?.password + : item.item_type === 'card' ? item.plain?.cvv + : item.item_type === 'bank' ? item.plain?.account_number + : item.plain?.ssn_number; + copyToClipboard(s || '', 'Copied to clipboard'); + }); + } + li.querySelector('[data-action="edit"]').addEventListener('click', e => { e.stopPropagation(); openModal('edit', item); }); + li.querySelector('[data-action="delete"]').addEventListener('click', e => { e.stopPropagation(); confirmDeleteItem(item); }); + li.addEventListener('click', () => openModal('edit', item)); + return li; + } + + // ── Security Dashboard ──────────────────────────────────────────────────── + + function renderSecurityDashboard() { + const summaryEl = document.getElementById('security-summary'); + const sectionsEl = document.getElementById('security-sections'); + if (!summaryEl || !sectionsEl) return; + + const pwItems = _items.filter(i => i.item_type === 'password' && i.plain?.password); + if (!pwItems.length) { + summaryEl.innerHTML = '
    No passwords to analyze.
    '; + sectionsEl.innerHTML = ''; + return; + } + + const weak = pwItems.filter(i => { + const p = i.plain.password; + if (p.length < 10) return true; + return [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter(r => r.test(p)).length < 2; + }); + + const passCounts = {}; + pwItems.forEach(i => { const p = i.plain.password; (passCounts[p] = passCounts[p] || []).push(i); }); + const reused = Object.values(passCounts).filter(a => a.length > 1).flat(); + + const cutoff = Date.now() - 180 * 86400000; + const old = pwItems.filter(i => new Date(i.created_at).getTime() < cutoff); + + const total = pwItems.length; + const score = Math.max(0, Math.round(100 - (weak.length/total)*40 - (reused.length/total)*30 - (old.length/total)*15)); + const cls = score >= 80 ? 'score-good' : score >= 50 ? 'score-fair' : 'score-poor'; + const label = score >= 80 ? 'Good' : score >= 50 ? 'Fair' : 'Needs attention'; + + summaryEl.innerHTML = ` +
    +
    ${score}
    +
    ${label}
    +
    Based on ${total} password${total !== 1 ? 's' : ''}
    +
    +
    +
    ${weak.length}Weak
    +
    ${reused.length}Reused
    +
    ${old.length}Old (>180d)
    +
    `; + + sectionsEl.innerHTML = ''; + const makeSection = (title, icon, items, desc) => { + if (!items.length) return; + const sec = document.createElement('div'); + sec.className = 'sec-section'; + sec.innerHTML = ` +
    ${icon} +
    ${title} (${items.length})
    ${desc}
    +
    +
      + ${items.map(i => `
    • + ${escHtml(i.name)} + ${escHtml(i.plain?.username || '')} + +
    • `).join('')} +
    `; + sec.querySelectorAll('[data-sec-edit]').forEach(btn => { + btn.addEventListener('click', () => { + const item = _items.find(it => it.id === parseInt(btn.dataset.secEdit)); + if (item) { switchView('vault'); openModal('edit', item); } + }); + }); + sectionsEl.appendChild(sec); + }; + + makeSection('Weak Passwords', '⚠️', weak, 'Short or low-complexity passwords.'); + makeSection('Reused Passwords', '♻️', [...new Set(reused.map(i => i.id))].map(id => _items.find(i => i.id === id)).filter(Boolean), 'Same password used on multiple sites.'); + makeSection('Old Passwords', '🕐', old, 'Not changed in over 180 days.'); + } + + // ── Sharing View ────────────────────────────────────────────────────────── + + async function loadSharingView() { + try { + const keysRes = await apiFetch('/api/sharing/keys'); + if (!keysRes) return; + const keysData = await keysRes.json(); + + const notice = document.getElementById('sharing-keys-notice'); + if (!keysData.keys_setup) { + notice.classList.remove('hidden'); + } else { + notice.classList.add('hidden'); + // Load sharing private key into SharingSession if vault key available + const vaultKey = VaultSession.getKey(); + if (vaultKey && !SharingSession.isReady()) { + try { + const privKey = await SharingCrypto.decryptPrivateKey( + vaultKey, keysData.private_key_enc, keysData.private_key_iv + ); + SharingSession.setKey(privKey); + } catch (err) { + console.warn('[PassKeeper] Could not load sharing private key:', err); + } + } + } + + await Promise.all([loadSharedOut(), loadSharedIn()]); + } catch (err) { + showToast('Could not load sharing data: ' + err.message, 'error'); + } + } + + async function loadSharedOut() { + try { + const res = await apiFetch('/api/sharing'); + if (!res) return; + const shares = await res.json(); + renderSharedOut(shares); + } catch (err) { + console.error('loadSharedOut:', err); + } + } + + async function loadSharedIn() { + try { + const res = await apiFetch('/api/sharing/inbox'); + if (!res) return; + const shares = await res.json(); + renderSharedIn(shares); + } catch (err) { + console.error('loadSharedIn:', err); + } + } + + function renderSharedOut(shares) { + const ul = document.getElementById('shared-out-list'); + if (!ul) return; + if (!shares.length) { + ul.innerHTML = '
  • You haven\'t shared any items yet.
  • '; + return; + } + ul.innerHTML = shares.map(s => ` + `).join(''); + + ul.querySelectorAll('[data-revoke]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Remove this share?')) return; + try { + await apiFetch(`/api/sharing/${btn.dataset.revoke}`, { method: 'DELETE' }); + showToast('Share removed'); + loadSharedOut(); + } catch (err) { showToast(err.message, 'error'); } + }); + }); + } + + function renderSharedIn(shares) { + const ul = document.getElementById('shared-in-list'); + if (!ul) return; + if (!shares.length) { + ul.innerHTML = '
  • No items have been shared with you.
  • '; + return; + } + ul.innerHTML = shares.map(s => ` + `).join(''); + + ul.querySelectorAll('[data-accept]').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await apiFetch(`/api/sharing/inbox/${btn.dataset.accept}/accept`, { method: 'POST' }); + showToast('Share accepted'); + loadSharedIn(); + } catch (err) { showToast(err.message, 'error'); } + }); + }); + + ul.querySelectorAll('[data-view-share]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!SharingSession.isReady()) { showToast('Sharing keys not loaded. Check Settings.', 'error'); return; } + try { + const ownerPubKey = await SharingCrypto.importPublicKey(btn.dataset.ownerKey); + const sharedKey = await SharingCrypto.deriveSharedKey(SharingSession.getKey(), ownerPubKey); + const plain = await SharingCrypto.decryptShare(sharedKey, btn.dataset.enc, btn.dataset.iv); + showSharedItemDetails(btn.dataset.name, btn.dataset.type, plain); + } catch (err) { showToast('Could not decrypt: ' + err.message, 'error'); } + }); + }); + } + + function showSharedItemDetails(name, itemType, plain) { + document.getElementById('detail-modal-title').textContent = `${itemIcon(itemType || 'password')} ${name}`; + renderDetailFields(document.getElementById('detail-modal-body'), plain); + document.getElementById('detail-modal').classList.add('open'); + } + + function renderDetailFields(container, plain) { + container.innerHTML = Object.entries(plain) + .filter(([, v]) => v) + .map(([k, v]) => { + const label = FIELD_LABELS[k] || k.replace(/_/g, ' '); + const val = String(v); + const sensitive = SENSITIVE_FIELDS.has(k); + const valHtml = sensitive + ? ` + •••••••• + + + ` + : `${escHtml(val)}`; + return `
    + ${escHtml(label)} +
    + ${valHtml} + +
    +
    `; + }).join('') || '

    No fields to display.

    '; + + container.querySelectorAll('.btn-reveal').forEach(btn => { + btn.addEventListener('click', () => { + const span = btn.previousElementSibling; + const revealed = span.dataset.revealed === 'true'; + span.dataset.revealed = String(!revealed); + span.querySelector('.detail-masked').classList.toggle('hidden', !revealed); + span.querySelector('.detail-actual').classList.toggle('hidden', revealed); + }); + }); + container.querySelectorAll('.btn-copy-field').forEach(btn => { + btn.addEventListener('click', () => { + navigator.clipboard.writeText(btn.dataset.copy).then(() => showToast('Copied!')); + }); + }); + } + + async function handleShareFormSubmit(e) { + e.preventDefault(); + const errEl = document.getElementById('share-error'); + errEl.classList.add('hidden'); + + if (!SharingSession.isReady()) { + showToast('Set up sharing keys first (Settings)', 'error'); + return; + } + + const itemId = parseInt(document.getElementById('share-item-select').value); + const recipientEmail = document.getElementById('share-recipient-email').value.trim().toLowerCase(); + + if (!itemId || !recipientEmail) { + errEl.textContent = 'Please select an item and enter a recipient email.'; + errEl.classList.remove('hidden'); + return; + } + + try { + // Fetch recipient's public key + const pkRes = await apiFetch(`/api/sharing/public-key?email=${encodeURIComponent(recipientEmail)}`); + if (!pkRes) return; + const pkData = await pkRes.json(); + + const recipientPubKey = await SharingCrypto.importPublicKey(pkData.public_key); + const sharedKey = await SharingCrypto.deriveSharedKey(SharingSession.getKey(), recipientPubKey); + + // Find item plaintext + const item = _items.find(i => i.id === itemId); + if (!item?.plain) { showToast('Item not found or not decrypted', 'error'); return; } + + const { enc_data, iv } = await SharingCrypto.encryptForShare(sharedKey, item.plain); + + const res = await apiFetch('/api/sharing', { + method: 'POST', + body: JSON.stringify({ + item_id: itemId, + recipient_email: recipientEmail, + enc_data, iv, + item_name: item.name, + item_type: item.item_type, + }), + }); + if (!res) return; + + closeModal('share-modal'); + showToast('Item shared successfully'); + loadSharedOut(); + } catch (err) { + errEl.textContent = err.message; + errEl.classList.remove('hidden'); + } + } + + function openShareModal() { + const sel = document.getElementById('share-item-select'); + sel.innerHTML = _items.map(i => + `` + ).join(''); + document.getElementById('share-recipient-email').value = ''; + document.getElementById('share-error').classList.add('hidden'); + document.getElementById('share-modal').classList.add('open'); + } + + // ── Emergency Access View ───────────────────────────────────────────────── + + async function loadEmergencyView() { + try { + const res = await apiFetch('/api/emergency'); + if (!res) return; + const data = await res.json(); + renderEmergencyGrants(data.grants); + renderEmergencyAccess(data.access); + } catch (err) { + showToast('Could not load emergency access data: ' + err.message, 'error'); + } + } + + function renderEmergencyGrants(grants) { + const ul = document.getElementById('em-grants-list'); + if (!ul) return; + if (!grants.length) { + ul.innerHTML = '
  • No trusted contacts set up yet.
  • '; + return; + } + + ul.innerHTML = grants.map(g => { + let actions = ''; + if (g.status === 'accepted') { + actions = ``; + } + if (g.status === 'pending') { + const waitInfo = g.wait_elapsed ? 'Wait period elapsed' : 'Access requested'; + actions = `${waitInfo} + `; + } + return ``; + }).join(''); + + ul.querySelectorAll('[data-provide]').forEach(btn => { + btn.addEventListener('click', () => handleProvideEmergencyData(parseInt(btn.dataset.provide))); + }); + ul.querySelectorAll('[data-deny]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Deny this access request?')) return; + try { + await apiFetch(`/api/emergency/${btn.dataset.deny}/deny`, { method: 'POST' }); + showToast('Access request denied'); + loadEmergencyView(); + } catch (err) { showToast(err.message, 'error'); } + }); + }); + ul.querySelectorAll('[data-remove-ea]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Remove this emergency access grant?')) return; + try { + await apiFetch(`/api/emergency/${btn.dataset.removeEa}`, { method: 'DELETE' }); + showToast('Emergency access removed'); + loadEmergencyView(); + } catch (err) { showToast(err.message, 'error'); } + }); + }); + } + + async function handleProvideEmergencyData(eaId) { + if (!SharingSession.isReady()) { + showToast('Set up sharing keys first (Settings → Sharing Keys)', 'error'); + return; + } + if (!confirm('This will create an encrypted snapshot of all your vault items for your trusted contact. Continue?')) return; + + try { + // Get grantee's public key from the emergency record + const listRes = await apiFetch('/api/emergency'); + const listData = await listRes.json(); + const ea = listData.grants.find(g => g.id === eaId); + if (!ea) { showToast('Emergency access record not found', 'error'); return; } + + // Fetch grantee's public key + const pkRes = await apiFetch(`/api/sharing/public-key?email=${encodeURIComponent(ea.grantee_email)}`); + if (!pkRes) return; + const pkData = await pkRes.json(); + + const granteePubKey = await SharingCrypto.importPublicKey(pkData.public_key); + const sharedKey = await SharingCrypto.deriveSharedKey(SharingSession.getKey(), granteePubKey); + + // Re-encrypt all decrypted vault items + const encItems = await Promise.all( + _items.filter(i => i.plain).map(async i => { + const { enc_data, iv } = await SharingCrypto.encryptForShare(sharedKey, i.plain); + return { id: i.id, name: i.name, item_type: i.item_type, enc_data, iv }; + }) + ); + + await apiFetch(`/api/emergency/${eaId}/provide`, { + method: 'POST', + body: JSON.stringify({ enc_vault: JSON.stringify(encItems) }), + }); + + showToast('Recovery data provided'); + loadEmergencyView(); + } catch (err) { + showToast('Failed to provide recovery data: ' + err.message, 'error'); + } + } + + function renderEmergencyAccess(access) { + const ul = document.getElementById('em-access-list'); + if (!ul) return; + if (!access.length) { + ul.innerHTML = '
  • No one has designated you as an emergency contact.
  • '; + return; + } + + ul.innerHTML = access.map(a => { + let actions = ''; + if (a.status === 'invited') { + actions = ``; + } else if (a.status === 'ready') { + actions = ``; + } else if (a.status === 'pending') { + let daysLeft = a.wait_days; + if (a.request_initiated_at) { + const elapsed = (Date.now() - new Date(a.request_initiated_at).getTime()) / 86400000; + daysLeft = Math.max(0, Math.ceil(a.wait_days - elapsed)); + } + actions = a.wait_elapsed + ? `` + : `Waiting (~${daysLeft} day${daysLeft !== 1 ? 's' : ''} left)`; + } + return ``; + }).join(''); + + ul.querySelectorAll('[data-accept-ea]').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await apiFetch(`/api/emergency/${btn.dataset.acceptEa}/accept`, { method: 'POST' }); + showToast('Emergency access accepted'); + loadEmergencyView(); + } catch (err) { showToast(err.message, 'error'); } + }); + }); + ul.querySelectorAll('[data-request-ea]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Request emergency access? This will start the waiting period. The vault owner will be notified.')) return; + try { + await apiFetch(`/api/emergency/${btn.dataset.requestEa}/request`, { method: 'POST' }); + showToast('Access request sent — wait period has started'); + loadEmergencyView(); + } catch (err) { showToast(err.message, 'error'); } + }); + }); + ul.querySelectorAll('[data-get-vault]').forEach(btn => { + btn.addEventListener('click', () => handleGetEmergencyVault(parseInt(btn.dataset.getVault))); + }); + } + + async function handleGetEmergencyVault(eaId) { + if (!SharingSession.isReady()) { + showToast('Set up sharing keys first (Settings → Sharing Keys)', 'error'); + return; + } + try { + const res = await apiFetch(`/api/emergency/${eaId}/vault`); + if (!res) return; + const data = await res.json(); + + const grantorPubKey = await SharingCrypto.importPublicKey(data.grantor_public_key); + const sharedKey = await SharingCrypto.deriveSharedKey(SharingSession.getKey(), grantorPubKey); + + const encItems = JSON.parse(data.enc_vault); + const decItems = await Promise.all(encItems.map(async i => { + try { + const plain = await SharingCrypto.decryptShare(sharedKey, i.enc_data, i.iv); + return { ...i, plain }; + } catch { + return { ...i, plain: null }; + } + })); + + renderEmergencyVaultPanel(decItems); + } catch (err) { + showToast('Could not retrieve vault: ' + err.message, 'error'); + } + } + + function renderEmergencyVaultPanel(items) { + const list = document.getElementById('em-access-list'); + const panel = document.getElementById('em-vault-panel'); + const vaultList = document.getElementById('em-vault-list'); + if (!panel || !vaultList) return; + + list.classList.add('hidden'); + panel.classList.remove('hidden'); + + if (!items.length) { + vaultList.innerHTML = '
  • No items in emergency vault.
  • '; + return; + } + + vaultList.innerHTML = items.map((item, idx) => { + const icon = itemIcon(item.item_type); + const hasFields = item.plain && Object.values(item.plain).some(v => v); + return `
  • + + +
  • `; + }).join(''); + + // Populate detail fields and wire expand/collapse + items.forEach((item, idx) => { + const header = vaultList.querySelector(`[data-em-idx="${idx}"]`); + const body = document.getElementById(`em-vault-body-${idx}`); + const detailBody = body?.querySelector('.em-detail-body'); + if (detailBody && item.plain) renderDetailFields(detailBody, item.plain); + + header?.addEventListener('click', () => { + const expanded = header.getAttribute('aria-expanded') === 'true'; + header.setAttribute('aria-expanded', String(!expanded)); + header.querySelector('.em-vault-chevron').textContent = expanded ? '▸' : '▾'; + body?.classList.toggle('hidden', expanded); + }); + }); + + document.getElementById('btn-em-vault-back')?.addEventListener('click', () => { + panel.classList.add('hidden'); + list.classList.remove('hidden'); + }); + } + + // ── Account Settings ────────────────────────────────────────────────────── + + let _pendingMfaSecret = null; + + async function openSettingsModal() { + document.getElementById('settings-modal').classList.add('open'); + await Promise.all([loadMfaStatus(), loadSharingKeysStatus()]); + } + + async function loadMfaStatus() { + try { + const res = await apiFetch('/api/auth/mfa/status'); + if (!res) return; + const data = await res.json(); + renderMfaStatus(data.totp_enabled); + } catch (err) { + console.error('loadMfaStatus:', err); + } + } + + function renderMfaStatus(enabled) { + const statusText = document.getElementById('mfa-status-text'); + const actions = document.getElementById('mfa-actions'); + document.getElementById('mfa-setup-area').classList.add('hidden'); + document.getElementById('mfa-disable-area').classList.add('hidden'); + + if (enabled) { + statusText.textContent = '✅ Two-factor authentication is enabled.'; + actions.innerHTML = ''; + document.getElementById('btn-mfa-disable')?.addEventListener('click', () => { + document.getElementById('mfa-disable-area').classList.remove('hidden'); + document.getElementById('mfa-actions').innerHTML = ''; + document.getElementById('mfa-disable-code').focus(); + }); + } else { + statusText.textContent = 'Two-factor authentication is not enabled.'; + actions.innerHTML = ''; + document.getElementById('btn-mfa-setup')?.addEventListener('click', handleMfaSetup); + } + } + + async function handleMfaSetup() { + try { + const res = await apiFetch('/api/auth/mfa/setup'); + if (!res) return; + const data = await res.json(); + _pendingMfaSecret = data.secret; + + document.getElementById('mfa-qr-img').src = data.qr_code; + document.getElementById('mfa-secret-display').textContent = data.secret; + document.getElementById('mfa-setup-area').classList.remove('hidden'); + document.getElementById('mfa-actions').innerHTML = ''; + document.getElementById('mfa-status-text').textContent = 'Scan the QR code below with your authenticator app.'; + document.getElementById('mfa-verify-code').value = ''; + document.getElementById('mfa-verify-code').focus(); + } catch (err) { + showToast('Could not start MFA setup: ' + err.message, 'error'); + } + } + + async function handleMfaConfirm() { + const code = document.getElementById('mfa-verify-code').value.trim(); + if (!code || !_pendingMfaSecret) return; + try { + const res = await apiFetch('/api/auth/mfa/enable', { + method: 'POST', + body: JSON.stringify({ secret: _pendingMfaSecret, totp_code: code }), + }); + if (!res) return; + _pendingMfaSecret = null; + showToast('MFA enabled successfully'); + renderMfaStatus(true); + } catch (err) { + showToast('Failed to enable MFA: ' + err.message, 'error'); + } + } + + async function handleMfaDisableConfirm() { + const code = document.getElementById('mfa-disable-code').value.trim(); + if (!code) return; + try { + const res = await apiFetch('/api/auth/mfa/disable', { + method: 'POST', + body: JSON.stringify({ totp_code: code }), + }); + if (!res) return; + showToast('MFA disabled'); + renderMfaStatus(false); + } catch (err) { + showToast('Failed to disable MFA: ' + err.message, 'error'); + } + } + + async function loadSharingKeysStatus() { + try { + const res = await apiFetch('/api/sharing/keys'); + if (!res) return; + const data = await res.json(); + const statusEl = document.getElementById('sharing-keys-status'); + const actionsEl = document.getElementById('sharing-keys-actions'); + + if (data.keys_setup) { + statusEl.textContent = '✅ Sharing keys are set up. You can share items and use emergency access.'; + actionsEl.innerHTML = ''; + document.getElementById('btn-regen-keys')?.addEventListener('click', handleGenerateKeys); + } else { + statusEl.textContent = 'No sharing keys set up. Generate keys to enable item sharing and emergency access.'; + actionsEl.innerHTML = ''; + document.getElementById('btn-gen-keys')?.addEventListener('click', handleGenerateKeys); + } + } catch (err) { + console.error('loadSharingKeysStatus:', err); + } + } + + async function handleGenerateKeys() { + const vaultKey = VaultSession.getKey(); + if (!vaultKey) { showToast('Vault is locked. Unlock first.', 'error'); return; } + + try { + const keyPair = await SharingCrypto.generateKeyPair(); + const public_key = await SharingCrypto.exportPublicKey(keyPair.publicKey); + const { private_key_enc, private_key_iv } = await SharingCrypto.encryptPrivateKey(vaultKey, keyPair.privateKey); + + const res = await apiFetch('/api/sharing/keys', { + method: 'POST', + body: JSON.stringify({ public_key, private_key_enc, private_key_iv }), + }); + if (!res) return; + + // Cache private key in session + SharingSession.setKey(keyPair.privateKey); + + showToast('Sharing keys generated'); + await loadSharingKeysStatus(); + } catch (err) { + showToast('Key generation failed: ' + err.message, 'error'); + } + } + + // ── Folder CRUD ─────────────────────────────────────────────────────────── + + function showNewFolderRow() { + document.getElementById('new-folder-row').classList.remove('hidden'); + document.getElementById('new-folder-input').focus(); + } + + function hideNewFolderRow() { + document.getElementById('new-folder-row').classList.add('hidden'); + document.getElementById('new-folder-input').value = ''; + } + + async function handleNewFolder(e) { + e.preventDefault(); + const name = document.getElementById('new-folder-input').value.trim(); + if (!name) return; + try { + const res = await apiFetch('/api/folders', { method: 'POST', body: JSON.stringify({ name }) }); + if (!res) return; + hideNewFolderRow(); + await loadFolders(); + showToast('Folder created'); + } catch (err) { + showToast('Could not create folder: ' + err.message, 'error'); + } + } + + async function confirmDeleteFolder(folder) { + if (!confirm(`Delete folder "${folder.name}"?\nItems will be moved to No Folder.`)) return; + try { + const res = await apiFetch(`/api/folders/${folder.id}`, { method: 'DELETE' }); + if (!res) return; + _items = _items.map(i => i.folder_id === folder.id ? { ...i, folder_id: null } : i); + if (_activeFilter?.type === 'folder' && _activeFilter.value === folder.id) { + _activeFilter = null; + updateVaultTitle('All Items'); + } + await loadFolders(); + applyCurrentFilter(); + showToast('Folder deleted'); + } catch (err) { + showToast('Could not delete folder: ' + err.message, 'error'); + } + } + + // ── Filtering / Search ──────────────────────────────────────────────────── + + function applyCurrentFilter() { + if (!_activeFilter) { renderItemList(_items); return; } + if (_activeFilter.type === 'itemType') renderItemList(_items.filter(i => i.item_type === _activeFilter.value)); + else if (_activeFilter.type === 'folder') renderItemList(_items.filter(i => i.folder_id === _activeFilter.value)); + } + + function filterByFolder(folderId, name) { + _activeFilter = { type: 'folder', value: folderId }; + switchView('vault'); + document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active')); + document.querySelector(`[data-folder-id="${folderId}"]`)?.classList.add('active'); + updateVaultTitle(name); + renderItemList(_items.filter(i => i.folder_id === folderId)); + } + + function handleSearch(e) { + const q = e.target.value.trim().toLowerCase(); + if (!q) { applyCurrentFilter(); return; } + const pool = _activeFilter ? (_activeFilter.type === 'itemType' ? _items.filter(i => i.item_type === _activeFilter.value) : _items.filter(i => i.folder_id === _activeFilter.value)) : _items; + renderItemList(pool.filter(item => + item.name.toLowerCase().includes(q) || + (item.plain?.username || '').toLowerCase().includes(q) || + (item.plain?.url || '').toLowerCase().includes(q) || + (item.plain?.note_body || '').toLowerCase().includes(q) + )); + } + + function updateVaultTitle(text) { + const el = document.getElementById('vault-title'); + if (el) el.textContent = text; + } + + // ── Item Modal ──────────────────────────────────────────────────────────── + + const TYPE_LABELS = { password:'🔑 Password', note:'📝 Secure Note', card:'💳 Payment Card', bank:'🏦 Bank Account', address:'🏠 Address', ssn:'🪪 Identity (SSN)' }; + + function switchModalType(type) { + document.querySelectorAll('.type-fields').forEach(el => { + el.classList.toggle('hidden', !el.dataset.forTypes.split(' ').includes(type)); + }); + } + + function openModal(mode, item = null) { + const form = document.getElementById('item-form'); + form.reset(); + form.dataset.mode = mode; + form.dataset.itemId = item ? item.id : ''; + + const folderSel = document.getElementById('field-folder'); + folderSel.innerHTML = ''; + _folders.forEach(f => { + const opt = document.createElement('option'); + opt.value = f.id; + opt.textContent = f.name; + folderSel.appendChild(opt); + }); + + if (mode === 'add') { + document.getElementById('type-selector-group').classList.remove('hidden'); + document.getElementById('modal-title').textContent = 'Add Item'; + switchModalType(document.getElementById('field-type').value || 'password'); + } else { + document.getElementById('type-selector-group').classList.add('hidden'); + const t = item.item_type || 'password'; + document.getElementById('modal-title').textContent = `Edit ${TYPE_LABELS[t] || 'Item'}`; + switchModalType(t); + form.dataset.itemType = t; + setVal('field-name', item.name); + setVal('field-folder', item.folder_id || ''); + if (item.plain) { + const p = item.plain; + switch (t) { + case 'password': setVal('field-url',p.url); setVal('field-username',p.username); setVal('field-password',p.password); setVal('field-notes',p.notes); break; + case 'note': setVal('field-note-body',p.note_body); break; + case 'card': setVal('field-cardholder',p.cardholder_name); setVal('field-card-number',p.card_number); setVal('field-expiry-month',p.expiry_month); setVal('field-expiry-year',p.expiry_year); setVal('field-cvv',p.cvv); setVal('field-notes',p.notes); break; + case 'bank': setVal('field-bank-name',p.bank_name); setVal('field-account-type',p.account_type); setVal('field-routing',p.routing_number); setVal('field-account-number',p.account_number); setVal('field-notes',p.notes); break; + case 'address': ['first_name','last_name','company','address_line','city','state','zip','country','phone','email'].forEach(k => setVal('field-'+k.replace(/_/g,'-'), p[k])); setVal('field-notes',p.notes); break; + case 'ssn': setVal('field-ssn-number',p.ssn_number); setVal('field-notes',p.notes); break; + } + } + } + document.getElementById('item-modal').classList.add('open'); + document.getElementById('field-name').focus(); + } + + function setVal(id, val) { const el = document.getElementById(id); if (el) el.value = val || ''; } + function getVal(id) { const el = document.getElementById(id); return el ? el.value : ''; } + + function closeModal(id = 'item-modal') { + document.getElementById(id)?.classList.remove('open'); + } + + function buildPlainData(t) { + switch (t) { + case 'password': return { url: getVal('field-url').trim(), username: getVal('field-username').trim(), password: getVal('field-password'), notes: getVal('field-notes').trim() }; + case 'note': return { note_body: getVal('field-note-body') }; + case 'card': return { cardholder_name: getVal('field-cardholder').trim(), card_number: getVal('field-card-number').trim(), expiry_month: getVal('field-expiry-month'), expiry_year: getVal('field-expiry-year').trim(), cvv: getVal('field-cvv').trim(), notes: getVal('field-notes').trim() }; + case 'bank': return { bank_name: getVal('field-bank-name').trim(), account_type: getVal('field-account-type'), routing_number: getVal('field-routing').trim(), account_number: getVal('field-account-number').trim(), notes: getVal('field-notes').trim() }; + case 'address': return { first_name: getVal('field-first-name').trim(), last_name: getVal('field-last-name').trim(), company: getVal('field-company').trim(), address_line: getVal('field-address-line').trim(), city: getVal('field-city').trim(), state: getVal('field-state').trim(), zip: getVal('field-zip').trim(), country: getVal('field-country').trim(), phone: getVal('field-phone').trim(), email: getVal('field-email').trim(), notes: getVal('field-notes').trim() }; + case 'ssn': return { ssn_number: getVal('field-ssn-number'), notes: getVal('field-notes').trim() }; + default: return {}; + } + } + + async function handleFormSubmit(e) { + e.preventDefault(); + try { + const vaultKey = VaultSession.getKey(); + if (!vaultKey) { closeModal(); showUnlockOverlay(); return; } + const form = e.target; + const mode = form.dataset.mode; + const itemId = form.dataset.itemId; + const name = getVal('field-name').trim(); + if (!name) { showToast('Name is required', 'error'); return; } + + const itemType = mode === 'add' ? getVal('field-type') : (form.dataset.itemType || 'password'); + const { enc_data, iv } = await Crypto.encryptItem(vaultKey, buildPlainData(itemType)); + const folderVal = getVal('field-folder'); + const payload = { name, item_type: itemType, folder_id: folderVal ? parseInt(folderVal) : null, enc_data, iv }; + + if (mode === 'add') { + const res = await apiFetch('/api/vault', { method: 'POST', body: JSON.stringify(payload) }); + if (!res) return; + showToast('Item added'); + } else { + const res = await apiFetch(`/api/vault/${itemId}`, { method: 'PUT', body: JSON.stringify(payload) }); + if (!res) return; + showToast('Item updated'); + } + closeModal(); + await loadVault(); + } catch (err) { + showToast('Save failed: ' + err.message, 'error'); + } + } + + async function confirmDeleteItem(item) { + if (!confirm(`Delete "${item.name}"? This cannot be undone.`)) return; + try { + const res = await apiFetch(`/api/vault/${item.id}`, { method: 'DELETE' }); + if (!res) return; + showToast('Item deleted'); + await loadVault(); + } catch (err) { + showToast('Delete failed: ' + err.message, 'error'); + } + } + + // ── Logout ──────────────────────────────────────────────────────────────── + + async function handleLogout() { + const refreshToken = localStorage.getItem('refresh_token'); + await fetch('/api/auth/logout', { + method: 'POST', + headers: { 'Authorization': `Bearer ${sessionStorage.getItem('access_token') || ''}`, 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() }, + body: JSON.stringify({ refresh_token: refreshToken }), + }).catch(() => {}); + VaultSession.clear(); + SharingSession.clear(); + sessionStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + window.dispatchEvent(new CustomEvent('passkeeper:logout')); + window.location.href = '/login'; + } + + // ── Utilities ───────────────────────────────────────────────────────────── + + function copyToClipboard(text, msg = 'Copied') { + navigator.clipboard.writeText(text).then(() => showToast(msg)).catch(() => {}); + } + + function escHtml(str) { + return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + function itemIcon(type) { + return { password:'🔑', note:'📝', card:'💳', bank:'🏦', address:'🏠', ssn:'🪪' }[type] || '🔑'; + } + + function showToast(message, type = 'success') { + const toast = document.getElementById('toast'); + if (!toast) return; + toast.textContent = message; + toast.className = `toast toast-${type} show`; + clearTimeout(toast._timer); + toast._timer = setTimeout(() => toast.classList.remove('show'), 2500); + } + + function showLoadingState(loading) { + document.getElementById('vault-spinner')?.classList.toggle('hidden', !loading); + } + + // ── Unlock overlay ──────────────────────────────────────────────────────── + + function showUnlockOverlay() { + document.getElementById('unlock-overlay').classList.add('open'); + document.getElementById('unlock-password').focus(); + } + + async function handleUnlock(e) { + e.preventDefault(); + const password = document.getElementById('unlock-password').value; + const enc_key_salt = sessionStorage.getItem('enc_key_salt'); + const btn = e.target.querySelector('[type="submit"]'); + const errEl = document.getElementById('unlock-error'); + if (!enc_key_salt) { redirectToLogin(); return; } + + btn.disabled = true; btn.textContent = 'Unlocking…'; errEl.classList.add('hidden'); + try { + const vaultKey = await Crypto.deriveVaultKey(password, enc_key_salt); + VaultSession.setKey(vaultKey); + document.getElementById('unlock-overlay').classList.remove('open'); + await loadFolders(); + await loadVault(); + } catch { + errEl.classList.remove('hidden'); + } finally { + btn.disabled = false; btn.textContent = 'Unlock'; + document.getElementById('unlock-password').value = ''; + } + } + + // ── Tabs ────────────────────────────────────────────────────────────────── + + function initTabs(container) { + const tabs = container.querySelectorAll('.tab-btn'); + tabs.forEach(btn => { + btn.addEventListener('click', () => { + tabs.forEach(t => t.classList.remove('active')); + btn.classList.add('active'); + const target = btn.dataset.tab; + container.querySelectorAll('.tab-pane').forEach(pane => { + pane.classList.toggle('hidden', !pane.id.endsWith(target)); + }); + }); + }); + } + + // ── Init ────────────────────────────────────────────────────────────────── + + async function init() { + if (!sessionStorage.getItem('access_token')) { + // Give the extension bridge up to 800 ms to inject a session before redirecting + await new Promise(resolve => { + const timer = setTimeout(resolve, 800); + window.addEventListener('passkeeper:ext-login', () => { clearTimeout(timer); resolve(); }, { once: true }); + }); + if (!sessionStorage.getItem('access_token')) { window.location.href = '/login'; return; } + } + + // Vault + document.getElementById('unlock-form')?.addEventListener('submit', handleUnlock); + document.getElementById('btn-new-folder')?.addEventListener('click', showNewFolderRow); + document.getElementById('btn-cancel-folder')?.addEventListener('click', hideNewFolderRow); + document.getElementById('new-folder-form')?.addEventListener('submit', handleNewFolder); + document.getElementById('search-input')?.addEventListener('input', handleSearch); + document.getElementById('btn-add-item')?.addEventListener('click', () => openModal('add')); + document.getElementById('btn-close-modal')?.addEventListener('click', () => closeModal()); + document.getElementById('btn-cancel-modal')?.addEventListener('click', () => closeModal()); + document.getElementById('item-form')?.addEventListener('submit', handleFormSubmit); + document.getElementById('btn-logout')?.addEventListener('click', handleLogout); + + document.getElementById('sort-select')?.addEventListener('change', e => { _sortOrder = e.target.value; applyCurrentFilter(); }); + document.getElementById('field-type')?.addEventListener('change', e => switchModalType(e.target.value)); + document.getElementById('btn-toggle-pass')?.addEventListener('click', () => { const el = document.getElementById('field-password'); el.type = el.type === 'password' ? 'text' : 'password'; }); + document.getElementById('btn-toggle-ssn')?.addEventListener('click', () => { const el = document.getElementById('field-ssn-number'); el.type = el.type === 'password' ? 'text' : 'password'; }); + + // Sidebar navigation + document.getElementById('sidebar-all')?.addEventListener('click', () => { + _activeFilter = null; switchView('vault'); + document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active')); + document.getElementById('sidebar-all').classList.add('active'); + updateVaultTitle('All Items'); renderItemList(_items); + }); + document.getElementById('sidebar-security')?.addEventListener('click', () => switchView('security')); + document.getElementById('sidebar-sharing')?.addEventListener('click', () => switchView('sharing')); + document.getElementById('sidebar-emergency')?.addEventListener('click', () => switchView('emergency')); + + document.querySelectorAll('[data-type-filter]').forEach(el => { + el.addEventListener('click', () => { + const type = el.dataset.typeFilter; + const labels = { password:'Passwords', note:'Secure Notes', card:'Payment Cards', bank:'Bank Accounts', address:'Addresses', ssn:'Identities' }; + _activeFilter = { type: 'itemType', value: type }; + switchView('vault'); + document.querySelectorAll('.sidebar-item').forEach(s => s.classList.remove('active')); + el.classList.add('active'); + updateVaultTitle(labels[type] || type); + renderItemList(_items.filter(i => i.item_type === type)); + }); + }); + + document.getElementById('item-modal')?.addEventListener('click', e => { if (e.target.id === 'item-modal') closeModal(); }); + document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal(); closeModal('share-modal'); closeModal('emergency-modal'); closeModal('settings-modal'); closeModal('detail-modal'); } }); + + // Detail modal (shared item view) + const closeDetailModal = () => closeModal('detail-modal'); + document.getElementById('btn-close-detail-modal')?.addEventListener('click', closeDetailModal); + document.getElementById('btn-detail-done')?.addEventListener('click', closeDetailModal); + document.getElementById('detail-modal')?.addEventListener('click', e => { if (e.target.id === 'detail-modal') closeDetailModal(); }); + + document.getElementById('field-card-number')?.addEventListener('input', e => { + let v = e.target.value.replace(/\D/g,'').slice(0,16); + e.target.value = v.replace(/(.{4})/g,'$1 ').trim(); + }); + + // Settings modal + document.getElementById('btn-settings')?.addEventListener('click', openSettingsModal); + document.getElementById('btn-close-settings')?.addEventListener('click', () => closeModal('settings-modal')); + document.getElementById('settings-modal')?.addEventListener('click', e => { if (e.target.id === 'settings-modal') closeModal('settings-modal'); }); + document.getElementById('btn-mfa-confirm')?.addEventListener('click', handleMfaConfirm); + document.getElementById('btn-mfa-cancel-setup')?.addEventListener('click', () => { document.getElementById('mfa-setup-area').classList.add('hidden'); loadMfaStatus(); }); + document.getElementById('btn-mfa-disable-confirm')?.addEventListener('click', handleMfaDisableConfirm); + document.getElementById('btn-mfa-disable-cancel')?.addEventListener('click', () => { document.getElementById('mfa-disable-area').classList.add('hidden'); loadMfaStatus(); }); + + // Share modal + document.getElementById('btn-share-item')?.addEventListener('click', openShareModal); + document.getElementById('btn-close-share-modal')?.addEventListener('click', () => closeModal('share-modal')); + document.getElementById('btn-cancel-share')?.addEventListener('click', () => closeModal('share-modal')); + document.getElementById('share-modal')?.addEventListener('click', e => { if (e.target.id === 'share-modal') closeModal('share-modal'); }); + document.getElementById('share-form')?.addEventListener('submit', handleShareFormSubmit); + document.getElementById('btn-setup-keys-from-sharing')?.addEventListener('click', () => { closeModal('share-modal'); openSettingsModal(); }); + + // Emergency modal + document.getElementById('btn-add-emergency')?.addEventListener('click', () => { + document.getElementById('emergency-error').classList.add('hidden'); + document.getElementById('emergency-modal').classList.add('open'); + }); + document.getElementById('btn-close-emergency-modal')?.addEventListener('click', () => closeModal('emergency-modal')); + document.getElementById('btn-cancel-emergency')?.addEventListener('click', () => closeModal('emergency-modal')); + document.getElementById('emergency-modal')?.addEventListener('click', e => { if (e.target.id === 'emergency-modal') closeModal('emergency-modal'); }); + document.getElementById('emergency-form')?.addEventListener('submit', handleEmergencyFormSubmit); + + // Tabs + document.querySelectorAll('.panel-tabs').forEach(c => initTabs(c)); + + if (!VaultSession.getKey()) { showUnlockOverlay(); } else { loadVault(); } + } + + async function handleEmergencyFormSubmit(e) { + e.preventDefault(); + const errEl = document.getElementById('emergency-error'); + errEl.classList.add('hidden'); + const email = document.getElementById('emergency-email').value.trim().toLowerCase(); + const waitDays = document.getElementById('emergency-wait-days').value; + if (!email) { errEl.textContent = 'Email is required.'; errEl.classList.remove('hidden'); return; } + try { + const res = await apiFetch('/api/emergency', { + method: 'POST', + body: JSON.stringify({ grantee_email: email, wait_days: parseInt(waitDays) }), + }); + if (!res) return; + closeModal('emergency-modal'); + showToast('Invitation sent'); + loadEmergencyView(); + } catch (err) { + errEl.textContent = err.message; + errEl.classList.remove('hidden'); + } + } + + return { init }; +})(); + +document.addEventListener('DOMContentLoaded', Vault.init); diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..f463d7c --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block title %}Sign In — PassKeeper{% endblock %} +{% block body_class %}auth-page{% endblock %} + +{% block body %} +
    +
    + + + + + +
    +

    Sign in

    +
    +
    + + +
    +
    + +
    + + +
    +
    + +
    + +
    + + + + +
    +
    +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..5adfe30 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}Create Account — PassKeeper{% endblock %} +{% block body_class %}auth-page{% endblock %} + +{% block body %} +
    +
    + + +

    Create account

    + +
    + Important: Your master password encrypts all your data. + It is never sent to our servers. If you forget it, your vault cannot be recovered. +
    + +
    +
    + + +
    +
    + +
    + + +
    +
    +
    +
    + + +
    + +
    + + +
    +
    +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..e6aede6 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,20 @@ + + + + + + + + {% block title %}PassKeeper{% endblock %} + + {% block head_extra %}{% endblock %} + + + {% block body %}{% endblock %} + +
    + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html new file mode 100644 index 0000000..cc9d3e4 --- /dev/null +++ b/app/templates/vault/index.html @@ -0,0 +1,419 @@ +{% extends "base.html" %} +{% block title %}My Vault — PassKeeper{% endblock %} +{% block body_class %}vault-page{% endblock %} + +{% block body %} +
    + + + + + +
    + + +
    +
    +

    All Items

    +
    +
    + 🔍 + +
    +
    + +
    + +
    +
    + +
      +
      + + + + + + + + + + +
      +
      + + + + + + + + + + + + + + + + + + + +{% endblock %} + +{% block scripts %} + + + + +{% endblock %} diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..8e6d0b8 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,122 @@ +/** + * extension/background.js — Manifest V3 service worker. + * + * Responsibilities: + * - Update the action badge (number of matching vault items) for the active tab. + * - Bridge SAVE_CREDENTIALS messages from content script → chrome.storage.session + * so the popup can pick them up as a save-prompt. + * - Re-update badges when the vault cache changes. + */ + +// ── Badge helpers ──────────────────────────────────────────────────────────── + +async function updateBadgeForTab(tabId, url) { + try { + const { vault_items } = await chrome.storage.session.get('vault_items'); + if (!vault_items?.length) { + chrome.action.setBadgeText({ text: '', tabId }); + return; + } + + let hostname; + try { hostname = new URL(url).hostname.replace(/^www\./, ''); } + catch { chrome.action.setBadgeText({ text: '', tabId }); return; } + + const matches = vault_items.filter(item => { + if (item.item_type !== 'password' || !item.plain?.url) return false; + try { + const h = new URL(item.plain.url).hostname.replace(/^www\./, ''); + return h === hostname || h.endsWith(`.${hostname}`) || hostname.endsWith(`.${h}`); + } catch { return false; } + }); + + if (matches.length > 0) { + chrome.action.setBadgeText({ text: String(matches.length), tabId }); + chrome.action.setBadgeBackgroundColor({ color: '#1a73e8', tabId }); + } else { + chrome.action.setBadgeText({ text: '', tabId }); + } + } catch { /* tab may have closed */ } +} + +async function refreshAllBadges() { + const tabs = await chrome.tabs.query({}); + for (const tab of tabs) { + if (tab.url?.startsWith('http')) updateBadgeForTab(tab.id, tab.url); + } +} + +// ── Tab events ─────────────────────────────────────────────────────────────── + +chrome.tabs.onActivated.addListener(async ({ tabId }) => { + try { + const tab = await chrome.tabs.get(tabId); + if (tab.url?.startsWith('http')) updateBadgeForTab(tabId, tab.url); + } catch {} +}); + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === 'complete' && tab.url?.startsWith('http')) { + updateBadgeForTab(tabId, tab.url); + } +}); + +// ── Message handler ────────────────────────────────────────────────────────── + +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + + // Popup signals vault cache was refreshed → re-check all tab badges. + if (msg.type === 'VAULT_UPDATED') { + refreshAllBadges(); + sendResponse({ ok: true }); + return false; + } + + // Content script detected credentials on form submit → store for save-prompt. + if (msg.type === 'SAVE_CREDENTIALS') { + chrome.storage.session.set({ pending_save: msg.data }); + sendResponse({ ok: true }); + return false; + } + + // Bridge: web app logged in → store tokens in extension storage. + if (msg.type === 'WEB_SESSION_SYNC') { + chrome.storage.session.set({ + access_token: msg.access_token, + enc_key_salt: msg.enc_key_salt || '', + }); + // Also persist enc_key_salt locally so the unlock-only view survives browser restarts + chrome.storage.local.set({ + refresh_token: msg.refresh_token, + ...(msg.enc_key_salt ? { enc_key_salt: msg.enc_key_salt } : {}), + }); + sendResponse({ ok: true }); + return false; + } + + // Bridge: web app logged out → clear extension session. + if (msg.type === 'WEB_SESSION_CLEAR') { + chrome.storage.session.remove(['access_token', 'vault_key_jwk', 'vault_items', 'enc_key_salt']); + chrome.storage.local.remove('refresh_token'); + sendResponse({ ok: true }); + return false; + } + + // Popup logged in via extension → inject session into open vault tabs. + if (msg.type === 'EXT_SESSION_SYNC') { + chrome.tabs.query({ url: 'https://pwkeeper.ngodanguyen.tech/*' }, tabs => { + tabs.forEach(tab => { + chrome.tabs.sendMessage(tab.id, { + type: 'INJECT_SESSION', + access_token: msg.access_token, + refresh_token: msg.refresh_token, + enc_key_salt: msg.enc_key_salt, + }).catch(() => {}); + }); + }); + sendResponse({ ok: true }); + return false; + } + + return false; +}); diff --git a/extension/bridge/bridge.js b/extension/bridge/bridge.js new file mode 100644 index 0000000..1a50458 --- /dev/null +++ b/extension/bridge/bridge.js @@ -0,0 +1,111 @@ +/** + * extension/bridge/bridge.js + * + * Content script injected ONLY on pwkeeper.ngodanguyen.tech. + * + * Web app → Extension: + * Listens for the 'passkeeper:session' custom event dispatched by auth.js + * after login, and forwards access_token + refresh_token + enc_key_salt to + * the background service worker via chrome.runtime.sendMessage. + * + * Extension → Web app: + * Listens for INJECT_SESSION messages from the background (triggered when the + * user logs in through the popup). Writes tokens into the web app's storage + * and fires 'passkeeper:ext-login' so auth.js can unlock the vault overlay. + */ +(() => { + 'use strict'; + + // ── Web app → Extension ──────────────────────────────────────────────────── + + function syncToExtension(access_token, refresh_token, enc_key_salt) { + if (!access_token || !refresh_token) return; + chrome.runtime.sendMessage({ + type: 'WEB_SESSION_SYNC', + access_token, + refresh_token, + enc_key_salt: enc_key_salt || '', + }).catch(() => {}); + } + + // Fired by auth.js completeLogin + window.addEventListener('passkeeper:session', e => { + const { access_token, refresh_token, enc_key_salt } = e.detail || {}; + syncToExtension(access_token, refresh_token, enc_key_salt); + }); + + // Fired by auth.js on logout / sign-out + window.addEventListener('passkeeper:logout', () => { + chrome.runtime.sendMessage({ type: 'WEB_SESSION_CLEAR' }).catch(() => {}); + }); + + // On initial page load: sync in both directions + + // Web → Extension: if the web app is already logged in, forward tokens to extension + const existing_token = sessionStorage.getItem('access_token'); + const existing_refresh = localStorage.getItem('refresh_token'); + const existing_salt = sessionStorage.getItem('enc_key_salt'); + if (existing_token && existing_refresh) { + syncToExtension(existing_token, existing_refresh, existing_salt); + } + + // Extension → Web: if the web app has no session but the extension does, + // refresh the access token first (so we inject a guaranteed-fresh pair) then + // fire passkeeper:ext-login so vault.js can proceed without hitting 401. + if (!existing_token) { + (async () => { + try { + const local = await chrome.storage.local.get(['refresh_token', 'enc_key_salt']); + const refresh = local.refresh_token; + const salt = local.enc_key_salt || + (await chrome.storage.session.get('enc_key_salt')).enc_key_salt || ''; + if (!refresh || !salt) return; + + // Exchange the stored refresh token for a fresh access token + const res = await fetch(`${location.origin}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refresh }), + }); + if (!res.ok) return; + const data = await res.json(); + + const freshAccess = data.access_token; + const freshRefresh = data.refresh_token || refresh; + + // Write fresh tokens into the page session so vault.js works immediately + sessionStorage.setItem('access_token', freshAccess); + localStorage.setItem('refresh_token', freshRefresh); + sessionStorage.setItem('enc_key_salt', salt); + + // Keep extension storage in sync with the rotated tokens + chrome.storage.session.set({ access_token: freshAccess, enc_key_salt: salt }); + chrome.storage.local.set({ refresh_token: freshRefresh, enc_key_salt: salt }); + + window.dispatchEvent(new CustomEvent('passkeeper:ext-login', { + detail: { enc_key_salt: salt }, + })); + } catch {} + })(); + } + + // ── Extension → Web app ──────────────────────────────────────────────────── + + chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'INJECT_SESSION') { + // Write tokens so the web app is effectively logged in + sessionStorage.setItem('access_token', msg.access_token); + localStorage.setItem('refresh_token', msg.refresh_token); + if (msg.enc_key_salt) sessionStorage.setItem('enc_key_salt', msg.enc_key_salt); + + // Tell the web app a session was injected — vault.js listens for this + // to dismiss the unlock overlay and load the vault + window.dispatchEvent(new CustomEvent('passkeeper:ext-login', { + detail: { enc_key_salt: msg.enc_key_salt }, + })); + + sendResponse({ ok: true }); + } + return false; + }); +})(); diff --git a/extension/content/content.js b/extension/content/content.js new file mode 100644 index 0000000..89e0451 --- /dev/null +++ b/extension/content/content.js @@ -0,0 +1,345 @@ +/** + * extension/content/content.js — PassKeeper content script. + * + * 1. Detects login forms → notifies background (badge count). + * 2. Injects a small autofill button next to password fields when vault + * has matching credentials for the current site. + * 3. Listens for DO_AUTOFILL from the popup → fills fields. + * 4. Watches form submissions → shows save-credentials banner. + */ +(() => { + 'use strict'; + + const PK_ATTR = 'data-pk-decorated'; + const PK_BTN_CLASS = '__pk_autofill_btn__'; + + let _bannerEl = null; + let _hasNotifiedForm = false; + let _formObserver = null; + + // ── Helpers ────────────────────────────────────────────────────────────────── + + function escHtml(str) { + return String(str ?? '').replace(/&/g,'&').replace(//g,'>'); + } + + function visiblePasswordFields() { + return Array.from(document.querySelectorAll('input[type="password"]')) + .filter(el => el.offsetParent !== null && !el.disabled); + } + + function findUsernameField(pwField) { + 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 (!el.offsetParent || el.disabled) continue; + if (['email', 'text', 'tel'].includes(el.type)) return el; + } + return null; + } + + // ── Framework-compatible fill ───────────────────────────────────────────────── + + function fillField(el, value) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + if (setter) setter.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 #1a73e8'; + setTimeout(() => { el.style.outline = ''; }, 1500); + }); + } + + // ── Inline autofill button ──────────────────────────────────────────────────── + + /** + * Inject a small PassKeeper icon button just inside the right edge of each + * password field. Clicking it opens a tiny dropdown listing matching items. + */ + function injectAutofillButtons(matchingItems) { + visiblePasswordFields().forEach(pwField => { + if (pwField.getAttribute(PK_ATTR)) return; // already decorated + pwField.setAttribute(PK_ATTR, '1'); + + // Wrap the field if it isn't already positioned + const wrap = document.createElement('div'); + wrap.style.cssText = 'position:relative;display:inline-block;width:100%;'; + pwField.parentNode.insertBefore(wrap, pwField); + wrap.appendChild(pwField); + + // Add right-side padding so text doesn't overlap the button + pwField.style.paddingRight = '32px'; + + // The icon button + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = PK_BTN_CLASS; + btn.title = 'Autofill with PassKeeper'; + btn.setAttribute('aria-label', 'Autofill with PassKeeper'); + btn.style.cssText = [ + 'position:absolute', + 'right:6px', + 'top:50%', + 'transform:translateY(-50%)', + 'background:none', + 'border:none', + 'cursor:pointer', + 'padding:3px', + 'display:flex', + 'align-items:center', + 'justify-content:center', + 'z-index:2147483646', + ].join(';'); + + btn.innerHTML = ` + + + + `; + + wrap.appendChild(btn); + + btn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + toggleDropdown(btn, pwField, matchingItems); + }); + }); + } + + // Dropdown showing matching credentials + function toggleDropdown(btn, pwField, items) { + // Remove any existing dropdown + const existing = document.getElementById('__pk_dropdown__'); + if (existing) { existing.remove(); return; } + + const usernameField = findUsernameField(pwField); + + const dropdown = document.createElement('div'); + dropdown.id = '__pk_dropdown__'; + dropdown.style.cssText = [ + 'position:fixed', + 'background:#fff', + 'border:1px solid #e2e8f0', + 'border-radius:10px', + 'box-shadow:0 8px 30px rgba(0,0,0,0.15)', + 'z-index:2147483647', + 'min-width:240px', + 'max-width:300px', + 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', + 'font-size:13px', + 'overflow:hidden', + ].join(';'); + + // Position below the button + const rect = btn.getBoundingClientRect(); + dropdown.style.top = (rect.bottom + 6) + 'px'; + dropdown.style.left = Math.max(8, rect.right - 260) + 'px'; + + // Header + const header = document.createElement('div'); + header.style.cssText = 'padding:9px 12px 8px;border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:6px;'; + header.innerHTML = `PassKeeper`; + dropdown.appendChild(header); + + if (!items.length) { + const empty = document.createElement('div'); + empty.style.cssText = 'padding:14px 12px;color:#9ca3af;font-size:12px;text-align:center;'; + empty.textContent = 'No matching credentials'; + dropdown.appendChild(empty); + } else { + items.forEach(item => { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;gap:9px;padding:9px 12px;cursor:pointer;transition:background 0.1s;'; + row.onmouseenter = () => { row.style.background = '#f9fafb'; }; + row.onmouseleave = () => { row.style.background = ''; }; + + const username = escHtml(item.plain?.username || ''); + const name = escHtml(item.name); + + row.innerHTML = ` +
      🔑
      +
      +
      ${name}
      + ${username ? `
      ${username}
      ` : ''} +
      `; + + row.addEventListener('click', () => { + if (usernameField && item.plain?.username) fillField(usernameField, item.plain.username); + if (item.plain?.password) fillField(pwField, item.plain.password); + dropdown.remove(); + }); + + dropdown.appendChild(row); + }); + } + + document.body.appendChild(dropdown); + + // Close on outside click + const close = e => { + if (!dropdown.contains(e.target) && e.target !== btn) { + dropdown.remove(); + document.removeEventListener('click', close, true); + } + }; + setTimeout(() => document.addEventListener('click', close, true), 0); + } + + // ── Form detection ──────────────────────────────────────────────────────────── + + function notifyFormDetected() { + if (_hasNotifiedForm) return; + if (!visiblePasswordFields().length) return; + _hasNotifiedForm = true; + chrome.runtime.sendMessage({ type: 'FORMS_DETECTED' }).catch(() => {}); + } + + async function decorateFields() { + if (!visiblePasswordFields().length) return; + + // Get cached vault items from session storage + const { vault_items } = await chrome.storage.session.get('vault_items').catch(() => ({})); + if (!vault_items?.length) return; + + const host = location.hostname.replace(/^www\./, ''); + const matching = vault_items.filter(item => { + if (item.item_type !== 'password' || !item.plain?.url) return false; + try { + const h = new URL(item.plain.url).hostname.replace(/^www\./, ''); + return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`); + } catch { return false; } + }); + + injectAutofillButtons(matching); + } + + // ── Auto-save banner ────────────────────────────────────────────────────────── + + function showSaveBanner(username, password) { + if (_bannerEl) _bannerEl.remove(); + + const 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', + }); + + const site = escHtml(location.hostname); + const user = escHtml(username); + + banner.innerHTML = ` +
      + + Save to PassKeeper? + +
      +

      + ${user} on ${site} +

      +
      + + +
      `; + + document.body.appendChild(banner); + _bannerEl = banner; + + const dismiss = () => { 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', () => { + chrome.runtime.sendMessage({ + type: 'SAVE_CREDENTIALS', + data: { url: location.href, siteName: document.title || location.hostname, username, password }, + }).catch(() => {}); + dismiss(); + }); + + setTimeout(dismiss, 20_000); + } + + // ── Form submission watch ───────────────────────────────────────────────────── + + function watchSubmissions() { + document.addEventListener('submit', e => { + const form = e.target; + const pwField = form.querySelector('input[type="password"]:not([disabled])'); + if (!pwField?.value) return; + + const userField = findUsernameField(pwField) + ?? form.querySelector('input[type="email"]:not([disabled])') + ?? form.querySelector('input[type="text"]:not([disabled])'); + + const username = userField?.value?.trim() || ''; + const password = pwField.value; + + if (username && password) { + setTimeout(() => showSaveBanner(username, password), 500); + } + }, true); + } + + // ── Message listener ────────────────────────────────────────────────────────── + + chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'DO_AUTOFILL') { + doAutofill(msg.username, msg.password); + sendResponse({ ok: true }); + } + if (msg.type === 'VAULT_UPDATED') { + // Re-decorate fields with fresh data + document.querySelectorAll(`[${PK_ATTR}]`).forEach(el => el.removeAttribute(PK_ATTR)); + document.querySelectorAll(`.${PK_BTN_CLASS}`).forEach(el => el.remove()); + decorateFields(); + } + return false; + }); + + // ── Init ────────────────────────────────────────────────────────────────────── + + function init() { + notifyFormDetected(); + decorateFields(); + watchSubmissions(); + + _formObserver = new MutationObserver(() => { + _hasNotifiedForm = false; + notifyFormDetected(); + decorateFields(); + }); + _formObserver.observe(document.body, { childList: true, subtree: true }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/extension/icons/icon128.png b/extension/icons/icon128.png new file mode 100644 index 0000000..5922c72 Binary files /dev/null and b/extension/icons/icon128.png differ diff --git a/extension/icons/icon16.png b/extension/icons/icon16.png new file mode 100644 index 0000000..db84a62 Binary files /dev/null and b/extension/icons/icon16.png differ diff --git a/extension/icons/icon48.png b/extension/icons/icon48.png new file mode 100644 index 0000000..315b80e Binary files /dev/null and b/extension/icons/icon48.png differ diff --git a/extension/make_icons.py b/extension/make_icons.py new file mode 100644 index 0000000..a5aa755 --- /dev/null +++ b/extension/make_icons.py @@ -0,0 +1,93 @@ +""" +Generate PassKeeper extension icons (16×16, 48×48, 128×128 PNG). + +Requires Pillow (already a project dependency via qrcode[pil]): + python extension/make_icons.py +""" +import os +import math +from PIL import Image, ImageDraw + +SIZES = [16, 48, 128] +OUT_DIR = os.path.join(os.path.dirname(__file__), 'icons') +BG_COLOR = '#1a73e8' # Google-blue background +FG_COLOR = '#ffffff' # White lock + + +def draw_icon(size: int) -> Image.Image: + img = Image.new('RGBA', (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Rounded-rectangle background + pad = max(1, size // 10) + draw.rounded_rectangle( + [pad, pad, size - pad - 1, size - pad - 1], + radius=max(2, size // 6), + fill=BG_COLOR, + ) + + # ── Lock body (rectangle with rounded bottom) ────────────────────────── + bx = size * 0.28 + by = size * 0.50 + bw = size * 0.44 + bh = size * 0.36 + draw.rounded_rectangle( + [bx, by, bx + bw, by + bh], + radius=max(1, int(size * 0.07)), + fill=FG_COLOR, + ) + + # Keyhole + kr = max(1, int(size * 0.07)) + kx = size / 2 + ky = by + bh * 0.40 + draw.ellipse([kx - kr, ky - kr, kx + kr, ky + kr], fill=BG_COLOR) + # Small stem below the hole + stem_w = max(1, int(size * 0.06)) + draw.rectangle( + [kx - stem_w, ky, kx + stem_w, ky + bh * 0.30], + fill=BG_COLOR, + ) + + # ── Shackle (arc) ────────────────────────────────────────────────────── + sw = max(1, int(size * 0.09)) # stroke width + slm = size * 0.28 # left margin of shackle oval + srm = size * 0.72 # right margin + st = size * 0.15 # top of shackle + sb = size * 0.58 # bottom of shackle (overlaps lock body top) + + # Draw as thick arc by layering concentric arcs + for offset in range(sw): + f = offset / max(sw - 1, 1) + draw.arc( + [slm + offset, st + offset, srm - offset, sb - offset], + start=180, end=0, + fill=FG_COLOR, + width=1, + ) + + # Simpler approach: draw white arc with width parameter (Pillow 8+) + try: + draw.arc( + [slm, st, srm, sb], + start=180, end=0, + fill=FG_COLOR, + width=sw, + ) + except TypeError: + pass # older Pillow — arcs from the loop above are sufficient + + return img + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + for s in SIZES: + path = os.path.join(OUT_DIR, f'icon{s}.png') + draw_icon(s).save(path, 'PNG') + print(f' OK {path}') + print('Icons generated.') + + +if __name__ == '__main__': + main() diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..da58bc3 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,44 @@ +{ + "manifest_version": 3, + "name": "PassKeeper", + "version": "1.0.0", + "description": "Autofill and manage your PassKeeper vault", + + "permissions": ["storage", "activeTab", "tabs"], + "host_permissions": [ + "https://pwkeeper.ngodanguyen.tech/*" + ], + + "background": { + "service_worker": "background.js" + }, + + "action": { + "default_popup": "popup/popup.html", + "default_title": "PassKeeper", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + + "content_scripts": [ + { + "matches": ["http://*/*", "https://*/*"], + "js": ["content/content.js"], + "run_at": "document_idle" + }, + { + "matches": ["https://pwkeeper.ngodanguyen.tech/*"], + "js": ["bridge/bridge.js"], + "run_at": "document_idle" + } + ], + + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/extension/popup/popup.css b/extension/popup/popup.css new file mode 100644 index 0000000..763c979 --- /dev/null +++ b/extension/popup/popup.css @@ -0,0 +1,253 @@ +/* PassKeeper Extension Popup */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + width: 320px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + color: #1a1a2e; + background: #fff; + overflow: hidden; +} + +.view { display: flex; flex-direction: column; min-height: 0; } +.view.hidden { display: none; } + +/* ── Login ───────────────────────────────────────────────────────── */ +.login-header { + display: flex; + flex-direction: column; + align-items: center; + padding: 28px 20px 18px; + background: linear-gradient(135deg, #c0392b, #922b21); + color: #fff; +} +.login-logo { font-size: 36px; margin-bottom: 8px; } +.login-header h1 { font-size: 18px; font-weight: 600; letter-spacing: 0.3px; } + +.login-body { padding: 18px 20px 20px; } +.pk-hint { font-size: 12px; color: #666; margin-bottom: 12px; text-align: center; } + +.form-group { margin-bottom: 11px; } +.form-group label { display: block; font-size: 11px; color: #555; font-weight: 500; margin-bottom: 3px; } +.form-group input { + width: 100%; padding: 8px 11px; + border: 1px solid #ddd; border-radius: 6px; + font-size: 13px; outline: none; background: #fff; color: #1a1a2e; + transition: border-color 0.15s, box-shadow 0.15s; +} +.form-group input:focus { border-color: #c0392b; box-shadow: 0 0 0 2px rgba(192,57,43,0.12); } + +/* ── Buttons ─────────────────────────────────────────────────────── */ +.btn-primary { + display: block; width: 100%; padding: 9px; + background: #c0392b; color: #fff; + border: none; border-radius: 6px; + font-size: 13px; font-weight: 600; cursor: pointer; + margin-top: 10px; transition: background 0.15s; +} +.btn-primary:hover { background: #a93226; } +.btn-primary:disabled { background: #e8a49e; cursor: default; } +.btn-sm { width: auto; padding: 6px 16px; margin-top: 8px; } + +.btn-ghost { + display: block; width: 100%; padding: 9px; + background: transparent; color: #c0392b; + border: 1px solid #e5c0bb; border-radius: 6px; + font-size: 13px; font-weight: 500; cursor: pointer; + margin-top: 6px; transition: background 0.15s; +} +.btn-ghost:hover { background: #fdf2f1; } + +/* ── Vault top bar ───────────────────────────────────────────────── */ +.vault-topbar { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 10px 8px; + background: #fff; + border-bottom: 1px solid #f0f0f0; +} + +.search-wrap { + flex: 1; + display: flex; + align-items: center; + background: #f3f4f6; + border-radius: 8px; + padding: 6px 10px; + gap: 7px; +} +.search-icon { flex-shrink: 0; opacity: 0.6; } +#vault-search { + flex: 1; border: none; background: transparent; + font-size: 13px; outline: none; color: #1a1a2e; +} +#vault-search::placeholder { color: #9ca3af; } + +.btn-vault-link { + display: inline-flex; align-items: center; gap: 5px; + padding: 6px 10px; + background: #1a1a2e; color: #fff; + border: none; border-radius: 7px; + font-size: 12px; font-weight: 600; cursor: pointer; + text-decoration: none; + white-space: nowrap; + transition: background 0.15s; + flex-shrink: 0; +} +.btn-vault-link:hover { background: #2d2d4e; } + +.btn-add { + width: 32px; height: 32px; + background: #f3f4f6; color: #1a1a2e; + border: none; border-radius: 7px; + font-size: 20px; line-height: 1; cursor: pointer; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + transition: background 0.15s; +} +.btn-add:hover { background: #e5e7eb; } + +/* ── Save prompt ─────────────────────────────────────────────────── */ +.save-prompt { + background: #fef9f0; + border-bottom: 2px solid #f59e0b; + padding: 10px 14px 12px; +} +.save-prompt.hidden { display: none; } +.save-prompt-title { + display: flex; align-items: center; gap: 6px; + font-size: 12px; font-weight: 600; color: #92400e; + margin-bottom: 8px; +} +.save-dismiss { + margin-left: auto; background: none; border: none; + cursor: pointer; color: #92400e; font-size: 14px; line-height: 1; +} +.save-prompt .form-group { margin-bottom: 6px; } + +/* ── Tabs ────────────────────────────────────────────────────────── */ +.vault-tabs { + display: flex; + gap: 0; + padding: 8px 10px 0; + border-bottom: 1px solid #f0f0f0; + background: #fff; + overflow-x: auto; + scrollbar-width: none; +} +.vault-tabs::-webkit-scrollbar { display: none; } + +.tab-btn { + padding: 6px 12px 8px; + background: none; border: none; + font-size: 12px; font-weight: 500; + color: #6b7280; cursor: pointer; + border-bottom: 2px solid transparent; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; + margin-bottom: -1px; +} +.tab-btn:hover { color: #1a1a2e; } +.tab-btn.active { color: #1a1a2e; border-bottom-color: #1a1a2e; font-weight: 600; } + +/* ── Vault list ──────────────────────────────────────────────────── */ +.vault-list { + max-height: 280px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #e5e7eb transparent; +} +.vault-list::-webkit-scrollbar { width: 4px; } +.vault-list::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 2px; } + +.vault-item { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid #f3f4f6; + cursor: default; + transition: background 0.1s; +} +.vault-item:hover { background: #f9fafb; } +.vault-item:last-child { border-bottom: none; } + +.item-avatar { + width: 36px; height: 36px; + border-radius: 8px; + background: #1e2d5a; + display: flex; align-items: center; justify-content: center; + font-size: 17px; + flex-shrink: 0; + overflow: hidden; +} +.item-avatar.color-red { background: #7f1d1d; } +.item-avatar.color-green { background: #14532d; } +.item-avatar.color-purple { background: #3b0764; } +.item-avatar.color-teal { background: #134e4a; } + +.item-avatar img { width: 22px; height: 22px; border-radius: 3px; } + +.item-info { flex: 1; min-width: 0; } +.item-site { + font-size: 12px; font-weight: 600; color: #374151; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.item-name { + font-size: 11px; color: #9ca3af; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + margin-top: 1px; +} + +.item-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; } + +.btn-item-action { + background: none; border: none; cursor: pointer; + padding: 4px 6px; border-radius: 5px; color: #6b7280; + display: flex; align-items: center; justify-content: center; + transition: background 0.12s, color 0.12s; +} +.btn-item-action:hover { background: #f3f4f6; color: #1a1a2e; } +.btn-item-action svg { width: 16px; height: 16px; } + +.badge-match { + display: inline-block; + background: #dbeafe; color: #1d4ed8; + font-size: 9px; font-weight: 700; + padding: 1px 5px; border-radius: 8px; + letter-spacing: 0.3px; margin-left: 4px; + vertical-align: middle; +} + +/* ── States ──────────────────────────────────────────────────────── */ +.pk-error { + font-size: 12px; color: #b91c1c; + background: #fef2f2; border-radius: 5px; + padding: 7px 10px; margin-bottom: 10px; +} +.pk-error.hidden { display: none; } + +.pk-loading, .pk-empty { + text-align: center; color: #9ca3af; + padding: 28px 16px; font-size: 13px; +} +.pk-loading.hidden, .pk-empty.hidden { display: none; } + +/* ── Bottom nav ──────────────────────────────────────────────────── */ +.bottom-nav { + display: flex; + border-top: 1px solid #f0f0f0; + background: #fff; +} +.nav-btn { + flex: 1; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 3px; + padding: 8px 6px; + background: none; border: none; cursor: pointer; + color: #9ca3af; + font-size: 10px; font-weight: 500; + transition: color 0.15s; +} +.nav-btn:hover { color: #374151; } +.nav-btn.active { color: #1a1a2e; } diff --git a/extension/popup/popup.html b/extension/popup/popup.html new file mode 100644 index 0000000..6e9dc18 --- /dev/null +++ b/extension/popup/popup.html @@ -0,0 +1,144 @@ + + + + + PassKeeper + + + +
      + + + + + + + + + + + + + +
      + + + + diff --git a/extension/popup/popup.js b/extension/popup/popup.js new file mode 100644 index 0000000..e10686c --- /dev/null +++ b/extension/popup/popup.js @@ -0,0 +1,542 @@ +/** + * extension/popup/popup.js — PassKeeper popup UI + * + * Storage layout: + * chrome.storage.session — access_token, vault_key_jwk, vault_items (cleared on browser close) + * chrome.storage.local — refresh_token, enc_key_salt (persists across restarts) + * + * SSO flow (web app → extension): + * bridge.js syncs tokens to background → background writes to chrome.storage. + * On popup open: if session has no vault_key but local has enc_key_salt + refresh_token, + * we refresh the access_token and show the unlock-only view (master password only). + */ + +const API_BASE = 'https://pwkeeper.ngodanguyen.tech'; +const VAULT_URL = 'https://pwkeeper.ngodanguyen.tech/vault'; + +// ── State ───────────────────────────────────────────────────────────────────── + +let _vaultKey = null; +let _items = []; +let _mfaToken = null; +let _currentUrl = ''; +let _activeTab = 'relevant'; + +// ── DOM helpers ─────────────────────────────────────────────────────────────── + +const $ = id => document.getElementById(id); + +function showView(name) { + ['login', 'mfa', 'unlock', 'vault'].forEach(v => + $(`view-${v}`).classList.toggle('hidden', v !== name) + ); +} + +function showError(elId, msg) { + const el = $(elId); + el.textContent = msg; + el.classList.remove('hidden'); +} + +function hideError(elId) { $(elId).classList.add('hidden'); } + +function escHtml(str) { + return String(str ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// ── Avatar helpers ──────────────────────────────────────────────────────────── + +const AVATAR_COLORS = ['', 'color-red', 'color-green', 'color-purple', 'color-teal']; + +function avatarColor(str) { + let h = 0; + for (const c of str) h = (h * 31 + c.charCodeAt(0)) >>> 0; + return AVATAR_COLORS[h % AVATAR_COLORS.length]; +} + +function itemEmoji(type) { + return { password: '🔑', note: '📝', card: '💳', bank: '🏦', address: '🏠', ssn: '🪪', passkey: '🔐' }[type] || '🔑'; +} + +function siteLabel(item) { + if (item.plain?.url) { + try { return new URL(item.plain.url).hostname.replace(/^www\./, ''); } catch {} + } + return item.name; +} + +// ── Domain matching ─────────────────────────────────────────────────────────── + +function currentHostname() { + if (!_currentUrl) return ''; + try { return new URL(_currentUrl).hostname.replace(/^www\./, ''); } catch { return ''; } +} + +function isMatch(item) { + const host = currentHostname(); + if (!host || item.item_type !== 'password' || !item.plain?.url) return false; + try { + const h = new URL(item.plain.url).hostname.replace(/^www\./, ''); + return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`); + } catch { return false; } +} + +// ── API helpers ─────────────────────────────────────────────────────────────── + +async function apiFetch(path, options = {}) { + const { access_token } = await chrome.storage.session.get('access_token'); + const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; + if (access_token) headers['Authorization'] = `Bearer ${access_token}`; + + let res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + + if (res.status === 401) { + const refreshed = await tryRefreshToken(); + if (!refreshed) { signOut(); return null; } + const { access_token: tok } = await chrome.storage.session.get('access_token'); + headers['Authorization'] = `Bearer ${tok}`; + res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + } + return res; +} + +async function tryRefreshToken() { + const { refresh_token } = await chrome.storage.local.get('refresh_token'); + if (!refresh_token) return false; + try { + const res = await fetch(`${API_BASE}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token }), + }); + if (!res.ok) return false; + const data = await res.json(); + await chrome.storage.session.set({ access_token: data.access_token }); + if (data.refresh_token) await chrome.storage.local.set({ refresh_token: data.refresh_token }); + return true; + } catch { return false; } +} + +// ── Auth ────────────────────────────────────────────────────────────────────── + +async function handleLogin() { + const email = $('login-email').value.trim().toLowerCase(); + const password = $('login-password').value; + hideError('login-error'); + if (!email || !password) { showError('login-error', 'Email and master password are required.'); return; } + + const btn = $('btn-login'); + btn.disabled = true; btn.textContent = 'Unlocking…'; + try { + const authHash = await ExtCrypto.deriveAuthHash(password, email); + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, auth_hash: authHash }), + }); + const data = await res.json(); + if (!res.ok) { showError('login-error', data.error || 'Login failed.'); return; } + + if (data.mfa_required) { + _mfaToken = data.mfa_token; + await chrome.storage.session.set({ _pending_enc_key_salt: data.enc_key_salt }); + handleMfaStage(password); + showView('mfa'); + $('mfa-code').focus(); + return; + } + await completeLogin(data, password); + } catch (err) { + showError('login-error', 'Network error: ' + err.message); + } finally { + btn.disabled = false; btn.textContent = 'Unlock Vault'; + } +} + +function handleMfaStage(password) { + $('btn-mfa-verify').onclick = async () => { + const code = $('mfa-code').value.trim(); + hideError('mfa-error'); + if (code.length !== 6) { showError('mfa-error', 'Enter the 6-digit code.'); return; } + $('btn-mfa-verify').disabled = true; + try { + const res = await fetch(`${API_BASE}/api/auth/mfa/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mfa_token: _mfaToken, totp_code: code }), + }); + const data = await res.json(); + if (!res.ok) { showError('mfa-error', data.error || 'Verification failed.'); return; } + _mfaToken = null; + const { _pending_enc_key_salt } = await chrome.storage.session.get('_pending_enc_key_salt'); + await completeLogin({ ...data, enc_key_salt: _pending_enc_key_salt }, password); + } catch (err) { + showError('mfa-error', 'Error: ' + err.message); + } finally { + $('btn-mfa-verify').disabled = false; + } + }; +} + +async function completeLogin(data, masterPassword) { + await chrome.storage.session.set({ access_token: data.access_token, enc_key_salt: data.enc_key_salt }); + // enc_key_salt also persisted locally — not sensitive without the master password, + // needed to show unlock-only view after browser restart. + await chrome.storage.local.set({ refresh_token: data.refresh_token, enc_key_salt: data.enc_key_salt }); + + _vaultKey = await ExtCrypto.deriveVaultKey(masterPassword, data.enc_key_salt); + const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey); + await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk }); + + // Push session to any open web app tabs so they don't need to re-login + chrome.runtime.sendMessage({ + type: 'EXT_SESSION_SYNC', + access_token: data.access_token, + refresh_token: data.refresh_token, + enc_key_salt: data.enc_key_salt, + }).catch(() => {}); + + showView('vault'); + await checkPendingSave(); + await fetchAndDecryptVault(); + renderList(); +} + +// ── Unlock-only (tokens from web app, just need master password) ────────────── + +async function handleUnlockOnly() { + const password = $('unlock-password').value; + hideError('unlock-error'); + if (!password) { showError('unlock-error', 'Enter your master password.'); return; } + + const btn = $('btn-unlock'); + btn.disabled = true; btn.textContent = 'Unlocking…'; + try { + // enc_key_salt may be in session (fresh) or local (after browser restart) + let { enc_key_salt } = await chrome.storage.session.get('enc_key_salt'); + if (!enc_key_salt) { + const local = await chrome.storage.local.get('enc_key_salt'); + enc_key_salt = local.enc_key_salt; + } + if (!enc_key_salt) { showError('unlock-error', 'Session expired. Please log in again.'); return; } + + _vaultKey = await ExtCrypto.deriveVaultKey(password, enc_key_salt); + const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey); + await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk }); + + showView('vault'); + await checkPendingSave(); + await fetchAndDecryptVault(); + renderList(); + } catch { + showError('unlock-error', 'Incorrect password or session expired.'); + } finally { + btn.disabled = false; btn.textContent = 'Unlock'; + } +} + +async function signOut() { + try { + const { access_token } = await chrome.storage.session.get('access_token'); + const { refresh_token } = await chrome.storage.local.get('refresh_token'); + if (access_token) { + fetch(`${API_BASE}/api/auth/logout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${access_token}` }, + body: JSON.stringify({ refresh_token }), + }).catch(() => {}); + } + } catch {} + await chrome.storage.session.clear(); + await chrome.storage.local.remove(['refresh_token', 'enc_key_salt']); + _vaultKey = null; _items = []; + showView('login'); +} + +// ── Vault loading ───────────────────────────────────────────────────────────── + +/** + * Returns: + * 'vault' — fully unlocked session restored + * 'unlock' — tokens exist (from web app SSO) but vault key needs deriving + * false — no session, show login + */ +async function restoreSessionIfAvailable() { + const data = await chrome.storage.session.get(['access_token', 'vault_key_jwk', 'vault_items', 'enc_key_salt']); + + if (data.access_token) { + if (data.vault_key_jwk) { + _vaultKey = await ExtCrypto.importVaultKey(data.vault_key_jwk); + _items = data.vault_items || []; + return 'vault'; + } + // Tokens exist (synced from web app) but vault key not yet derived + if (data.enc_key_salt) return 'unlock'; + } + + // Session cleared (e.g. browser restart) — try refreshing with persisted local tokens + const local = await chrome.storage.local.get(['refresh_token', 'enc_key_salt']); + if (local.refresh_token && local.enc_key_salt) { + const refreshed = await tryRefreshToken(); + if (refreshed) { + await chrome.storage.session.set({ enc_key_salt: local.enc_key_salt }); + return 'unlock'; + } + } + + return false; +} + +async function fetchAndDecryptVault() { + $('vault-spinner').classList.remove('hidden'); + try { + const res = await apiFetch('/api/vault'); + if (!res) return; + const raw = await res.json(); + + _items = await Promise.all(raw.map(async item => { + try { + const plain = await ExtCrypto.decryptItem(_vaultKey, item.enc_data, item.iv); + return { ...item, plain }; + } catch { return { ...item, plain: null }; } + })); + + await chrome.storage.session.set({ vault_items: _items }); + chrome.runtime.sendMessage({ type: 'VAULT_UPDATED' }).catch(() => {}); + } catch (err) { + console.error('fetchAndDecryptVault:', err); + } finally { + $('vault-spinner').classList.add('hidden'); + } +} + +// ── Rendering ───────────────────────────────────────────────────────────────── + +function getTabItems() { + const q = $('vault-search').value.trim().toLowerCase(); + + let items = _items; + + // Text filter + if (q) { + items = items.filter(i => + i.name.toLowerCase().includes(q) || + (i.plain?.username || '').toLowerCase().includes(q) || + (i.plain?.url || '').toLowerCase().includes(q) + ); + } + + if (_activeTab === 'relevant') { + // Show matching items first, then others (only if there are matches or no query) + const matches = items.filter(isMatch); + const rest = items.filter(i => !isMatch(i)); + // If we have matches, show matches first; otherwise fall back to all + items = matches.length ? [...matches, ...rest] : items; + // Sort matched first by name, rest by name + items = [ + ...matches.sort((a, b) => a.name.localeCompare(b.name)), + ...rest.sort((a, b) => a.name.localeCompare(b.name)), + ]; + } else if (_activeTab === 'recents') { + items = [...items].sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at)).slice(0, 20); + } else { + items = [...items].sort((a, b) => a.name.localeCompare(b.name)); + } + + return items; +} + +function renderList() { + const listEl = $('vault-list'); + const emptyEl = $('vault-empty'); + const items = getTabItems(); + + if (!items.length) { + listEl.innerHTML = ''; + emptyEl.classList.remove('hidden'); + return; + } + emptyEl.classList.add('hidden'); + + // SVG icons for action buttons + const svgCopy = ``; + const svgDots = ``; + const svgFill = ``; + + listEl.innerHTML = items.map(item => { + const matched = isMatch(item); + const site = escHtml(siteLabel(item)); + const name = escHtml(item.name); + const badge = matched ? 'match' : ''; + const color = avatarColor(item.name); + const emoji = itemEmoji(item.item_type); + const canFill = item.item_type === 'password' && item.plain?.username && item.plain?.password; + const canCopy = item.item_type === 'password' && item.plain?.password; + + return `
      +
      ${emoji}
      +
      +
      ${site}${badge}
      +
      ${name}
      +
      +
      + ${canCopy ? `` : ''} + ${canFill ? `` : ''} + +
      +
      `; + }).join(''); + + // Copy password + listEl.querySelectorAll('[data-copy-pass]').forEach(btn => + btn.addEventListener('click', e => { + e.stopPropagation(); + const item = _items.find(i => i.id === parseInt(btn.dataset.copyPass)); + if (item?.plain?.password) { + navigator.clipboard.writeText(item.plain.password); + btn.title = 'Copied!'; + setTimeout(() => { btn.title = 'Copy password'; }, 1500); + } + }) + ); + + // Autofill + listEl.querySelectorAll('[data-autofill]').forEach(btn => + btn.addEventListener('click', async e => { + e.stopPropagation(); + const item = _items.find(i => i.id === parseInt(btn.dataset.autofill)); + if (!item?.plain) return; + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab?.id) { + chrome.tabs.sendMessage(tab.id, { + type: 'DO_AUTOFILL', + username: item.plain.username || '', + password: item.plain.password || '', + }).catch(() => {}); + } + window.close(); + }) + ); + + // Three-dot menu: copy username + listEl.querySelectorAll('[data-menu]').forEach(btn => + btn.addEventListener('click', e => { + e.stopPropagation(); + const item = _items.find(i => i.id === parseInt(btn.dataset.menu)); + if (!item?.plain) return; + // Simple: copy username on dots click (could be a dropdown in future) + if (item.plain.username) { + navigator.clipboard.writeText(item.plain.username); + btn.title = 'Username copied!'; + setTimeout(() => { btn.title = 'More'; }, 1500); + } + }) + ); +} + +// ── Tabs ────────────────────────────────────────────────────────────────────── + +function initTabs() { + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + _activeTab = btn.dataset.tab; + document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b === btn)); + renderList(); + }); + }); +} + +// ── Auto-save prompt ────────────────────────────────────────────────────────── + +async function checkPendingSave() { + const { pending_save } = await chrome.storage.session.get('pending_save'); + if (!pending_save) return; + + const prompt = $('save-prompt'); + $('save-name').value = pending_save.siteName || ''; + $('save-username').value = pending_save.username || ''; + prompt.classList.remove('hidden'); + + $('btn-save-yes').onclick = async () => { + prompt.classList.add('hidden'); + await saveCredential(pending_save); + await chrome.storage.session.remove('pending_save'); + }; + $('btn-save-no').onclick = async () => { + prompt.classList.add('hidden'); + await chrome.storage.session.remove('pending_save'); + }; +} + +async function saveCredential(data) { + if (!_vaultKey) return; + const plain = { url: data.url || '', username: data.username || '', password: data.password || '', notes: '' }; + const name = $('save-name').value.trim() || data.siteName || 'Untitled'; + const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain); + try { + const res = await apiFetch('/api/vault', { + method: 'POST', + body: JSON.stringify({ name, item_type: 'password', folder_id: null, enc_data, iv }), + }); + if (res?.ok) { await fetchAndDecryptVault(); renderList(); } + } catch (err) { console.error('saveCredential failed:', err); } +} + +// ── Init ────────────────────────────────────────────────────────────────────── + +async function init() { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + _currentUrl = tab?.url || ''; + } catch {} + + // Set vault link + $('btn-open-vault').href = VAULT_URL; + + const sessionState = await restoreSessionIfAvailable(); + + if (sessionState === 'vault') { + showView('vault'); + await checkPendingSave(); + if (_items.length) { + renderList(); + fetchAndDecryptVault().then(() => renderList()); + } else { + await fetchAndDecryptVault(); + renderList(); + } + } else if (sessionState === 'unlock') { + showView('unlock'); + $('unlock-password').focus(); + } else { + showView('login'); + } + + // Login + $('btn-login').addEventListener('click', handleLogin); + $('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') handleLogin(); }); + $('login-email').addEventListener('keydown', e => { if (e.key === 'Enter') $('login-password').focus(); }); + + // MFA + $('btn-mfa-back').addEventListener('click', () => { _mfaToken = null; showView('login'); }); + $('mfa-code').addEventListener('keydown', e => { if (e.key === 'Enter') $('btn-mfa-verify')?.click(); }); + + // Unlock-only + $('btn-unlock').addEventListener('click', handleUnlockOnly); + $('unlock-password').addEventListener('keydown', e => { if (e.key === 'Enter') handleUnlockOnly(); }); + $('btn-unlock-signout').addEventListener('click', signOut); + + // Vault actions + $('btn-add-item').addEventListener('click', () => { chrome.tabs.create({ url: VAULT_URL }); }); + $('nav-account').addEventListener('click', signOut); + + // Search + $('vault-search').addEventListener('input', () => renderList()); + + // Tabs + initTabs(); +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/extension/shared/crypto.js b/extension/shared/crypto.js new file mode 100644 index 0000000..cd8c027 --- /dev/null +++ b/extension/shared/crypto.js @@ -0,0 +1,99 @@ +/** + * extension/shared/crypto.js — Zero-knowledge cryptography for the extension. + * + * Mirrors the web app's crypto.js but: + * - Uses `crypto.subtle` (no `window.`) so it works in both popup and service worker. + * - deriveVaultKey uses extractable:true so the key can be serialised to + * chrome.storage.session (exportVaultKey / importVaultKey). + */ + +const ExtCrypto = (() => { + const subtle = crypto.subtle; + + // ── Helpers ───────────────────────────────────────────────────────────────── + + function strToBytes(str) { return new TextEncoder().encode(str); } + + function base64ToBytes(b64) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + + function bytesToBase64(bytes) { + let bin = ''; + bytes.forEach(b => (bin += String.fromCharCode(b))); + return btoa(bin); + } + + // ── PBKDF2 base key ───────────────────────────────────────────────────────── + + async function importPbkdf2Key(password) { + return subtle.importKey('raw', strToBytes(password), 'PBKDF2', false, ['deriveBits', 'deriveKey']); + } + + // ── Auth hash (sent to server for login/register) ─────────────────────────── + + async function deriveAuthHash(password, email) { + const baseKey = await importPbkdf2Key(password); + const bits = await subtle.deriveBits( + { name: 'PBKDF2', salt: strToBytes(email.toLowerCase()), iterations: 100_000, hash: 'SHA-256' }, + baseKey, + 256 + ); + return bytesToBase64(new Uint8Array(bits)); + } + + // ── Vault key (AES-256-GCM, extractable for session storage) ──────────────── + + async function deriveVaultKey(password, enc_key_salt) { + const baseKey = await importPbkdf2Key(password); + return subtle.deriveKey( + { name: 'PBKDF2', salt: base64ToBytes(enc_key_salt), iterations: 600_000, hash: 'SHA-256' }, + baseKey, + { name: 'AES-GCM', length: 256 }, + true, // extractable — needed to serialise into chrome.storage.session + ['encrypt', 'decrypt'] + ); + } + + /** Serialise a CryptoKey to a JSON string for chrome.storage.session. */ + async function exportVaultKey(vaultKey) { + const jwk = await subtle.exportKey('jwk', vaultKey); + return JSON.stringify(jwk); + } + + /** Deserialise a CryptoKey from chrome.storage.session. */ + async function importVaultKey(jwkStr) { + const jwk = JSON.parse(jwkStr); + return subtle.importKey('jwk', jwk, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + } + + // ── Encrypt / Decrypt ──────────────────────────────────────────────────────── + + async function encryptItem(vaultKey, plainObj) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await subtle.encrypt( + { name: 'AES-GCM', iv }, + vaultKey, + strToBytes(JSON.stringify(plainObj)) + ); + return { enc_data: bytesToBase64(new Uint8Array(ct)), iv: bytesToBase64(iv) }; + } + + async function decryptItem(vaultKey, enc_data, iv) { + const pt = await subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBytes(iv) }, + vaultKey, + base64ToBytes(enc_data) + ); + return JSON.parse(new TextDecoder().decode(pt)); + } + + function generateSalt(byteLength = 16) { + return bytesToBase64(crypto.getRandomValues(new Uint8Array(byteLength))); + } + + return { deriveAuthHash, deriveVaultKey, exportVaultKey, importVaultKey, encryptItem, decryptItem, generateSalt }; +})(); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..94586e5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +flask>=3.0 +flask-sqlalchemy>=3.1 +flask-migrate>=4.0 +flask-login>=0.6 +flask-wtf>=1.2 +flask-limiter>=3.5 +flask-cors>=4.0 +pymysql>=1.1 +argon2-cffi>=23.1 +pyjwt>=2.8 +python-dotenv>=1.0 +gunicorn>=21.0 +pyotp>=2.9.0 +qrcode[pil]>=7.4.2 diff --git a/reset_db.py b/reset_db.py new file mode 100644 index 0000000..a12c0cd --- /dev/null +++ b/reset_db.py @@ -0,0 +1,22 @@ +""" +reset_db.py — Drop and recreate all tables from the current models. +WARNING: This deletes all existing data. Development use only. + +Run with: python reset_db.py +""" +from app import create_app, db + +app = create_app('development') + +with app.app_context(): + print('Dropping all tables...') + with db.engine.connect() as conn: + conn.execute(db.text('SET FOREIGN_KEY_CHECKS=0')) + for table in reversed(db.metadata.sorted_tables): + print(f' dropping {table.name}') + conn.execute(db.text(f'DROP TABLE IF EXISTS `{table.name}`')) + conn.execute(db.text('SET FOREIGN_KEY_CHECKS=1')) + conn.commit() + print('Creating all tables...') + db.create_all() + print('Done. All tables recreated.') diff --git a/run.py b/run.py new file mode 100644 index 0000000..8237ab7 --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app('development') + +if __name__ == '__main__': + app.run(debug=True) diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..f87e4d7 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,3 @@ +from app import create_app + +app = create_app('production')