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:
@@ -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` |
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user