From 26c01a4fc37beaf10bb77af241901bf5c00571f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:05:30 +0000 Subject: [PATCH] Vorschlaege weichen Seiten-Comboboxen, 2FA-Code bereitlegen Vorschlagsliste: Bisher galt jedes E-Mail-Feld als Anmeldefeld (isUsernameField gibt bei type=email bedingungslos true zurueck), und das Dropdown liegt mit maximalem z-index direkt unter dem Feld. Auf Seiten mit eigener Auswahlliste - etwa einem Benutzer-Picker - verdeckte es deren Treffer, die dadurch nicht mehr anklickbar waren. Zwei Regeln davor: Felder mit eigener Vorschlagsliste (ARIA-Combobox mit aria-autocomplete/aria-controls/aria-expanded oder ) bekommen keine Vault-Vorschlaege mehr, sofern autocomplete sie nicht ausdruecklich als Anmeldefeld ausweist. Und sobald der Nutzer selbst tippt, blendet sich die Liste aus und bleibt es, bis das Feld wieder leer ist. 2FA-Code bereitlegen: Nach dem Ausfuellen eines Eintrags mit 2FA bleibt dieser fuenf Minuten vorgemerkt. Taucht danach ein 2FA-Feld auf oder wird es fokussiert - auch auf einer Folgeseite -, wird ein frischer Code geholt und in die Zwischenablage gelegt, statt wie bisher nur einmal zum Fuellzeitpunkt (wo er bis zum 2FA-Schritt laengst rotiert waere). Automatisch wird ausschliesslich kopiert; ins Feld geschrieben wird ein Code weiterhin nur bei ausdruecklicher Auswahl. Geschrieben wird zuerst ueber die Seite und, falls diese keinen Zugriff bekommt, ueber das Offscreen-Dokument des Hintergrunds. Abschaltbar in den Einstellungen. Beim Sperren des Tresors werden Vormerkung und ausstehender Fuellauftrag verworfen. Der Seiten-Scan des MutationObservers laeuft jetzt gedrosselt statt bei jeder einzelnen Mutation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1 --- CHANGELOG.md | 11 +++ README.md | 3 + docs/ARCHITECTURE.md | 3 +- docs/PERMISSIONS.md | 3 +- extension/background.js | 12 +++- extension/content.js | 152 +++++++++++++++++++++++++++++++++++----- extension/options.html | 4 ++ extension/options.js | 8 ++- 8 files changed, 169 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9af616..4d5db7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,18 @@ Die Erweiterungsversion (`manifest.json`) ist unabhängig von der OpenNIT-Server ## [Unreleased] +### Hinzugefügt +- **2FA-Code wird für den nächsten Schritt bereitgelegt:** Nach dem Ausfüllen eines Eintrags mit 2FA + landet beim nächsten 2FA-Feld automatisch ein **frischer** Code in der Zwischenablage – auch dann, + wenn die Abfrage erst auf einer Folgeseite kommt (z. B. Microsoft-Anmeldung). Einfügen genügt mit + Strg + V; der bisherige Inhalt der Zwischenablage wird dabei überschrieben und der Code nach 30 + Sekunden wieder entfernt. Abschaltbar unter „Sicherheit → 2FA-Code beim Anmelden bereitlegen". + ### Geändert +- **Vorschläge weichen der Seite:** Sobald in ein Feld getippt wird, verschwindet die Vault-Liste – + darunter erscheint typischerweise die Suche der Seite selbst. Bei Feldern, die erkennbar eine eigene + Auswahlliste öffnen (etwa Benutzer-Auswahlfelder), erscheinen gar keine Vault-Vorschläge mehr. + Ausdrücklich als Anmeldefeld ausgezeichnete Felder sind davon ausgenommen. - **Anmeldung nur noch per SSO:** Das Feld für den manuell erzeugten API-Token samt „Verbindung testen" ist aus den Einstellungen entfernt. Die Verbindung entsteht ausschließlich über „Mit OpenNIT anmelden". Ein aus einer früheren Version übernommener Token wird beim Update aus dem lokalen Speicher gelöscht; diff --git a/README.md b/README.md index 29b6148..8522e70 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ deiner selbst gehosteten OpenNIT-Instanz und bietet: - ➕ **Neue Einträge anlegen** inkl. **Passwort-Generator** - 🔒 **PIN-Sperre** mit demselben PIN wie der Web-Tresor (Dauer frei wählbar, bis „bis Browser schließt") - 🧹 **Zwischenablage-Auto-Clear** nach dem Kopieren von Geheimnissen +- 📋 **2FA-Code bereitgelegt:** nach dem Ausfüllen landet beim nächsten 2FA-Feld – auch auf einer + Folgeseite – automatisch ein frischer Code in der Zwischenablage (Strg + V genügt) - 🖼️ **Favicons** der hinterlegten Seiten (serverseitig gecacht – keine externen Aufrufe) - 🌙 **Heller & dunkler Modus** (folgt dem System) @@ -82,6 +84,7 @@ Repository gedacht. | **Anmeldung** | Ausschließlich per **SSO** („Mit OpenNIT anmelden"). | | **PIN-Sperre** | Aus / 5 Min / 15 Min / 1 Std / bis der Browser geschlossen wird. Nutzt den **Tresor-PIN**. | | **Zwischenablage** | Nach 30 s automatisch leeren (Standard: an). | +| **2FA bereitlegen** | Frischen 2FA-Code beim nächsten 2FA-Feld in die Zwischenablage legen (Standard: an). | ## Build / Paketierung diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3c20238..3388e7b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,7 +28,7 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht | `content.js` | Wird auf jeder Seite ausgeführt. Erkennt Benutzer-/Passwort-/OTP-Felder (inkl. Shadow-DOM, mehrstufige Logins, segmentierte OTP-Felder), zeigt das Vorschlags-Dropdown und füllt Felder framework-kompatibel (React/Vue/Angular). | | `popup.html` / `popup.js` | Toolbar-Popup: Liste, Suche, Detailansicht, Anlegen + Generator, PIN-Schirm. | | `options.html` / `options.js` | Einstellungen: Server-URL, SSO-Anmeldung, PIN-Sperrdauer, Zwischenablage. | -| `offscreen.html` / `offscreen.js` | Minimaldokument, das ausschließlich die Zwischenablage leert (MV3-konform). | +| `offscreen.html` / `offscreen.js` | Minimaldokument, das ausschließlich die Zwischenablage beschreibt bzw. leert (MV3-konform). | ## Nachrichten (Auszug) @@ -42,6 +42,7 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht | `GET_FAVICON` | popup/content → bg | Favicon als Data-URL (serverseitig gecacht) | | `VAULT_FILL` | popup → content | Aktives Tab-Formular ausfüllen | | `SCHEDULE_CLIP_CLEAR` | popup/content → bg | Zwischenablage-Leerung planen | +| `CLIP_WRITE` | content → bg | In die Zwischenablage schreiben, wenn die Seite selbst keinen Zugriff bekommt | ## Server-API (in OpenNIT) diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 1114260..08d428b 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -38,7 +38,8 @@ Einträge-Caches (5 Min). ## `offscreen` **Warum:** In Manifest V3 hat der Service Worker keinen DOM-Zugriff. Zum programmatischen Leeren der -Zwischenablage wird ein kurzlebiges Offscreen-Dokument (Reason `CLIPBOARD`) genutzt. +Zwischenablage wird ein kurzlebiges Offscreen-Dokument (Reason `CLIPBOARD`) genutzt – sowohl zum +Bereitlegen eines 2FA-Codes als auch zum anschließenden Leeren. ## `identity` diff --git a/extension/background.js b/extension/background.js index 5d50de6..6e6928c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -100,11 +100,13 @@ async function setUnlockedLocal(dur) { } async function clearUnlocked() { await chrome.storage.session.remove('unlock'); + await chrome.storage.local.remove(['__armedTotp', '__pendingFill']); cachedEntries = null; cacheTime = 0; faviconCache.clear(); try { await apiFetch('/api/vault/extension/lock', { method: 'POST' }); } catch (e) { /* ignore */ } } async function onServerLocked() { await chrome.storage.session.remove('unlock'); + await chrome.storage.local.remove(['__armedTotp', '__pendingFill']); cachedEntries = null; cacheTime = 0; } async function doUnlock(pin) { @@ -242,19 +244,22 @@ async function scheduleClipClear(text) { pendingClip = text || ''; chrome.alarms.create('clipClear', { delayInMinutes: 0.5 }); } -async function clearClipboard() { +// Schreibt über das Offscreen-Dokument. Dieser Weg funktioniert auch dann, wenn +// die Seite selbst keinen Zugriff bekommt – etwa ohne frische Nutzerinteraktion. +async function writeClipboard(text) { try { if (!chrome.offscreen) return; const has = chrome.offscreen.hasDocument ? await chrome.offscreen.hasDocument() : false; if (!has) { await chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['CLIPBOARD'], - justification: 'Zwischenablage nach dem Kopieren von Zugangsdaten leeren.', + justification: 'Zugangsdaten in die Zwischenablage legen und nach kurzer Zeit wieder entfernen.', }); } - await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: '' }); + await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: text || '' }); } catch (e) { /* ignore */ } } +async function clearClipboard() { await writeClipboard(''); } // ── Status prüfen (Bearer) ────────────────────────────────────────────────── async function checkStatus() { @@ -293,6 +298,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } if (msg.type === 'DO_UNLOCK') { doUnlock(msg.pin || '').then(sendResponse); return true; } if (msg.type === 'LOCK_NOW') { clearUnlocked().then(() => sendResponse({ ok: true })); return true; } + if (msg.type === 'CLIP_WRITE') { writeClipboard(msg.text || '').then(() => sendResponse({ ok: true })); return true; } if (msg.type === 'SCHEDULE_CLIP_CLEAR') { scheduleClipClear(msg.text || ''); sendResponse({ ok: true }); return true; } if (msg.type === 'CHECK_STATUS') { checkStatus().then(sendResponse); return true; } if (msg.type === 'CLEAR_CACHE') { cachedEntries = null; cacheTime = 0; faviconCache.clear(); sendResponse({ ok: true }); return true; } diff --git a/extension/content.js b/extension/content.js index c365cce..04f6bc3 100644 --- a/extension/content.js +++ b/extension/content.js @@ -14,6 +14,8 @@ const DROPDOWN_ID = '__vault_dropdown__'; let appLabel = 'Vault'; let currentField = null; let showGen = 0; +let typedField = null; // Feld, in das der Nutzer zuletzt selbst getippt hat +let fillingInProgress = false; // unterdrückt die Tipp-Erkennung beim Autofill // ── Heuristik-Muster ──────────────────────────────────────────────────────── const RE_USER = /(user(name|id)?|login|logon|sign[-_ ]?in|account|konto|benutzer|kennung|anmeld|e[-_ ]?mail|email|mail|uid|userid|handle|identifier|ident\b|loginid)/i; @@ -28,7 +30,11 @@ function lc(s) { return String(s || '').toLowerCase(); } function esc(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>'); } function vaultHue(s) { s = String(s || '?'); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; } function vaultClipCopy(text) { - navigator.clipboard.writeText(text).catch(() => {}); + // Ohne frische Nutzerinteraktion verweigert der Browser den direkten Zugriff; + // dann schreibt der Hintergrund über das Offscreen-Dokument. + navigator.clipboard.writeText(text).catch(() => { + try { chrome.runtime.sendMessage({ type: 'CLIP_WRITE', text: text }); } catch (e) { /* ignore */ } + }); try { chrome.runtime.sendMessage({ type: 'SCHEDULE_CLIP_CLEAR', text: text }); } catch (e) { /* ignore */ } } function attr(el, n) { try { return el.getAttribute(n) || ''; } catch { return ''; } } @@ -117,6 +123,28 @@ function isUsernameField(el) { return RE_USER.test(s) && !RE_USER_NEG.test(s); } +// Felder, die eine eigene Vorschlagsliste unter sich aufklappen (ARIA-Combobox, +// ). Dort verdeckt unser Dropdown die Treffer der Seite. +function hasOwnSuggestionList(el) { + if (!el) return false; + if (attr(el, 'list')) return true; // datalist + const isCombo = lc(attr(el, 'role')) === 'combobox' + || !!(el.closest && el.closest('[role="combobox"]')); + if (!isCombo) return false; + const auto = lc(attr(el, 'aria-autocomplete')); + if (auto === 'list' || auto === 'both') return true; + // Verweist die Combobox auf ein eigenes Listenelement bzw. meldet ihren + // Auf-/Zuklapp-Zustand, hat sie eine eigene Trefferliste. + return !!(attr(el, 'aria-controls') || attr(el, 'aria-owns') || attr(el, 'aria-expanded')); +} + +// Vom Seitenautor ausdrücklich als Login-Feld ausgezeichnet – wiegt schwerer als +// die Combobox-Erkennung, damit echte Anmeldeformulare weiter Vorschläge bekommen. +function isDeclaredLoginField(el) { + const a = ac(el); + return a.includes('username') || a.includes('password') || a.includes('one-time-code'); +} + function isLoginField(el) { return isPasswordField(el) || isUsernameField(el) || isOtpField(el); } function fieldKind(el) { if (isPasswordField(el)) return 'password'; @@ -203,6 +231,7 @@ function init() { document.addEventListener('focusin', onFocusIn, true); document.addEventListener('focusout', onFocusOut, true); document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('input', onInput, true); document.addEventListener('keydown', onKeyDown, true); document.addEventListener('click', onDocClick, true); window.addEventListener('scroll', repositionDrop, true); @@ -230,24 +259,41 @@ function fillFromPopup(msg) { else if (msg.password) chrome.storage.local.set({ __pendingFill: { id: msg.id, pw: msg.password, user: msg.username || '', ts: Date.now() } }); if (msg.has_totp && msg.id != null) { - const otps = findOtpFields(passField || userField || document.body); - chrome.runtime.sendMessage({ type: 'GET_TOTP', id: msg.id }, t => { - if (!t || !t.code) return; - if (otps.length) distributeOtp(otps, t.code); - vaultClipCopy(t.code); - showTotpNotification(t.code, t.remaining); - }); + armTotp(msg.id); + deliverTotp(msg.id, findOtpFields(passField || userField || document.body)); } } -function onFocusIn(e) { maybeShow(e.target); } +function onFocusIn(e) { + // Beim Betreten des 2FA-Feldes einen frischen Code bereitlegen. + if (isOtpField(e.target) && isVisible(e.target)) serveArmedTotp([e.target], false); + maybeShow(e.target); +} function onPointerDown(e) { const drop = document.getElementById(DROPDOWN_ID); if (drop && drop.contains(e.target)) return; maybeShow(e.target); } + +// Sobald der Nutzer selbst tippt, gehört der Platz unter dem Feld der Seite – +// dort erscheint typischerweise deren eigene Suche. Das Feld bleibt bis zum +// Leeren gesperrt, damit ein erneuter Klick das Dropdown nicht zurückholt. +function onInput(e) { + const el = e.target; + if (fillingInProgress || !el || !el.value) { + if (el && !el.value && typedField === el) typedField = null; + return; + } + typedField = el; + if (el === currentField) hideDrop(); +} +function isTypingSuppressed(el) { return el === typedField && !!el.value; } + function maybeShow(el) { if (!isLoginField(el) || !isVisible(el)) return; + // Eigene Vorschlagsliste der Seite hat Vorrang. + if (hasOwnSuggestionList(el) && !isDeclaredLoginField(el)) return; + if (isTypingSuppressed(el)) return; currentField = el; showSuggestions(el); } @@ -446,14 +492,63 @@ async function fillEntry(entry, focused) { else chrome.storage.local.set({ __pendingFill: { id: entry.id, pw: pw, user: entry.username || '', ts: Date.now() } }); if (entry.has_totp) { - const otps = findOtpFields(passField || userField || focused); - chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entry.id }, t => { - if (!t || !t.code) return; - if (otps.length) distributeOtp(otps, t.code); + // Der 2FA-Schritt folgt oft erst nach dem Absenden – ggf. auf der nächsten + // Seite. Deshalb den Eintrag vormerken, damit dort ein frischer Code + // bereitgestellt werden kann. + armTotp(entry.id); + deliverTotp(entry.id, findOtpFields(passField || userField || focused)); + } +} + +// ── 2FA-Code für den nächsten Schritt bereitlegen ─────────────────────────── +// Nach dem Ausfüllen eines Eintrags mit 2FA bleibt der Eintrag kurz vorgemerkt. +// Taucht danach ein 2FA-Feld auf – auf derselben oder einer Folgeseite –, landet +// ein frischer Code in der Zwischenablage, sodass Strg+V genügt. +const TOTP_ARM_TTL = 5 * 60 * 1000; // Vormerkung nach dem Ausfüllen +const TOTP_RECOPY_MS = 5000; // Mindestabstand zwischen zwei Kopiervorgängen +let lastTotpCopy = 0; +let totpRequestPending = false; // Fokus und Seiten-Scan können gleichzeitig auslösen +const totpServedFields = new WeakSet(); + +function armTotp(entryId) { + try { chrome.storage.local.set({ __armedTotp: { id: entryId, ts: Date.now() } }); } catch (e) { /* ignore */ } +} + +function deliverTotp(entryId, fields, done) { + chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entryId }, t => { + if (t && t.code) { + if (fields && fields.length) distributeOtp(fields, t.code); + lastTotpCopy = Date.now(); vaultClipCopy(t.code); showTotpNotification(t.code, t.remaining); - }); - } + } + if (done) done(); + }); +} + +/** + * Automatischer Weg: nur kopieren und anzeigen. Ins Feld geschrieben wird ein Code + * ausschließlich, wenn der Nutzer den Eintrag selbst ausgewählt hat. + * + * @param {Element[]} fields erkannte 2FA-Felder + * @param {boolean} oncePerField true beim automatischen Auftauchen (je Feld einmal), + * false beim Fokussieren – dort darf nach Ablauf des + * Codes erneut ein frischer kopiert werden. + */ +function serveArmedTotp(fields, oncePerField) { + const relevant = oncePerField ? fields.filter(f => !totpServedFields.has(f)) : fields; + if (!relevant.length || totpRequestPending) return; + if (Date.now() - lastTotpCopy < TOTP_RECOPY_MS) return; + totpRequestPending = true; + chrome.storage.local.get(['__armedTotp', 'totpAutoCopy'], res => { + const armed = res.__armedTotp; + if (res.totpAutoCopy === false || !armed || Date.now() - armed.ts > TOTP_ARM_TTL) { + totpRequestPending = false; + return; + } + relevant.forEach(f => totpServedFields.add(f)); + deliverTotp(armed.id, null, () => { totpRequestPending = false; }); + }); } function fillOtp(field, entry) { @@ -479,6 +574,7 @@ function distributeOtp(fields, code) { } function setFieldValue(field, value) { + fillingInProgress = true; try { field.focus({ preventScroll: true }); const proto = (typeof HTMLTextAreaElement !== 'undefined' && field instanceof HTMLTextAreaElement) @@ -491,15 +587,26 @@ function setFieldValue(field, value) { field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); field.dispatchEvent(new Event('blur', { bubbles: true })); } catch {} + fillingInProgress = false; } -// ── Mehrstufiger Login: Passwort/User nach dem Erscheinen befüllen ─────────── -const _obs = new MutationObserver(() => { +// ── Mehrstufiger Login: Passwort/2FA nach dem Erscheinen bedienen ──────────── +// Der Scan ist entkoppelt vom Mutations-Takt: eine Seite kann pro Sekunde +// hunderte Mutationen erzeugen, die Feldsuche läuft davon unabhängig gedrosselt. +const SCAN_DEBOUNCE_MS = 250; +let scanTimer = null; + +function scanForPendingWork() { + const inputs = collectInputs(document).filter(isVisible); + + const otps = inputs.filter(isOtpField); + if (otps.length) serveArmedTotp(otps, true); + + const pw = inputs.filter(isPasswordField); + if (!pw.length) return; chrome.storage.local.get(['__pendingFill'], result => { const p = result.__pendingFill; if (!p || Date.now() - p.ts > 30000) return; - const pw = collectInputs(document).filter(f => isVisible(f) && isPasswordField(f)); - if (!pw.length) return; pw.forEach(f => setFieldValue(f, p.pw)); if (p.user) { const uf = findUsernameField(pw[0]); @@ -507,8 +614,15 @@ const _obs = new MutationObserver(() => { } chrome.storage.local.remove('__pendingFill'); }); +} + +const _obs = new MutationObserver(() => { + if (scanTimer) return; + scanTimer = setTimeout(() => { scanTimer = null; scanForPendingWork(); }, SCAN_DEBOUNCE_MS); }); try { _obs.observe(document.documentElement, { childList: true, subtree: true }); } catch {} +// Bei einem Seitenwechsel steht das 2FA-Feld oft schon im ersten Rendering. +scanForPendingWork(); // ── TOTP-Benachrichtigung (unten rechts) ──────────────────────────────────── const TOTP_PERIOD = 30; // Sekunden pro Code (RFC 6238, Serverseite nutzt denselben Wert) diff --git a/extension/options.html b/extension/options.html index f92cb61..a11605b 100644 --- a/extension/options.html +++ b/extension/options.html @@ -259,6 +259,10 @@ label.control-label { Kopierte Passwörter und 2FA-Codes werden nach kurzer Zeit automatisch aus der Zwischenablage entfernt. +
+ + Nach dem Ausfüllen eines Eintrags mit 2FA wird beim nächsten 2FA-Feld – auch auf einer Folgeseite – automatisch ein frischer Code in die Zwischenablage gelegt. Einfügen genügt dann mit Strg + V. +
diff --git a/extension/options.js b/extension/options.js index 8559555..eec70f4 100644 --- a/extension/options.js +++ b/extension/options.js @@ -8,15 +8,17 @@ chrome.storage.local.get(['serverUrl'], cfg => { }); // Sicherheits-Einstellungen laden -chrome.storage.local.get(['lockDuration', 'clipClear'], cfg => { - $('lockDuration').value = (cfg.lockDuration && cfg.lockDuration !== 'off') ? cfg.lockDuration : '15'; - $('clipClear').checked = cfg.clipClear !== false; // Standard: an +chrome.storage.local.get(['lockDuration', 'clipClear', 'totpAutoCopy'], cfg => { + $('lockDuration').value = (cfg.lockDuration && cfg.lockDuration !== 'off') ? cfg.lockDuration : '15'; + $('clipClear').checked = cfg.clipClear !== false; // Standard: an + $('totpAutoCopy').checked = cfg.totpAutoCopy !== false; // Standard: an }); $('btnSaveSec').addEventListener('click', () => { chrome.storage.local.set({ lockDuration: $('lockDuration').value, clipClear: $('clipClear').checked, + totpAutoCopy: $('totpAutoCopy').checked, }, () => { chrome.runtime.sendMessage({ type: 'LOCK_NOW' }); $('savedSecMsg').innerHTML = '✓ Gespeichert';