From ea5cd8d62375b9e5ce4b1847226e07a6f7492897 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 12:44:03 +0000 Subject: [PATCH 1/7] Fix: Eintrag anlegen scheiterte nach SSO-Anmeldung Das Speichern eines neuen Eintrags im Popup las den Token direkt aus chrome.storage.local und verlangte den manuellen apiToken. Nach einer SSO-Anmeldung wird dieser Schluessel jedoch geloescht (nur Refresh-Token plus kurzlebiger Access-Token in storage.session), sodass das Anlegen immer mit "Nicht konfiguriert." abbrach. Das Anlegen laeuft jetzt wie alle uebrigen Aufrufe ueber den Background-Service-Worker (apiFetch): SSO-Access-Token mit automatischer Erneuerung oder, falls gesetzt, manueller Token. Eine serverseitige Sperre (423) fuehrt zur PIN-Abfrage statt zu einer Fehlermeldung, und der Eintrags-Cache wird nach dem Anlegen verworfen. Ausserdem entfaellt in den Optionen ein redundanter Statusabruf, der ebenfalls nur den manuellen Token beruecksichtigte; loadConnStatus() deckt beide Anmeldewege bereits ab. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1 --- CHANGELOG.md | 9 +++++++++ extension/background.js | 32 +++++++++++++++++++++++++++++ extension/options.js | 19 ----------------- extension/popup.js | 45 +++++++++++++++++------------------------ 4 files changed, 59 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de2519..fb53562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). Die Erweiterungsversion (`manifest.json`) ist unabhängig von der OpenNIT-Serverversion. +## [Unreleased] + +### Behoben +- **Neuen Eintrag anlegen funktioniert wieder nach SSO-Anmeldung:** Das Speichern eines im Popup + erzeugten Passworts brach bisher mit „Nicht konfiguriert." ab, wenn die Erweiterung über + „Mit OpenNIT anmelden" verbunden war – es wurde ausschließlich der manuelle Token akzeptiert. + Das Anlegen nutzt nun denselben Zugang wie alle übrigen Aufrufe (inkl. automatischer + Token-Erneuerung). Ist der Tresor gesperrt, erscheint die PIN-Abfrage statt einer Fehlermeldung. + ## [2.4.0] - 2026-07-01 ### Hinzugefügt diff --git a/extension/background.js b/extension/background.js index 01b988c..4291339 100644 --- a/extension/background.js +++ b/extension/background.js @@ -147,6 +147,37 @@ 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: SSO-Access-Token (inkl. automatischer Erneuerung) oder – falls + * gesetzt – der manuelle Token. + * + * @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] ?? '')); + try { + const res = await apiFetch('/api/vault/extension/entries', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + 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 { @@ -252,6 +283,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { }); return true; } + if (msg.type === 'CREATE_ENTRY') { createEntry(msg.entry || {}).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; } diff --git a/extension/options.js b/extension/options.js index 25181f6..c0ebc76 100644 --- a/extension/options.js +++ b/extension/options.js @@ -25,25 +25,6 @@ $('btnSaveSec').addEventListener('click', () => { }); }); -// 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) { diff --git a/extension/popup.js b/extension/popup.js index 545e113..871d1ff 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -261,45 +261,36 @@ function generatePassword() { $('nePassword').type = 'text'; } -async function saveNewEntry() { +// Speichern läuft – wie alle anderen Aufrufe – über den Background-Service-Worker, +// der den gültigen Zugang (SSO oder manueller Token) beisteuert. +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 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()); + const entry = { + title: title, + username: $('neUsername').value.trim(), + password: $('nePassword').value, + url: $('neUrl').value.trim(), + notes: $('neNotes').value.trim(), + }; - 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' }); + chrome.runtime.sendMessage({ type: 'CREATE_ENTRY', entry }, resp => { + $('btnSaveNew').disabled = false; + $('btnSaveNew').textContent = 'Speichern'; + if (resp?.ok) { closeNewPanel(); reload(true); showToast('Eintrag gespeichert'); - } else { - $('newEntryMsg').textContent = data.error || 'Fehler beim Speichern.'; + return; } - } catch (e) { - $('newEntryMsg').textContent = 'Verbindungsfehler: ' + e.message; - } - - $('btnSaveNew').disabled = false; - $('btnSaveNew').textContent = 'Speichern'; + if (resp?.locked) { showLockScreen(); return; } + $('newEntryMsg').textContent = resp?.error || 'Fehler beim Speichern.'; + }); } // ── Liste (Klick öffnet Detailansicht) ───────────────────────────────────── From 19051bbacd0c0e7c8991b84ee07426561d287de7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 12:53:34 +0000 Subject: [PATCH 2/7] 2FA-Restlaufzeit korrigiert, manuellen API-Token entfernt Countdown des 2FA-Codes: Die Anzeige zaehlte blind im Sekundentakt herunter und lief dadurch aus dem Takt. Am Ende eines Zeitfensters wurde zwar ein neuer Code angefordert, der Zaehler aber nicht angehalten - bei langsamer oder fehlschlagender Antwort lief er ins Negative und stiess jede Sekunde eine weitere Anfrage an (inkl. Audit-Eintrag je Abruf), waehrend die Anzeige auf dem alten Wert stehenblieb. Die Restlaufzeit wird jetzt aus einem festen Ablaufzeitpunkt berechnet, sodass gedrosselte oder ausgefallene Ticks sie nicht verschieben. Pro Ablauf wird genau einmal nachgeladen; liefert der Server keinen Code mehr (gesperrt/offline), stoppt der Timer und die Anzeige wird geleert. Dieselbe Rechnung gilt fuer die Benachrichtigung im Seiteninhalt; die Periode steckt dort wie im Popup in einer Konstanten statt als 30 im Ausdruck. Manueller API-Token: Eingabefeld, "Token speichern" und "Verbindung testen" sind aus den Optionen entfernt; die Server-URL laesst sich weiterhin separat speichern. getAccessToken() kennt nur noch den SSO-Weg, und ein aus einer frueheren Version uebernommener Token wird bei onInstalled aus chrome.storage.local geloescht. Doku und Store-Listing entsprechend angepasst. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1 --- CHANGELOG.md | 11 ++++++ PRIVACY.md | 14 ++++---- README.md | 15 ++++---- docs/ARCHITECTURE.md | 5 +-- extension/background.js | 14 +++++--- extension/content.js | 14 +++++--- extension/options.html | 28 ++++----------- extension/options.js | 54 ++++------------------------- extension/popup.js | 76 ++++++++++++++++++++++++++++------------- store/listing.md | 6 ++-- 10 files changed, 115 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb53562..e9af616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,18 @@ Die Erweiterungsversion (`manifest.json`) ist unabhängig von der OpenNIT-Server ## [Unreleased] +### Geändert +- **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; + wer bisher nur damit verbunden war, meldet sich einmal per SSO an. + ### Behoben +- **Restlaufzeit des 2FA-Codes wird wieder korrekt angezeigt:** Der Countdown blieb am Ende eines + Zeitfensters stehen und lief mit der Zeit aus dem Takt. Er rechnet nun gegen den tatsächlichen + Ablaufzeitpunkt und holt den neuen Code beim Wechsel genau einmal nach. Lässt sich kein neuer Code + laden (Tresor gesperrt oder Verbindung weg), zeigt die Anzeige das an, statt auf einem abgelaufenen + Wert stehen zu bleiben. - **Neuen Eintrag anlegen funktioniert wieder nach SSO-Anmeldung:** Das Speichern eines im Popup erzeugten Passworts brach bisher mit „Nicht konfiguriert." ab, wenn die Erweiterung über „Mit OpenNIT anmelden" verbunden war – es wurde ausschließlich der manuelle Token akzeptiert. diff --git a/PRIVACY.md b/PRIVACY.md index 825b1e8..31a5a69 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -17,7 +17,7 @@ technisches Client-Werkzeug ohne eigenen Server. | Datum | Zweck | Speicherort | |-------|-------|-------------| | **Server-URL** | Adresse deiner OpenNIT-Instanz | lokal (`chrome.storage.local`) | -| **API-Token** | Authentifizierung gegenüber deiner Instanz | lokal (`chrome.storage.local`) | +| **Sitzungstoken** | Authentifizierung gegenüber deiner Instanz (per SSO ausgestellt) | lokal (`chrome.storage.local`) | | **Einstellungen** (PIN-Sperrdauer, Zwischenablage-Option) | Verhalten der Erweiterung | lokal (`chrome.storage.local`) | | **Entsperr-Status** | Merkt, ob die PIN-Sperre entsperrt ist | Sitzungsspeicher (`chrome.storage.session`, wird beim Schließen des Browsers gelöscht) | | **Tresor-Einträge** (Titel, Benutzername, URL, Notizen, ob 2FA vorhanden) | Anzeige & Suche | flüchtig im Arbeitsspeicher (max. 5 Min zwischengespeichert) | @@ -30,7 +30,7 @@ Die Erweiterung erstellt **keine** Nutzungsprofile und protokolliert **kein** Su - Es werden Daten **ausschließlich** an die von dir eingetragene **OpenNIT-Server-URL** übertragen (verschlüsselt via HTTPS, sofern deine Instanz HTTPS nutzt). -- Übertragen werden: der API-Token (zur Authentifizierung), die aktuelle Seiten-Adresse (zum Finden +- Übertragen werden: der Sitzungstoken (zur Authentifizierung), die aktuelle Seiten-Adresse (zum Finden passender Einträge), sowie beim Anlegen neuer Einträge die von dir eingegebenen Felder. - Es erfolgt **keine** Übermittlung an Anthropic, Google (außer der Chrome-Sync deines eigenen Browsers, falls du ihn aktiviert hast) oder sonstige Dritte. @@ -42,7 +42,7 @@ Siehe [`docs/PERMISSIONS.md`](docs/PERMISSIONS.md). Kurz: - **Zugriff auf alle Websites** (`host_permissions: `): nötig, um Login-Felder auf beliebigen Seiten zu erkennen und auf Wunsch auszufüllen. Es werden **keine** Seiteninhalte gelesen oder übertragen, außer den zum Ausfüllen nötigen Formularfeldern – und diese verlassen den Browser nicht. -- **storage**: lokale Speicherung von URL, Token und Einstellungen. +- **storage**: lokale Speicherung von URL, Sitzungstoken und Einstellungen. - **scripting / activeTab**: Einfügen der Zugangsdaten in das aktive Tab-Formular. - **alarms / offscreen**: automatisches Leeren der Zwischenablage nach dem Kopieren. @@ -53,7 +53,7 @@ Zwischenablage entfernt. ## 6. Speicherdauer -- Server-URL, Token und Einstellungen bleiben lokal gespeichert, bis du sie änderst oder die Erweiterung +- Server-URL, Sitzungstoken und Einstellungen bleiben lokal gespeichert, bis du sie änderst oder die Erweiterung entfernst. - Zwischengespeicherte Einträge werden spätestens nach 5 Minuten bzw. beim Sperren verworfen. - Passwörter/2FA-Codes werden nicht dauerhaft gespeichert. @@ -62,7 +62,7 @@ Zwischenablage entfernt. Da die eigentliche Datenverarbeitung in deiner OpenNIT-Instanz stattfindet, richten sich Auskunfts-, Lösch- und Berichtigungsrechte an deren Betreiber. Lokale Daten der Erweiterung entfernst du durch -Deinstallieren der Erweiterung oder Löschen des Tokens in den Einstellungen. +Deinstallieren der Erweiterung oder „Abmelden" in den Einstellungen. ## 8. Kontakt @@ -78,7 +78,7 @@ eine erreichbare Kontaktadresse ergänzen. **Controller.** The operator of the OpenNIT instance you connect to. The extension itself has no server. -**Data processed.** Server URL, API token and settings (stored locally); unlock state (session storage, +**Data processed.** Server URL, session tokens and settings (stored locally); unlock state (session storage, cleared when the browser closes); vault entry metadata (title, username, URL, notes, whether 2FA exists – cached in memory for up to 5 minutes); passwords/2FA codes (fetched **only** at the moment of use, never stored permanently); the active tab URL (to find matching entries). @@ -96,4 +96,4 @@ browser. See [`docs/PERMISSIONS.md`](docs/PERMISSIONS.md). within 5 minutes; secrets are never stored persistently. **Your rights.** Because processing happens in your OpenNIT instance, direct data-subject requests to its -operator. Remove local extension data by uninstalling the extension or clearing the token. +operator. Remove local extension data by uninstalling the extension or signing out in the settings. diff --git a/README.md b/README.md index a0a531c..29b6148 100644 --- a/README.md +++ b/README.md @@ -37,19 +37,18 @@ Die Erweiterung ist ein reiner **Client** zu deiner OpenNIT-Instanz. Sie enthäl und **keinen** Remote-Code – alle Skripte sind im Paket enthalten. Kommuniziert wird ausschließlich mit dem von dir konfigurierten OpenNIT-Server über dessen REST-API (`/api/vault/extension/...`) per **Bearer-Token**. -- Die Anmeldung erfolgt per **SSO** („Mit OpenNIT anmelden"). Die Erweiterung erhält dabei automatisch - ein kurzlebiges, rotierendes Zugriffstoken – ein manuelles Erzeugen von Tokens im Web-Tresor entfällt. +- Die Anmeldung erfolgt ausschließlich per **SSO** („Mit OpenNIT anmelden"). Die Erweiterung erhält dabei + ein kurzlebiges, rotierendes Zugriffstoken – ein manuelles Erzeugen von Tokens entfällt. - Passwörter werden **serverseitig** ver-/entschlüsselt; die Erweiterung fordert das Klartext-Passwort eines Eintrags erst **im Moment des Ausfüllens/Kopierens** an – nicht beim Laden der Liste. - Es findet **keine** Ende-zu-Ende-Entschlüsselung im Browser statt; die Erweiterung speichert keine - Passwörter dauerhaft (nur Server-URL, Token und Einstellungen in `chrome.storage`). + Passwörter dauerhaft (nur Server-URL, Sitzungstoken und Einstellungen in `chrome.storage`). Details: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · Berechtigungen: [`docs/PERMISSIONS.md`](docs/PERMISSIONS.md) -> **Anmeldung per SSO (Standard):** „Mit OpenNIT anmelden" (OAuth 2.0 + PKCE) – Anmeldung wie an OpenNIT -> (lokal + 2FA / M365 / Keycloak), automatische Token-Erneuerung mit Rotation. Ein manuell erzeugter Token -> ist nur noch als „Erweitert"-Fallback in den Erweiterungs-Einstellungen vorgesehen; OpenNIT bietet dafür -> im Frontend keine Token-Erzeugung mehr an. Konzept/Details: [`docs/SSO-PLAN.md`](docs/SSO-PLAN.md). +> **Anmeldung per SSO:** „Mit OpenNIT anmelden" (OAuth 2.0 + PKCE) – Anmeldung wie an OpenNIT +> (lokal + 2FA / M365 / Keycloak), automatische Token-Erneuerung mit Rotation. Einen manuell eingetragenen +> API-Token unterstützt die Erweiterung nicht mehr. ## Installation @@ -80,7 +79,7 @@ Repository gedacht. | Einstellung | Beschreibung | |--------------------|--------------| | **Server-URL** | Adresse deiner OpenNIT-Instanz, ohne abschließenden Slash (`https://…`). | -| **Anmeldung** | Per **SSO** („Mit OpenNIT anmelden"). Ein manueller API-Token ist nur als „Erweitert"-Fallback vorgesehen. | +| **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). | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d2f2bfc..3c20238 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,7 +27,7 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht | `background.js` | Zentrale Logik: API-Aufrufe, 5-Minuten-Cache, **Lock-Gate**, Favicon-Cache (Data-URLs), Zwischenablage-Timer. Alle Secrets fließen hier durch. | | `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, Token, PIN-Sperrdauer, Zwischenablage. | +| `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). | ## Nachrichten (Auszug) @@ -38,6 +38,7 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht | `GET_LOCK` / `DO_UNLOCK` / `LOCK_NOW` | popup → bg | PIN-Sperre abfragen/entsperren/sperren | | `GET_ENTRIES` / `GET_MATCHING_ENTRIES` | popup/content → bg | Einträge (alle / passend zur URL) | | `GET_PASSWORD` / `GET_TOTP` | popup/content → bg | Secret **on demand** | +| `CREATE_ENTRY` | popup → bg | Neuen Eintrag anlegen | | `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 | @@ -74,5 +75,5 @@ Fensterdauer fest; „Bis der Browser geschlossen wird" nutzt ein langes Serverf - **Kein Remote-Code** – alle Skripte im Paket (MV3-CSP-konform, keine Inline-Skripte). - **Secrets on demand** – Passwörter/TOTP erst bei Nutzung, nie in der Liste. -- **Kein persistentes Secret** – nur URL, Token, Einstellungen in `chrome.storage`. +- **Kein persistentes Secret** – nur URL, Sitzungstoken, Einstellungen in `chrome.storage`. - **Server-seitige Krypto** – Ver-/Entschlüsselung im OpenNIT-Server, nicht im Browser. diff --git a/extension/background.js b/extension/background.js index 4291339..5d50de6 100644 --- a/extension/background.js +++ b/extension/background.js @@ -13,13 +13,13 @@ async function getServerUrl() { return c.serverUrl || null; } -// ── Zugangstoken beschaffen (SSO-Refresh oder manueller Token) ────────────── -// Reihenfolge: manueller Token (Erweitert) > SSO-Access-Token (mit Auto-Refresh). +// ── Zugangstoken beschaffen (SSO) ────────────────────────────────────────── +// Kurzlebiger Access-Token aus der Session; ist er abgelaufen, wird er über den +// rotierenden Refresh-Token erneuert. async function getAccessToken() { const cfg = await new Promise(r => chrome.storage.local.get( - ['serverUrl', 'apiToken', 'apiRefreshToken', 'apiRefreshExpiresAt'], r)); + ['serverUrl', '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) { @@ -298,6 +298,12 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { 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. +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.local.remove('apiToken'); +}); + // 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 667e6cf..c365cce 100644 --- a/extension/content.js +++ b/extension/content.js @@ -511,6 +511,8 @@ const _obs = new MutationObserver(() => { try { _obs.observe(document.documentElement, { childList: true, subtree: true }); } catch {} // ── 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); @@ -531,18 +533,20 @@ function showTotpNotification(code, remaining) { + '2FA-Code kopiert' + '
' + esc(formatted) + '
' + '
' - + '
' + + '
' + '' + remaining + 's'; document.documentElement.appendChild(notif); - let secs = remaining; + // 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; const iv = setInterval(() => { - secs--; + const secs = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); 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 = (secs / 30 * 100) + '%'; if (secs < 10) bar.style.background = '#dc3545'; } + if (bar) { bar.style.width = Math.min(100, secs / TOTP_PERIOD * 100) + '%'; if (secs <= 10) bar.style.background = '#dc3545'; } }, 1000); notif.addEventListener('click', () => { clearInterval(iv); notif.remove(); }); } diff --git a/extension/options.html b/extension/options.html index 85b50a4..f92cb61 100644 --- a/extension/options.html +++ b/extension/options.html @@ -229,29 +229,13 @@ label.control-label { Mit OpenNIT anmelden + + - Empfohlen: Anmeldung wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert. + Die Anmeldung erfolgt wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert und lässt sich jederzeit im Vault widerrufen.
- - -
- Erweitert: manueller Token -
-
- - - Alternative ohne SSO (z. B. Kiosk/Headless): Vault → „Extension“ → Token generieren. -
-
- - - -
-
-
@@ -289,9 +273,9 @@ label.control-label {
    -
  • 1Öffnen Sie den Passwort-Vault in Ihrer OpenNIT-Anwendung.
  • -
  • 2Klicken Sie auf „Extension“ in der Toolbar und generieren Sie einen API-Token.
  • -
  • 3Fügen Sie den Token oben in das Token-Feld ein und klicken Sie auf Speichern.
  • +
  • 1Tragen Sie oben die Server-URL Ihrer OpenNIT-Instanz ein (nur https://).
  • +
  • 2Klicken Sie auf „Mit OpenNIT anmelden“ und melden Sie sich wie gewohnt an.
  • +
  • 3Bestätigen Sie den Zugriff auf den Passwort-Vault – die Erweiterung ist damit verbunden.
  • 4Fokussieren Sie ein Login-Feld auf einer Website – Vault-Vorschläge erscheinen automatisch.
diff --git a/extension/options.js b/extension/options.js index c0ebc76..8559555 100644 --- a/extension/options.js +++ b/extension/options.js @@ -3,9 +3,8 @@ function $(id) { return document.getElementById(id); } // Gespeicherte Werte laden (überschreibt das vorausgefüllte Feld nur wenn bereits gespeichert) -chrome.storage.local.get(['serverUrl', 'apiToken'], cfg => { +chrome.storage.local.get(['serverUrl'], cfg => { if (cfg.serverUrl) $('serverUrl').value = cfg.serverUrl; - if (cfg.apiToken) $('apiToken').value = '••••••••'; }); // Sicherheits-Einstellungen laden @@ -25,8 +24,8 @@ $('btnSaveSec').addEventListener('click', () => { }); }); -// HTTPS erzwingen (außer localhost) – sonst gingen Token und Passwörter im -// Klartext über die Leitung. +// HTTPS erzwingen (außer localhost) – sonst gingen Zugangsdaten und Passwörter +// im Klartext über die Leitung. function isSecureServerUrl(url) { try { const u = new URL(url); @@ -37,58 +36,20 @@ function isSecureServerUrl(url) { } $('btnSave').addEventListener('click', () => { - const url = $('serverUrl').value.trim().replace(/\/$/, ''); - const token = $('apiToken').value.trim(); + const url = $('serverUrl').value.trim().replace(/\/$/, ''); 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 Token und Passwörter unverschlüsselt übertragen.', false); + showStatus('Bitte eine https://-Adresse verwenden (nur localhost darf http:// sein). Sonst würden Zugangsdaten und Passwörter unverschlüsselt übertragen.', false); return; } - const data = { serverUrl: url }; - if (token && !token.startsWith('•')) data.apiToken = token; - - chrome.storage.local.set(data, () => { + chrome.storage.local.set({ serverUrl: url }, () => { 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))); @@ -149,7 +110,6 @@ 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); @@ -184,7 +144,7 @@ function reflectAuthState() { }); } -// Verbindungsstatus über den Background (nutzt SSO-Access-Token oder manuellen Token) +// Verbindungsstatus über den Background (nutzt den SSO-Access-Token) function loadConnStatus() { chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, data => { if (data && data.ok) { diff --git a/extension/popup.js b/extension/popup.js index 871d1ff..2f09f3a 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -262,7 +262,7 @@ function generatePassword() { } // Speichern läuft – wie alle anderen Aufrufe – über den Background-Service-Worker, -// der den gültigen Zugang (SSO oder manueller Token) beisteuert. +// der den gültigen Zugang beisteuert. function saveNewEntry() { const title = $('neTitle').value.trim(); if (!title) { $('newEntryMsg').textContent = 'Titel ist erforderlich.'; return; } @@ -475,6 +475,12 @@ 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'); @@ -483,33 +489,55 @@ function loadDetailTotp(id) { codeEl.dataset.code = ''; secsEl.textContent = ''; - chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, resp => { - if (!detailState || detailState.id !== String(id)) return; - if (!resp?.code) { codeEl.textContent = '—'; return; } + let deadline = 0; + let refetching = false; - 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) + '%'; - }; + 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 (!resp?.code) { codeEl.textContent = '—'; return; } apply(resp.code, resp.remaining); - let secs = resp.remaining; detailState.totpInterval = setInterval(() => { - 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'; - } + if (!stillOpen()) return; + if (render() === 0) refetch(); }, 1000); }); } diff --git a/store/listing.md b/store/listing.md index 8de2f66..a61bb15 100644 --- a/store/listing.md +++ b/store/listing.md @@ -45,7 +45,7 @@ SICHERHEIT & DATENSCHUTZ • Passwörter werden erst im Moment der Nutzung abgerufen und nicht dauerhaft gespeichert EINRICHTUNG -Server-URL eintragen, im OpenNIT-Tresor einen API-Token erzeugen und einfügen – fertig. +Server-URL eintragen, auf „Mit OpenNIT anmelden" klicken und den Zugriff bestätigen – fertig. OpenNIT Vault benötigt eine laufende OpenNIT-Instanz. ``` @@ -71,7 +71,7 @@ SECURITY & PRIVACY • Passwords are fetched only at the moment of use and never stored permanently SETUP -Enter your server URL, generate an API token in the OpenNIT vault, paste it — done. +Enter your server URL, click "Sign in with OpenNIT" and approve access — done. OpenNIT Vault requires a running OpenNIT instance. ``` @@ -93,7 +93,7 @@ Deutsch (primär), Englisch ## Datenschutz-Angaben (Data usage im Dashboard) -- Erhobene Datentypen: **Authentifizierungsinformationen** (API-Token), **Website-Inhalte** nur für +- Erhobene Datentypen: **Authentifizierungsinformationen** (Sitzungstoken), **Website-Inhalte** nur für Autofill (verlassen den Browser nicht). - Nicht verkauft/weitergegeben. Nicht für Werbung/Bonität genutzt. - Verschlüsselte Übertragung (HTTPS). From 26c01a4fc37beaf10bb77af241901bf5c00571f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:05:30 +0000 Subject: [PATCH 3/7] 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'; From 07a57855801e8816dbe54385dfa912ad4e1e5f4d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:17:23 +0000 Subject: [PATCH 4/7] Sechs Review-Punkte: Speicherort, Domain-Pruefung, Rechte, Generator, Notizen, Bearbeiten 1. Passwort mehrstufiger Logins nicht mehr auf der Platte: Der Auftrag lag als __pendingFill in chrome.storage.local, also im Klartext auf der Festplatte. Die 30-Sekunden-Pruefung verhinderte nur die Verwendung, nicht die Speicherung - wurde der zweite Schritt nie erreicht, blieb das Passwort liegen. Er liegt jetzt ausschliesslich im Speicher des Service Workers, je Tab, und wird beim Abholen verbraucht, nach 30 s verworfen, beim Sperren geleert und beim Schliessen des Tabs entfernt. Reste frueherer Versionen raeumt onInstalled ab. 2. Domain-Warnung: fillDomainMatches akzeptierte mit eh.endsWith('.' + pageHost) auch die Gegenrichtung - ein Eintrag fuer vpn.firma.de galt auf firma.de als passend und die Warnung blieb aus. Diese Klausel entfaellt. 3. scripting und activeTab werden nicht mehr angefordert; beide waren unbenutzt (das Content-Script laeuft ueber content_scripts, der Tab-Zugriff ueber host_permissions). PERMISSIONS.md begruendete scripting mit dem nativen Value-Setter, was nichts damit zu tun hat. 4. Passwort-Generator: buf lieferte dieselben Werte fuer Zeichenwahl und Mischreihenfolge, wodurch die Permutation mit dem Inhalt korrelierte. Beides zieht jetzt getrennt ueber randomBelow(), das den obersten, unvollstaendigen Block verwirft (gleichverteilt statt Rest-Modulo). Laenge (12-48) und Sonderzeichen sind waehlbar. 5. Notizen laufen ueber copySecret und werden damit ebenfalls aus der Zwischenablage entfernt; sie enthalten in der Praxis oft Wiederherstellungscodes. copyToClipboard entfaellt. 6. urlmatch.js buendelt die drei abweichenden matchUrl-Fassungen zu einer Regel, geladen in Service Worker, Popup und Seiten. escAttr escapt jetzt auch & < > und Apostroph, traegt also in jedem Attributkontext. Eintraege lassen sich im Popup bearbeiten und loeschen; beim Bearbeiten bedeutet ein leeres Passwortfeld unveraendert, sodass das Passwort das Popup nicht verlaesst. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1 --- CHANGELOG.md | 20 ++++++ README.md | 1 + docs/ARCHITECTURE.md | 12 +++- docs/PERMISSIONS.md | 13 ++-- extension/background.js | 95 +++++++++++++++++++++------- extension/content.js | 38 +++--------- extension/manifest.json | 3 +- extension/popup.html | 34 +++++++++- extension/popup.js | 133 +++++++++++++++++++++++++++------------- extension/urlmatch.js | 63 +++++++++++++++++++ 10 files changed, 305 insertions(+), 107 deletions(-) create mode 100644 extension/urlmatch.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5db7b..e0998d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,32 @@ Die Erweiterungsversion (`manifest.json`) ist unabhängig von der OpenNIT-Server ## [Unreleased] ### Hinzugefügt +- **Einträge bearbeiten und löschen:** In der Detailansicht gibt es jetzt „Bearbeiten" und „Löschen". + Beim Bearbeiten bleibt das Passwortfeld leer – wer es leer lässt, behält das gespeicherte Passwort; + 2FA-Secret und Ordner bleiben unangetastet. Für Team-Einträge greift das Schreibrecht des Teams. +- **Passwort-Generator einstellbar:** Länge (12–48) und Sonderzeichen lassen sich beim Anlegen wählen – + hilfreich bei Seiten, die Sonderzeichen ablehnen. - **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". +### Sicherheit +- **Passwort für mehrstufige Logins nicht mehr auf der Festplatte:** Verteilt eine Anmeldung Benutzername + und Passwort auf zwei Schritte, wurde das Passwort bisher im lokalen Speicher der Erweiterung abgelegt – + und blieb dort liegen, wenn der zweite Schritt nie erreicht wurde. Es wird jetzt nur noch im Arbeits- + speicher gehalten, an den jeweiligen Tab gebunden und nach 30 Sekunden bzw. beim Sperren verworfen. +- **Warnung bei fremder Domain greift zuverlässiger:** Ein Eintrag für `vpn.firma.de` galt auf `firma.de` + fälschlich als passend, sodass die Warnung ausblieb. Zuordnung und Warnung nutzen jetzt dieselbe Regel: + nur die hinterlegte Adresse selbst und deren Unteradressen gelten als passend. +- **Weniger Berechtigungen:** `scripting` und `activeTab` werden nicht mehr angefordert – beide wurden + nicht benötigt. +- **Passwort-Generator:** Der Zufall für die Mischreihenfolge wird jetzt getrennt von der Zeichenauswahl + gezogen, und die Zeichenwahl ist gleichverteilt. +- Notizen werden beim Kopieren wie andere Geheimnisse behandelt und nach 30 Sekunden aus der + Zwischenablage entfernt. + ### 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 diff --git a/README.md b/README.md index 8522e70..f88f24d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ deiner selbst gehosteten OpenNIT-Instanz und bietet: - 👁️ **Detailansicht** mit Anzeigen/Kopieren von Zugangsdaten und 2FA-Code mit Countdown - ➕ **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") +- ✏️ **Anlegen, Bearbeiten und Löschen** von Einträgen direkt im Popup - 🧹 **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) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3388e7b..1b05bf7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,7 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht └───────────────┘ └──────────┬───────────┘ │ CLIP_WRITE ┌────────▼─────────┐ - │ offscreen.html │ (Zwischenablage leeren) + │ offscreen.html │ (Zwischenablage schreiben/leeren) └──────────────────┘ ``` @@ -29,6 +29,7 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht | `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 beschreibt bzw. leert (MV3-konform). | +| `urlmatch.js` | Gemeinsame Zuordnung Eintrag ↔ Seite (`VaultUrl`), geladen in allen drei Kontexten – damit Vorschlagsliste und Sicherheitswarnung dieselbe Regel anwenden. | ## Nachrichten (Auszug) @@ -38,7 +39,8 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht | `GET_LOCK` / `DO_UNLOCK` / `LOCK_NOW` | popup → bg | PIN-Sperre abfragen/entsperren/sperren | | `GET_ENTRIES` / `GET_MATCHING_ENTRIES` | popup/content → bg | Einträge (alle / passend zur URL) | | `GET_PASSWORD` / `GET_TOTP` | popup/content → bg | Secret **on demand** | -| `CREATE_ENTRY` | popup → bg | Neuen Eintrag anlegen | +| `CREATE_ENTRY` / `UPDATE_ENTRY` / `DELETE_ENTRY` | popup → bg | Eintrag anlegen / ändern / löschen | +| `SET_PENDING_FILL` / `TAKE_PENDING_FILL` | content → bg | Passwort für den zweiten Login-Schritt hinterlegen bzw. abholen (nur im Speicher, je Tab) | | `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 | @@ -53,6 +55,8 @@ Alle Endpunkte unter `/api/vault/extension/` mit `Authorization: Bearer ` - `GET /entries/{id}/totp` – aktueller TOTP-Code + Restsekunden - `GET /entries/{id}/favicon?fetch=1` – gecachtes Favicon (bei Bedarf serverseitig geholt) - `POST /entries` – neuen Eintrag anlegen +- `POST /entries/{id}` – Eintrag ändern (leeres Passwortfeld = unverändert) +- `POST /entries/{id}/delete` – Eintrag löschen - `GET /status` – Token gültig? + `pin_enabled` / `pin_lock_secs` - `POST /unlock` – Tresor-PIN verifizieren + serverseitiges Entsperr-Fenster für den Token setzen - `POST /lock` – Token sofort wieder sperren (Entsperr-Fenster zurücksetzen) @@ -76,5 +80,7 @@ Fensterdauer fest; „Bis der Browser geschlossen wird" nutzt ein langes Serverf - **Kein Remote-Code** – alle Skripte im Paket (MV3-CSP-konform, keine Inline-Skripte). - **Secrets on demand** – Passwörter/TOTP erst bei Nutzung, nie in der Liste. -- **Kein persistentes Secret** – nur URL, Sitzungstoken, Einstellungen in `chrome.storage`. +- **Kein persistentes Secret** – nur URL, Sitzungstoken, Einstellungen in `chrome.storage`. Das Passwort + für einen mehrstufigen Login liegt ausschließlich im Speicher des Service Workers (je Tab, 30 s), + nie in `chrome.storage`, das auf die Festplatte geschrieben würde. - **Server-seitige Krypto** – Ver-/Entschlüsselung im OpenNIT-Server, nicht im Browser. diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 08d428b..1262ee6 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -16,15 +16,6 @@ Browser nicht. Es findet kein Tracking und keine Analyse statt. **Warum:** Das In-Seite-Dropdown mit Vorschlägen und die Felderkennung laufen als Content-Script. Notwendig für Autofill und die 2FA-Erkennung (inkl. Shadow-DOM und mehrstufiger Logins). -## `scripting` - -**Warum:** Werte werden über den nativen Value-Setter gesetzt und Events ausgelöst, damit auch -React/Vue/Angular-Formulare die Eingaben übernehmen. - -## `activeTab` - -**Warum:** Zugriff auf den aktiven Tab beim Ausfüllen aus dem Popup („Auf dieser Seite ausfüllen"). - ## `storage` **Warum:** Lokale Speicherung von Server-URL, Zugriffstoken (per SSO) und Einstellungen; Entsperr-Status in @@ -49,6 +40,10 @@ OpenNIT-Login-Seite und empfängt die Weiterleitung an `https://.c ## Bewusst NICHT angefordert +- **`scripting` / `activeTab`** – nicht nötig: Das Content-Script wird über `content_scripts` geladen, + das Ausfüllen aus dem Popup läuft über eine Nachricht an das bereits laufende Script. Der Zugriff auf + den aktiven Tab ist bereits durch `host_permissions` abgedeckt. + - **`tabs`** – entfällt: Die aktive Tab-Adresse ist bereits über `host_permissions` verfügbar. Dadurch erscheint **keine** „Browserverlauf lesen"-Warnung. - Keine `cookies`, `history`, `webRequest`, `downloads`, `notifications` o. Ä. diff --git a/extension/background.js b/extension/background.js index 6e6928c..86113c5 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,5 +1,7 @@ 'use strict'; +importScripts('urlmatch.js'); + // ── Cache ────────────────────────────────────────────────────────────────── let cachedEntries = null; let cacheTime = 0; @@ -100,13 +102,15 @@ async function setUnlockedLocal(dur) { } async function clearUnlocked() { await chrome.storage.session.remove('unlock'); - await chrome.storage.local.remove(['__armedTotp', '__pendingFill']); + 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', '__pendingFill']); + await chrome.storage.local.remove('__armedTotp'); + clearPendingFills(); cachedEntries = null; cacheTime = 0; } async function doUnlock(pin) { @@ -153,8 +157,7 @@ async function fetchEntries(force = false) { * Legt einen persönlichen Eintrag im Tresor an. * * Läuft bewusst über `apiFetch`, damit derselbe Zugang wie für alle übrigen - * Aufrufe gilt: SSO-Access-Token (inkl. automatischer Erneuerung) oder – falls - * gesetzt – der manuelle Token. + * 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}>} @@ -163,11 +166,42 @@ 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('/api/vault/extension/entries', { + const res = await apiFetch(path, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: body.toString(), + body: body, }); if (!res) return { ok: false, error: 'Nicht konfiguriert.' }; if (res.status === 423) { await onServerLocked(); return { ok: false, locked: true, error: 'Tresor gesperrt.' }; } @@ -221,21 +255,28 @@ async function fetchFavicon(entryId) { } catch (e) { return null; } } -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; } - }); + +// ── 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 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) { @@ -283,12 +324,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } if (msg.type === 'GET_MATCHING_ENTRIES') { fetchEntries().then(r => { - const matched = (r.entries || []).filter(e => matchUrl(e.url, msg.url)); + const matched = (r.entries || []).filter(e => VaultUrl.matches(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; } @@ -298,6 +341,12 @@ 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; } @@ -306,8 +355,10 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { // 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'); + chrome.storage.local.remove(['apiToken', '__pendingFill']); }); // Cache alle 5 Minuten leeren; Zwischenablage-Clear nach Timeout. diff --git a/extension/content.js b/extension/content.js index 04f6bc3..7687cde 100644 --- a/extension/content.js +++ b/extension/content.js @@ -16,6 +16,7 @@ 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; @@ -206,26 +207,6 @@ 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); @@ -256,7 +237,7 @@ function fillFromPopup(msg) { if (userField && msg.username) setFieldValue(userField, msg.username); if (passField && msg.password) setFieldValue(passField, msg.password); - else if (msg.password) chrome.storage.local.set({ __pendingFill: { id: msg.id, pw: msg.password, user: msg.username || '', ts: Date.now() } }); + else if (msg.password) chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: msg.id, pw: msg.password, user: msg.username || '' }); if (msg.has_totp && msg.id != null) { armTotp(msg.id); @@ -336,7 +317,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 => matchUrl(e.url, location.href)); + let entries = (resp && resp.entries || []).filter(e => VaultUrl.matches(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); @@ -489,7 +470,7 @@ async function fillEntry(entry, focused) { if (userField && entry.username) setFieldValue(userField, entry.username); if (passField) setFieldValue(passField, pw); - else chrome.storage.local.set({ __pendingFill: { id: entry.id, pw: pw, user: entry.username || '', ts: Date.now() } }); + else chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: entry.id, pw: pw, user: entry.username || '' }); if (entry.has_totp) { // Der 2FA-Schritt folgt oft erst nach dem Absenden – ggf. auf der nächsten @@ -603,16 +584,17 @@ function scanForPendingWork() { 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; + 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; 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'); }); } diff --git a/extension/manifest.json b/extension/manifest.json index cae0877..a7cdc20 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -5,8 +5,6 @@ "description": "OpenNIT Vault – Passwort-Manager mit Autofill für Benutzer-, Passwort- und 2FA-Felder direkt im Browser.", "permissions": [ "storage", - "activeTab", - "scripting", "alarms", "offscreen", "identity" @@ -31,6 +29,7 @@ "" ], "js": [ + "urlmatch.js", "content.js" ], "run_at": "document_idle" diff --git a/extension/popup.html b/extension/popup.html index c6f1a77..1f9290e 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -147,6 +147,14 @@ 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 ────────────────────────────────────────── */ @@ -204,7 +212,7 @@ body {
- Neuen Eintrag anlegen + Neuen Eintrag anlegen
@@ -219,6 +227,19 @@ body {
+
+ + +
@@ -298,6 +319,16 @@ body { Auf dieser Seite ausfüllen +
+ + +
@@ -342,6 +373,7 @@ body {
+ \ No newline at end of file diff --git a/extension/popup.js b/extension/popup.js index 2f09f3a..104ea9d 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -5,6 +5,7 @@ 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); } @@ -33,8 +34,10 @@ async function init() { $('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', () => copyToClipboard($('detailNotes').dataset.value || '', 'Notiz kopiert')); + $('btnCopyNotes').addEventListener('click', () => copySecret($('detailNotes').dataset.value || '', 'Notiz kopiert')); $('btnDetailFill').addEventListener('click', fillActiveTab); + $('btnDetailEdit').addEventListener('click', openEditPanel); + $('btnDetailDelete').addEventListener('click', deleteCurrentEntry); // Lock-Screen $('lockSubmit').addEventListener('click', submitPin); @@ -211,6 +214,9 @@ 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'; @@ -232,35 +238,92 @@ 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 = 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 + 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. for (let i = out.length - 1; i > 0; i--) { - const j = buf[i] % (i + 1); + const j = randomBelow(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() { @@ -278,14 +341,18 @@ function saveNewEntry() { url: $('neUrl').value.trim(), notes: $('neNotes').value.trim(), }; + const msg = editingId + ? { type: 'UPDATE_ENTRY', id: editingId, entry } + : { type: 'CREATE_ENTRY', entry }; + const wasEditing = !!editingId; - chrome.runtime.sendMessage({ type: 'CREATE_ENTRY', entry }, resp => { + chrome.runtime.sendMessage(msg, resp => { $('btnSaveNew').disabled = false; $('btnSaveNew').textContent = 'Speichern'; if (resp?.ok) { closeNewPanel(); reload(true); - showToast('Eintrag gespeichert'); + showToast(wasEditing ? 'Eintrag aktualisiert' : 'Eintrag gespeichert'); return; } if (resp?.locked) { showLockScreen(); return; } @@ -579,30 +646,12 @@ async function fillActiveTab() { } // ── Helfer ──────────────────────────────────────────────────────────────── -function hostOf(u) { - try { - const s = String(u || ''); - return new URL(s.includes('://') ? s : 'https://' + s).hostname.replace(/^www\./, '').toLowerCase(); - } catch { return ''; } -} +function hostOf(u) { return VaultUrl.host(u); } // 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) { - 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 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 copySecret(text, msg) { navigator.clipboard.writeText(text).then(() => { showToast(msg); @@ -616,6 +665,6 @@ function showToast(msg) { setTimeout(() => t.classList.remove('show'), 1800); } function esc(s) { return String(s||'').replace(/&/g,'&').replace(//g,'>'); } -function escAttr(s) { return String(s||'').replace(/"/g,'"'); } +function escAttr(s) { return esc(s).replace(/"/g, '"').replace(/'/g, '''); } init(); \ No newline at end of file diff --git a/extension/urlmatch.js b/extension/urlmatch.js new file mode 100644 index 0000000..c835b08 --- /dev/null +++ b/extension/urlmatch.js @@ -0,0 +1,63 @@ +'use strict'; + +/* + * Gemeinsame Zuordnung Eintrag ↔ Seite. + * + * Wird in allen drei Kontexten geladen (Service Worker via importScripts, + * Popup via \ 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