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.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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
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");
|
||||
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 };
|
||||
})();
|
||||
|
||||
+132
-4
@@ -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 = '<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) {
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
|
||||
@@ -55,6 +55,15 @@ block body_class %}auth-page{% endblock %} {% block body %}
|
||||
Sign in
|
||||
</button>
|
||||
</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">
|
||||
Don't have an account? <a href="/register">Create one</a><br />
|
||||
Forgot your password? <a href="/recover">Use recovery code</a>
|
||||
|
||||
@@ -956,6 +956,29 @@
|
||||
<div id="sharing-keys-actions"></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 -->
|
||||
<div class="settings-section">
|
||||
<h4 class="settings-section-title">🔑 Change Master Password</h4>
|
||||
|
||||
Reference in New Issue
Block a user