refactor(js): extract attachments, autofill, unlock modules from app.js (3.1)
app.js 9363 -> 7962 lines. Three new classic-script modules: - app.attachments.js (250): blob crypto + upload/download UI, pure declarations, loads before app.js - app.autofill.js (276): Win32 combos, title->entry matching, picker, pure declarations, loads before app.js - app.unlock.js (907): Quick Unlock + PIN + recovery code grouped (same "enter without master pw" theme); assigns Bridge.onPinResult / onQuickUnlockResult at top level so it loads AFTER app.js, like app.sync.js Audit viewer stays in app.js (only 65 lines, not worth a file). Clipboard bridge helpers stay too (were interleaved in the quick-unlock section but unrelated). Registered in BuildAssets whitelist + index.html + APP_PARTS. Verified in-app: quick unlock cold-start, attachment upload/download. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,907 @@
|
||||
// ============================================================
|
||||
// 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 =
|
||||
'<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 · 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: '<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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user