05/18 Enhanced codes and functionalities 5
This commit is contained in:
@@ -106,6 +106,7 @@ def create_app(config_name: str = 'development') -> Flask:
|
|||||||
from .models.audit_log import AuditLog
|
from .models.audit_log import AuditLog
|
||||||
from .models.recovery_challenge import RecoveryChallenge
|
from .models.recovery_challenge import RecoveryChallenge
|
||||||
from .models.totp_used_code import TotpUsedCode
|
from .models.totp_used_code import TotpUsedCode
|
||||||
|
from .models.webauthn_credential import WebAuthnCredential
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
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.folders import folders_bp
|
||||||
from .routes.sharing import sharing_bp
|
from .routes.sharing import sharing_bp
|
||||||
from .routes.emergency import emergency_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(auth_bp, url_prefix='/api/auth')
|
||||||
app.register_blueprint(vault_bp, url_prefix='/api/vault')
|
app.register_blueprint(vault_bp, url_prefix='/api/vault')
|
||||||
app.register_blueprint(folders_bp, url_prefix='/api/folders')
|
app.register_blueprint(folders_bp, url_prefix='/api/folders')
|
||||||
app.register_blueprint(sharing_bp, url_prefix='/api/sharing')
|
app.register_blueprint(sharing_bp, url_prefix='/api/sharing')
|
||||||
app.register_blueprint(emergency_bp, url_prefix='/api/emergency')
|
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
|
# Exempt all API blueprints from CSRF — JWT bearer tokens make CSRF irrelevant
|
||||||
csrf.exempt(auth_bp)
|
csrf.exempt(auth_bp)
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ _INSECURE_SECRET_DEFAULTS = {'dev-secret-change-me', 'jwt-secret-change-me', '',
|
|||||||
class BaseConfig:
|
class BaseConfig:
|
||||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-change-me')
|
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_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_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15)
|
||||||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7)
|
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7)
|
||||||
|
|
||||||
|
|||||||
@@ -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'<WebAuthnCredential user_id={self.user_id} name={self.name!r}>'
|
||||||
@@ -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/<id> → remove a passkey
|
||||||
|
PATCH /api/webauthn/credentials/<id> → 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/<int:cred_id>', 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/<int:cred_id>', 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
|
||||||
@@ -2363,3 +2363,103 @@ html.sidebar-open {
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
margin-bottom: 14px;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -316,6 +316,44 @@ const Auth = (() => {
|
|||||||
const notice = document.getElementById("register-notice");
|
const notice = document.getElementById("register-notice");
|
||||||
if (notice) notice.classList.remove("hidden");
|
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 };
|
return { init };
|
||||||
@@ -337,3 +375,208 @@ const VaultSession = (() => {
|
|||||||
}
|
}
|
||||||
return { setKey, getKey, clear };
|
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 };
|
||||||
|
})();
|
||||||
|
|||||||
+132
-4
@@ -1753,7 +1753,9 @@ const Vault = (() => {
|
|||||||
granteePubKey,
|
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(
|
const encItems = await Promise.all(
|
||||||
_items
|
_items
|
||||||
.filter((i) => i.plain)
|
.filter((i) => i.plain)
|
||||||
@@ -1762,12 +1764,17 @@ const Vault = (() => {
|
|||||||
sharedKey,
|
sharedKey,
|
||||||
i.plain,
|
i.plain,
|
||||||
);
|
);
|
||||||
|
const { enc_name, iv_name } = await SharingCrypto.encryptName(
|
||||||
|
sharedKey,
|
||||||
|
i.name,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id: i.id,
|
id: i.id,
|
||||||
name: i.name,
|
|
||||||
item_type: i.item_type,
|
item_type: i.item_type,
|
||||||
enc_data,
|
enc_data,
|
||||||
iv,
|
iv,
|
||||||
|
enc_name,
|
||||||
|
iv_name,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1889,9 +1896,20 @@ const Vault = (() => {
|
|||||||
i.enc_data,
|
i.enc_data,
|
||||||
i.iv,
|
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 {
|
} catch {
|
||||||
return { ...i, plain: null };
|
return { ...i, name: i.item_type || "password", plain: null };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -2211,6 +2229,7 @@ const Vault = (() => {
|
|||||||
loadSharingKeysStatus(),
|
loadSharingKeysStatus(),
|
||||||
loadRecoveryStatus(),
|
loadRecoveryStatus(),
|
||||||
loadAuditLog(0, true),
|
loadAuditLog(0, true),
|
||||||
|
loadPasskeys(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2229,6 +2248,115 @@ const Vault = (() => {
|
|||||||
let _auditOffset = 0;
|
let _auditOffset = 0;
|
||||||
let _auditTotal = 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 = '<p class="settings-desc">No passkeys registered yet.</p>';
|
||||||
|
} 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 `<div class="passkey-item" data-cred-id="${c.id}">
|
||||||
|
<div class="passkey-info">
|
||||||
|
<span class="passkey-name">${escHtml(c.name)}</span>
|
||||||
|
<span class="passkey-meta">${lastUsed} · Added ${escHtml(created)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="passkey-actions">
|
||||||
|
<button class="btn-text btn-sm btn-rename-passkey" data-id="${c.id}" data-name="${escHtml(c.name)}">Rename</button>
|
||||||
|
<button class="btn-danger-text btn-sm btn-delete-passkey" data-id="${c.id}" data-name="${escHtml(c.name)}">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.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 = '<p class="settings-desc">Could not load passkeys.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
async function loadAuditLog(offset = 0, reset = false) {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ block body_class %}auth-page{% endblock %} {% block body %}
|
|||||||
Sign in
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div class="auth-divider"><span>or</span></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary btn-full"
|
||||||
|
id="btn-passkey-login"
|
||||||
|
>
|
||||||
|
🔑 Sign in with Passkey
|
||||||
|
</button>
|
||||||
|
<p id="passkey-error" class="form-error hidden"></p>
|
||||||
<p class="auth-footer">
|
<p class="auth-footer">
|
||||||
Don't have an account? <a href="/register">Create one</a><br />
|
Don't have an account? <a href="/register">Create one</a><br />
|
||||||
Forgot your password? <a href="/recover">Use recovery code</a>
|
Forgot your password? <a href="/recover">Use recovery code</a>
|
||||||
|
|||||||
@@ -956,6 +956,29 @@
|
|||||||
<div id="sharing-keys-actions"></div>
|
<div id="sharing-keys-actions"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Passkeys Section -->
|
||||||
|
<div class="settings-section" id="passkeys-section">
|
||||||
|
<h4 class="settings-section-title">🔑 Passkeys</h4>
|
||||||
|
<p class="settings-desc">
|
||||||
|
Sign in without typing your email and password. Your master password
|
||||||
|
is still required to unlock the vault.
|
||||||
|
</p>
|
||||||
|
<div id="passkeys-list" class="passkeys-list"></div>
|
||||||
|
<p id="passkeys-error" class="form-error hidden"></p>
|
||||||
|
<div class="passkey-register-row" id="passkey-register-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="passkey-name-input"
|
||||||
|
placeholder="Passkey name (e.g. iPhone 15)"
|
||||||
|
maxlength="128"
|
||||||
|
class="passkey-name-input"
|
||||||
|
/>
|
||||||
|
<button class="btn-secondary" id="btn-register-passkey">
|
||||||
|
+ Add passkey
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Change Password Section -->
|
<!-- Change Password Section -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h4 class="settings-section-title">🔑 Change Master Password</h4>
|
<h4 class="settings-section-title">🔑 Change Master Password</h4>
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -14,4 +14,5 @@ pyotp>=2.9.0
|
|||||||
qrcode[pil]>=7.4.2
|
qrcode[pil]>=7.4.2
|
||||||
cryptography>=42.0 # AES-256-GCM server-side TOTP secret encryption
|
cryptography>=42.0 # AES-256-GCM server-side TOTP secret encryption
|
||||||
redis>=5.0 # Shared rate-limit storage across Gunicorn workers
|
redis>=5.0 # Shared rate-limit storage across Gunicorn workers
|
||||||
|
webauthn>=2.0 # Passkey / WebAuthn registration and authentication (py_webauthn)
|
||||||
APScheduler
|
APScheduler
|
||||||
|
|||||||
Reference in New Issue
Block a user