Files
Password-Manager/js/app.unlock.js
T
r-zakarya 2c7f6188e3 fix(clipboard): copy toasts reflect the configured clear delay
The "clears in 30s" suffix was hardcoded in ~14 toasts. Single helper
clipClearSuffix() reads clipboardClearSeconds (''=Never); prefixes concatenated
to it. Cheatsheet text made generic "(auto-clears)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:42:26 +01:00

908 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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 (46 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 (412 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: '412 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 412 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 =
'<div class="prc-sheet">' +
' <h1>PMServer · Recovery Code</h1>' +
' <div class="prc-meta">' +
' <div><b>Account:</b> ' + (username || '') + '</div>' +
' <div><b>Generated:</b> ' + today + '</div>' +
' </div>' +
' <div class="prc-code">' + code + '</div>' +
' <div class="prc-instructions">' +
' <p><b>Keep this sheet offline and physically secure.</b></p>' +
' <p>Use this code if you forget your master password:</p>' +
' <ol>' +
' <li>On the unlock screen, click <i>"Use recovery code"</i>.</li>' +
' <li>Enter your username and the 16-character code above.</li>' +
' <li>You will be asked to set a new master password — the code is then consumed.</li>' +
' </ol>' +
' <p>The code can be used up to <b>5 times</b>. It is permanently erased the moment you successfully change the master password.</p>' +
' <p style="font-size:11px;color:#666">Anyone with this code can reset your master password — store it like a paper key, not a sticky note.</p>' +
' </div>' +
'</div>';
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' + clipClearSuffix());
} else {
navigator.clipboard.writeText(code).then(() => {
toast('Recovery code copied' + clipClearSuffix());
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: '<p>Save this code somewhere safe (password manager, ' +
'safe deposit box, printed copy). It will <b>not</b> be ' +
'shown again.</p>' +
'<div style="display:flex;align-items:center;gap:8px;' +
'padding:14px;background:var(--bg-elev);border-radius:6px">' +
'<span style="font-family:JetBrains Mono,monospace;font-size:20px;' +
'letter-spacing:2px;flex:1;text-align:center;user-select:all">' +
code + '</span>' +
'<button type="button" class="btn btn-ghost btn-sm" ' +
'id="copyRecoveryCodeBtn" title="Copy code">' +
'<svg><use href="#i-copy"/></svg> Copy</button>' +
'<button type="button" class="btn btn-ghost btn-sm" ' +
'id="printRecoveryCodeBtn" title="Print this code">' +
'<svg><use href="#i-printer"/></svg> Print</button>' +
'</div>' +
'<p style="color:var(--text-dim);font-size:12px">' +
'Using it lets you recover access if you forget your master ' +
'password. The code can be used up to <b>5 times</b>, and ' +
'is permanently erased as soon as you successfully change ' +
'your master password — so set a new one right after ' +
'recovering.</p>',
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);
}