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:
r-zakarya
2026-07-11 19:28:19 +01:00
parent 83a3dfdfc2
commit d583a3f7d3
8 changed files with 1457 additions and 1404 deletions
+8
View File
@@ -39,7 +39,12 @@ js/app.import.js (export container + import CSV/JSON — extrait §3.1)
js/app.backup.js (auto-backup planifié — extrait §3.1)
js/app.health.js (vault health dashboard — extrait §3.1)
js/app.overlays.js (quick search + cheatsheet + password history — extrait §3.1)
js/app.attachments.js (crypto blob + UI attachments — extrait §3.1)
js/app.autofill.js (hotkey combos + matching titre→entry + picker — extrait §3.1)
js/app.js (le reste : state, Bridge, api, UI…)
js/app.unlock.js (Quick Unlock + PIN + recovery code — extrait §3.1, APRÈS
app.js car effets de bord top-level `Bridge.onPinResult`,
`Bridge.onQuickUnlockResult`…)
js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car
effet de bord top-level `Bridge.onWebdavResult = …`)
```
@@ -111,6 +116,9 @@ seule `api()` est stubbée).
| Auto-backup frontend (planifié, chiffré) — extrait §3.1 | `js/app.backup.js` |
| Vault health dashboard — extrait §3.1 | `js/app.health.js` |
| Quick search + cheatsheet + history modal — extrait §3.1 | `js/app.overlays.js` |
| Attachments chiffrés (crypto blob + UI) — extrait §3.1 | `js/app.attachments.js` |
| Autofill (combos Win32, matching titre→entry, picker) — extrait §3.1 | `js/app.autofill.js` |
| Quick Unlock + PIN + recovery code (charge APRÈS app.js) — extrait §3.1 | `js/app.unlock.js` |
| Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.js` |
| Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` |
| HTML racine | `index.html` |
+3
View File
@@ -59,7 +59,10 @@ $patterns = @(
'js\app.backup.js',
'js\app.health.js',
'js\app.overlays.js',
'js\app.attachments.js',
'js\app.autofill.js',
'js\app.js',
'js\app.unlock.js',
'js\app.sync.js',
'css\style.css'
)
+3
View File
@@ -1228,7 +1228,10 @@
<script src="js/app.backup.js"></script>
<script src="js/app.health.js"></script>
<script src="js/app.overlays.js"></script>
<script src="js/app.attachments.js"></script>
<script src="js/app.autofill.js"></script>
<script src="js/app.js"></script>
<script src="js/app.unlock.js"></script>
<script src="js/app.sync.js"></script>
</body>
</html>
+250
View File
@@ -0,0 +1,250 @@
// ============================================================
// app.attachments.js — ENCRYPTED ATTACHMENTS module (extracted from app.js, §3.1)
// ============================================================
//
// Per-entry file crypto + upload/download/slideover UI. Pure declarations
// (no top-level side effects) → loads BEFORE app.js. Uses state, api,
// authHeaders, Bridge, toast — all resolved via the shared global scope at
// call time.
// Per-entry files (PDFs, backup-code images, recovery sheets, …)
// encrypted client-side with the vault key, base64-shipped to the server
// for opaque storage. Listing returns metadata only — the ciphertext is
// fetched on demand when the user clicks Download.
const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024; // raw file size cap (5 MB)
async function encryptBlobBytes(bytes) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, state.cryptoKey, bytes);
return { encrypted: bytesToBase64(ct), iv: bytesToBase64(iv) };
}
async function decryptBlobBytes(encB64, ivB64) {
const ct = base64ToBytes(encB64);
const iv = base64ToBytes(ivB64);
const dec = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv }, state.cryptoKey, ct);
return new Uint8Array(dec);
}
function humanFileSize(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(2) + ' MB';
}
async function uploadAttachment(entryId, file) {
if (!file) return null;
if (file.size > ATTACHMENT_MAX_BYTES) {
toast('File too large (max 5 MB raw)', 'error');
return null;
}
const bytes = new Uint8Array(await file.arrayBuffer());
const { encrypted, iv } = await encryptBlobBytes(bytes);
const meta = await api('/entries/' + entryId + '/attachments', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
filename: file.name,
mime: file.type || 'application/octet-stream',
encrypted_blob: encrypted,
iv,
size_bytes: file.size,
}),
});
return meta;
}
async function downloadAttachment(att) {
// Spinner for large attachments — fetch + decrypt + chunked transfer
// of a multi-MB file takes a moment before the Save dialog appears.
const big = (att.size_bytes || 0) > 512 * 1024;
try {
if (big) showBusy('Preparing download…');
const full = await api('/attachments/' + att.id, { headers: authHeaders() });
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
if (Bridge.active && typeof Bridge.saveFile === 'function') {
const res = await Bridge.saveFile(att.filename, bytes, pct => {
if (big) updateBusy('Preparing download… ' + pct + '%');
});
if (big) hideBusy();
if (res.ok) toast('Saved to ' + res.path);
else if (res.error) toast('Save failed: ' + res.error, 'error');
} else {
if (big) hideBusy();
// Web fallback: trigger a browser download.
const blob = new Blob([bytes], { type: att.mime || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = el('a', { href: url, download: att.filename });
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
}
} catch (e) {
if (big) hideBusy();
toast('Download failed: ' + (e && e.message ? e.message : e), 'error');
}
}
// Slideover field: header + upload button + list of attachments.
// Re-renders the list in place when the contents change (upload/delete).
function soAttachmentsField(entryId) {
const isStaging = (entryId === null || entryId === undefined);
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Attachments'));
const addBtn = el('button', {
class: 'btn btn-ghost btn-sm', type: 'button',
title: 'Attach a file (encrypted before upload, max 5 MB)',
});
addBtn.appendChild(icon('i-paperclip'));
addBtn.appendChild(document.createTextNode(' Attach file'));
wrap.appendChild(addBtn);
const fileInput = el('input', { type: 'file', style: 'display:none' });
wrap.appendChild(fileInput);
const list = el('div', { class: 'so-attach-list', id: 'soAttachList' });
wrap.appendChild(list);
addBtn.addEventListener('click', ev => {
ev.stopPropagation();
fileInput.click();
});
fileInput.addEventListener('change', async ev => {
ev.stopPropagation();
const file = fileInput.files && fileInput.files[0];
if (!file) return;
fileInput.value = ''; // allow re-uploading the same name later
if (file.size > ATTACHMENT_MAX_BYTES) {
toast('File too large (max 5 MB raw)', 'error');
return;
}
addBtn.disabled = true;
try {
if (isStaging) {
// Stage in memory — uploaded by soSave after the new
// entry's id is known.
const bytes = new Uint8Array(await file.arrayBuffer());
soState.pendingAttachments.push({
filename: file.name,
mime: file.type || 'application/octet-stream',
size_bytes: file.size,
bytes,
});
soDirtyCheck();
renderStagedAttachments(list);
} else {
await uploadAttachment(entryId, file);
toast('Attachment uploaded');
await refreshAttachments(entryId, list);
}
} catch (e) {
toast('Upload failed: ' + e.message, 'error');
} finally {
addBtn.disabled = false;
}
});
if (isStaging) renderStagedAttachments(list);
else refreshAttachments(entryId, list);
return wrap;
}
// Renders the pendingAttachments array (new-entry mode). Mirrors the
// existing-entry layout so users can't tell the difference until they
// hit Save.
function renderStagedAttachments(container) {
container.innerHTML = '';
const list = soState.pendingAttachments || [];
if (list.length === 0) {
container.appendChild(el('div', { class: 'so-attach-empty' },
'No attachments yet. Files will upload after you save.'));
return;
}
list.forEach((att, idx) => {
const row = el('div', { class: 'so-attach-row' });
const info = el('div', { class: 'so-attach-info' });
info.appendChild(el('div', { class: 'so-attach-name', title: att.filename }, att.filename));
info.appendChild(el('div', { class: 'so-attach-meta' },
humanFileSize(att.size_bytes) + ' · ' + (att.mime || '?') +
' · pending'));
row.appendChild(info);
const del = el('button',
{ class: 'icon-btn icon-btn-sm', type: 'button', title: 'Remove' });
del.appendChild(icon('i-trash'));
del.addEventListener('click', ev => {
ev.stopPropagation();
soState.pendingAttachments.splice(idx, 1);
soDirtyCheck();
renderStagedAttachments(container);
});
row.appendChild(del);
container.appendChild(row);
});
}
async function refreshAttachments(entryId, container) {
container.innerHTML = '';
let items = [];
try {
items = await api('/entries/' + entryId + '/attachments',
{ headers: authHeaders() });
} catch (e) {
container.appendChild(el('div', { class: 'so-attach-empty' },
'Failed to load attachments'));
return;
}
if (!items || items.length === 0) {
container.appendChild(el('div', { class: 'so-attach-empty' },
'No attachments yet.'));
return;
}
items.forEach(att => container.appendChild(buildAttachmentRow(att, entryId, container)));
}
function buildAttachmentRow(att, entryId, listContainer) {
const row = el('div', { class: 'so-attach-row' });
const name = el('div', { class: 'so-attach-name', title: att.filename }, att.filename);
const meta = el('div', { class: 'so-attach-meta' },
humanFileSize(att.size_bytes) + ' · ' + (att.mime || '?'));
const info = el('div', { class: 'so-attach-info' });
info.appendChild(name);
info.appendChild(meta);
row.appendChild(info);
const dl = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Download' });
dl.appendChild(icon('i-download'));
dl.addEventListener('click', ev => {
ev.stopPropagation();
downloadAttachment(att);
});
row.appendChild(dl);
const del = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Delete' });
del.appendChild(icon('i-trash'));
del.addEventListener('click', async ev => {
ev.stopPropagation();
const ok = await confirmDialog({
title: 'Delete attachment?',
message: 'This will permanently remove <b>' + att.filename + '</b>. There is no trash for attachments.',
okText: 'Delete',
danger: true,
});
if (!ok) return;
try {
await api('/attachments/' + att.id, {
method: 'DELETE', headers: authHeaders(),
});
toast('Attachment deleted');
await refreshAttachments(entryId, listContainer);
} catch (e) {
toast('Delete failed: ' + e.message, 'error');
}
});
row.appendChild(del);
return row;
}
+276
View File
@@ -0,0 +1,276 @@
// ============================================================
// app.autofill.js — AUTOFILL module (extracted from app.js, §3.1)
// ============================================================
//
// Global-hotkey autofill: Win32 combo helpers, browser-title → entry
// matching/scoring, request handling and the multi-match picker modal.
// Pure declarations (no top-level side effects) → loads BEFORE app.js.
// Uses state, Bridge, toast, $ — resolved via shared global scope at
// call time. Delphi fires Bridge.onAutofillRequest (declared in app.js).
// Win32 modifier flags for RegisterHotKey.
const WIN32_MOD = { alt: 0x0001, ctrl: 0x0002, shift: 0x0004, win: 0x0008 };
// Format a combo for human display: "Ctrl+Shift+L".
function autofillComboLabel(c) {
if (!c || !c.key) return '— not set —';
const parts = [];
if (c.ctrl) parts.push('Ctrl');
if (c.alt) parts.push('Alt');
if (c.shift) parts.push('Shift');
if (c.win) parts.push('Win');
parts.push(c.key);
return parts.join('+');
}
// Convert a combo to the Win32 (mods bitmask, virtual-key code) pair that
// Delphi's RegisterHotKey takes. key='A'..'Z'/'0'..'9' → ASCII code;
// 'F1'..'F12' → 0x70..0x7B.
function autofillComboToWin32(c) {
let mods = 0;
if (c.ctrl) mods |= WIN32_MOD.ctrl;
if (c.alt) mods |= WIN32_MOD.alt;
if (c.shift) mods |= WIN32_MOD.shift;
if (c.win) mods |= WIN32_MOD.win;
let vk = 0;
const k = (c.key || '').toUpperCase();
if (/^F([1-9]|1[0-2])$/.test(k)) vk = 0x70 + parseInt(k.slice(1)) - 1;
else if (k.length === 1 && k >= 'A' && k <= 'Z') vk = k.charCodeAt(0);
else if (k.length === 1 && k >= '0' && k <= '9') vk = k.charCodeAt(0);
return { mods, vk };
}
// Validate a captured combo. Requires at least one modifier (otherwise a
// single key would steal that letter globally) and a valid main key.
function autofillComboValid(c) {
if (!c) return false;
if (!(c.ctrl || c.alt || c.win)) return false; // shift-only is unreliable
const w = autofillComboToWin32(c);
return w.vk !== 0;
}
// Capture a key combo from a single keydown event. Returns null if the
// event is "incomplete" (only modifiers pressed so far) or Escape.
function autofillCaptureFromEvent(e) {
const k = e.key;
if (k === 'Escape') return 'cancel';
// Ignore pure-modifier keydowns (user is still building the combo).
if (k === 'Control' || k === 'Shift' || k === 'Alt' ||
k === 'Meta' || k === 'OS') return null;
// Accept letter, digit, F1-F12.
let key = null;
if (k.length === 1 && /[a-z0-9]/i.test(k)) {
key = k.toUpperCase();
} else if (/^F([1-9]|1[0-2])$/i.test(k)) {
key = k.toUpperCase();
} else {
return 'invalid';
}
return {
ctrl: !!e.ctrlKey,
shift: !!e.shiftKey,
alt: !!e.altKey,
win: !!e.metaKey,
key,
};
}
// Push current state to Delphi (toggle + both combos) and persist.
// Called after any change so Delphi's RegisterHotKey reflects state.
function autofillPushHotkeys() {
localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull));
localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd));
localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey));
if (Bridge.active) {
Bridge.setAutofillHotkeys(state.autofillEnabled, {
full: state.autofillHotkeyFull,
password: state.autofillHotkeyPwd,
quickSearch: state.quickSearchHotkey,
});
}
}
// Extract a bare hostname from a site string for fuzzy matching.
// "https://www.github.com/login" → "github.com"
// Strip the browser brand suffix that lives at the end of every tab title
// ("Some Page - Google Chrome", "Page — Mozilla Firefox", etc.). Without
// this, entries whose site is "google" / "mozilla" / "edge" would match
// every single page that has Chrome / Firefox / Edge as the browser brand.
const BROWSER_SUFFIX_RE =
/\s*[-—–|]\s*(google chrome|chromium|mozilla firefox|firefox|microsoft edge|edge|brave|opera|vivaldi|safari|tor browser|tor|arc)\s*$/i;
function autofillStripBrowserSuffix(title) {
return (title || '').replace(BROWSER_SUFFIX_RE, '').trim();
}
function autofillExtractHost(site) {
return site.toLowerCase()
.replace(/^https?:\/\//i, '')
.replace(/^www\./i, '')
.split('/')[0]
.split(':')[0];
}
// Get the second-level domain (brand part) from a hostname.
// "github.com" → "github" ; "mail.google.com" → "google" ; "x.com" → "x"
function autofillSLD(host) {
const parts = host.split('.').filter(p => p.length > 0);
if (parts.length <= 1) return host;
return parts[parts.length - 2];
}
// Escape a string for safe insertion into a RegExp.
function autofillEscapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Score how well a vault entry matches the foreground window title.
// Returns 0 (no match) or a positive integer (higher = better).
//
// Strategy (browser titles rarely contain the full hostname — usually
// just the brand name, e.g. "Sign in to GitHub" or "X. C'est… - Google Chrome"):
// 1. Full hostname substring → strongest (score 1000 + len)
// 2. SLD ≥3 chars as substring → medium (score 500 + len)
// 3. SLD <3 chars as word → weak (score 100), requires word
// boundaries to avoid matching "x" inside arbitrary words.
function autofillScore(entry, titleLower) {
// 1. Display name (entry.title) lowercased substring — strongest brand
// match. Skips when title is empty or same as site (already tested
// via the site path below).
const displayTitle = (entry.title || '').trim().toLowerCase();
if (displayTitle.length >= 2 && titleLower.includes(displayTitle))
return 1500 + displayTitle.length;
if (!entry.site) return 0;
const host = autofillExtractHost(entry.site);
if (host.length < 2) return 0;
// 2. Full hostname (rare in tab titles, but strongest URL signal)
if (titleLower.includes(host)) return 1000 + host.length;
// 3/4. Second-level domain
const sld = autofillSLD(host);
if (sld.length === 0) return 0;
if (sld.length >= 3) {
if (titleLower.includes(sld)) return 500 + sld.length;
return 0;
}
// Short SLD ("x", "qq", "vk"…) — require word boundaries so we don't
// match the letter inside random words.
const re = new RegExp('(^|[^a-z0-9])' + autofillEscapeRegex(sld) +
'([^a-z0-9]|$)', 'i');
if (re.test(titleLower)) return 100;
return 0;
}
// Called by Bridge.onAutofillRequest when a hotkey fires.
// kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P.
async function autofillHandleRequest(windowTitle, kind) {
if (!state.autofillEnabled) return;
if (!state.cryptoKey || state.locked || !state.token) {
// Vault is locked — bring the app to the front so the user can
// unlock immediately, rather than silently no-op'ing the hotkey.
Bridge.cancelAutofill();
Bridge.focusApp();
setTimeout(() => {
const pwd = document.getElementById('loginPassword');
const user = document.getElementById('loginUsername');
if (pwd && !document.getElementById('authScreen').classList.contains('is-hidden')) {
if (user && !user.value) user.focus();
else pwd.focus();
}
}, 80);
toast('Vault is locked — unlock to autofill', 'warning');
return;
}
const titleLower = autofillStripBrowserSuffix(windowTitle).toLowerCase();
const scored = state.entries
.map(e => ({ entry: e, score: autofillScore(e, titleLower) }))
.filter(x => x.score > 0)
.sort((a, b) => b.score - a.score);
if (scored.length === 0) {
toast('Autofill: no match for "' + windowTitle.slice(0, 40) + '"', 'warning');
Bridge.cancelAutofill();
return;
}
if (scored.length === 1) {
await autofillFillEntry(scored[0].entry, kind);
return;
}
// Multiple candidates — show picker. kind is captured so clicking a
// candidate honours password-only mode.
openAutofillPicker(scored.map(x => x.entry), windowTitle, kind);
}
// Decrypt and type an entry. kind = 'full' or 'password'.
async function autofillFillEntry(entry, kind) {
const password = await decryptPwd(entry.encrypted_password, entry.iv);
if (password === '[ERROR]') {
toast('Autofill: decryption error', 'error');
Bridge.cancelAutofill();
return;
}
// password-only kind → empty username → Delphi skips Tab.
// full kind with empty entry.username → also no Tab (Delphi handles it).
const user = (kind === 'password') ? '' : (entry.username || '');
Bridge.executeAutofill(user, password);
toast((kind === 'password' ? 'Password filled: ' : 'Autofilled: ') + entry.site);
// Audit (best-effort, ignore failures)
fetch('' + '/audit', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
action: kind === 'password' ? 'autofill_pwd' : 'autofill',
site: entry.site,
}),
}).catch(() => {});
}
// Picker modal for multi-match case. kind is forwarded to autofillFillEntry
// so the user's hotkey intent (full vs password-only) is preserved through
// the manual choice.
function openAutofillPicker(entries, windowTitle, kind) {
// Bring the app to front so the picker is unambiguously visible —
// otherwise the modal opens behind / next to the user's original
// window (e.g. Notepad) and easy to miss. ExecuteAutofill restores
// the original target HWND via ForceForegroundWindow on selection.
Bridge.focusApp();
const list = $('#autofillPickerList');
list.innerHTML = '';
entries.forEach(e => {
const btn = el('button', {
class: 'autofill-pick-btn',
on: {
click: async () => {
closeAutofillPicker(false);
await autofillFillEntry(e, kind);
},
},
});
btn.appendChild(el('span', { class: 'autofill-pick-site' }, entryDisplayName(e)));
if (e.username) {
btn.appendChild(el('span', { class: 'autofill-pick-user' }, e.username));
}
list.appendChild(btn);
});
const head = (kind === 'password' ? 'Pick entry (password only) — ' : 'Pick entry — ')
+ entries.length + ' match "' + windowTitle.slice(0, 30) + '…"';
$('#autofillPickerTitle').textContent = head;
$('#autofillPickerModal').classList.remove('is-hidden');
}
function closeAutofillPicker(notifyCancel = true) {
$('#autofillPickerModal').classList.add('is-hidden');
if (notifyCancel) Bridge.cancelAutofill();
}
+9 -1403
View File
File diff suppressed because it is too large Load Diff
+907
View File
@@ -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 (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 · 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);
}
+1 -1
View File
@@ -30,7 +30,7 @@ const { webcrypto } = require('node:crypto');
// top-level const/let across separate runInContext calls, so we CONCATENATE
// the app.* parts (in <script> load order) into one script. argon2.js is a
// self-contained IIFE and loads separately (see below).
const APP_PARTS = ['app.crypto.js', 'app.totp.js', 'app.favicon.js', 'app.import.js', 'app.backup.js', 'app.health.js', 'app.overlays.js', 'app.js', 'app.sync.js'].map(f => path.join(__dirname, '..', f));
const APP_PARTS = ['app.crypto.js', 'app.totp.js', 'app.favicon.js', 'app.import.js', 'app.backup.js', 'app.health.js', 'app.overlays.js', 'app.attachments.js', 'app.autofill.js', 'app.js', 'app.unlock.js', 'app.sync.js'].map(f => path.join(__dirname, '..', f));
// In-memory Storage stub (Web Storage API surface used by app.js).
function makeStorage() {