diff --git a/extension/LICENSE.txt b/extension/LICENSE.txt new file mode 100644 index 0000000..c382718 --- /dev/null +++ b/extension/LICENSE.txt @@ -0,0 +1,17 @@ +OpenNIT Vault – Browser-Erweiterung +Copyright (C) 2025 Friederich Loheide + +Dieses Programm ist freie Software: Sie koennen es unter den Bedingungen +der GNU Affero General Public License, Version 3, weitergeben und/oder +veraendern. + +Die Veroeffentlichung erfolgt in der Hoffnung, dass es nuetzlich ist, +jedoch OHNE JEDE GEWAEHRLEISTUNG - sogar ohne die implizite Gewaehr- +leistung der Marktreife oder der Eignung fuer einen bestimmten Zweck. +Einzelheiten regelt die GNU Affero General Public License. + +Lizenztext: https://www.gnu.org/licenses/agpl-3.0.html +Quelltext: https://opennit.de + +Der vollstaendige Lizenztext liegt der OpenNIT-Installation als Datei +LICENSE bei und kann ueber die oben genannte Adresse bezogen werden. diff --git a/extension/background.js b/extension/background.js index 86113c5..01b988c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,7 +1,5 @@ 'use strict'; -importScripts('urlmatch.js'); - // ── Cache ────────────────────────────────────────────────────────────────── let cachedEntries = null; let cacheTime = 0; @@ -15,13 +13,13 @@ async function getServerUrl() { return c.serverUrl || null; } -// ── Zugangstoken beschaffen (SSO) ────────────────────────────────────────── -// Kurzlebiger Access-Token aus der Session; ist er abgelaufen, wird er über den -// rotierenden Refresh-Token erneuert. +// ── Zugangstoken beschaffen (SSO-Refresh oder manueller Token) ────────────── +// Reihenfolge: manueller Token (Erweitert) > SSO-Access-Token (mit Auto-Refresh). async function getAccessToken() { const cfg = await new Promise(r => chrome.storage.local.get( - ['serverUrl', 'apiRefreshToken', 'apiRefreshExpiresAt'], r)); + ['serverUrl', 'apiToken', 'apiRefreshToken', 'apiRefreshExpiresAt'], r)); if (!cfg.serverUrl) return null; + if (cfg.apiToken) return cfg.apiToken; // manueller Token if (!cfg.apiRefreshToken) return null; // nicht angemeldet const sess = await chrome.storage.session.get(['accessToken', 'accessExpiresAt']); if (sess.accessToken && sess.accessExpiresAt && Date.now() < sess.accessExpiresAt - 30000) { @@ -102,15 +100,11 @@ async function setUnlockedLocal(dur) { } async function clearUnlocked() { await chrome.storage.session.remove('unlock'); - await chrome.storage.local.remove('__armedTotp'); - clearPendingFills(); 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'); - clearPendingFills(); cachedEntries = null; cacheTime = 0; } async function doUnlock(pin) { @@ -153,67 +147,6 @@ async function fetchEntries(force = false) { return { entries: null, locked: false }; } -/** - * Legt einen persönlichen Eintrag im Tresor an. - * - * Läuft bewusst über `apiFetch`, damit derselbe Zugang wie für alle übrigen - * Aufrufe gilt, inklusive automatischer Erneuerung des Access-Tokens. - * - * @param {{title?:string,username?:string,password?:string,url?:string,notes?:string}} fields - * @return {Promise<{ok:boolean,id?:number,locked?:boolean,error?:string}>} - */ -async function createEntry(fields) { - if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' }; - const body = new URLSearchParams(); - ['title', 'username', 'password', 'url', 'notes'].forEach(k => body.append(k, fields?.[k] ?? '')); - return writeEntry('/api/vault/extension/entries', body.toString()); -} - -/** - * Ändert einen bestehenden Eintrag. Ein leeres Passwortfeld lässt das gespeicherte - * Passwort unangetastet; 2FA-Secret und Ordner bleiben serverseitig erhalten. - * - * @param {number|string} entryId - * @param {{title?:string,username?:string,password?:string,url?:string,notes?:string}} fields - * @return {Promise<{ok:boolean,locked?:boolean,error?:string}>} - */ -async function updateEntry(entryId, fields) { - if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' }; - const body = new URLSearchParams(); - ['title', 'username', 'password', 'url', 'notes'].forEach(k => body.append(k, fields?.[k] ?? '')); - return writeEntry(`/api/vault/extension/entries/${entryId}`, body.toString()); -} - -/** - * Löscht einen Eintrag (persönlich oder Team, sofern Schreibrecht besteht). - * - * @param {number|string} entryId - * @return {Promise<{ok:boolean,locked?:boolean,error?:string}>} - */ -async function deleteEntry(entryId) { - if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' }; - return writeEntry(`/api/vault/extension/entries/${entryId}/delete`, ''); -} - -// Gemeinsame Auswertung der schreibenden Endpunkte. -async function writeEntry(path, body) { - try { - const res = await apiFetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: body, - }); - if (!res) return { ok: false, error: 'Nicht konfiguriert.' }; - if (res.status === 423) { await onServerLocked(); return { ok: false, locked: true, error: 'Tresor gesperrt.' }; } - const data = await res.json().catch(() => ({})); - if (data.ok) { - cachedEntries = null; cacheTime = 0; - return { ok: true, id: data.id }; - } - return { ok: false, error: data.error || 'Fehler beim Speichern.' }; - } catch (e) { return { ok: false, error: 'Verbindungsfehler.' }; } -} - async function fetchPassword(entryId) { if (!(await isUnlocked())) return null; try { @@ -255,28 +188,21 @@ async function fetchFavicon(entryId) { } catch (e) { return null; } } - -// ── Ausstehendes Ausfüllen (mehrstufiger Login) ───────────────────────────── -// Bei Logins, die Benutzername und Passwort auf zwei Schritte verteilen, muss das -// Passwort den Seitenwechsel überdauern. Es bleibt dafür ausschließlich im -// Speicher des Service Workers – niemals in `chrome.storage`, das auf die -// Festplatte geschrieben wird. Je Tab ein Auftrag, mit harter Verfallszeit. -const PENDING_FILL_TTL = 30 * 1000; -const pendingFills = new Map(); // tabId -> { id, pw, user, ts } - -function setPendingFill(tabId, data) { - if (tabId == null) return; - pendingFills.set(tabId, Object.assign({ ts: Date.now() }, data)); +function matchUrl(entryUrl, pageUrl) { + if (!entryUrl) return false; + let pHost; + try { pHost = new URL(pageUrl).hostname.replace(/^www\./, ''); } catch { return false; } + return String(entryUrl).split('\n').some(line => { + const raw = line.trim(); + if (!raw) return false; + try { + const eu = raw.includes('://') ? raw : 'https://' + raw; + let eHost = new URL(eu).hostname.replace(/^www\./, ''); + if (eHost.startsWith('*.')) eHost = eHost.slice(2); + return pHost === eHost || pHost.endsWith('.' + eHost); + } catch { return false; } + }); } -function takePendingFill(tabId) { - if (tabId == null) return null; - const p = pendingFills.get(tabId); - if (!p) return null; - pendingFills.delete(tabId); - return (Date.now() - p.ts > PENDING_FILL_TTL) ? null : p; -} -function clearPendingFills() { pendingFills.clear(); } -chrome.tabs.onRemoved.addListener(tabId => pendingFills.delete(tabId)); // ── Zwischenablage automatisch leeren (Offscreen) ─────────────────────────── async function scheduleClipClear(text) { @@ -285,22 +211,19 @@ async function scheduleClipClear(text) { pendingClip = text || ''; chrome.alarms.create('clipClear', { delayInMinutes: 0.5 }); } -// 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) { +async function clearClipboard() { 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: 'Zugangsdaten in die Zwischenablage legen und nach kurzer Zeit wieder entfernen.', + justification: 'Zwischenablage nach dem Kopieren von Zugangsdaten leeren.', }); } - await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: text || '' }); + await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: '' }); } catch (e) { /* ignore */ } } -async function clearClipboard() { await writeClipboard(''); } // ── Status prüfen (Bearer) ────────────────────────────────────────────────── async function checkStatus() { @@ -324,14 +247,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } if (msg.type === 'GET_MATCHING_ENTRIES') { fetchEntries().then(r => { - const matched = (r.entries || []).filter(e => VaultUrl.matches(e.url, msg.url)); + const matched = (r.entries || []).filter(e => matchUrl(e.url, msg.url)); sendResponse({ entries: matched, locked: r.locked }); }); return true; } - if (msg.type === 'CREATE_ENTRY') { createEntry(msg.entry || {}).then(sendResponse); return true; } - if (msg.type === 'UPDATE_ENTRY') { updateEntry(msg.id, msg.entry || {}).then(sendResponse); return true; } - if (msg.type === 'DELETE_ENTRY') { deleteEntry(msg.id).then(sendResponse); return true; } if (msg.type === 'GET_PASSWORD') { fetchPassword(msg.id).then(password => sendResponse({ password })); return true; } if (msg.type === 'GET_TOTP') { fetchTotp(msg.id).then(result => sendResponse(result)); return true; } if (msg.type === 'GET_FAVICON') { fetchFavicon(msg.id).then(dataUrl => sendResponse({ dataUrl })); return true; } @@ -341,26 +261,11 @@ 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 === 'SET_PENDING_FILL') { - setPendingFill(sender.tab?.id, { id: msg.id, pw: msg.pw || '', user: msg.user || '' }); - sendResponse({ ok: true }); - return true; - } - if (msg.type === 'TAKE_PENDING_FILL') { sendResponse({ fill: takePendingFill(sender.tab?.id) }); 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; } }); -// Der manuell eingetragene API-Token wird nicht mehr unterstützt; ein aus einer -// früheren Version übernommener Wert wird beim Update aus dem Speicher entfernt. -// Der manuelle API-Token entfaellt, und ein ausstehender Fuellauftrag liegt nicht -// mehr im Speicher auf der Platte – Reste frueherer Versionen hier entfernen. -chrome.runtime.onInstalled.addListener(() => { - chrome.storage.local.remove(['apiToken', '__pendingFill']); -}); - // Cache alle 5 Minuten leeren; Zwischenablage-Clear nach Timeout. chrome.alarms.create('clearCache', { periodInMinutes: 5 }); chrome.alarms.onAlarm.addListener(async (alarm) => { diff --git a/extension/content.js b/extension/content.js index 7687cde..667e6cf 100644 --- a/extension/content.js +++ b/extension/content.js @@ -14,9 +14,6 @@ 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 -let pendingFillInFlight = false; // der Auftrag wird beim Abholen verbraucht – nur einmal gleichzeitig // ── 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; @@ -31,11 +28,7 @@ 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) { - // 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 */ } - }); + navigator.clipboard.writeText(text).catch(() => {}); try { chrome.runtime.sendMessage({ type: 'SCHEDULE_CLIP_CLEAR', text: text }); } catch (e) { /* ignore */ } } function attr(el, n) { try { return el.getAttribute(n) || ''; } catch { return ''; } } @@ -124,28 +117,6 @@ 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'; @@ -207,12 +178,31 @@ function findOtpFields(ref) { return collectInputs(scopeOf(ref)).filter(el => isVisible(el) && isOtpField(el)); } +// ── URL-Matching ──────────────────────────────────────────────────────────── +function normalizeHost(raw) { + if (!raw) return ''; + try { + const s = raw.includes('://') ? raw : 'https://' + raw; + return new URL(s).hostname.replace(/^www\./, '').toLowerCase(); + } catch { return lc(raw).replace(/^www\./, ''); } +} +function matchUrl(entryUrls, pageUrl) { + const pageHost = normalizeHost(pageUrl); + if (!pageHost) return false; + const urls = typeof entryUrls === 'string' ? entryUrls.split('\n') : [entryUrls]; + return urls.some(u => { + let eh = normalizeHost((u || '').trim()); + if (!eh) return false; + if (eh.startsWith('*.')) eh = eh.slice(2); + return pageHost === eh || pageHost.endsWith('.' + eh); + }); +} + // ── Events ────────────────────────────────────────────────────────────────── 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); @@ -237,44 +227,27 @@ function fillFromPopup(msg) { if (userField && msg.username) setFieldValue(userField, msg.username); if (passField && msg.password) setFieldValue(passField, msg.password); - else if (msg.password) chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: msg.id, pw: msg.password, user: msg.username || '' }); + 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) { - armTotp(msg.id); - deliverTotp(msg.id, findOtpFields(passField || userField || document.body)); + 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); + }); } } -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 onFocusIn(e) { 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); } @@ -317,7 +290,7 @@ function showSuggestions(field) { const mode = fieldKind(field) === 'otp' ? 'otp' : 'login'; chrome.runtime.sendMessage({ type: 'GET_MATCHING_ENTRIES', url: location.href }, resp => { if (gen !== showGen) return; - let entries = (resp && resp.entries || []).filter(e => VaultUrl.matches(e.url, location.href)); + let entries = (resp && resp.entries || []).filter(e => matchUrl(e.url, location.href)); if (mode === 'otp') entries = entries.filter(e => e.has_totp); if (!entries.length) { hideDrop(); return; } if (document.contains(field) && isVisible(field)) renderDrop(field, entries, mode); @@ -470,66 +443,17 @@ async function fillEntry(entry, focused) { if (userField && entry.username) setFieldValue(userField, entry.username); if (passField) setFieldValue(passField, pw); - else chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: entry.id, pw: pw, user: entry.username || '' }); + else chrome.storage.local.set({ __pendingFill: { id: entry.id, pw: pw, user: entry.username || '', ts: Date.now() } }); if (entry.has_totp) { - // 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(); + 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); 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) { @@ -555,7 +479,6 @@ function distributeOtp(fields, code) { } function setFieldValue(field, value) { - fillingInProgress = true; try { field.focus({ preventScroll: true }); const proto = (typeof HTMLTextAreaElement !== 'undefined' && field instanceof HTMLTextAreaElement) @@ -568,47 +491,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/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 || pendingFillInFlight) return; - pendingFillInFlight = true; - chrome.runtime.sendMessage({ type: 'TAKE_PENDING_FILL' }, resp => { - pendingFillInFlight = false; - const p = resp && resp.fill; - if (!p) return; +// ── Mehrstufiger Login: Passwort/User nach dem Erscheinen befüllen ─────────── +const _obs = new MutationObserver(() => { + 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]); if (uf && !uf.value) setFieldValue(uf, p.user); } + 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) - function showTotpNotification(code, remaining) { const ID = '__vault_totp_notif__'; const old = document.getElementById(ID); @@ -629,20 +531,18 @@ function showTotpNotification(code, remaining) { + '2FA-Code kopiert' + '
' + esc(formatted) + '
' + '
' - + '
' + + '
' + '' + remaining + 's'; document.documentElement.appendChild(notif); - // Gegen einen festen Ablaufzeitpunkt rechnen, damit die Anzeige auch nach - // gedrosselten oder ausgefallenen Timer-Ticks stimmt. - const deadline = Date.now() + (Number(remaining) > 0 ? Number(remaining) : TOTP_PERIOD) * 1000; + let secs = remaining; const iv = setInterval(() => { - const secs = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); + secs--; const t = document.getElementById('__vault_totp_t'); const bar = document.getElementById('__vault_totp_bar'); - if (secs === 0 || !t) { clearInterval(iv); notif.style.transition = 'opacity .4s'; notif.style.opacity = '0'; setTimeout(() => notif.remove(), 400); return; } + if (secs <= 0 || !t) { clearInterval(iv); notif.style.transition = 'opacity .4s'; notif.style.opacity = '0'; setTimeout(() => notif.remove(), 400); return; } t.textContent = secs + 's'; - if (bar) { bar.style.width = Math.min(100, secs / TOTP_PERIOD * 100) + '%'; if (secs <= 10) bar.style.background = '#dc3545'; } + if (bar) { bar.style.width = (secs / 30 * 100) + '%'; if (secs < 10) bar.style.background = '#dc3545'; } }, 1000); notif.addEventListener('click', () => { clearInterval(iv); notif.remove(); }); } diff --git a/extension/manifest.json b/extension/manifest.json index 6e50a73..8b25f90 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,10 +1,13 @@ { "manifest_version": 3, "name": "OpenNIT Vault", - "version": "2.5.0", + "version": "2.4.1", "description": "OpenNIT Vault – Passwort-Manager mit Autofill für Benutzer-, Passwort- und 2FA-Felder direkt im Browser.", + "homepage_url": "https://opennit.de", "permissions": [ "storage", + "activeTab", + "scripting", "alarms", "offscreen", "identity" @@ -29,7 +32,6 @@ "" ], "js": [ - "urlmatch.js", "content.js" ], "run_at": "document_idle" diff --git a/extension/options.html b/extension/options.html index a11605b..85b50a4 100644 --- a/extension/options.html +++ b/extension/options.html @@ -229,13 +229,29 @@ label.control-label { Mit OpenNIT anmelden - - - Die Anmeldung erfolgt wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert und lässt sich jederzeit im Vault widerrufen. + Empfohlen: Anmeldung wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert.
+ + +
+ Erweitert: manueller Token +
+
+ + + Alternative ohne SSO (z. B. Kiosk/Headless): Vault → „Extension“ → Token generieren. +
+
+ + + +
+
+
@@ -259,10 +275,6 @@ 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. -
@@ -277,9 +289,9 @@ label.control-label {
diff --git a/extension/options.js b/extension/options.js index eec70f4..25181f6 100644 --- a/extension/options.js +++ b/extension/options.js @@ -3,22 +3,21 @@ function $(id) { return document.getElementById(id); } // Gespeicherte Werte laden (überschreibt das vorausgefüllte Feld nur wenn bereits gespeichert) -chrome.storage.local.get(['serverUrl'], cfg => { +chrome.storage.local.get(['serverUrl', 'apiToken'], cfg => { if (cfg.serverUrl) $('serverUrl').value = cfg.serverUrl; + if (cfg.apiToken) $('apiToken').value = '••••••••'; }); // Sicherheits-Einstellungen laden -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 +chrome.storage.local.get(['lockDuration', 'clipClear'], cfg => { + $('lockDuration').value = (cfg.lockDuration && cfg.lockDuration !== 'off') ? cfg.lockDuration : '15'; + $('clipClear').checked = cfg.clipClear !== 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'; @@ -26,8 +25,27 @@ $('btnSaveSec').addEventListener('click', () => { }); }); -// HTTPS erzwingen (außer localhost) – sonst gingen Zugangsdaten und Passwörter -// im Klartext über die Leitung. +// App-Name und Verbindungsstatus laden (falls Token bereits gesetzt) +chrome.storage.local.get(['serverUrl', 'apiToken'], async cfg => { + if (!cfg.serverUrl || !cfg.apiToken) return; + try { + const res = await fetch(`${cfg.serverUrl}/api/vault/extension/status`, { + headers: { 'Authorization': `Bearer ${cfg.apiToken}` } + }); + const data = await res.json(); + if (data.ok) { + // Name der Erweiterung bleibt fest „OpenNIT Vault"; die Instanz wird + // beim angemeldeten Nutzer zur Orientierung angezeigt. + if (data.user) { + $('headerUser').textContent = data.app_name ? (data.user + ' · ' + data.app_name) : data.user; + $('headerStatus').style.display = ''; + } + } + } catch { /* ignore */ } +}); + +// HTTPS erzwingen (außer localhost) – sonst gingen Token und Passwörter im +// Klartext über die Leitung. function isSecureServerUrl(url) { try { const u = new URL(url); @@ -38,20 +56,58 @@ function isSecureServerUrl(url) { } $('btnSave').addEventListener('click', () => { - const url = $('serverUrl').value.trim().replace(/\/$/, ''); + const url = $('serverUrl').value.trim().replace(/\/$/, ''); + const token = $('apiToken').value.trim(); if (!url) { showStatus('Server-URL darf nicht leer sein.', false); return; } if (!isSecureServerUrl(url)) { - showStatus('Bitte eine https://-Adresse verwenden (nur localhost darf http:// sein). Sonst würden Zugangsdaten und Passwörter unverschlüsselt übertragen.', false); + showStatus('Bitte eine https://-Adresse verwenden (nur localhost darf http:// sein). Sonst würden Token und Passwörter unverschlüsselt übertragen.', false); return; } - chrome.storage.local.set({ serverUrl: url }, () => { + const data = { serverUrl: url }; + if (token && !token.startsWith('•')) data.apiToken = token; + + chrome.storage.local.set(data, () => { chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' }); $('savedMsg').innerHTML = '✓ Gespeichert'; setTimeout(() => { $('savedMsg').textContent = ''; }, 2000); }); }); +$('btnTest').addEventListener('click', async () => { + const url = $('serverUrl').value.trim().replace(/\/$/, ''); + const token = $('apiToken').value.trim(); + + if (!url || !token || token.startsWith('•')) { + showStatus('Bitte zuerst URL und Token eingeben und speichern.', false); + return; + } + + $('btnTest').innerHTML = ' Teste…'; + $('btnTest').disabled = true; + + try { + const res = await fetch(`${url}/api/vault/extension/status`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await res.json(); + if (data.ok) { + showStatus(`Verbunden als ${esc(data.user)}`, true); + if (data.user) { + $('headerUser').textContent = data.user; + $('headerStatus').style.display = ''; + } + } else { + showStatus('Ungültiger Token oder Server-Fehler.', false); + } + } catch (e) { + showStatus('Server nicht erreichbar: ' + esc(e.message), false); + } + + $('btnTest').innerHTML = 'Verbindung testen'; + $('btnTest').disabled = false; +}); + // ── SSO-Anmeldung (OAuth 2.0 + PKCE via chrome.identity) ──────────────────── function b64url(bytes) { let s = btoa(String.fromCharCode.apply(null, new Uint8Array(bytes))); @@ -112,6 +168,7 @@ async function loginWithSso() { const data = await res.json().catch(() => ({})); if (!res.ok || !data.access_token) { ssoMsg('Token konnte nicht ausgestellt werden.', false); return; } await ssoSet('local', { serverUrl: url, apiRefreshToken: data.refresh_token, apiRefreshExpiresAt: Date.now() + (data.refresh_expires_in || 0) * 1000 }); + await ssoRemove('local', ['apiToken']); await ssoSet('session', { accessToken: data.access_token, accessExpiresAt: Date.now() + (data.expires_in || 0) * 1000 }); chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' }); ssoMsg('Angemeldet.', true); @@ -146,7 +203,7 @@ function reflectAuthState() { }); } -// Verbindungsstatus über den Background (nutzt den SSO-Access-Token) +// Verbindungsstatus über den Background (nutzt SSO-Access-Token oder manuellen Token) function loadConnStatus() { chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, data => { if (data && data.ok) { diff --git a/extension/popup.html b/extension/popup.html index b6a9da3..c6f1a77 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -147,14 +147,6 @@ body { .ne-pass-row .panel-input { flex: 1; } .ne-icon-btn { flex-shrink: 0; width: 36px; height: 36px; border: 1.5px solid var(--line); background: var(--card); border-radius: 9px; cursor: pointer; color: #5b6478; display: inline-flex; align-items: center; justify-content: center; transition: background .12s, color .12s, border-color .12s; } .ne-icon-btn:hover { background: var(--brand); border-color: var(--brand); color: #fff; } -.gen-opts { display: flex; align-items: center; gap: 14px; margin: -2px 0 2px; font-size: 11px; color: var(--muted); } -.gen-opt { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; } -.gen-select { border: 1.5px solid var(--line); border-radius: 7px; padding: 2px 4px; font-size: 11px; font-family: inherit; background: var(--card); color: var(--ink); cursor: pointer; } -.detail-actions { display: flex; gap: 8px; margin-top: 8px; } -.detail-action { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; background: var(--card); border: 1.5px solid var(--line); border-radius: 9px; padding: 8px 10px; font-size: 11.5px; font-weight: 600; font-family: inherit; color: #5b6478; cursor: pointer; transition: background .12s, color .12s, border-color .12s; } -.detail-action:hover { background: var(--brand); border-color: var(--brand); color: #fff; } -.detail-action.danger:hover { background: #dc3545; border-color: #dc3545; } -.detail-action:disabled { opacity: .55; cursor: default; } .field-notes { font-size: 12px; color: var(--ink); white-space: pre-wrap; word-break: break-word; max-height: 90px; overflow-y: auto; margin-top: 2px; } /* ── Lock Screen ────────────────────────────────────────── */ @@ -212,7 +204,7 @@ body {
- Neuen Eintrag anlegen + Neuen Eintrag anlegen
@@ -227,19 +219,6 @@ body {
-
- - -
@@ -269,6 +248,9 @@ body {
Benutzername
+ @@ -316,16 +298,6 @@ body { Auf dieser Seite ausfüllen -
- - -
@@ -370,7 +342,6 @@ body {
- \ No newline at end of file diff --git a/extension/popup.js b/extension/popup.js index bb8890c..545e113 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -5,7 +5,6 @@ let pageMatches = []; let entryIndex = {}; // id -> entry let detailState = null; // aktiver Eintrag im Detail-Panel let selIndex = -1; // Tastatur-Auswahl in der Liste -let editingId = null; // gesetzt, solange das Panel einen bestehenden Eintrag bearbeitet function $(id) { return document.getElementById(id); } @@ -29,14 +28,13 @@ async function init() { // Detail-Panel $('btnDetailBack').addEventListener('click', closeDetail); $('btnDetailClose').addEventListener('click', closeDetail); + $('btnRevealUser').addEventListener('click', toggleRevealUser); $('btnCopyUser').addEventListener('click', () => copySecret($('detailUser').dataset.value || '', 'Benutzername kopiert')); $('btnRevealPass').addEventListener('click', toggleRevealPass); $('btnCopyPass').addEventListener('click', copyDetailPassword); $('btnCopyTotp').addEventListener('click', () => { const c = $('detailTotp').dataset.code || ''; if (c) copySecret(c, 'TOTP kopiert'); }); - $('btnCopyNotes').addEventListener('click', () => copySecret($('detailNotes').dataset.value || '', 'Notiz kopiert')); + $('btnCopyNotes').addEventListener('click', () => copyToClipboard($('detailNotes').dataset.value || '', 'Notiz kopiert')); $('btnDetailFill').addEventListener('click', fillActiveTab); - $('btnDetailEdit').addEventListener('click', openEditPanel); - $('btnDetailDelete').addEventListener('click', deleteCurrentEntry); // Lock-Screen $('lockSubmit').addEventListener('click', submitPin); @@ -213,9 +211,6 @@ function setSel(items, idx) { // ── New Entry ───────────────────────────────────────────────────────────── function openNewPanel() { - editingId = null; - $('panelTitle').textContent = 'Neuen Eintrag anlegen'; - $('nePassword').placeholder = 'Passwort'; $('listWrap').style.display = 'none'; $('search').closest('.search-wrap').style.display = 'none'; $('detailPanel').style.display = 'none'; @@ -237,126 +232,74 @@ function openNewPanel() { } function closeNewPanel() { - editingId = null; $('newEntryPanel').style.display = 'none'; $('listWrap').style.display = ''; $('search').closest('.search-wrap').style.display = ''; } -// Gleichverteilte Zufallszahl aus [0, max) – verwirft die Werte des obersten, -// unvollständigen Blocks, damit kein Rest-Modulo einzelne Zeichen bevorzugt. -function randomBelow(max) { - const limit = Math.floor(0x100000000 / max) * max; - const buf = new Uint32Array(1); - do { crypto.getRandomValues(buf); } while (buf[0] >= limit); - return buf[0] % max; -} - -/** Zeichensätze ohne optisch verwechselbare Zeichen (l/I/1, O/0). */ -const GEN_SETS = [ - 'abcdefghijkmnopqrstuvwxyz', - 'ABCDEFGHJKLMNPQRSTUVWXYZ', - '23456789', -]; -const GEN_SYMBOLS = '!@#$%^&*()-_=+[]{}'; - function generatePassword() { - const len = Math.max(8, parseInt($('genLen').value, 10) || 20); - const sets = $('genSymbols').checked ? GEN_SETS.concat(GEN_SYMBOLS) : GEN_SETS.slice(); - const all = sets.join(''); - - // Je Satz ein Zeichen garantieren, den Rest frei ziehen … - const out = sets.map(s => s[randomBelow(s.length)]); - while (out.length < len) out.push(all[randomBelow(all.length)]); - - // … und danach mischen, damit die garantierten Zeichen nicht vorne stehen. - // Der Zufall dafür wird frisch gezogen und nicht aus der Zeichenwahl wiederverwendet. + const len = 20; + const sets = [ + 'abcdefghijkmnopqrstuvwxyz', + 'ABCDEFGHJKLMNPQRSTUVWXYZ', + '23456789', + '!@#$%^&*()-_=+[]{}', + ]; + const all = sets.join(''); + const buf = new Uint32Array(len); + crypto.getRandomValues(buf); + let out = []; + // Mindestens ein Zeichen je Set + sets.forEach((s, i) => { out.push(s[buf[i] % s.length]); }); + for (let i = sets.length; i < len; i++) out.push(all[buf[i] % all.length]); + // mischen for (let i = out.length - 1; i > 0; i--) { - const j = randomBelow(i + 1); + const j = buf[i] % (i + 1); [out[i], out[j]] = [out[j], out[i]]; } - $('nePassword').value = out.join(''); $('nePassword').type = 'text'; } -// Bestehenden Eintrag im selben Panel bearbeiten. Das Passwortfeld bleibt leer – -// leer bedeutet serverseitig „unverändert", sodass das Passwort das Popup nie verlässt. -function openEditPanel() { - if (!detailState) return; - const e = entryIndex[detailState.id]; - if (!e) return; - - editingId = detailState.id; - closeDetail(); - - $('panelTitle').textContent = 'Eintrag bearbeiten'; - $('neTitle').value = e.title || ''; - $('neUsername').value = e.username || ''; - $('nePassword').value = ''; - $('nePassword').type = 'password'; - $('nePassword').placeholder = 'Passwort (leer = unverändert)'; - $('neUrl').value = e.url || ''; - $('neNotes').value = e.notes || ''; - $('newEntryMsg').textContent = ''; - - $('listWrap').style.display = 'none'; - $('search').closest('.search-wrap').style.display = 'none'; - $('detailPanel').style.display = 'none'; - $('newEntryPanel').style.display = 'block'; - $('neTitle').focus(); -} - -function deleteCurrentEntry() { - if (!detailState) return; - const e = entryIndex[detailState.id]; - if (!e) return; - if (!window.confirm('Eintrag „' + (e.title || '') + '" wirklich löschen?')) return; - - const btn = $('btnDetailDelete'); - btn.disabled = true; - chrome.runtime.sendMessage({ type: 'DELETE_ENTRY', id: detailState.id }, resp => { - btn.disabled = false; - if (resp?.ok) { closeDetail(); reload(true); showToast('Eintrag gelöscht'); return; } - if (resp?.locked) { showLockScreen(); return; } - showToast(resp?.error || 'Löschen fehlgeschlagen'); - }); -} - -// Speichern läuft – wie alle anderen Aufrufe – über den Background-Service-Worker, -// der den gültigen Zugang beisteuert. -function saveNewEntry() { +async function saveNewEntry() { const title = $('neTitle').value.trim(); if (!title) { $('newEntryMsg').textContent = 'Titel ist erforderlich.'; return; } + const cfg = await new Promise(r => chrome.storage.local.get(['serverUrl', 'apiToken'], r)); + if (!cfg.serverUrl || !cfg.apiToken) { $('newEntryMsg').textContent = 'Nicht konfiguriert.'; return; } + $('btnSaveNew').disabled = true; $('btnSaveNew').textContent = '...'; $('newEntryMsg').textContent = ''; - const entry = { - title: title, - username: $('neUsername').value.trim(), - password: $('nePassword').value, - url: $('neUrl').value.trim(), - notes: $('neNotes').value.trim(), - }; - const msg = editingId - ? { type: 'UPDATE_ENTRY', id: editingId, entry } - : { type: 'CREATE_ENTRY', entry }; - const wasEditing = !!editingId; + const fd = new FormData(); + fd.append('title', title); + fd.append('username', $('neUsername').value.trim()); + fd.append('password', $('nePassword').value); + fd.append('url', $('neUrl').value.trim()); + fd.append('notes', $('neNotes').value.trim()); - chrome.runtime.sendMessage(msg, resp => { - $('btnSaveNew').disabled = false; - $('btnSaveNew').textContent = 'Speichern'; - if (resp?.ok) { + try { + const res = await fetch(cfg.serverUrl + '/api/vault/extension/entries', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + cfg.apiToken }, + body: fd, + }); + const data = await res.json(); + if (data.ok) { + chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' }); closeNewPanel(); reload(true); - showToast(wasEditing ? 'Eintrag aktualisiert' : 'Eintrag gespeichert'); - return; + showToast('Eintrag gespeichert'); + } else { + $('newEntryMsg').textContent = data.error || 'Fehler beim Speichern.'; } - if (resp?.locked) { showLockScreen(); return; } - $('newEntryMsg').textContent = resp?.error || 'Fehler beim Speichern.'; - }); + } catch (e) { + $('newEntryMsg').textContent = 'Verbindungsfehler: ' + e.message; + } + + $('btnSaveNew').disabled = false; + $('btnSaveNew').textContent = 'Speichern'; } // ── Liste (Klick öffnet Detailansicht) ───────────────────────────────────── @@ -421,7 +364,7 @@ function openDetail(id) { const e = entryIndex[String(id)]; if (!e) return; closeDetailTimers(); - detailState = { id: String(id), password: null, revealPass: false, totpInterval: null }; + detailState = { id: String(id), password: null, revealUser: true, revealPass: false, totpInterval: null }; $('listWrap').style.display = 'none'; $('search').closest('.search-wrap').style.display = 'none'; @@ -456,18 +399,21 @@ function openDetail(id) { urlLink.style.display = 'none'; } - // Benutzername – kein Geheimnis, daher immer im Klartext. + // Benutzername (standardmäßig sichtbar; Auge schaltet Maskierung) const uval = e.username || ''; const uEl = $('detailUser'); uEl.dataset.value = uval; + detailState.revealUser = true; if (uval) { uEl.classList.remove('empty'); uEl.textContent = uval; - $('btnCopyUser').style.display = ''; + $('btnRevealUser').style.display = ''; + $('btnCopyUser').style.display = ''; } else { uEl.classList.add('empty'); uEl.textContent = 'Kein Benutzername'; - $('btnCopyUser').style.display = 'none'; + $('btnRevealUser').style.display = 'none'; + $('btnCopyUser').style.display = 'none'; } // Passwort (standardmäßig maskiert) @@ -501,6 +447,14 @@ function closeDetail() { $('search').closest('.search-wrap').style.display = ''; } +function toggleRevealUser() { + const uEl = $('detailUser'); + const val = uEl.dataset.value || ''; + if (!val) return; + detailState.revealUser = !detailState.revealUser; + uEl.textContent = detailState.revealUser ? val : '•'.repeat(Math.min(val.length, 14)); +} + async function ensurePassword(id) { if (detailState && detailState.password !== null) return detailState.password; const pw = await new Promise(resolve => { @@ -530,12 +484,6 @@ async function copyDetailPassword() { else showToast('Kein Passwort'); } -const TOTP_PERIOD = 30; // Sekunden pro Code (RFC 6238, Serverseite nutzt denselben Wert) - -// Restlaufzeit des 2FA-Codes. Die Anzeige rechnet gegen einen festen Ablaufzeitpunkt -// statt blind herunterzuzählen – so bleibt sie korrekt, wenn der Timer gedrosselt wird -// oder ein Tick ausfällt. Nachgeladen wird erst, wenn der Code abgelaufen ist, und pro -// Ablauf nur einmal. function loadDetailTotp(id) { const codeEl = $('detailTotp'); const secsEl = $('detailTotpSecs'); @@ -544,55 +492,33 @@ function loadDetailTotp(id) { codeEl.dataset.code = ''; secsEl.textContent = ''; - let deadline = 0; - let refetching = false; - - const stillOpen = () => detailState && detailState.id === String(id); - - const render = () => { - const secs = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); - secsEl.textContent = secs + 's'; - if (barEl) { - barEl.style.width = Math.min(100, Math.round(secs / TOTP_PERIOD * 100)) + '%'; - barEl.style.background = secs <= 10 ? '#dc3545' : '#34d399'; - } - return secs; - }; - - const apply = (code, remaining) => { - codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3); - codeEl.dataset.code = code; - // Der Server liefert die Restsekunden des laufenden Zeitfensters; daraus wird - // ein Ablaufzeitpunkt, gegen den die Anzeige lokal rechnet. - deadline = Date.now() + (Number(remaining) > 0 ? Number(remaining) : TOTP_PERIOD) * 1000; - render(); - }; - - const refetch = () => { - if (refetching) return; - refetching = true; - chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, r => { - refetching = false; - if (!stillOpen()) return; - if (r?.code) { apply(r.code, r.remaining); return; } - // Kein Code mehr (gesperrt oder Verbindung weg) – Anzeige zurücksetzen - // statt weiter auf einem abgelaufenen Wert stehen zu bleiben. - closeDetailTimers(); - codeEl.textContent = '—'; - codeEl.dataset.code = ''; - secsEl.textContent = ''; - if (barEl) barEl.style.width = '0%'; - }); - }; - chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, resp => { - if (!stillOpen()) return; + if (!detailState || detailState.id !== String(id)) return; if (!resp?.code) { codeEl.textContent = '—'; return; } + + const apply = (code, remaining) => { + codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3); + codeEl.dataset.code = code; + secsEl.textContent = remaining + 's'; + if (barEl) barEl.style.width = Math.round(remaining / 30 * 100) + '%'; + }; apply(resp.code, resp.remaining); + let secs = resp.remaining; detailState.totpInterval = setInterval(() => { - if (!stillOpen()) return; - if (render() === 0) refetch(); + secs--; + if (secs <= 0) { + chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, r2 => { + if (!detailState || detailState.id !== String(id)) return; + if (r2?.code) { secs = r2.remaining; apply(r2.code, r2.remaining); } + }); + return; + } + secsEl.textContent = secs + 's'; + if (barEl) { + barEl.style.width = Math.round(secs / 30 * 100) + '%'; + barEl.style.background = secs < 10 ? '#dc3545' : '#34d399'; + } }, 1000); }); } @@ -634,12 +560,30 @@ async function fillActiveTab() { } // ── Helfer ──────────────────────────────────────────────────────────────── -function hostOf(u) { return VaultUrl.host(u); } +function hostOf(u) { + try { + const s = String(u || ''); + return new URL(s.includes('://') ? s : 'https://' + s).hostname.replace(/^www\./, '').toLowerCase(); + } catch { return ''; } +} // True, wenn eine der (mehrzeiligen) Eintrags-URLs zur Seiten-Domain passt – // oder wenn im Eintrag gar keine URL hinterlegt ist (dann keine Warnung). -function fillDomainMatches(entryUrls, pageUrl) { return VaultUrl.matchesOrUnset(entryUrls, pageUrl); } -// Alles, was aus einem Eintrag kommt, gilt als Geheimnis – Notizen enthalten in -// der Praxis ebenso oft Wiederherstellungscodes wie das Passwortfeld selbst. +function fillDomainMatches(entryUrls, pageUrl) { + const pageHost = hostOf(pageUrl); + const list = String(entryUrls || '').split('\n').map(s => s.trim()).filter(Boolean); + if (!list.length) return true; + if (!pageHost) return false; + return list.some(u => { + let eh = hostOf(u); + if (eh.startsWith('*.')) eh = eh.slice(2); + return eh && (pageHost === eh || pageHost.endsWith('.' + eh) || eh.endsWith('.' + pageHost)); + }); +} +function copyToClipboard(text, msg) { + navigator.clipboard.writeText(text).then(() => showToast(msg)).catch(() => showToast('Fehler')); +} +// Wie copyToClipboard, plant aber zusätzlich das automatische Leeren der +// Zwischenablage (für Zugangsdaten/2FA). function copySecret(text, msg) { navigator.clipboard.writeText(text).then(() => { showToast(msg); @@ -653,6 +597,6 @@ function showToast(msg) { setTimeout(() => t.classList.remove('show'), 1800); } function esc(s) { return String(s||'').replace(/&/g,'&').replace(//g,'>'); } -function escAttr(s) { return esc(s).replace(/"/g, '"').replace(/'/g, '''); } +function escAttr(s) { return String(s||'').replace(/"/g,'"'); } init(); \ No newline at end of file