// ============================================================ // app.unlock.js — QUICK UNLOCK + PIN + RECOVERY KEY module (extracted §3.1) // ============================================================ // // The three "get in without typing the master password" flows: Quick Unlock // (DPAPI blob), PIN unlock (PIN-wrapped key blob), and the one-shot recovery // code. IMPORTANT: this file assigns Bridge.onQuickUnlockResult / onPinResult // etc. at TOP LEVEL, so it must load AFTER app.js (where Bridge is declared) // — same rule as app.sync.js. init() runs on DOMContentLoaded, after every // classic script has loaded, so call-time references are safe. // // User-controlled convenience feature. When opted in, the current vault // state (raw AES key + salt + username + session token) is bundled into // a JSON blob and handed to the Delphi side, which DPAPI-encrypts it // (CRYPTPROTECT_CURRENT_USER) and stashes the blob on disk. Subsequent // app starts can recover the entire session without re-entering the // master password. // // Honest trade-off: the encrypted blob is readable by ANY process // running as the same Windows user. The protection is no stronger than // the Windows account itself. Users with Windows Hello / fingerprint / // PIN configured at the OS level get biometric gating transitively // (via login). Without that, this is "remember on trusted device". // // Only available when running inside the Delphi host (Bridge.active); // the PHP standalone has no DPAPI equivalent. // Resolver for the Delphi → JS callback. Delphi side fires // Bridge.onQuickUnlockResult(b64|null) after processing cmd://quickunlock/get. let quickUnlockResolver = null; // Same for the status query. let quickUnlockStatusResolver = null; function bridgeRequestQuickUnlock() { if (!Bridge.active) return Promise.resolve(null); return new Promise(resolve => { quickUnlockResolver = resolve; // Safety: if Delphi never responds, time out after 3 s so the auth // screen doesn't hang. Falls back to master-pw login. setTimeout(() => { if (quickUnlockResolver === resolve) { quickUnlockResolver = null; resolve(null); } }, 3000); window.location.href = 'cmd://quickunlock/get'; }); } function bridgeQuickUnlockStatus() { if (!Bridge.active) return Promise.resolve(false); return new Promise(resolve => { quickUnlockStatusResolver = resolve; setTimeout(() => { if (quickUnlockStatusResolver === resolve) { quickUnlockStatusResolver = null; resolve(false); } }, 2000); window.location.href = 'cmd://quickunlock/status'; }); } // Patch Bridge.onQuickUnlockResult / Status to feed the resolvers above. Bridge.onQuickUnlockResult = function(b64) { if (quickUnlockResolver) { const cb = quickUnlockResolver; quickUnlockResolver = null; cb(b64); } }; Bridge.onQuickUnlockStatus = function(configured) { if (quickUnlockStatusResolver) { const cb = quickUnlockStatusResolver; quickUnlockStatusResolver = null; cb(!!configured); } }; // ============================================================ // PIN UNLOCK (DPAPI blob, PIN-derived wrap of the vault key) // ============================================================ // Three modes governed by state.unlockMode (synced setting): // 'pw' — current behaviour, master pw only (PIN ignored even if set) // 'pin' — PIN unlocks the vault on this device // 'both' — master pw unlocks; PIN is then verified before access // // Anti-brute-force: each failed PIN attempt rewrites the blob with an // incremented counter. Past PIN_MAX_ATTEMPTS the blob self-destructs and // the user has to fall back to master pw (and re-set the PIN if desired). // // Sensitive actions (export, change master pw, recovery code…) still go // through askReauth which always asks for the master pw — the PIN never // substitutes there. const PIN_KDF_ITERS = 100000; // lower than master pw — PIN entropy // is low (4–6 digits) so high iters // mostly slow down honest users. const PIN_MAX_ATTEMPTS = 5; let pinResolver = null; let pinStatusResolver = null; function bridgePinGet() { if (!Bridge.active) return Promise.resolve(null); return new Promise(resolve => { pinResolver = resolve; setTimeout(() => { if (pinResolver === resolve) { pinResolver = null; resolve(null); } }, 3000); window.location.href = 'cmd://pin/get'; }); } function bridgePinStatus() { if (!Bridge.active) return Promise.resolve(false); return new Promise(resolve => { pinStatusResolver = resolve; setTimeout(() => { if (pinStatusResolver === resolve) { pinStatusResolver = null; resolve(false); } }, 2000); window.location.href = 'cmd://pin/status'; }); } function bridgePinStore(payloadB64) { if (!Bridge.active) return; window.location.href = 'cmd://pin/store?data=' + encodeURIComponent(payloadB64); } function bridgePinClear() { if (!Bridge.active) return; window.location.href = 'cmd://pin/clear'; } Bridge.onPinResult = function(b64) { if (pinResolver) { const cb = pinResolver; pinResolver = null; cb(b64); } }; Bridge.onPinStatus = function(configured) { if (pinStatusResolver) { const cb = pinStatusResolver; pinStatusResolver = null; cb(!!configured); } }; async function derivePinWrapKey(pin, saltBytes, iters) { const km = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(pin), 'PBKDF2', false, ['deriveKey']); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: iters, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, // extractable: false would be safer but we need to re-wrap on attempt increment ['encrypt', 'decrypt']); } async function pinBuildBlob(pin) { // Snapshot of everything the cold-start unlock needs to log back in // without the master pw. Mirrors the Quick Unlock payload shape. const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const wrap = await derivePinWrapKey(pin, salt, PIN_KDF_ITERS); const raw = await crypto.subtle.exportKey('raw', state.cryptoKey); const wrapped = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, wrap, raw); return { v: 1, username: state.username, loginSalt: state.salt, loginIters: state.kdfIterations || 600000, // Auth scheme so cold-start sends the right verifier (v2 accounts // need the decoupled transform, not the raw key hex). Absent on // pre-decoupling blobs → cold-start defaults to the key hex, which // is correct for those (legacy) accounts. hashAlgo: state.hashAlgo || '', argon2Params: state.argon2Params || null, salt: bytesToBase64(salt), iters: PIN_KDF_ITERS, iv: bytesToBase64(iv), wrapped: bytesToBase64(wrapped), attempts: 0, }; } function pinBlobToB64(obj) { return bytesToBase64(new TextEncoder().encode(JSON.stringify(obj))); } function pinB64ToBlob(b64) { return JSON.parse(new TextDecoder().decode(base64ToBytes(b64))); } async function pinFetchBlob() { const b64 = await bridgePinGet(); if (!b64) return null; try { return pinB64ToBlob(b64); } catch { return null; } } // Validates a PIN against the stored blob. Returns the unwrapped vault // key bytes on success, null on failure (and rewrites the blob with the // incremented attempts counter or wipes it past the cap). async function pinTryUnwrap(pin) { const blob = await pinFetchBlob(); if (!blob) return null; try { const wrap = await derivePinWrapKey(pin, base64ToBytes(blob.salt), blob.iters || PIN_KDF_ITERS); const raw = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: base64ToBytes(blob.iv) }, wrap, base64ToBytes(blob.wrapped)); // Successful unlock — reset the attempts counter so a future // bad-then-good streak doesn't accidentally wipe the blob. if ((blob.attempts || 0) !== 0) { blob.attempts = 0; bridgePinStore(pinBlobToB64(blob)); } return { rawKey: new Uint8Array(raw), blob }; } catch (_) { const next = (blob.attempts || 0) + 1; if (next >= PIN_MAX_ATTEMPTS) { bridgePinClear(); } else { blob.attempts = next; bridgePinStore(pinBlobToB64(blob)); } return null; } } async function setupPin(pin) { if (!Bridge.active) return toast('PIN requires the Delphi app', 'warning'); if (!state.cryptoKey) return toast('Unlock the vault first', 'warning'); const blob = await pinBuildBlob(pin); bridgePinStore(pinBlobToB64(blob)); state.pinConfigured = true; toast('PIN set'); } async function removePin() { bridgePinClear(); state.pinConfigured = false; // If we were in pin-only mode, fall back to pw — leaving the user // unable to log in next time would be a self-foot-gun. if (state.unlockMode === 'pin' || state.unlockMode === 'both') { state.unlockMode = 'pw'; localStorage.setItem('unlockMode', 'pw'); saveServerSettings(); } toast('PIN removed'); } // Refresh the Settings panel PIN row: dropdown value, status text, // button labels. Idempotent — safe to call from listeners or after the // async pinStatus probe completes. function refreshPinUnlockUI() { const sel = document.getElementById('settingUnlockMode'); const status = document.getElementById('pinStatus'); const setBtn = document.getElementById('pinSetBtn'); const delBtn = document.getElementById('pinRemoveBtn'); if (sel) sel.value = state.unlockMode || 'pw'; if (status) { status.textContent = state.pinConfigured ? 'PIN is set on this device.' : 'No PIN set.'; } if (setBtn) setBtn.textContent = state.pinConfigured ? 'Change PIN' : 'Set PIN'; if (delBtn) delBtn.style.display = state.pinConfigured ? '' : 'none'; } // Prompt + setup flow used by the Set/Change PIN button. Validates the // PIN client-side (4–12 digits) then writes the DPAPI blob. async function pinSetupFlow() { if (!Bridge.active) return toast('PIN requires the Delphi app', 'warning'); if (!state.cryptoKey) return toast('Unlock the vault first', 'warning'); // Setting/changing a PIN creates a new unlock path → treat as a // sensitive operation. An unattended unlocked vault must not be // possible for a passerby to pin-backdoor. const masterPwd = await askReauth( 'Confirm your master password to set or change the PIN.'); if (!masterPwd) return; try { const verifier = await computeVerifier( masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ verifier: verifier }), }); } catch (err) { return toast('Wrong master password', 'error'); } let lastError = ''; let attempts = 0; for (;;) { const pin = await promptDialog({ title: state.pinConfigured ? 'Change PIN' : 'Set a PIN', message: '4–12 digits. PIN unlock is device-local and never leaves this machine.', placeholder: 'PIN', password: true, okText: 'Save', error: lastError, }); // Cancel / Esc / X resolves with `false` (not null) — treat any // falsy value as "user backed out", not as a wrong attempt. if (pin === false || pin === null || pin === undefined || pin === '') return; if (!/^\d{4,12}$/.test(pin)) { attempts++; if (attempts >= 5) return toast('Too many invalid attempts', 'error'); lastError = 'PIN must be 4–12 digits (attempt ' + attempts + ' / 5).'; continue; } await setupPin(pin); refreshPinUnlockUI(); return; } } // ----- Auth screen mode switching --------------------------------- // Picks which inputs are visible on the lock screen based on the // user's unlockMode + whether a PIN blob actually exists on this // device. Always falls back to the master-pw layout if PIN unlock can't // realistically work (no Delphi bridge, no DPAPI blob). function applyAuthScreenMode() { const pwField = document.getElementById('loginPasswordField'); const pinField = document.getElementById('loginPinField'); const useMaster = document.getElementById('loginUseMasterBtn'); if (!pwField || !pinField) return; const havePin = Bridge.active && state.pinConfigured; // Drift fix: if the user previously chose pin/both but the blob is // no longer on disk (auto-wiped after 5 wrong attempts, or removed // from another session), demote the mode locally so the Settings // dropdown reflects reality. Server sync happens next time the user // logs in and openSettings saves changes. if (!havePin && (state.unlockMode === 'pin' || state.unlockMode === 'both')) { state.unlockMode = 'pw'; localStorage.setItem('unlockMode', 'pw'); } const mode = havePin ? (state.unlockMode || 'pw') : 'pw'; pwField.style.display = (mode === 'pin') ? 'none' : ''; pinField.style.display = (mode === 'pin' || mode === 'both') ? '' : 'none'; // PIN-only mode: offer a one-shot escape hatch so the user can fall // back to master pw if the PIN blob got corrupted or they forgot it. if (useMaster) useMaster.style.display = (mode === 'pin') ? '' : 'none'; // Required attribute on hidden inputs blocks form submission — keep // it in sync with visibility. const pwInput = document.getElementById('loginPassword'); const pinInput = document.getElementById('loginPin'); if (pwInput) pwInput.required = (mode !== 'pin'); if (pinInput) pinInput.required = (mode === 'pin' || mode === 'both'); } // Try the PIN-unlock cold-start. Mirrors tryQuickUnlock but gated by a // user-typed PIN. Returns true on success (vault unlocked), false on // any failure (wrong PIN, missing blob, server refused). The caller is // responsible for showing the master-pw fallback UI on false. async function loginViaPin(pin) { if (!Bridge.active) return false; if (!pin) return false; const ok = await pinTryUnwrap(pin); if (!ok) return false; const { rawKey, blob } = ok; // Restore the identity bits we need to call /login with a verifier. state.username = blob.username || state.username; state.salt = blob.loginSalt || state.salt; state.kdfIterations = blob.loginIters || state.kdfIterations || 600000; state.hashAlgo = blob.hashAlgo || ''; state.argon2Params = blob.argon2Params || null; try { state.cryptoKey = await crypto.subtle.importKey( 'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); } catch (_) { return false; } try { // v2 accounts need the decoupled verifier; legacy → key hex. const verifier = await verifierFromKeyHex(bytesToHex(rawKey), state.hashAlgo); const r = await api('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: state.username, verifier }), }); state.token = r.token; state.csrf = r.csrfToken; if (r.salt) state.salt = r.salt; if (r.kdfIterations) state.kdfIterations = r.kdfIterations; } catch (_) { // Server credentials drifted (master pw rotation since PIN // setup). Force user back to master pw + ask them to redo PIN. return false; } sessionStorage.setItem('username', state.username); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); await persistCryptoKey(); state.locked = false; state.justRecovered = false; return true; } // 'both' mode helper: master pw has already populated state.cryptoKey // via the regular login flow. Now check the PIN matches the stored // blob (validation only — we don't use the unwrapped key from here). async function verifyPinAfterMasterUnlock(pin) { const ok = await pinTryUnwrap(pin); return !!ok; } async function enableQuickUnlock() { if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning'); if (!state.cryptoKey) return toast('Unlock the vault first', 'warning'); const masterPwd = await askReauth( 'Confirm your master password to enable Quick unlock on this device.'); if (!masterPwd) return; try { const verifier = await computeVerifier( masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ verifier: verifier }), }); } catch (err) { return toast('Wrong master password', 'error'); } // Export the raw key + identity. We don't store the session token — // tryQuickUnlock re-logs in with a verifier derived from the key, // which always yields a fresh server session (the stored token would // expire after 24 h and break cold-start restore on a moved exe). const raw = await crypto.subtle.exportKey('raw', state.cryptoKey); const blob = JSON.stringify({ v: 2, username: state.username, salt: state.salt, kdfIterations: state.kdfIterations, // Auth scheme for cold-start verifier selection (see pinBuildBlob). hashAlgo: state.hashAlgo || '', argon2Params: state.argon2Params || null, key: bytesToBase64(raw), }); const b64 = bytesToBase64(new TextEncoder().encode(blob)); window.location.href = 'cmd://quickunlock/store?data=' + encodeURIComponent(b64); localStorage.setItem('quickUnlockEnabled', '1'); state.quickUnlockEnabled = true; if ($('#quickUnlockStatus')) updateQuickUnlockUI(); toast('Quick unlock enabled'); } async function disableQuickUnlock() { window.location.href = 'cmd://quickunlock/clear'; localStorage.removeItem('quickUnlockEnabled'); state.quickUnlockEnabled = false; if ($('#quickUnlockStatus')) updateQuickUnlockUI(); toast('Quick unlock disabled'); } function updateQuickUnlockUI() { const lbl = $('#quickUnlockStatus'); const btn = $('#quickUnlockToggleBtn'); if (!lbl || !btn) return; if (state.quickUnlockEnabled) { lbl.textContent = 'Enabled on this device.'; btn.textContent = 'Disable'; btn.classList.add('is-danger'); } else { lbl.textContent = 'Disabled.'; btn.textContent = 'Enable on this device'; btn.classList.remove('is-danger'); } } // Try to unlock via DPAPI. Returns true if the vault is now unlocked, // false otherwise (caller falls back to master-pw login). async function tryQuickUnlock() { if (!Bridge.active) return false; const b64 = await bridgeRequestQuickUnlock(); if (!b64) return false; localStorage.setItem('quickUnlockEnabled', '1'); state.quickUnlockEnabled = true; let parsed; try { const jsonStr = new TextDecoder().decode(base64ToBytes(b64)); parsed = JSON.parse(jsonStr); } catch (e) { return false; } if (!parsed || !parsed.key || !parsed.salt || !parsed.username) return false; // Restore identity + crypto key from the blob. state.username = parsed.username; state.salt = parsed.salt; state.kdfIterations = parsed.kdfIterations || 600000; state.hashAlgo = parsed.hashAlgo || ''; state.argon2Params = parsed.argon2Params || null; const rawKey = base64ToBytes(parsed.key); try { state.cryptoKey = await crypto.subtle.importKey( 'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); } catch (e) { return false; } // Always request a fresh session token via /login using the key-derived // verifier. The stored token (if any) may have expired or been cleaned // up by the server's session GC, which used to drop the user back to // the login screen on cold start. try { // v2 accounts need the decoupled verifier; legacy → key hex. const verifier = await verifierFromKeyHex(bytesToHex(rawKey), state.hashAlgo); const r = await api('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: state.username, verifier }), }); state.token = r.token; state.csrf = r.csrfToken; if (r.salt) state.salt = r.salt; if (r.kdfIterations) state.kdfIterations = r.kdfIterations; } catch (e) { // Login failed — vault credentials may have changed (master pw // rotation) since Quick Unlock was set up. Force a fresh master-pw // login; the user will need to re-enable Quick Unlock afterwards. return false; } sessionStorage.setItem('username', state.username); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); await persistCryptoKey(); state.locked = false; return true; } // ============================================================ // RECOVERY KEY — one-shot emergency access // ============================================================ // // Generated at the user's request from Settings. The plaintext code is // shown exactly once; the server stores only SHA-256(code) for lookup // + an AES-GCM wrap of the current vault key under a KEK derived from // PBKDF2(code, kdfSalt, 600k). // // Recovery flow (master pw forgotten): // 1. Auth screen → "Use recovery code" → enter username + code // 2. Server hashes code, looks up user, verifies match, DELETES the // recovery row (single-use), returns wrapped key + KEK salt + // a fresh session. // 3. Client derives the KEK, unwraps the AES key. // 4. Client immediately forces a master-password change so the // account isn't left with the recovery code's KEK as the only // escape hatch. // Renders a printable A4 sheet (CSS-only, no external libs) with the // recovery code in large monospace + a fold-and-stash instruction. // Removes the print container after the print dialog closes. function printRecoveryCode(code, username) { const existing = document.getElementById('printRecoveryArea'); if (existing) existing.remove(); const wrap = document.createElement('div'); wrap.id = 'printRecoveryArea'; const today = new Date().toISOString().slice(0, 10); wrap.innerHTML = '
' + '

