diff --git a/CLAUDE.md b/CLAUDE.md index b28b778..6793b14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` | diff --git a/delphi-backend/assets/BuildAssets.ps1 b/delphi-backend/assets/BuildAssets.ps1 index 43b7973..bce8bfb 100644 --- a/delphi-backend/assets/BuildAssets.ps1 +++ b/delphi-backend/assets/BuildAssets.ps1 @@ -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' ) diff --git a/index.html b/index.html index 9344ad4..56c32c1 100644 --- a/index.html +++ b/index.html @@ -1228,7 +1228,10 @@ + + + diff --git a/js/app.attachments.js b/js/app.attachments.js new file mode 100644 index 0000000..743948e --- /dev/null +++ b/js/app.attachments.js @@ -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 ' + att.filename + '. 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; +} + diff --git a/js/app.autofill.js b/js/app.autofill.js new file mode 100644 index 0000000..7d62844 --- /dev/null +++ b/js/app.autofill.js @@ -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(); +} + diff --git a/js/app.js b/js/app.js index bc6a5fa..d3c6366 100644 --- a/js/app.js +++ b/js/app.js @@ -5443,248 +5443,9 @@ function touchEntry(id) { } // ============================================================ -// ENCRYPTED ATTACHMENTS +// ENCRYPTED ATTACHMENTS — extracted to js/app.attachments.js (§3.1), +// loaded as a separate