From 46765b9448c149a4f80cb3167b58f2e0b2d3195f Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 18 May 2026 18:27:24 -0400 Subject: [PATCH] 05/18 Enhanced codes and functionalities 5 --- app/__init__.py | 3 + app/config.py | 16 + app/models/webauthn_credential.py | 74 ++++ app/routes/webauthn.py | 388 ++++++++++++++++++ app/static/css/app.css | 100 +++++ app/static/js/auth.js | 243 +++++++++++ app/static/js/vault.js | 136 +++++- app/templates/auth/login.html | 9 + app/templates/vault/index.html | 23 ++ ...j0k1l2m3_add_webauthn_credentials_table.py | 53 +++ requirements.txt | 1 + 11 files changed, 1042 insertions(+), 4 deletions(-) create mode 100644 app/models/webauthn_credential.py create mode 100644 app/routes/webauthn.py create mode 100644 migrations/versions/h8i9j0k1l2m3_add_webauthn_credentials_table.py diff --git a/app/__init__.py b/app/__init__.py index 17fe52a..044b884 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -106,6 +106,7 @@ def create_app(config_name: str = 'development') -> Flask: from .models.audit_log import AuditLog from .models.recovery_challenge import RecoveryChallenge from .models.totp_used_code import TotpUsedCode + from .models.webauthn_credential import WebAuthnCredential @login_manager.user_loader def load_user(user_id): @@ -117,12 +118,14 @@ def create_app(config_name: str = 'development') -> Flask: from .routes.folders import folders_bp from .routes.sharing import sharing_bp from .routes.emergency import emergency_bp + from .routes.webauthn import webauthn_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') + app.register_blueprint(webauthn_bp, url_prefix='/api/webauthn') # Exempt all API blueprints from CSRF — JWT bearer tokens make CSRF irrelevant csrf.exempt(auth_bp) diff --git a/app/config.py b/app/config.py index 51977f2..5108c91 100644 --- a/app/config.py +++ b/app/config.py @@ -11,6 +11,22 @@ _INSECURE_SECRET_DEFAULTS = {'dev-secret-change-me', 'jwt-secret-change-me', '', 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') + + # WebAuthn / Passkey + # RP_ID must be the effective domain of the site (no scheme, no port). + # In development this is "localhost"; in production use your actual domain. + WEBAUTHN_RP_ID = os.environ.get('WEBAUTHN_RP_ID', 'localhost') + WEBAUTHN_RP_NAME = os.environ.get('WEBAUTHN_RP_NAME', 'PassKeeper') + # Allowed origins for WebAuthn ceremonies (comma-separated in env). + # Must include the full origin (scheme + host + optional port). + WEBAUTHN_ORIGINS = [ + o.strip() + for o in os.environ.get( + 'WEBAUTHN_ORIGINS', + 'http://localhost:5000,https://localhost', + ).split(',') + if o.strip() + ] JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7) diff --git a/app/models/webauthn_credential.py b/app/models/webauthn_credential.py new file mode 100644 index 0000000..dc0e8ad --- /dev/null +++ b/app/models/webauthn_credential.py @@ -0,0 +1,74 @@ +""" +app/models/webauthn_credential.py — WebAuthn (passkey) credential storage. + +Each user can register multiple passkeys (phone, laptop, YubiKey, etc.). +Credentials are used for server authentication only — the vault key is +still derived from the master password client-side (zero-knowledge preserved). + +Zero-knowledge note: + WebAuthn replaces TOTP as a second factor OR replaces the password-based + server auth (passwordless flow). In both cases the master password is still + required client-side to derive the vault key. +""" +import json +from datetime import datetime, timezone + +from sqlalchemy.dialects.mysql import INTEGER + +from app import db + + +class WebAuthnCredential(db.Model): + __tablename__ = 'webauthn_credentials' + + 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, + index=True, + ) + # Base64url-encoded credential ID from the authenticator. + credential_id = db.Column(db.String(512), nullable=False, unique=True) + # COSE-encoded public key bytes, base64url. + public_key = db.Column(db.Text, nullable=False) + # Monotonically increasing counter — used to detect cloned authenticators. + sign_count = db.Column(db.BigInteger, nullable=False, default=0) + # JSON list of transport hints e.g. '["internal", "hybrid"]' + transports = db.Column(db.String(255), nullable=True) + # Authenticator AAGUID (UUID string) from attestation. + aaguid = db.Column(db.String(64), nullable=True) + # User-assigned friendly name shown in the UI. + name = db.Column(db.String(128), nullable=False, default='Passkey') + created_at = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), + ) + last_used_at = db.Column(db.DateTime, nullable=True) + + def get_transports(self) -> list: + """Return transport hints as a Python list (empty list if none).""" + if not self.transports: + return [] + try: + return json.loads(self.transports) + except (ValueError, TypeError): + return [] + + def set_transports(self, transports: list) -> None: + self.transports = json.dumps(transports) if transports else None + + def to_dict(self) -> dict: + return { + 'id': self.id, + 'credential_id': self.credential_id, + 'name': self.name, + 'aaguid': self.aaguid, + 'transports': self.get_transports(), + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None, + } + + def __repr__(self): + return f'' diff --git a/app/routes/webauthn.py b/app/routes/webauthn.py new file mode 100644 index 0000000..635c60e --- /dev/null +++ b/app/routes/webauthn.py @@ -0,0 +1,388 @@ +""" +app/routes/webauthn.py — Passkey (WebAuthn) registration and authentication. + +Zero-knowledge design: + WebAuthn handles *server authentication* only. The vault key is still derived + from the master password client-side — WebAuthn does not weaken the ZK model. + +Flows: + Registration (settings page, user must already be logged in): + POST /api/webauthn/register/begin → returns PublicKeyCredentialCreationOptions JSON + POST /api/webauthn/register/complete → verifies attestation, stores credential + + Authentication (login page, passwordless): + POST /api/webauthn/authenticate/begin → returns PublicKeyCredentialRequestOptions JSON + POST /api/webauthn/authenticate/complete → verifies assertion, returns tokens + enc_key_salt + └── Client still prompts for master password to derive vault key. + + Management: + GET /api/webauthn/credentials → list user's registered passkeys + DELETE /api/webauthn/credentials/ → remove a passkey + PATCH /api/webauthn/credentials/ → rename a passkey + +Challenge storage: + Challenges are stored in the *Flask session* (server-side cookie) which is + signed with SECRET_KEY. They expire when the browser session ends or after + a configurable TTL. This is safe across Gunicorn workers because the cookie + is stored client-side (signed, not encrypted — the challenge is not secret). +""" +import json +from datetime import datetime, timezone + +import webauthn +from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + ResidentKeyRequirement, + UserVerificationRequirement, + AuthenticatorAttachment, +) +from webauthn.helpers.exceptions import InvalidCBORData, InvalidAuthenticatorResponse +from flask import Blueprint, request, jsonify, g, session, current_app + +from app import db, limiter, client_ip +from app.models.user import User +from app.models.webauthn_credential import WebAuthnCredential +from app.models.audit_log import AuditLog +from app.services.auth_service import require_jwt, generate_tokens + +webauthn_bp = Blueprint('webauthn', __name__) + +# Session key for the pending challenge bytes. +_REG_CHALLENGE_KEY = 'webauthn_reg_challenge' +_AUTH_CHALLENGE_KEY = 'webauthn_auth_challenge' +_AUTH_USER_ID_KEY = 'webauthn_auth_user_id' + + +def _rp_id(): + return current_app.config['WEBAUTHN_RP_ID'] + + +def _rp_name(): + return current_app.config['WEBAUTHN_RP_NAME'] + + +def _origins(): + return current_app.config['WEBAUTHN_ORIGINS'] + + +# ── Registration ────────────────────────────────────────────────────────────── + +@webauthn_bp.route('/register/begin', methods=['POST']) +@limiter.limit('10 per minute') +@require_jwt +def register_begin(): + """ + Generate PublicKeyCredentialCreationOptions for passkey registration. + Requires an active JWT session (user must be logged in). + """ + user = db.session.get(User, g.current_user_id) + if not user: + return jsonify({'error': 'User not found'}), 404 + + # Collect existing credential IDs to exclude (prevent re-registering same key). + existing = WebAuthnCredential.query.filter_by(user_id=user.id).all() + exclude_credentials = [ + webauthn.helpers.structs.PublicKeyCredentialDescriptor( + id=webauthn.base64url_to_bytes(cred.credential_id), + transports=cred.get_transports() or [], + ) + for cred in existing + ] + + options = webauthn.generate_registration_options( + rp_id=_rp_id(), + rp_name=_rp_name(), + user_id=str(user.id).encode(), + user_name=user.email, + user_display_name=user.email, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.PREFERRED, + authenticator_attachment=AuthenticatorAttachment.PLATFORM, + ), + exclude_credentials=exclude_credentials, + ) + + # Store challenge in signed session for verification in /complete. + session[_REG_CHALLENGE_KEY] = webauthn.options_to_json(options) + + return jsonify(json.loads(webauthn.options_to_json(options))), 200 + + +@webauthn_bp.route('/register/complete', methods=['POST']) +@limiter.limit('10 per minute') +@require_jwt +def register_complete(): + """ + Verify the authenticator's attestation response and persist the credential. + """ + user = db.session.get(User, g.current_user_id) + if not user: + return jsonify({'error': 'User not found'}), 404 + + options_json = session.pop(_REG_CHALLENGE_KEY, None) + if not options_json: + return jsonify({'error': 'No pending registration challenge. Call /register/begin first.'}), 400 + + data = request.get_json(silent=True) or {} + credential_name = (data.get('name') or 'Passkey').strip()[:128] + + # Remove 'name' before passing to py-webauthn — it doesn't expect this field. + credential_data = {k: v for k, v in data.items() if k != 'name'} + + try: + import json as _json + options_obj = _json.loads(options_json) + expected_challenge = webauthn.base64url_to_bytes(options_obj['challenge']) + + verification = webauthn.verify_registration_response( + credential=credential_data, + expected_challenge=expected_challenge, + expected_rp_id=_rp_id(), + expected_origin=_origins(), + require_user_verification=False, + ) + except (InvalidCBORData, InvalidAuthenticatorResponse, Exception) as e: + return jsonify({'error': f'Registration verification failed: {str(e)}'}), 400 + + # Persist the new credential. + import base64 + cred = WebAuthnCredential( + user_id=user.id, + credential_id=base64.urlsafe_b64encode( + verification.credential_id + ).rstrip(b'=').decode(), + public_key=base64.urlsafe_b64encode( + verification.credential_public_key + ).rstrip(b'=').decode(), + sign_count=verification.sign_count, + aaguid=str(verification.aaguid) if verification.aaguid else None, + name=credential_name, + ) + # Store transport hints if available. + if hasattr(verification, 'credential_device_type'): + pass # transport not exposed directly — set via request data if present + transports = data.get('response', {}).get('transports', []) + if transports: + cred.set_transports(transports) + + db.session.add(cred) + db.session.flush() + + AuditLog.log( + user_id=user.id, + action='webauthn.register', + resource_type='webauthn_credential', + resource_id=cred.id, + detail=f'Registered passkey "{credential_name}" (id={cred.id})', + ip_address=client_ip(), + ) + db.session.commit() + + return jsonify({'message': 'Passkey registered successfully', 'credential': cred.to_dict()}), 201 + + +# ── Authentication ──────────────────────────────────────────────────────────── + +@webauthn_bp.route('/authenticate/begin', methods=['POST']) +@limiter.limit('20 per minute') +def authenticate_begin(): + """ + Generate PublicKeyCredentialRequestOptions for passkey authentication. + + Accepts optional 'email' in the request body to pre-filter credentials. + If email is omitted, all credentials for discoverable-credential flow are allowed. + """ + data = request.get_json(silent=True) or {} + email = (data.get('email') or '').strip().lower() + + allow_credentials = [] + user_id = None + + if email: + user = User.query.filter_by(email=email).first() + if user: + user_id = user.id + creds = WebAuthnCredential.query.filter_by(user_id=user.id).all() + allow_credentials = [ + webauthn.helpers.structs.PublicKeyCredentialDescriptor( + id=webauthn.base64url_to_bytes(c.credential_id), + transports=c.get_transports() or [], + ) + for c in creds + ] + + options = webauthn.generate_authentication_options( + rp_id=_rp_id(), + allow_credentials=allow_credentials, + user_verification=UserVerificationRequirement.PREFERRED, + ) + + session[_AUTH_CHALLENGE_KEY] = webauthn.options_to_json(options) + if user_id: + session[_AUTH_USER_ID_KEY] = user_id + + return jsonify(json.loads(webauthn.options_to_json(options))), 200 + + +@webauthn_bp.route('/authenticate/complete', methods=['POST']) +@limiter.limit('20 per minute') +def authenticate_complete(): + """ + Verify the authenticator's assertion response and issue JWT tokens. + + On success returns the same payload as /api/auth/login: + { access_token, refresh_token, enc_key_salt } + + The client must still prompt for the master password to derive the vault key. + MFA is not required after a successful WebAuthn assertion (the passkey IS the MFA). + """ + import base64 + + options_json = session.pop(_AUTH_CHALLENGE_KEY, None) + stored_user_id = session.pop(_AUTH_USER_ID_KEY, None) + + if not options_json: + return jsonify({'error': 'No pending authentication challenge. Call /authenticate/begin first.'}), 400 + + data = request.get_json(silent=True) or {} + + # Identify the credential being used. + raw_credential_id = data.get('id') or data.get('rawId', '') + + # Normalise to base64url without padding for DB lookup. + cred_id_lookup = raw_credential_id.rstrip('=').replace('+', '-').replace('/', '_') + + credential = WebAuthnCredential.query.filter_by( + credential_id=cred_id_lookup + ).first() + + if not credential: + return jsonify({'error': 'Passkey not recognised'}), 401 + + user = db.session.get(User, credential.user_id) + if not user: + return jsonify({'error': 'User not found'}), 401 + + # Guard: if we pre-filtered to a specific user, reject mismatches. + if stored_user_id and credential.user_id != stored_user_id: + return jsonify({'error': 'Credential does not match the requested user'}), 401 + + try: + import json as _json + options_obj = _json.loads(options_json) + expected_challenge = webauthn.base64url_to_bytes(options_obj['challenge']) + + verification = webauthn.verify_authentication_response( + credential=data, + expected_challenge=expected_challenge, + expected_rp_id=_rp_id(), + expected_origin=_origins(), + credential_public_key=webauthn.base64url_to_bytes(credential.public_key), + credential_current_sign_count=credential.sign_count, + require_user_verification=False, + ) + except Exception as e: + AuditLog.log( + user_id=user.id, + action='webauthn.auth_failed', + resource_type='webauthn_credential', + resource_id=credential.id, + detail=f'Passkey authentication failed: {str(e)[:200]}', + ip_address=client_ip(), + ) + db.session.commit() + return jsonify({'error': 'Passkey authentication failed'}), 401 + + # Update sign count (clone detection). + credential.sign_count = verification.new_sign_count + credential.last_used_at = datetime.now(timezone.utc).replace(tzinfo=None) + + # Issue tokens. + tokens = generate_tokens(user.id) + + AuditLog.log( + user_id=user.id, + action='webauthn.auth_success', + resource_type='webauthn_credential', + resource_id=credential.id, + detail=f'Passkey authentication succeeded (credential id={credential.id})', + ip_address=client_ip(), + ) + db.session.commit() + + return jsonify({ + 'access_token': tokens['access_token'], + 'refresh_token': tokens['refresh_token'], + 'enc_key_salt': user.enc_key_salt, + 'webauthn': True, # signals client to skip master-password server auth + }), 200 + + +# ── Credential management ───────────────────────────────────────────────────── + +@webauthn_bp.route('/credentials', methods=['GET']) +@limiter.limit('30 per minute') +@require_jwt +def list_credentials(): + """List all passkeys registered by the current user.""" + creds = WebAuthnCredential.query.filter_by(user_id=g.current_user_id).all() + return jsonify([c.to_dict() for c in creds]), 200 + + +@webauthn_bp.route('/credentials/', methods=['PATCH']) +@limiter.limit('20 per minute') +@require_jwt +def rename_credential(cred_id): + """Rename a passkey.""" + cred = WebAuthnCredential.query.filter_by( + id=cred_id, user_id=g.current_user_id + ).first() + if not cred: + return jsonify({'error': 'Credential not found'}), 404 + + data = request.get_json(silent=True) or {} + new_name = (data.get('name') or '').strip()[:128] + if not new_name: + return jsonify({'error': 'name is required'}), 400 + + old_name = cred.name + cred.name = new_name + + AuditLog.log( + user_id=g.current_user_id, + action='webauthn.rename', + resource_type='webauthn_credential', + resource_id=cred.id, + detail=f'Renamed passkey (id={cred.id}) from "{old_name}" to "{new_name}"', + ip_address=client_ip(), + ) + db.session.commit() + return jsonify(cred.to_dict()), 200 + + +@webauthn_bp.route('/credentials/', methods=['DELETE']) +@limiter.limit('10 per minute') +@require_jwt +def delete_credential(cred_id): + """Remove a registered passkey.""" + cred = WebAuthnCredential.query.filter_by( + id=cred_id, user_id=g.current_user_id + ).first() + if not cred: + return jsonify({'error': 'Credential not found'}), 404 + + cred_name = cred.name + db.session.delete(cred) + db.session.flush() + + AuditLog.log( + user_id=g.current_user_id, + action='webauthn.delete', + resource_type='webauthn_credential', + resource_id=cred_id, + detail=f'Deleted passkey "{cred_name}" (id={cred_id})', + ip_address=client_ip(), + ) + db.session.commit() + return jsonify({'message': 'Passkey removed'}), 200 diff --git a/app/static/css/app.css b/app/static/css/app.css index d55d215..c6a3b79 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -2363,3 +2363,103 @@ html.sidebar-open { padding: 10px 12px; margin-bottom: 14px; } + +/* ── Passkey management (settings modal) ────────────────────────────────────── */ + +.passkeys-list { + margin-bottom: 12px; +} + +.passkey-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} + +.passkey-item:last-child { + border-bottom: none; +} + +.passkey-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.passkey-name { + font-size: 14px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.passkey-meta { + font-size: 12px; + color: var(--text-muted); +} + +.passkey-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.btn-danger-text { + background: none; + border: none; + cursor: pointer; + color: var(--danger); + font-size: 12px; + padding: 2px 4px; +} + +.btn-danger-text:hover { + text-decoration: underline; +} + +.passkey-register-row { + display: flex; + gap: 8px; + align-items: center; + margin-top: 10px; +} + +.passkey-name-input { + flex: 1; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13px; + color: var(--text); + background: var(--surface); +} + +.passkey-name-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(192, 57, 43, 0.15); +} + +/* Login page divider */ +.auth-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0; + color: var(--text-muted); + font-size: 12px; +} + +.auth-divider::before, +.auth-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} diff --git a/app/static/js/auth.js b/app/static/js/auth.js index 46c2092..dde6fb7 100644 --- a/app/static/js/auth.js +++ b/app/static/js/auth.js @@ -316,6 +316,44 @@ const Auth = (() => { const notice = document.getElementById("register-notice"); if (notice) notice.classList.remove("hidden"); } + + // Passkey login button (only present on login.html) + const passkeyBtn = document.getElementById("btn-passkey-login"); + if (passkeyBtn) { + passkeyBtn.addEventListener("click", async () => { + const errEl = document.getElementById("passkey-error"); + errEl.classList.add("hidden"); + passkeyBtn.disabled = true; + passkeyBtn.textContent = "Waiting for passkey…"; + + const email = (document.getElementById("email")?.value || "").trim().toLowerCase(); + const result = await PasskeyAuth.loginWithPasskey(email); + + passkeyBtn.disabled = false; + passkeyBtn.textContent = "🔑 Sign in with Passkey"; + + if (result.error) { + errEl.textContent = result.error; + errEl.classList.remove("hidden"); + return; + } + + // Tokens received — store them, then prompt for master password to unlock vault. + sessionStorage.setItem("access_token", result.data.access_token); + localStorage.setItem("refresh_token", result.data.refresh_token); + sessionStorage.setItem("enc_key_salt", result.data.enc_key_salt); + + // Derive vault key from master password. + // Re-use the same master-password input field; if empty, show unlock overlay. + const pw = document.getElementById("password")?.value; + if (pw) { + const vaultKey = await Crypto.deriveVaultKey(pw, result.data.enc_key_salt); + VaultSession.setKey(vaultKey); + } + // Navigate to vault — if vault key wasn't derived, unlock overlay will show. + window.location.href = "/vault"; + }); + } } return { init }; @@ -337,3 +375,208 @@ const VaultSession = (() => { } return { setKey, getKey, clear }; })(); + +// ── PasskeyAuth — WebAuthn / Passkey login and registration management ──────── +const PasskeyAuth = (() => { + // ── Helpers ───────────────────────────────────────────────────────────────── + + /** Convert ArrayBuffer → base64url string (no padding). */ + function _bufToB64url(buf) { + const bytes = new Uint8Array(buf); + let bin = ''; + bytes.forEach((b) => (bin += String.fromCharCode(b))); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + /** Convert base64url string → Uint8Array. */ + function _b64urlToBuf(str) { + str = str.replace(/-/g, '+').replace(/_/g, '/'); + while (str.length % 4) str += '='; + const bin = atob(str); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr; + } + + /** + * Convert a PublicKeyCredentialCreationOptions or RequestOptions object + * returned by the server (JSON) into the format expected by navigator.credentials. + * The browser API requires ArrayBuffers for challenge and user.id; the server + * sends base64url strings. + */ + function _prepareCreationOptions(opts) { + opts.challenge = _b64urlToBuf(opts.challenge); + if (opts.user?.id) opts.user.id = _b64urlToBuf(opts.user.id); + if (opts.excludeCredentials) { + opts.excludeCredentials = opts.excludeCredentials.map((c) => ({ + ...c, + id: _b64urlToBuf(c.id), + })); + } + return opts; + } + + function _prepareRequestOptions(opts) { + opts.challenge = _b64urlToBuf(opts.challenge); + if (opts.allowCredentials) { + opts.allowCredentials = opts.allowCredentials.map((c) => ({ + ...c, + id: _b64urlToBuf(c.id), + })); + } + return opts; + } + + /** + * Serialise a PublicKeyCredential returned by navigator.credentials.create() + * or navigator.credentials.get() into a plain JSON-serialisable object that + * the server can accept. + */ + function _credentialToJson(cred) { + const resp = cred.response; + const obj = { + id: cred.id, + rawId: _bufToB64url(cred.rawId), + type: cred.type, + response: {}, + }; + + if (resp.clientDataJSON !== undefined) + obj.response.clientDataJSON = _bufToB64url(resp.clientDataJSON); + if (resp.attestationObject !== undefined) + obj.response.attestationObject = _bufToB64url(resp.attestationObject); + if (resp.authenticatorData !== undefined) + obj.response.authenticatorData = _bufToB64url(resp.authenticatorData); + if (resp.signature !== undefined) + obj.response.signature = _bufToB64url(resp.signature); + if (resp.userHandle !== undefined && resp.userHandle !== null) + obj.response.userHandle = _bufToB64url(resp.userHandle); + + // Include transport hints if available (registration only). + if (typeof resp.getTransports === 'function') { + obj.response.transports = resp.getTransports(); + } + if (cred.authenticatorAttachment) { + obj.authenticatorAttachment = cred.authenticatorAttachment; + } + if (cred.clientExtensionResults) { + obj.clientExtensionResults = cred.getClientExtensionResults?.() ?? {}; + } + + return obj; + } + + // ── Login flow ─────────────────────────────────────────────────────────────── + + /** + * Initiate a passkey login. + * 1. Call /api/webauthn/authenticate/begin (optionally with email hint). + * 2. Invoke navigator.credentials.get() — browser shows passkey picker. + * 3. Send the assertion to /api/webauthn/authenticate/complete. + * 4. On success: store tokens and derive vault key from master password. + * The master password is still required to unlock the vault (ZK preserved). + */ + async function loginWithPasskey(email) { + if (!window.PublicKeyCredential) { + return { error: 'Passkeys are not supported in this browser.' }; + } + + // Step 1: get options from server. + const beginRes = await fetch('/api/webauthn/authenticate/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email || '' }), + }); + if (!beginRes.ok) { + const d = await beginRes.json().catch(() => ({})); + return { error: d.error || 'Could not start passkey authentication.' }; + } + const options = await beginRes.json(); + + // Step 2: browser passkey picker. + let assertion; + try { + assertion = await navigator.credentials.get({ + publicKey: _prepareRequestOptions(options), + }); + } catch (e) { + if (e.name === 'NotAllowedError') return { error: 'Passkey cancelled.' }; + return { error: e.message || 'Passkey authentication failed.' }; + } + + // Step 3: send assertion to server. + const completeRes = await fetch('/api/webauthn/authenticate/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(_credentialToJson(assertion)), + }); + const completeData = await completeRes.json().catch(() => ({})); + if (!completeRes.ok) { + return { error: completeData.error || 'Passkey authentication failed.' }; + } + + return { ok: true, data: completeData }; + } + + // ── Registration flow (settings page) ──────────────────────────────────────── + + /** + * Register a new passkey for the currently logged-in user. + * Requires an active access_token in sessionStorage (set by vault.js on login). + * @param {string} name User-friendly name for the passkey (e.g. "iPhone 15"). + */ + async function registerPasskey(name) { + if (!window.PublicKeyCredential) { + return { error: 'Passkeys are not supported in this browser.' }; + } + + const token = sessionStorage.getItem('access_token'); + if (!token) return { error: 'Not authenticated.' }; + + // Step 1: get creation options from server. + const beginRes = await fetch('/api/webauthn/register/begin', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + if (!beginRes.ok) { + const d = await beginRes.json().catch(() => ({})); + return { error: d.error || 'Could not start passkey registration.' }; + } + const options = await beginRes.json(); + + // Step 2: create credential. + let credential; + try { + credential = await navigator.credentials.create({ + publicKey: _prepareCreationOptions(options), + }); + } catch (e) { + if (e.name === 'NotAllowedError') return { error: 'Passkey registration cancelled.' }; + return { error: e.message || 'Passkey creation failed.' }; + } + + // Step 3: send attestation to server. + const payload = _credentialToJson(credential); + payload.name = name || 'Passkey'; + + const completeRes = await fetch('/api/webauthn/register/complete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); + const completeData = await completeRes.json().catch(() => ({})); + if (!completeRes.ok) { + return { error: completeData.error || 'Passkey registration failed.' }; + } + + return { ok: true, credential: completeData.credential }; + } + + return { loginWithPasskey, registerPasskey }; +})(); diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 2361734..a543359 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -1753,7 +1753,9 @@ const Vault = (() => { granteePubKey, ); - // Re-encrypt all decrypted vault items + // Re-encrypt all decrypted vault items. + // Item name is encrypted with the same ECDH shared key so the server + // never sees plaintext names inside enc_vault (zero-knowledge). const encItems = await Promise.all( _items .filter((i) => i.plain) @@ -1762,12 +1764,17 @@ const Vault = (() => { sharedKey, i.plain, ); + const { enc_name, iv_name } = await SharingCrypto.encryptName( + sharedKey, + i.name, + ); return { id: i.id, - name: i.name, item_type: i.item_type, enc_data, iv, + enc_name, + iv_name, }; }), ); @@ -1889,9 +1896,20 @@ const Vault = (() => { i.enc_data, i.iv, ); - return { ...i, plain }; + // Decrypt the item display name if available (new format). + // Fall back to item_type label for legacy enc_vault snapshots. + let displayName = i.item_type || "password"; + if (i.enc_name && i.iv_name) { + const decName = await SharingCrypto.decryptName( + sharedKey, + i.enc_name, + i.iv_name, + ); + if (decName) displayName = decName; + } + return { ...i, name: displayName, plain }; } catch { - return { ...i, plain: null }; + return { ...i, name: i.item_type || "password", plain: null }; } }), ); @@ -2211,6 +2229,7 @@ const Vault = (() => { loadSharingKeysStatus(), loadRecoveryStatus(), loadAuditLog(0, true), + loadPasskeys(), ]); } @@ -2229,6 +2248,115 @@ const Vault = (() => { let _auditOffset = 0; let _auditTotal = 0; + // ── Passkey (WebAuthn) management ──────────────────────────────────────────── + + async function loadPasskeys() { + const listEl = document.getElementById("passkeys-list"); + const errEl = document.getElementById("passkeys-error"); + if (!listEl) return; + errEl?.classList.add("hidden"); + + // Hide the section if WebAuthn is not supported in this browser. + if (!window.PublicKeyCredential) { + document.getElementById("passkeys-section")?.classList.add("hidden"); + return; + } + + try { + const res = await apiFetch("/api/webauthn/credentials"); + const creds = await res.json(); + if (!Array.isArray(creds) || !creds.length) { + listEl.innerHTML = '

