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 <input list>) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1
This commit is contained in:
parent
19051bbacd
commit
26c01a4fc3
8 changed files with 169 additions and 27 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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, '<').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,
|
||||
// <input list>). 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)
|
||||
|
|
|
|||
|
|
@ -259,6 +259,10 @@ label.control-label {
|
|||
<label class="control-label"><input type="checkbox" id="clipClear" checked> Zwischenablage nach 30 Sekunden leeren</label>
|
||||
<span class="form-text">Kopierte Passwörter und 2FA-Codes werden nach kurzer Zeit automatisch aus der Zwischenablage entfernt.</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label"><input type="checkbox" id="totpAutoCopy" checked> 2FA-Code beim Anmelden bereitlegen</label>
|
||||
<span class="form-text">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 <strong>Strg + V</strong>.</span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary btn-sm" id="btnSaveSec">Speichern</button>
|
||||
<span id="savedSecMsg"></span>
|
||||
|
|
|
|||
|
|
@ -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 = '<span class="save-ok">✓ Gespeichert</span>';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue