05/18 Enhanced codes and functionalities 5

This commit is contained in:
2026-05-18 18:27:24 -04:00
parent 09d3bbdc15
commit 46765b9448
11 changed files with 1042 additions and 4 deletions
+3
View File
@@ -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)
+16
View File
@@ -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)
+74
View File
@@ -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}>'
+388
View File
@@ -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
+100
View File
@@ -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);
}
+243
View File
@@ -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
View File
@@ -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(
+9
View File
@@ -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>
+23
View File
@@ -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>
@@ -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')
+1
View File
@@ -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