No passkeys registered yet.

'; + } else { + listEl.innerHTML = creds + .map((c) => { + const created = c.created_at + ? new Date(c.created_at).toLocaleDateString() + : ""; + const lastUsed = c.last_used_at + ? `Last used ${new Date(c.last_used_at).toLocaleDateString()}` + : "Never used"; + return `
+
+ ${escHtml(c.name)} + ${lastUsed} · Added ${escHtml(created)} +
+
+ + +
+
`; + }) + .join(""); + + // Wire rename buttons. + listEl.querySelectorAll(".btn-rename-passkey").forEach((btn) => { + btn.addEventListener("click", async () => { + const newName = prompt("New passkey name:", btn.dataset.name); + if (!newName?.trim()) return; + try { + await apiFetch(`/api/webauthn/credentials/${btn.dataset.id}`, { + method: "PATCH", + body: JSON.stringify({ name: newName.trim() }), + }); + await loadPasskeys(); + } catch (e) { + showToast("Failed to rename passkey", "error"); + } + }); + }); + + // Wire delete buttons. + listEl.querySelectorAll(".btn-delete-passkey").forEach((btn) => { + btn.addEventListener("click", async () => { + if (!confirm(`Remove passkey "${btn.dataset.name}"?`)) return; + try { + await apiFetch(`/api/webauthn/credentials/${btn.dataset.id}`, { + method: "DELETE", + }); + showToast("Passkey removed"); + await loadPasskeys(); + } catch (e) { + showToast("Failed to remove passkey", "error"); + } + }); + }); + } + } catch (e) { + listEl.innerHTML = '

Could not load passkeys.

'; + } + + // Wire register button (only bind once). + const registerBtn = document.getElementById("btn-register-passkey"); + if (registerBtn && !registerBtn.dataset.bound) { + registerBtn.dataset.bound = "1"; + registerBtn.addEventListener("click", async () => { + const nameInput = document.getElementById("passkey-name-input"); + const name = (nameInput?.value || "").trim() || "Passkey"; + const errEl = document.getElementById("passkeys-error"); + errEl?.classList.add("hidden"); + registerBtn.disabled = true; + registerBtn.textContent = "Waiting…"; + + const result = await PasskeyAuth.registerPasskey(name); + + registerBtn.disabled = false; + registerBtn.textContent = "+ Add passkey"; + + if (result.error) { + if (errEl) { + errEl.textContent = result.error; + errEl.classList.remove("hidden"); + } + return; + } + if (nameInput) nameInput.value = ""; + showToast(`Passkey "${escHtml(result.credential.name)}" registered`); + await loadPasskeys(); + }); + } + } + async function loadAuditLog(offset = 0, reset = false) { try { const res = await apiFetch( diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 5f03d86..72b2f5a 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -55,6 +55,15 @@ block body_class %}auth-page{% endblock %} {% block body %} Sign in +
or
+ +
+ +
+

🔑 Passkeys

+

+ Sign in without typing your email and password. Your master password + is still required to unlock the vault. +

+
+ +
+ + +
+
+

🔑 Change Master Password

diff --git a/migrations/versions/h8i9j0k1l2m3_add_webauthn_credentials_table.py b/migrations/versions/h8i9j0k1l2m3_add_webauthn_credentials_table.py new file mode 100644 index 0000000..2839f68 --- /dev/null +++ b/migrations/versions/h8i9j0k1l2m3_add_webauthn_credentials_table.py @@ -0,0 +1,53 @@ +"""add webauthn_credentials table + +Revision ID: h8i9j0k1l2m3 +Revises: g7h8i9j0k1l2 +Create Date: 2026-05-18 00:00:00.000000 + +Stores WebAuthn (passkey) credentials per user. +Each user can register multiple passkeys (e.g. phone, laptop, YubiKey). + +Columns: + credential_id -- base64url-encoded credential ID from the authenticator + public_key -- COSE-encoded public key bytes (base64url) + sign_count -- monotonically increasing counter for clone detection + transports -- JSON list of transport hints (e.g. ["internal", "hybrid"]) + aaguid -- authenticator AAGUID (UUID string) for attestation metadata + name -- user-assigned friendly name (e.g. "iPhone 15") + created_at -- registration timestamp + last_used_at -- last successful authentication timestamp +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + + +revision = 'h8i9j0k1l2m3' +down_revision = 'g7h8i9j0k1l2' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'webauthn_credentials', + sa.Column('id', mysql.INTEGER(unsigned=True), autoincrement=True, nullable=False), + sa.Column('user_id', mysql.INTEGER(unsigned=True), nullable=False), + sa.Column('credential_id', sa.String(512), nullable=False), + sa.Column('public_key', sa.Text, nullable=False), + sa.Column('sign_count', sa.BigInteger, nullable=False, server_default='0'), + sa.Column('transports', sa.String(255), nullable=True), + sa.Column('aaguid', sa.String(64), nullable=True), + sa.Column('name', sa.String(128), nullable=False, server_default='Passkey'), + sa.Column('created_at', sa.DateTime, nullable=False), + sa.Column('last_used_at', sa.DateTime, nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('credential_id', name='uq_webauthn_credential_id'), + ) + op.create_index('ix_webauthn_credentials_user_id', 'webauthn_credentials', ['user_id']) + + +def downgrade(): + op.drop_index('ix_webauthn_credentials_user_id', table_name='webauthn_credentials') + op.drop_table('webauthn_credentials') diff --git a/requirements.txt b/requirements.txt index 2c5b327..2927809 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,4 +14,5 @@ pyotp>=2.9.0 qrcode[pil]>=7.4.2 cryptography>=42.0 # AES-256-GCM server-side TOTP secret encryption redis>=5.0 # Shared rate-limit storage across Gunicorn workers +webauthn>=2.0 # Passkey / WebAuthn registration and authentication (py_webauthn) APScheduler