PMServer · Recovery Code

' + '
' + '
Account: ' + (username || '') + '
' + '
Generated: ' + today + '
' + '
' + '
' + code + '
' + '
' + '

Keep this sheet offline and physically secure.

' + '

Use this code if you forget your master password:

' + '
    ' + '
  1. On the unlock screen, click "Use recovery code".
  2. ' + '
  3. Enter your username and the 16-character code above.
  4. ' + '
  5. You will be asked to set a new master password — the code is then consumed.
  6. ' + '
' + '

The code can be used up to 5 times. It is permanently erased the moment you successfully change the master password.

' + '

Anyone with this code can reset your master password — store it like a paper key, not a sticky note.

' + '
' + '
'; document.body.appendChild(wrap); // Restore on print-end and on focus (Edge fires focus when preview closes). const cleanup = () => { const n = document.getElementById('printRecoveryArea'); if (n) n.remove(); window.removeEventListener('afterprint', cleanup); window.removeEventListener('focus', cleanup); }; window.addEventListener('afterprint', cleanup); window.addEventListener('focus', cleanup); setTimeout(() => window.print(), 50); } // Random recovery code: 16 chars in 4 groups of 4. ~96 bits entropy // from a 36-char alphabet (no ambiguous chars: no 0/O/I/l/1) so the // printed form is misreading-resistant. function generateRecoveryCode() { const A = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 32 chars const bytes = crypto.getRandomValues(new Uint8Array(16)); let s = ''; for (let i = 0; i < 16; i++) { if (i > 0 && i % 4 === 0) s += '-'; s += A[bytes[i] % A.length]; } return s; } async function sha256HexLocal(input) { const buf = new TextEncoder().encode(input); const hashBuf = await crypto.subtle.digest('SHA-256', buf); const bytes = new Uint8Array(hashBuf); let hex = ''; for (const b of bytes) hex += b.toString(16).padStart(2, '0'); return hex; } // Derive a KEK from the recovery code + per-row salt, then wrap the // supplied AES key bytes under it. Returns base64 ciphertext + IV. async function wrapAesKeyForRecovery(aesKeyBytes, recoveryCode, kdfSaltHex) { const saltBytes = new TextEncoder().encode(kdfSaltHex); // match deriveKey's quirk const km = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(recoveryCode), 'PBKDF2', false, ['deriveKey']); const kek = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, kek, aesKeyBytes); return { wrappedKey: bytesToBase64(ct), wrappedIv: bytesToBase64(iv) }; } async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCode, kdfSaltHex) { const saltBytes = new TextEncoder().encode(kdfSaltHex); const km = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(recoveryCode), 'PBKDF2', false, ['deriveKey']); const kek = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); const iv = base64ToBytes(wrappedIvB64); const ct = base64ToBytes(wrappedKeyB64); return await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, ct); // raw bytes } // Generate a new recovery key for the logged-in user. Shows the plaintext // code in a modal that the user must explicitly acknowledge before closing. async function doGenerateRecoveryKey() { if (!state.cryptoKey) { return toast('Vault locked', 'warning'); } const masterPwd = await askReauth( 'Confirm your master password to generate a recovery key.'); if (!masterPwd) return; // Generate the code + a fresh per-row salt for the KEK PBKDF2. Salt is // per-recovery so regenerating doesn't reuse the same KDF parameters. const code = generateRecoveryCode(); const codeHash = await sha256HexLocal(code); const kdfSalt = randomHexSalt(); // Export the current AES key as raw bytes so we can wrap it under // the recovery KEK. The export only works because deriveKey was // called with `extractable=true` — already the case in our code. const rawKey = await crypto.subtle.exportKey('raw', state.cryptoKey); const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery( rawKey, code, kdfSalt); try { // Send a verifier instead of the master pw — server proves the // user still knows the master pw without ever seeing the plaintext. const verifier = await computeVerifier( masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); await api('/recovery-key/setup', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ verifier: verifier, codeHash: codeHash, kdfSalt: kdfSalt, wrappedKey: wrappedKey, wrappedIv: wrappedIv, }), }); } catch (err) { return toast('Setup failed: ' + (err.message || ''), 'error'); } // Refresh the Settings button label state.recoveryConfigured = true; if ($('#recoveryStatus')) updateRecoveryStatusLabel(); // Show the code ONCE. Use the confirm modal so the user has to // explicitly click "I saved it" before the value vanishes. // // Wire the inline Copy button BEFORE awaiting the dialog: confirmDialog // injects the HTML synchronously, so a 0-ms task fires after the DOM // is in place but before the user can interact. CSP forbids inline // onclick handlers, hence the addEventListener route. setTimeout(() => { const btn = document.getElementById('copyRecoveryCodeBtn'); if (btn) { btn.addEventListener('click', () => { // Same path as password copy: secure-clipboard via Delphi // (excluded from Win+V history, auto-cleared after 30s) when // running embedded, navigator.clipboard with manual scrub // otherwise. if (Bridge.copySecure(code, 30000)) { toast('Recovery code copied · clears in 30s'); } else { navigator.clipboard.writeText(code).then(() => { toast('Recovery code copied · clears in 30s'); setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); }); } }); } const printBtn = document.getElementById('printRecoveryCodeBtn'); if (printBtn) { printBtn.addEventListener('click', () => printRecoveryCode(code, state.username)); } }, 0); await confirmDialog({ title: 'Your recovery code', message: '

