diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de2519..7482b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,61 @@ Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). Die Erweiterungsversion (`manifest.json`) ist unabhängig von der OpenNIT-Serverversion. +## [Unreleased] + +## [2.5.0] - 2026-07-31 + +### 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 +- **Benutzername immer sichtbar:** Das Auge zum Ausblenden des Benutzernamens in der Detailansicht ist + entfallen – ein Benutzername ist kein Geheimnis. Das Passwort bleibt weiterhin maskiert. +- **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; + 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. + 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/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..f88f24d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,10 @@ 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) - 🖼️ **Favicons** der hinterlegten Seiten (serverseitig gecacht – keine externen Aufrufe) - 🌙 **Heller & dunkler Modus** (folgt dem System) @@ -37,19 +40,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,9 +82,10 @@ 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). | +| **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 d2f2bfc..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) └──────────────────┘ ``` @@ -27,8 +27,9 @@ 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. | -| `offscreen.html` / `offscreen.js` | Minimaldokument, das ausschließlich die Zwischenablage leert (MV3-konform). | +| `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,9 +39,12 @@ 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` / `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 | +| `CLIP_WRITE` | content → bg | In die Zwischenablage schreiben, wenn die Seite selbst keinen Zugriff bekommt | ## Server-API (in OpenNIT) @@ -51,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) @@ -74,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, Token, 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 1114260..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 @@ -38,7 +29,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` @@ -48,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 01b988c..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; @@ -13,13 +15,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) { @@ -100,11 +102,15 @@ async function setUnlockedLocal(dur) { } async function clearUnlocked() { await chrome.storage.session.remove('unlock'); + await chrome.storage.local.remove('__armedTotp'); + clearPendingFills(); cachedEntries = null; cacheTime = 0; faviconCache.clear(); try { await apiFetch('/api/vault/extension/lock', { method: 'POST' }); } catch (e) { /* ignore */ } } async function onServerLocked() { await chrome.storage.session.remove('unlock'); + await chrome.storage.local.remove('__armedTotp'); + clearPendingFills(); cachedEntries = null; cacheTime = 0; } async function doUnlock(pin) { @@ -147,6 +153,67 @@ async function fetchEntries(force = false) { return { entries: null, locked: false }; } +/** + * Legt einen persönlichen Eintrag im Tresor an. + * + * Läuft bewusst über `apiFetch`, damit derselbe Zugang wie für alle übrigen + * Aufrufe gilt, inklusive automatischer Erneuerung des Access-Tokens. + * + * @param {{title?:string,username?:string,password?:string,url?:string,notes?:string}} fields + * @return {Promise<{ok:boolean,id?:number,locked?:boolean,error?:string}>} + */ +async function createEntry(fields) { + if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' }; + const body = new URLSearchParams(); + ['title', 'username', 'password', 'url', 'notes'].forEach(k => body.append(k, fields?.[k] ?? '')); + return writeEntry('/api/vault/extension/entries', body.toString()); +} + +/** + * Ändert einen bestehenden Eintrag. Ein leeres Passwortfeld lässt das gespeicherte + * Passwort unangetastet; 2FA-Secret und Ordner bleiben serverseitig erhalten. + * + * @param {number|string} entryId + * @param {{title?:string,username?:string,password?:string,url?:string,notes?:string}} fields + * @return {Promise<{ok:boolean,locked?:boolean,error?:string}>} + */ +async function updateEntry(entryId, fields) { + if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' }; + const body = new URLSearchParams(); + ['title', 'username', 'password', 'url', 'notes'].forEach(k => body.append(k, fields?.[k] ?? '')); + return writeEntry(`/api/vault/extension/entries/${entryId}`, body.toString()); +} + +/** + * Löscht einen Eintrag (persönlich oder Team, sofern Schreibrecht besteht). + * + * @param {number|string} entryId + * @return {Promise<{ok:boolean,locked?:boolean,error?:string}>} + */ +async function deleteEntry(entryId) { + if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' }; + return writeEntry(`/api/vault/extension/entries/${entryId}/delete`, ''); +} + +// Gemeinsame Auswertung der schreibenden Endpunkte. +async function writeEntry(path, body) { + try { + const res = await apiFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body, + }); + if (!res) return { ok: false, error: 'Nicht konfiguriert.' }; + if (res.status === 423) { await onServerLocked(); return { ok: false, locked: true, error: 'Tresor gesperrt.' }; } + const data = await res.json().catch(() => ({})); + if (data.ok) { + cachedEntries = null; cacheTime = 0; + return { ok: true, id: data.id }; + } + return { ok: false, error: data.error || 'Fehler beim Speichern.' }; + } catch (e) { return { ok: false, error: 'Verbindungsfehler.' }; } +} + async function fetchPassword(entryId) { if (!(await isUnlocked())) return null; try { @@ -188,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) { @@ -211,19 +285,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() { @@ -247,11 +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; } @@ -261,11 +341,26 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } if (msg.type === 'DO_UNLOCK') { doUnlock(msg.pin || '').then(sendResponse); return true; } if (msg.type === 'LOCK_NOW') { clearUnlocked().then(() => sendResponse({ ok: true })); return true; } + if (msg.type === 'SET_PENDING_FILL') { + setPendingFill(sender.tab?.id, { id: msg.id, pw: msg.pw || '', user: msg.user || '' }); + sendResponse({ ok: true }); + return true; + } + if (msg.type === 'TAKE_PENDING_FILL') { sendResponse({ fill: takePendingFill(sender.tab?.id) }); return true; } + if (msg.type === 'CLIP_WRITE') { writeClipboard(msg.text || '').then(() => sendResponse({ ok: true })); return true; } if (msg.type === 'SCHEDULE_CLIP_CLEAR') { scheduleClipClear(msg.text || ''); sendResponse({ ok: true }); return true; } if (msg.type === 'CHECK_STATUS') { checkStatus().then(sendResponse); return true; } if (msg.type === 'CLEAR_CACHE') { cachedEntries = null; cacheTime = 0; faviconCache.clear(); sendResponse({ ok: true }); return true; } }); +// Der manuell eingetragene API-Token wird nicht mehr unterstützt; ein aus einer +// früheren Version übernommener Wert wird beim Update aus dem Speicher entfernt. +// Der manuelle API-Token entfaellt, und ein ausstehender Fuellauftrag liegt nicht +// mehr im Speicher auf der Platte – Reste frueherer Versionen hier entfernen. +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.local.remove(['apiToken', '__pendingFill']); +}); + // Cache alle 5 Minuten leeren; Zwischenablage-Clear nach Timeout. chrome.alarms.create('clearCache', { periodInMinutes: 5 }); chrome.alarms.onAlarm.addListener(async (alarm) => { diff --git a/extension/content.js b/extension/content.js index 667e6cf..7687cde 100644 --- a/extension/content.js +++ b/extension/content.js @@ -14,6 +14,9 @@ const DROPDOWN_ID = '__vault_dropdown__'; let appLabel = 'Vault'; let currentField = null; let showGen = 0; +let typedField = null; // Feld, in das der Nutzer zuletzt selbst getippt hat +let fillingInProgress = false; // unterdrückt die Tipp-Erkennung beim Autofill +let pendingFillInFlight = false; // der Auftrag wird beim Abholen verbraucht – nur einmal gleichzeitig // ── Heuristik-Muster ──────────────────────────────────────────────────────── const RE_USER = /(user(name|id)?|login|logon|sign[-_ ]?in|account|konto|benutzer|kennung|anmeld|e[-_ ]?mail|email|mail|uid|userid|handle|identifier|ident\b|loginid)/i; @@ -28,7 +31,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 +124,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'; @@ -178,31 +207,12 @@ function findOtpFields(ref) { return collectInputs(scopeOf(ref)).filter(el => isVisible(el) && isOtpField(el)); } -// ── URL-Matching ──────────────────────────────────────────────────────────── -function normalizeHost(raw) { - if (!raw) return ''; - try { - const s = raw.includes('://') ? raw : 'https://' + raw; - return new URL(s).hostname.replace(/^www\./, '').toLowerCase(); - } catch { return lc(raw).replace(/^www\./, ''); } -} -function matchUrl(entryUrls, pageUrl) { - const pageHost = normalizeHost(pageUrl); - if (!pageHost) return false; - const urls = typeof entryUrls === 'string' ? entryUrls.split('\n') : [entryUrls]; - return urls.some(u => { - let eh = normalizeHost((u || '').trim()); - if (!eh) return false; - if (eh.startsWith('*.')) eh = eh.slice(2); - return pageHost === eh || pageHost.endsWith('.' + eh); - }); -} - // ── Events ────────────────────────────────────────────────────────────────── function init() { document.addEventListener('focusin', onFocusIn, true); document.addEventListener('focusout', onFocusOut, true); document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('input', onInput, true); document.addEventListener('keydown', onKeyDown, true); document.addEventListener('click', onDocClick, true); window.addEventListener('scroll', repositionDrop, true); @@ -227,27 +237,44 @@ 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) { - 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); } @@ -290,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); @@ -443,17 +470,66 @@ 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) { - 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 +555,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,26 +568,47 @@ 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(() => { - 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; +// ── Mehrstufiger Login: Passwort/2FA nach dem Erscheinen bedienen ──────────── +// Der Scan ist entkoppelt vom Mutations-Takt: eine Seite kann pro Sekunde +// hunderte Mutationen erzeugen, die Feldsuche läuft davon unabhängig gedrosselt. +const SCAN_DEBOUNCE_MS = 250; +let scanTimer = null; + +function scanForPendingWork() { + const inputs = collectInputs(document).filter(isVisible); + + const otps = inputs.filter(isOtpField); + if (otps.length) serveArmedTotp(otps, true); + + const pw = inputs.filter(isPasswordField); + if (!pw.length || pendingFillInFlight) return; + pendingFillInFlight = true; + chrome.runtime.sendMessage({ type: 'TAKE_PENDING_FILL' }, resp => { + pendingFillInFlight = false; + const p = resp && resp.fill; + if (!p) return; pw.forEach(f => setFieldValue(f, p.pw)); if (p.user) { const uf = findUsernameField(pw[0]); if (uf && !uf.value) setFieldValue(uf, p.user); } - chrome.storage.local.remove('__pendingFill'); }); +} + +const _obs = new MutationObserver(() => { + if (scanTimer) return; + scanTimer = setTimeout(() => { scanTimer = null; scanForPendingWork(); }, SCAN_DEBOUNCE_MS); }); try { _obs.observe(document.documentElement, { childList: true, subtree: true }); } catch {} +// Bei einem Seitenwechsel steht das 2FA-Feld oft schon im ersten Rendering. +scanForPendingWork(); // ── TOTP-Benachrichtigung (unten rechts) ──────────────────────────────────── +const TOTP_PERIOD = 30; // Sekunden pro Code (RFC 6238, Serverseite nutzt denselben Wert) + function showTotpNotification(code, remaining) { const ID = '__vault_totp_notif__'; const old = document.getElementById(ID); @@ -531,18 +629,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/manifest.json b/extension/manifest.json index cae0877..6e50a73 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,12 +1,10 @@ { "manifest_version": 3, "name": "OpenNIT Vault", - "version": "2.4.1", + "version": "2.5.0", "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/options.html b/extension/options.html index 85b50a4..a11605b 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. -
-
- - - -
-
-
@@ -275,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. +
@@ -289,9 +277,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 25181f6..eec70f4 100644 --- a/extension/options.js +++ b/extension/options.js @@ -3,21 +3,22 @@ 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 -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'; @@ -25,27 +26,8 @@ $('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. +// 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); @@ -56,58 +38,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))); @@ -168,7 +112,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); @@ -203,7 +146,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.html b/extension/popup.html index c6f1a77..b6a9da3 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 {
+
+ + +
@@ -248,9 +269,6 @@ body {
Benutzername
- @@ -298,6 +316,16 @@ body { Auf dieser Seite ausfüllen +
+ + +
@@ -342,6 +370,7 @@ body {
+ \ No newline at end of file diff --git a/extension/popup.js b/extension/popup.js index 545e113..bb8890c 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); } @@ -28,13 +29,14 @@ 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', () => 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 +213,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,74 +237,126 @@ 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'; } -async function saveNewEntry() { +// 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() { 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(), + }; + const msg = editingId + ? { type: 'UPDATE_ENTRY', id: editingId, entry } + : { type: 'CREATE_ENTRY', entry }; + const wasEditing = !!editingId; - 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(msg, resp => { + $('btnSaveNew').disabled = false; + $('btnSaveNew').textContent = 'Speichern'; + if (resp?.ok) { closeNewPanel(); reload(true); - showToast('Eintrag gespeichert'); - } else { - $('newEntryMsg').textContent = data.error || 'Fehler beim Speichern.'; + showToast(wasEditing ? 'Eintrag aktualisiert' : 'Eintrag gespeichert'); + 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) ───────────────────────────────────── @@ -364,7 +421,7 @@ function openDetail(id) { const e = entryIndex[String(id)]; if (!e) return; closeDetailTimers(); - detailState = { id: String(id), password: null, revealUser: true, revealPass: false, totpInterval: null }; + detailState = { id: String(id), password: null, revealPass: false, totpInterval: null }; $('listWrap').style.display = 'none'; $('search').closest('.search-wrap').style.display = 'none'; @@ -399,21 +456,18 @@ function openDetail(id) { urlLink.style.display = 'none'; } - // Benutzername (standardmäßig sichtbar; Auge schaltet Maskierung) + // Benutzername – kein Geheimnis, daher immer im Klartext. const uval = e.username || ''; const uEl = $('detailUser'); uEl.dataset.value = uval; - detailState.revealUser = true; if (uval) { uEl.classList.remove('empty'); uEl.textContent = uval; - $('btnRevealUser').style.display = ''; - $('btnCopyUser').style.display = ''; + $('btnCopyUser').style.display = ''; } else { uEl.classList.add('empty'); uEl.textContent = 'Kein Benutzername'; - $('btnRevealUser').style.display = 'none'; - $('btnCopyUser').style.display = 'none'; + $('btnCopyUser').style.display = 'none'; } // Passwort (standardmäßig maskiert) @@ -447,14 +501,6 @@ 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 => { @@ -484,6 +530,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'); @@ -492,33 +544,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); }); } @@ -560,30 +634,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); @@ -597,6 +653,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