Add WebAuthn passkey biometric unlock + dark toggle on auth screen
- Dark/light theme toggle now always visible (moved outside auth/vault sections) - New passkey_challenges and passkey_credentials tables - /passkey/register/begin + /passkey/register/complete endpoints - /passkey/login/begin + /passkey/login/complete endpoints - CBOR decoder + COSE key parser for WebAuthn attestation/assertion - ES256 (P-256) signature verification via OpenSSL - Client-side: register passkey button in settings, passkey login on auth screen - First passkey login prompts for master password once to derive AES-GCM key - Passkey login requires username input before biometric prompt
This commit is contained in:
@@ -2,6 +2,8 @@ const API = '/password-manager/api.php';
|
||||
let token = sessionStorage.getItem('authToken');
|
||||
let csrfToken = sessionStorage.getItem('csrfToken') || '';
|
||||
let curUser = sessionStorage.getItem('currentUsername');
|
||||
function a2b64(arr) { return btoa(String.fromCharCode(...new Uint8Array(arr))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); }
|
||||
function b642ab(s) { return Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c=>c.charCodeAt(0)).buffer; }
|
||||
let view = localStorage.getItem('vaultView') || 'grid';
|
||||
let showView = localStorage.getItem('showViewBtn') !== 'false';
|
||||
let showMail = localStorage.getItem('showEmail') !== 'false';
|
||||
@@ -317,6 +319,34 @@ function syncSettingsUI() {
|
||||
document.getElementById('showEmailToggle').classList.toggle('active', showMail);
|
||||
document.getElementById('autoLockTimer').value = lockMin;
|
||||
}
|
||||
async function registerPasskey() {
|
||||
try {
|
||||
const r = await fetch(API + '/passkey/register/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }
|
||||
});
|
||||
if (!r.ok) { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); return; }
|
||||
const opts = await r.json();
|
||||
opts.challenge = b642ab(opts.challenge);
|
||||
opts.user.id = b642ab(opts.user.id);
|
||||
if (!window.PublicKeyCredential) { toast('❌ Passkeys not supported', 'error'); return; }
|
||||
const cred = await navigator.credentials.create({ publicKey: opts });
|
||||
const result = {
|
||||
id: cred.id,
|
||||
response: {
|
||||
clientDataJSON: a2b64(cred.response.clientDataJSON),
|
||||
attestationObject: a2b64(cred.response.attestationObject)
|
||||
}
|
||||
};
|
||||
const r2 = await fetch(API + '/passkey/register/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify(result)
|
||||
});
|
||||
if (r2.ok) { toast('✅ Passkey registered!'); playSound('success'); }
|
||||
else { const d = await r2.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); }
|
||||
} catch (e) { toast('⚠️ Passkey setup failed: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ==================== INIT ====================
|
||||
function init() {
|
||||
@@ -438,6 +468,68 @@ function checkAddStrength() {
|
||||
// ==================== AUTH ====================
|
||||
function switchTab(t) { document.querySelectorAll('.auth-tab').forEach(x => x.classList.remove('active')); event.target.classList.add('active'); document.getElementById('loginForm').classList.toggle('hidden', t !== 'login'); document.getElementById('registerForm').classList.toggle('hidden', t !== 'register'); }
|
||||
|
||||
async function loginWithPasskey() {
|
||||
if (!window.PublicKeyCredential) { toast('❌ Passkeys not supported', 'error'); return; }
|
||||
const u = document.getElementById('loginUsername').value.trim();
|
||||
if (!u) { toast('Enter username first', 'error'); return; }
|
||||
document.getElementById('loginBtn').disabled = true;
|
||||
try {
|
||||
const r = await fetch(API + '/passkey/login/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: u })
|
||||
});
|
||||
if (!r.ok) { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); document.getElementById('loginBtn').disabled = false; return; }
|
||||
const opts = await r.json();
|
||||
opts.challenge = b642ab(opts.challenge);
|
||||
opts.allowCredentials.forEach(c => { c.id = b642ab(c.id); });
|
||||
const cred = await navigator.credentials.get({ publicKey: opts });
|
||||
const result = {
|
||||
id: cred.id,
|
||||
response: {
|
||||
clientDataJSON: a2b64(cred.response.clientDataJSON),
|
||||
authenticatorData: a2b64(cred.response.authenticatorData),
|
||||
signature: a2b64(cred.response.signature),
|
||||
userHandle: cred.response.userHandle ? a2b64(cred.response.userHandle) : null
|
||||
}
|
||||
};
|
||||
const r2 = await fetch(API + '/passkey/login/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(result)
|
||||
});
|
||||
const d = await r2.json();
|
||||
if (r2.ok) {
|
||||
token = d.token; csrfToken = d.csrfToken || ''; curUser = d.username || u;
|
||||
sessionStorage.setItem('authToken', token);
|
||||
sessionStorage.setItem('csrfToken', csrfToken);
|
||||
sessionStorage.setItem('currentUsername', curUser);
|
||||
// Try to restore crypto key from sessionStorage
|
||||
const restored = await restoreCryptoKey();
|
||||
if (!restored) {
|
||||
// Need master password once to derive crypto key
|
||||
const mp = await new Promise(resolve => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'custom-modal-overlay show';
|
||||
overlay.innerHTML = `<div class="custom-modal"><h3>🔑 One more step</h3><p style="color:var(--text2);margin-bottom:1rem">Enter your master password to unlock the vault</p><input type="password" id="passkeyTempPwd" placeholder="Master password" style="width:100%"><div class="modal-actions" style="margin-top:1rem"><button class="btn btn-sm" id="passkeyTempBtn">Unlock</button></div></div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById('passkeyTempBtn').onclick = () => resolve(document.getElementById('passkeyTempPwd').value);
|
||||
overlay.addEventListener('keydown', function handler(e) { if (e.key === 'Enter') { resolve(document.getElementById('passkeyTempPwd').value); overlay.remove(); document.removeEventListener('keydown', handler); } });
|
||||
});
|
||||
const m = document.querySelector('.custom-modal-overlay.show');
|
||||
if (m) m.remove();
|
||||
cryptoKey = await deriveKey(mp, d.salt);
|
||||
persistCryptoKey();
|
||||
}
|
||||
await loadFolders();
|
||||
toast('✅ Biometric login!');
|
||||
playSound('login');
|
||||
showVault();
|
||||
loadEntries();
|
||||
} else { toast('❌ ' + (d.error || 'Failed'), 'error'); }
|
||||
} catch (e) { toast('⚠️ Passkey login failed: ' + e.message, 'error'); }
|
||||
finally { document.getElementById('loginBtn').disabled = false; }
|
||||
}
|
||||
async function login() {
|
||||
const u = document.getElementById('loginUsername').value.trim();
|
||||
const p = document.getElementById('loginPassword').value;
|
||||
|
||||
Reference in New Issue
Block a user