Save this code somewhere safe (password manager, ' + 'safe deposit box, printed copy). It will not be ' + 'shown again.

' + '
' + '' + code + '' + '' + '' + '
' + '

' + 'Using it lets you recover access if you forget your master ' + 'password. The code can be used up to 5 times, and ' + 'is permanently erased as soon as you successfully change ' + 'your master password — so set a new one right after ' + 'recovering.

', okText: 'I saved it', }); toast('Recovery code generated'); } async function doRemoveRecoveryKey() { const ok = await confirmDialog({ title: 'Remove recovery key', message: 'You will lose your ability to recover this account if you ' + 'forget the master password. Continue?', okText: 'Remove', danger: true, }); if (!ok) return; try { await api('/recovery-key', { method: 'DELETE', headers: authHeaders(), }); state.recoveryConfigured = false; if ($('#recoveryStatus')) updateRecoveryStatusLabel(); toast('Recovery key removed'); } catch (err) { toast(err.message, 'error'); } } function updateRecoveryStatusLabel() { const lbl = $('#recoveryStatus'); const setupBtn = $('#recoverySetupBtn'); const removeBtn = $('#recoveryRemoveBtn'); if (!lbl) return; if (state.recoveryConfigured) { const left = state.recoveryRemainingUses; const usesNote = (typeof left === 'number' && left < 5) ? ' (' + left + ' use' + (left === 1 ? '' : 's') + ' left)' : ''; lbl.textContent = 'Recovery key is configured.' + usesNote; if (setupBtn) setupBtn.textContent = 'Regenerate code'; if (removeBtn) removeBtn.style.display = ''; } else { lbl.textContent = 'No recovery key set.'; if (setupBtn) setupBtn.textContent = 'Generate recovery code'; if (removeBtn) removeBtn.style.display = 'none'; } } async function refreshRecoveryStatus() { try { const r = await api('/recovery-key/status', { headers: authHeaders() }); state.recoveryConfigured = !!r.configured; state.recoveryRemainingUses = (typeof r.remaining_uses === 'number') ? r.remaining_uses : 5; updateRecoveryStatusLabel(); } catch (e) { /* ignore */ } } // Recovery redeem flow — called from the auth screen when the user clicks // "Use a recovery code". Prompts for username + code, redeems, unwraps the // vault key, immediately forces a master password change. async function doRecoveryRedeem() { const u = await promptDialog({ title: 'Recover access', message: 'Enter your username — we\'ll ask for the recovery code next.', placeholder: 'Username', okText: 'Continue', }); if (!u) return; const code = await promptDialog({ title: 'Enter recovery code', message: 'Recovery codes look like XXXX-XXXX-XXXX-XXXX. They allow ' + 'up to 5 uses, and are erased when you set a new master ' + 'password — remember to generate a fresh code afterwards.', placeholder: 'XXXX-XXXX-XXXX-XXXX', okText: 'Recover', password: true, }); if (!code) return; let r; try { r = await api('/recovery-key/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u.trim(), code: code.trim() }), }); } catch (err) { if (err.status === 429 && err.body && err.body.retry_after) { return showLockoutCountdown(err.body.retry_after); } return toast('Recovery failed: ' + (err.message || 'invalid code'), 'error'); } // Unwrap the vault key with the code the user just typed. let rawKey; try { rawKey = await unwrapAesKeyFromRecovery( r.wrappedKey, r.wrappedIv, code.trim(), r.kdfSalt); } catch (e) { return toast('Could not decrypt vault — wrong code?', 'error'); } // Reconstitute state from the new session. state.token = r.token; state.csrf = r.csrfToken; state.salt = r.salt; state.username = u.trim(); state.kdfIterations = r.kdfIterations || 600000; // Account's auth scheme — needed so the recovery-mode master-pw change // proves the current key under the right verifier transform. state.hashAlgo = r.hashAlgo || ''; state.argon2Params = r.argon2 || null; sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('hashAlgo', state.hashAlgo); // Import the raw key bytes as a fresh AES-GCM CryptoKey (extractable // so master-pw change can later re-export and re-wrap as needed). state.cryptoKey = await crypto.subtle.importKey( 'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); await persistCryptoKey(); const remaining = (typeof r.remainingUses === 'number') ? r.remainingUses : 0; if (remaining <= 0) { toast('Last recovery use — set a new master password now or the code is gone forever', 'warning'); } else { toast('Recovery code used. ' + remaining + ' use(s) left before it expires. Change your master password now.', 'warning'); } state.justRecovered = true; await enterApp(); setTimeout(openChangeMasterModal, 300); }