Compare commits
6 commits
main
...
claude/chr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e4472241 | ||
|
|
5da80df416 | ||
|
|
65a07fbc18 | ||
|
|
32cc4de8be | ||
|
|
18f68d68ea | ||
|
|
d1647e7b58 |
15 changed files with 646 additions and 310 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
dist/
|
||||||
55
CHANGELOG.md
55
CHANGELOG.md
|
|
@ -3,6 +3,61 @@
|
||||||
Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
||||||
Die Erweiterungsversion (`manifest.json`) ist unabhängig von der OpenNIT-Serverversion.
|
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
|
## [2.4.0] - 2026-07-01
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|
|
||||||
14
PRIVACY.md
14
PRIVACY.md
|
|
@ -17,7 +17,7 @@ technisches Client-Werkzeug ohne eigenen Server.
|
||||||
| Datum | Zweck | Speicherort |
|
| Datum | Zweck | Speicherort |
|
||||||
|-------|-------|-------------|
|
|-------|-------|-------------|
|
||||||
| **Server-URL** | Adresse deiner OpenNIT-Instanz | lokal (`chrome.storage.local`) |
|
| **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`) |
|
| **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) |
|
| **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) |
|
| **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
|
- Es werden Daten **ausschließlich** an die von dir eingetragene **OpenNIT-Server-URL** übertragen
|
||||||
(verschlüsselt via HTTPS, sofern deine Instanz HTTPS nutzt).
|
(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.
|
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,
|
- Es erfolgt **keine** Übermittlung an Anthropic, Google (außer der Chrome-Sync deines eigenen Browsers,
|
||||||
falls du ihn aktiviert hast) oder sonstige Dritte.
|
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: <all_urls>`): nötig, um Login-Felder auf beliebigen
|
- **Zugriff auf alle Websites** (`host_permissions: <all_urls>`): nötig, um Login-Felder auf beliebigen
|
||||||
Seiten zu erkennen und auf Wunsch auszufüllen. Es werden **keine** Seiteninhalte gelesen oder
|
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.
|
ü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.
|
- **scripting / activeTab**: Einfügen der Zugangsdaten in das aktive Tab-Formular.
|
||||||
- **alarms / offscreen**: automatisches Leeren der Zwischenablage nach dem Kopieren.
|
- **alarms / offscreen**: automatisches Leeren der Zwischenablage nach dem Kopieren.
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@ Zwischenablage entfernt.
|
||||||
|
|
||||||
## 6. Speicherdauer
|
## 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.
|
entfernst.
|
||||||
- Zwischengespeicherte Einträge werden spätestens nach 5 Minuten bzw. beim Sperren verworfen.
|
- Zwischengespeicherte Einträge werden spätestens nach 5 Minuten bzw. beim Sperren verworfen.
|
||||||
- Passwörter/2FA-Codes werden nicht dauerhaft gespeichert.
|
- 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-,
|
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
|
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
|
## 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.
|
**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 –
|
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
|
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).
|
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.
|
within 5 minutes; secrets are never stored persistently.
|
||||||
|
|
||||||
**Your rights.** Because processing happens in your OpenNIT instance, direct data-subject requests to its
|
**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.
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -22,7 +22,10 @@ deiner selbst gehosteten OpenNIT-Instanz und bietet:
|
||||||
- 👁️ **Detailansicht** mit Anzeigen/Kopieren von Zugangsdaten und 2FA-Code mit Countdown
|
- 👁️ **Detailansicht** mit Anzeigen/Kopieren von Zugangsdaten und 2FA-Code mit Countdown
|
||||||
- ➕ **Neue Einträge anlegen** inkl. **Passwort-Generator**
|
- ➕ **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")
|
- 🔒 **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
|
- 🧹 **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)
|
- 🖼️ **Favicons** der hinterlegten Seiten (serverseitig gecacht – keine externen Aufrufe)
|
||||||
- 🌙 **Heller & dunkler Modus** (folgt dem System)
|
- 🌙 **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
|
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**.
|
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
|
- Die Anmeldung erfolgt ausschließlich per **SSO** („Mit OpenNIT anmelden"). Die Erweiterung erhält dabei
|
||||||
ein kurzlebiges, rotierendes Zugriffstoken – ein manuelles Erzeugen von Tokens im Web-Tresor entfällt.
|
ein kurzlebiges, rotierendes Zugriffstoken – ein manuelles Erzeugen von Tokens entfällt.
|
||||||
- Passwörter werden **serverseitig** ver-/entschlüsselt; die Erweiterung fordert das Klartext-Passwort
|
- 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.
|
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
|
- 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)
|
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
|
> **Anmeldung per SSO:** „Mit OpenNIT anmelden" (OAuth 2.0 + PKCE) – Anmeldung wie an OpenNIT
|
||||||
> (lokal + 2FA / M365 / Keycloak), automatische Token-Erneuerung mit Rotation. Ein manuell erzeugter Token
|
> (lokal + 2FA / M365 / Keycloak), automatische Token-Erneuerung mit Rotation. Einen manuell eingetragenen
|
||||||
> ist nur noch als „Erweitert"-Fallback in den Erweiterungs-Einstellungen vorgesehen; OpenNIT bietet dafür
|
> API-Token unterstützt die Erweiterung nicht mehr.
|
||||||
> im Frontend keine Token-Erzeugung mehr an. Konzept/Details: [`docs/SSO-PLAN.md`](docs/SSO-PLAN.md).
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -80,9 +82,10 @@ Repository gedacht.
|
||||||
| Einstellung | Beschreibung |
|
| Einstellung | Beschreibung |
|
||||||
|--------------------|--------------|
|
|--------------------|--------------|
|
||||||
| **Server-URL** | Adresse deiner OpenNIT-Instanz, ohne abschließenden Slash (`https://…`). |
|
| **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**. |
|
| **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). |
|
| **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
|
## Build / Paketierung
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht
|
||||||
└───────────────┘ └──────────┬───────────┘
|
└───────────────┘ └──────────┬───────────┘
|
||||||
│ CLIP_WRITE
|
│ 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. |
|
| `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). |
|
| `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. |
|
| `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). |
|
| `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)
|
## 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_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_ENTRIES` / `GET_MATCHING_ENTRIES` | popup/content → bg | Einträge (alle / passend zur URL) |
|
||||||
| `GET_PASSWORD` / `GET_TOTP` | popup/content → bg | Secret **on demand** |
|
| `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) |
|
| `GET_FAVICON` | popup/content → bg | Favicon als Data-URL (serverseitig gecacht) |
|
||||||
| `VAULT_FILL` | popup → content | Aktives Tab-Formular ausfüllen |
|
| `VAULT_FILL` | popup → content | Aktives Tab-Formular ausfüllen |
|
||||||
| `SCHEDULE_CLIP_CLEAR` | popup/content → bg | Zwischenablage-Leerung planen |
|
| `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)
|
## Server-API (in OpenNIT)
|
||||||
|
|
||||||
|
|
@ -51,6 +55,8 @@ Alle Endpunkte unter `/api/vault/extension/` mit `Authorization: Bearer <token>`
|
||||||
- `GET /entries/{id}/totp` – aktueller TOTP-Code + Restsekunden
|
- `GET /entries/{id}/totp` – aktueller TOTP-Code + Restsekunden
|
||||||
- `GET /entries/{id}/favicon?fetch=1` – gecachtes Favicon (bei Bedarf serverseitig geholt)
|
- `GET /entries/{id}/favicon?fetch=1` – gecachtes Favicon (bei Bedarf serverseitig geholt)
|
||||||
- `POST /entries` – neuen Eintrag anlegen
|
- `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`
|
- `GET /status` – Token gültig? + `pin_enabled` / `pin_lock_secs`
|
||||||
- `POST /unlock` – Tresor-PIN verifizieren + serverseitiges Entsperr-Fenster für den Token setzen
|
- `POST /unlock` – Tresor-PIN verifizieren + serverseitiges Entsperr-Fenster für den Token setzen
|
||||||
- `POST /lock` – Token sofort wieder sperren (Entsperr-Fenster zurücksetzen)
|
- `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).
|
- **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.
|
- **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.
|
- **Server-seitige Krypto** – Ver-/Entschlüsselung im OpenNIT-Server, nicht im Browser.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
**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).
|
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`
|
## `storage`
|
||||||
|
|
||||||
**Warum:** Lokale Speicherung von Server-URL, Zugriffstoken (per SSO) und Einstellungen; Entsperr-Status in
|
**Warum:** Lokale Speicherung von Server-URL, Zugriffstoken (per SSO) und Einstellungen; Entsperr-Status in
|
||||||
|
|
@ -38,7 +29,8 @@ Einträge-Caches (5 Min).
|
||||||
## `offscreen`
|
## `offscreen`
|
||||||
|
|
||||||
**Warum:** In Manifest V3 hat der Service Worker keinen DOM-Zugriff. Zum programmatischen Leeren der
|
**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`
|
## `identity`
|
||||||
|
|
||||||
|
|
@ -48,6 +40,10 @@ OpenNIT-Login-Seite und empfängt die Weiterleitung an `https://<extension-id>.c
|
||||||
|
|
||||||
## Bewusst NICHT angefordert
|
## 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
|
- **`tabs`** – entfällt: Die aktive Tab-Adresse ist bereits über `host_permissions` verfügbar. Dadurch
|
||||||
erscheint **keine** „Browserverlauf lesen"-Warnung.
|
erscheint **keine** „Browserverlauf lesen"-Warnung.
|
||||||
- Keine `cookies`, `history`, `webRequest`, `downloads`, `notifications` o. Ä.
|
- Keine `cookies`, `history`, `webRequest`, `downloads`, `notifications` o. Ä.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
importScripts('urlmatch.js');
|
||||||
|
|
||||||
// ── Cache ──────────────────────────────────────────────────────────────────
|
// ── Cache ──────────────────────────────────────────────────────────────────
|
||||||
let cachedEntries = null;
|
let cachedEntries = null;
|
||||||
let cacheTime = 0;
|
let cacheTime = 0;
|
||||||
|
|
@ -13,13 +15,13 @@ async function getServerUrl() {
|
||||||
return c.serverUrl || null;
|
return c.serverUrl || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Zugangstoken beschaffen (SSO-Refresh oder manueller Token) ──────────────
|
// ── Zugangstoken beschaffen (SSO) ──────────────────────────────────────────
|
||||||
// Reihenfolge: manueller Token (Erweitert) > SSO-Access-Token (mit Auto-Refresh).
|
// Kurzlebiger Access-Token aus der Session; ist er abgelaufen, wird er über den
|
||||||
|
// rotierenden Refresh-Token erneuert.
|
||||||
async function getAccessToken() {
|
async function getAccessToken() {
|
||||||
const cfg = await new Promise(r => chrome.storage.local.get(
|
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.serverUrl) return null;
|
||||||
if (cfg.apiToken) return cfg.apiToken; // manueller Token
|
|
||||||
if (!cfg.apiRefreshToken) return null; // nicht angemeldet
|
if (!cfg.apiRefreshToken) return null; // nicht angemeldet
|
||||||
const sess = await chrome.storage.session.get(['accessToken', 'accessExpiresAt']);
|
const sess = await chrome.storage.session.get(['accessToken', 'accessExpiresAt']);
|
||||||
if (sess.accessToken && sess.accessExpiresAt && Date.now() < sess.accessExpiresAt - 30000) {
|
if (sess.accessToken && sess.accessExpiresAt && Date.now() < sess.accessExpiresAt - 30000) {
|
||||||
|
|
@ -100,11 +102,15 @@ async function setUnlockedLocal(dur) {
|
||||||
}
|
}
|
||||||
async function clearUnlocked() {
|
async function clearUnlocked() {
|
||||||
await chrome.storage.session.remove('unlock');
|
await chrome.storage.session.remove('unlock');
|
||||||
|
await chrome.storage.local.remove('__armedTotp');
|
||||||
|
clearPendingFills();
|
||||||
cachedEntries = null; cacheTime = 0; faviconCache.clear();
|
cachedEntries = null; cacheTime = 0; faviconCache.clear();
|
||||||
try { await apiFetch('/api/vault/extension/lock', { method: 'POST' }); } catch (e) { /* ignore */ }
|
try { await apiFetch('/api/vault/extension/lock', { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
async function onServerLocked() {
|
async function onServerLocked() {
|
||||||
await chrome.storage.session.remove('unlock');
|
await chrome.storage.session.remove('unlock');
|
||||||
|
await chrome.storage.local.remove('__armedTotp');
|
||||||
|
clearPendingFills();
|
||||||
cachedEntries = null; cacheTime = 0;
|
cachedEntries = null; cacheTime = 0;
|
||||||
}
|
}
|
||||||
async function doUnlock(pin) {
|
async function doUnlock(pin) {
|
||||||
|
|
@ -147,6 +153,67 @@ async function fetchEntries(force = false) {
|
||||||
return { entries: null, locked: 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) {
|
async function fetchPassword(entryId) {
|
||||||
if (!(await isUnlocked())) return null;
|
if (!(await isUnlocked())) return null;
|
||||||
try {
|
try {
|
||||||
|
|
@ -188,21 +255,28 @@ async function fetchFavicon(entryId) {
|
||||||
} catch (e) { return null; }
|
} catch (e) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchUrl(entryUrl, pageUrl) {
|
|
||||||
if (!entryUrl) return false;
|
// ── Ausstehendes Ausfüllen (mehrstufiger Login) ─────────────────────────────
|
||||||
let pHost;
|
// Bei Logins, die Benutzername und Passwort auf zwei Schritte verteilen, muss das
|
||||||
try { pHost = new URL(pageUrl).hostname.replace(/^www\./, ''); } catch { return false; }
|
// Passwort den Seitenwechsel überdauern. Es bleibt dafür ausschließlich im
|
||||||
return String(entryUrl).split('\n').some(line => {
|
// Speicher des Service Workers – niemals in `chrome.storage`, das auf die
|
||||||
const raw = line.trim();
|
// Festplatte geschrieben wird. Je Tab ein Auftrag, mit harter Verfallszeit.
|
||||||
if (!raw) return false;
|
const PENDING_FILL_TTL = 30 * 1000;
|
||||||
try {
|
const pendingFills = new Map(); // tabId -> { id, pw, user, ts }
|
||||||
const eu = raw.includes('://') ? raw : 'https://' + raw;
|
|
||||||
let eHost = new URL(eu).hostname.replace(/^www\./, '');
|
function setPendingFill(tabId, data) {
|
||||||
if (eHost.startsWith('*.')) eHost = eHost.slice(2);
|
if (tabId == null) return;
|
||||||
return pHost === eHost || pHost.endsWith('.' + eHost);
|
pendingFills.set(tabId, Object.assign({ ts: Date.now() }, data));
|
||||||
} catch { return false; }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
function takePendingFill(tabId) {
|
||||||
|
if (tabId == null) return null;
|
||||||
|
const p = pendingFills.get(tabId);
|
||||||
|
if (!p) return null;
|
||||||
|
pendingFills.delete(tabId);
|
||||||
|
return (Date.now() - p.ts > PENDING_FILL_TTL) ? null : p;
|
||||||
|
}
|
||||||
|
function clearPendingFills() { pendingFills.clear(); }
|
||||||
|
chrome.tabs.onRemoved.addListener(tabId => pendingFills.delete(tabId));
|
||||||
|
|
||||||
// ── Zwischenablage automatisch leeren (Offscreen) ───────────────────────────
|
// ── Zwischenablage automatisch leeren (Offscreen) ───────────────────────────
|
||||||
async function scheduleClipClear(text) {
|
async function scheduleClipClear(text) {
|
||||||
|
|
@ -211,19 +285,22 @@ async function scheduleClipClear(text) {
|
||||||
pendingClip = text || '';
|
pendingClip = text || '';
|
||||||
chrome.alarms.create('clipClear', { delayInMinutes: 0.5 });
|
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 {
|
try {
|
||||||
if (!chrome.offscreen) return;
|
if (!chrome.offscreen) return;
|
||||||
const has = chrome.offscreen.hasDocument ? await chrome.offscreen.hasDocument() : false;
|
const has = chrome.offscreen.hasDocument ? await chrome.offscreen.hasDocument() : false;
|
||||||
if (!has) {
|
if (!has) {
|
||||||
await chrome.offscreen.createDocument({
|
await chrome.offscreen.createDocument({
|
||||||
url: 'offscreen.html', reasons: ['CLIPBOARD'],
|
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 */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
async function clearClipboard() { await writeClipboard(''); }
|
||||||
|
|
||||||
// ── Status prüfen (Bearer) ──────────────────────────────────────────────────
|
// ── Status prüfen (Bearer) ──────────────────────────────────────────────────
|
||||||
async function checkStatus() {
|
async function checkStatus() {
|
||||||
|
|
@ -247,11 +324,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
}
|
}
|
||||||
if (msg.type === 'GET_MATCHING_ENTRIES') {
|
if (msg.type === 'GET_MATCHING_ENTRIES') {
|
||||||
fetchEntries().then(r => {
|
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 });
|
sendResponse({ entries: matched, locked: r.locked });
|
||||||
});
|
});
|
||||||
return true;
|
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_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_TOTP') { fetchTotp(msg.id).then(result => sendResponse(result)); return true; }
|
||||||
if (msg.type === 'GET_FAVICON') { fetchFavicon(msg.id).then(dataUrl => sendResponse({ dataUrl })); 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 === 'DO_UNLOCK') { doUnlock(msg.pin || '').then(sendResponse); return true; }
|
||||||
if (msg.type === 'LOCK_NOW') { clearUnlocked().then(() => sendResponse({ ok: true })); 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 === 'SCHEDULE_CLIP_CLEAR') { scheduleClipClear(msg.text || ''); sendResponse({ ok: true }); return true; }
|
||||||
if (msg.type === 'CHECK_STATUS') { checkStatus().then(sendResponse); 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; }
|
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.
|
// Cache alle 5 Minuten leeren; Zwischenablage-Clear nach Timeout.
|
||||||
chrome.alarms.create('clearCache', { periodInMinutes: 5 });
|
chrome.alarms.create('clearCache', { periodInMinutes: 5 });
|
||||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ const DROPDOWN_ID = '__vault_dropdown__';
|
||||||
let appLabel = 'Vault';
|
let appLabel = 'Vault';
|
||||||
let currentField = null;
|
let currentField = null;
|
||||||
let showGen = 0;
|
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 ────────────────────────────────────────────────────────
|
// ── 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;
|
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, '<').replace(/>/g, '>'); }
|
function esc(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
function vaultHue(s) { s = String(s || '?'); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }
|
function 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) {
|
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 */ }
|
try { chrome.runtime.sendMessage({ type: 'SCHEDULE_CLIP_CLEAR', text: text }); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
function attr(el, n) { try { return el.getAttribute(n) || ''; } catch { return ''; } }
|
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);
|
return RE_USER.test(s) && !RE_USER_NEG.test(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Felder, die eine eigene Vorschlagsliste unter sich aufklappen (ARIA-Combobox,
|
||||||
|
// <input list>). Dort verdeckt unser Dropdown die Treffer der Seite.
|
||||||
|
function hasOwnSuggestionList(el) {
|
||||||
|
if (!el) return false;
|
||||||
|
if (attr(el, 'list')) return true; // datalist
|
||||||
|
const isCombo = lc(attr(el, 'role')) === 'combobox'
|
||||||
|
|| !!(el.closest && el.closest('[role="combobox"]'));
|
||||||
|
if (!isCombo) return false;
|
||||||
|
const auto = lc(attr(el, 'aria-autocomplete'));
|
||||||
|
if (auto === 'list' || auto === 'both') return true;
|
||||||
|
// Verweist die Combobox auf ein eigenes Listenelement bzw. meldet ihren
|
||||||
|
// Auf-/Zuklapp-Zustand, hat sie eine eigene Trefferliste.
|
||||||
|
return !!(attr(el, 'aria-controls') || attr(el, 'aria-owns') || attr(el, 'aria-expanded'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vom Seitenautor ausdrücklich als Login-Feld ausgezeichnet – wiegt schwerer als
|
||||||
|
// die Combobox-Erkennung, damit echte Anmeldeformulare weiter Vorschläge bekommen.
|
||||||
|
function isDeclaredLoginField(el) {
|
||||||
|
const a = ac(el);
|
||||||
|
return a.includes('username') || a.includes('password') || a.includes('one-time-code');
|
||||||
|
}
|
||||||
|
|
||||||
function isLoginField(el) { return isPasswordField(el) || isUsernameField(el) || isOtpField(el); }
|
function isLoginField(el) { return isPasswordField(el) || isUsernameField(el) || isOtpField(el); }
|
||||||
function fieldKind(el) {
|
function fieldKind(el) {
|
||||||
if (isPasswordField(el)) return 'password';
|
if (isPasswordField(el)) return 'password';
|
||||||
|
|
@ -178,31 +207,12 @@ function findOtpFields(ref) {
|
||||||
return collectInputs(scopeOf(ref)).filter(el => isVisible(el) && isOtpField(el));
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Events ──────────────────────────────────────────────────────────────────
|
||||||
function init() {
|
function init() {
|
||||||
document.addEventListener('focusin', onFocusIn, true);
|
document.addEventListener('focusin', onFocusIn, true);
|
||||||
document.addEventListener('focusout', onFocusOut, true);
|
document.addEventListener('focusout', onFocusOut, true);
|
||||||
document.addEventListener('pointerdown', onPointerDown, true);
|
document.addEventListener('pointerdown', onPointerDown, true);
|
||||||
|
document.addEventListener('input', onInput, true);
|
||||||
document.addEventListener('keydown', onKeyDown, true);
|
document.addEventListener('keydown', onKeyDown, true);
|
||||||
document.addEventListener('click', onDocClick, true);
|
document.addEventListener('click', onDocClick, true);
|
||||||
window.addEventListener('scroll', repositionDrop, true);
|
window.addEventListener('scroll', repositionDrop, true);
|
||||||
|
|
@ -227,27 +237,44 @@ function fillFromPopup(msg) {
|
||||||
|
|
||||||
if (userField && msg.username) setFieldValue(userField, msg.username);
|
if (userField && msg.username) setFieldValue(userField, msg.username);
|
||||||
if (passField && msg.password) setFieldValue(passField, msg.password);
|
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) {
|
if (msg.has_totp && msg.id != null) {
|
||||||
const otps = findOtpFields(passField || userField || document.body);
|
armTotp(msg.id);
|
||||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: msg.id }, t => {
|
deliverTotp(msg.id, findOtpFields(passField || userField || document.body));
|
||||||
if (!t || !t.code) return;
|
|
||||||
if (otps.length) distributeOtp(otps, t.code);
|
|
||||||
vaultClipCopy(t.code);
|
|
||||||
showTotpNotification(t.code, t.remaining);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function onPointerDown(e) {
|
||||||
const drop = document.getElementById(DROPDOWN_ID);
|
const drop = document.getElementById(DROPDOWN_ID);
|
||||||
if (drop && drop.contains(e.target)) return;
|
if (drop && drop.contains(e.target)) return;
|
||||||
maybeShow(e.target);
|
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) {
|
function maybeShow(el) {
|
||||||
if (!isLoginField(el) || !isVisible(el)) return;
|
if (!isLoginField(el) || !isVisible(el)) return;
|
||||||
|
// Eigene Vorschlagsliste der Seite hat Vorrang.
|
||||||
|
if (hasOwnSuggestionList(el) && !isDeclaredLoginField(el)) return;
|
||||||
|
if (isTypingSuppressed(el)) return;
|
||||||
currentField = el;
|
currentField = el;
|
||||||
showSuggestions(el);
|
showSuggestions(el);
|
||||||
}
|
}
|
||||||
|
|
@ -290,7 +317,7 @@ function showSuggestions(field) {
|
||||||
const mode = fieldKind(field) === 'otp' ? 'otp' : 'login';
|
const mode = fieldKind(field) === 'otp' ? 'otp' : 'login';
|
||||||
chrome.runtime.sendMessage({ type: 'GET_MATCHING_ENTRIES', url: location.href }, resp => {
|
chrome.runtime.sendMessage({ type: 'GET_MATCHING_ENTRIES', url: location.href }, resp => {
|
||||||
if (gen !== showGen) return;
|
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 (mode === 'otp') entries = entries.filter(e => e.has_totp);
|
||||||
if (!entries.length) { hideDrop(); return; }
|
if (!entries.length) { hideDrop(); return; }
|
||||||
if (document.contains(field) && isVisible(field)) renderDrop(field, entries, mode);
|
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 (userField && entry.username) setFieldValue(userField, entry.username);
|
||||||
if (passField) setFieldValue(passField, pw);
|
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) {
|
if (entry.has_totp) {
|
||||||
const otps = findOtpFields(passField || userField || focused);
|
// Der 2FA-Schritt folgt oft erst nach dem Absenden – ggf. auf der nächsten
|
||||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entry.id }, t => {
|
// Seite. Deshalb den Eintrag vormerken, damit dort ein frischer Code
|
||||||
if (!t || !t.code) return;
|
// bereitgestellt werden kann.
|
||||||
if (otps.length) distributeOtp(otps, t.code);
|
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);
|
vaultClipCopy(t.code);
|
||||||
showTotpNotification(t.code, t.remaining);
|
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) {
|
function fillOtp(field, entry) {
|
||||||
|
|
@ -479,6 +555,7 @@ function distributeOtp(fields, code) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFieldValue(field, value) {
|
function setFieldValue(field, value) {
|
||||||
|
fillingInProgress = true;
|
||||||
try {
|
try {
|
||||||
field.focus({ preventScroll: true });
|
field.focus({ preventScroll: true });
|
||||||
const proto = (typeof HTMLTextAreaElement !== 'undefined' && field instanceof HTMLTextAreaElement)
|
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 KeyboardEvent('keyup', { bubbles: true }));
|
||||||
field.dispatchEvent(new Event('blur', { bubbles: true }));
|
field.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||||
} catch {}
|
} catch {}
|
||||||
|
fillingInProgress = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mehrstufiger Login: Passwort/User nach dem Erscheinen befüllen ───────────
|
// ── Mehrstufiger Login: Passwort/2FA nach dem Erscheinen bedienen ────────────
|
||||||
const _obs = new MutationObserver(() => {
|
// Der Scan ist entkoppelt vom Mutations-Takt: eine Seite kann pro Sekunde
|
||||||
chrome.storage.local.get(['__pendingFill'], result => {
|
// hunderte Mutationen erzeugen, die Feldsuche läuft davon unabhängig gedrosselt.
|
||||||
const p = result.__pendingFill;
|
const SCAN_DEBOUNCE_MS = 250;
|
||||||
if (!p || Date.now() - p.ts > 30000) return;
|
let scanTimer = null;
|
||||||
const pw = collectInputs(document).filter(f => isVisible(f) && isPasswordField(f));
|
|
||||||
if (!pw.length) return;
|
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));
|
pw.forEach(f => setFieldValue(f, p.pw));
|
||||||
if (p.user) {
|
if (p.user) {
|
||||||
const uf = findUsernameField(pw[0]);
|
const uf = findUsernameField(pw[0]);
|
||||||
if (uf && !uf.value) setFieldValue(uf, p.user);
|
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 {}
|
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) ────────────────────────────────────
|
// ── TOTP-Benachrichtigung (unten rechts) ────────────────────────────────────
|
||||||
|
const TOTP_PERIOD = 30; // Sekunden pro Code (RFC 6238, Serverseite nutzt denselben Wert)
|
||||||
|
|
||||||
function showTotpNotification(code, remaining) {
|
function showTotpNotification(code, remaining) {
|
||||||
const ID = '__vault_totp_notif__';
|
const ID = '__vault_totp_notif__';
|
||||||
const old = document.getElementById(ID);
|
const old = document.getElementById(ID);
|
||||||
|
|
@ -531,18 +629,20 @@ function showTotpNotification(code, remaining) {
|
||||||
+ '<span style="color:#adb5bd;font-size:10px;flex:1;">2FA-Code kopiert</span></div>'
|
+ '<span style="color:#adb5bd;font-size:10px;flex:1;">2FA-Code kopiert</span></div>'
|
||||||
+ '<div style="font-family:monospace;font-size:18px;font-weight:700;letter-spacing:.12em;">' + esc(formatted) + '</div>'
|
+ '<div style="font-family:monospace;font-size:18px;font-weight:700;letter-spacing:.12em;">' + esc(formatted) + '</div>'
|
||||||
+ '<div style="display:flex;align-items:center;gap:7px;"><div style="flex:1;height:3px;background:rgba(255,255,255,.15);border-radius:2px;overflow:hidden;">'
|
+ '<div style="display:flex;align-items:center;gap:7px;"><div style="flex:1;height:3px;background:rgba(255,255,255,.15);border-radius:2px;overflow:hidden;">'
|
||||||
+ '<div id="__vault_totp_bar" style="height:100%;background:#28a745;width:' + (remaining / 30 * 100) + '%;transition:width 1s linear;"></div></div>'
|
+ '<div id="__vault_totp_bar" style="height:100%;background:#28a745;width:' + Math.min(100, remaining / TOTP_PERIOD * 100) + '%;transition:width 1s linear;"></div></div>'
|
||||||
+ '<span id="__vault_totp_t" style="font-size:10px;color:#adb5bd;min-width:22px;text-align:right;">' + remaining + 's</span></div>';
|
+ '<span id="__vault_totp_t" style="font-size:10px;color:#adb5bd;min-width:22px;text-align:right;">' + remaining + 's</span></div>';
|
||||||
document.documentElement.appendChild(notif);
|
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(() => {
|
const iv = setInterval(() => {
|
||||||
secs--;
|
const secs = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
||||||
const t = document.getElementById('__vault_totp_t');
|
const t = document.getElementById('__vault_totp_t');
|
||||||
const bar = document.getElementById('__vault_totp_bar');
|
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';
|
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);
|
}, 1000);
|
||||||
notif.addEventListener('click', () => { clearInterval(iv); notif.remove(); });
|
notif.addEventListener('click', () => { clearInterval(iv); notif.remove(); });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "OpenNIT Vault",
|
"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.",
|
"description": "OpenNIT Vault – Passwort-Manager mit Autofill für Benutzer-, Passwort- und 2FA-Felder direkt im Browser.",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"activeTab",
|
|
||||||
"scripting",
|
|
||||||
"alarms",
|
"alarms",
|
||||||
"offscreen",
|
"offscreen",
|
||||||
"identity"
|
"identity"
|
||||||
|
|
@ -31,6 +29,7 @@
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
],
|
],
|
||||||
"js": [
|
"js": [
|
||||||
|
"urlmatch.js",
|
||||||
"content.js"
|
"content.js"
|
||||||
],
|
],
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
|
|
|
||||||
|
|
@ -229,29 +229,13 @@ label.control-label {
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px;"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px;"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||||
Mit OpenNIT anmelden
|
Mit OpenNIT anmelden
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-default" id="btnSave">URL speichern</button>
|
||||||
<button class="btn btn-default" id="btnLogout" style="display:none;">Abmelden</button>
|
<button class="btn btn-default" id="btnLogout" style="display:none;">Abmelden</button>
|
||||||
<span id="ssoMsg"></span>
|
<span id="ssoMsg"></span>
|
||||||
</div>
|
|
||||||
<span class="form-text">Empfohlen: Anmeldung wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert.</span>
|
|
||||||
<div id="statusMsg"></div>
|
|
||||||
|
|
||||||
<!-- Erweitert: manueller Token -->
|
|
||||||
<details style="margin-top:1rem;">
|
|
||||||
<summary style="cursor:pointer;font-size:.82rem;color:#6c757d;font-weight:600;">Erweitert: manueller Token</summary>
|
|
||||||
<div style="margin-top:.75rem;">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label" for="apiToken">API-Token</label>
|
|
||||||
<input type="password" class="form-control" id="apiToken"
|
|
||||||
placeholder="Token aus dem Vault einfügen…">
|
|
||||||
<span class="form-text">Alternative ohne SSO (z. B. Kiosk/Headless): Vault → „Extension“ → Token generieren.</span>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-default btn-sm" id="btnSave">Token speichern</button>
|
|
||||||
<button class="btn btn-default btn-sm" id="btnTest">Verbindung testen</button>
|
|
||||||
<span id="savedMsg"></span>
|
<span id="savedMsg"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span class="form-text">Die Anmeldung erfolgt wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert und lässt sich jederzeit im Vault widerrufen.</span>
|
||||||
</details>
|
<div id="statusMsg"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -275,6 +259,10 @@ label.control-label {
|
||||||
<label class="control-label"><input type="checkbox" id="clipClear" checked> Zwischenablage nach 30 Sekunden leeren</label>
|
<label class="control-label"><input type="checkbox" id="clipClear" checked> Zwischenablage nach 30 Sekunden leeren</label>
|
||||||
<span class="form-text">Kopierte Passwörter und 2FA-Codes werden nach kurzer Zeit automatisch aus der Zwischenablage entfernt.</span>
|
<span class="form-text">Kopierte Passwörter und 2FA-Codes werden nach kurzer Zeit automatisch aus der Zwischenablage entfernt.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="control-label"><input type="checkbox" id="totpAutoCopy" checked> 2FA-Code beim Anmelden bereitlegen</label>
|
||||||
|
<span class="form-text">Nach dem Ausfüllen eines Eintrags mit 2FA wird beim nächsten 2FA-Feld – auch auf einer Folgeseite – automatisch ein frischer Code in die Zwischenablage gelegt. Einfügen genügt dann mit <strong>Strg + V</strong>.</span>
|
||||||
|
</div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-primary btn-sm" id="btnSaveSec">Speichern</button>
|
<button class="btn btn-primary btn-sm" id="btnSaveSec">Speichern</button>
|
||||||
<span id="savedSecMsg"></span>
|
<span id="savedSecMsg"></span>
|
||||||
|
|
@ -289,9 +277,9 @@ label.control-label {
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<ul class="steps-list">
|
<ul class="steps-list">
|
||||||
<li><span class="step-badge">1</span><span>Öffnen Sie den <strong>Passwort-Vault</strong> in Ihrer OpenNIT-Anwendung.</span></li>
|
<li><span class="step-badge">1</span><span>Tragen Sie oben die <strong>Server-URL</strong> Ihrer OpenNIT-Instanz ein (nur <strong>https://</strong>).</span></li>
|
||||||
<li><span class="step-badge">2</span><span>Klicken Sie auf <strong>„Extension“</strong> in der Toolbar und generieren Sie einen API-Token.</span></li>
|
<li><span class="step-badge">2</span><span>Klicken Sie auf <strong>„Mit OpenNIT anmelden“</strong> und melden Sie sich wie gewohnt an.</span></li>
|
||||||
<li><span class="step-badge">3</span><span>Fügen Sie den Token oben in das Token-Feld ein und klicken Sie auf <strong>Speichern</strong>.</span></li>
|
<li><span class="step-badge">3</span><span>Bestätigen Sie den Zugriff auf den <strong>Passwort-Vault</strong> – die Erweiterung ist damit verbunden.</span></li>
|
||||||
<li><span class="step-badge">4</span><span>Fokussieren Sie ein Login-Feld auf einer Website – Vault-Vorschläge erscheinen automatisch.</span></li>
|
<li><span class="step-badge">4</span><span>Fokussieren Sie ein Login-Feld auf einer Website – Vault-Vorschläge erscheinen automatisch.</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,22 @@
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
// Gespeicherte Werte laden (überschreibt das vorausgefüllte Feld nur wenn bereits gespeichert)
|
// 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.serverUrl) $('serverUrl').value = cfg.serverUrl;
|
||||||
if (cfg.apiToken) $('apiToken').value = '••••••••';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sicherheits-Einstellungen laden
|
// Sicherheits-Einstellungen laden
|
||||||
chrome.storage.local.get(['lockDuration', 'clipClear'], cfg => {
|
chrome.storage.local.get(['lockDuration', 'clipClear', 'totpAutoCopy'], cfg => {
|
||||||
$('lockDuration').value = (cfg.lockDuration && cfg.lockDuration !== 'off') ? cfg.lockDuration : '15';
|
$('lockDuration').value = (cfg.lockDuration && cfg.lockDuration !== 'off') ? cfg.lockDuration : '15';
|
||||||
$('clipClear').checked = cfg.clipClear !== false; // Standard: an
|
$('clipClear').checked = cfg.clipClear !== false; // Standard: an
|
||||||
|
$('totpAutoCopy').checked = cfg.totpAutoCopy !== false; // Standard: an
|
||||||
});
|
});
|
||||||
|
|
||||||
$('btnSaveSec').addEventListener('click', () => {
|
$('btnSaveSec').addEventListener('click', () => {
|
||||||
chrome.storage.local.set({
|
chrome.storage.local.set({
|
||||||
lockDuration: $('lockDuration').value,
|
lockDuration: $('lockDuration').value,
|
||||||
clipClear: $('clipClear').checked,
|
clipClear: $('clipClear').checked,
|
||||||
|
totpAutoCopy: $('totpAutoCopy').checked,
|
||||||
}, () => {
|
}, () => {
|
||||||
chrome.runtime.sendMessage({ type: 'LOCK_NOW' });
|
chrome.runtime.sendMessage({ type: 'LOCK_NOW' });
|
||||||
$('savedSecMsg').innerHTML = '<span class="save-ok">✓ Gespeichert</span>';
|
$('savedSecMsg').innerHTML = '<span class="save-ok">✓ Gespeichert</span>';
|
||||||
|
|
@ -25,27 +26,8 @@ $('btnSaveSec').addEventListener('click', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// App-Name und Verbindungsstatus laden (falls Token bereits gesetzt)
|
// HTTPS erzwingen (außer localhost) – sonst gingen Zugangsdaten und Passwörter
|
||||||
chrome.storage.local.get(['serverUrl', 'apiToken'], async cfg => {
|
// im Klartext über die Leitung.
|
||||||
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) {
|
function isSecureServerUrl(url) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
|
|
@ -57,57 +39,19 @@ function isSecureServerUrl(url) {
|
||||||
|
|
||||||
$('btnSave').addEventListener('click', () => {
|
$('btnSave').addEventListener('click', () => {
|
||||||
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
||||||
const token = $('apiToken').value.trim();
|
|
||||||
if (!url) { showStatus('Server-URL darf nicht leer sein.', false); return; }
|
if (!url) { showStatus('Server-URL darf nicht leer sein.', false); return; }
|
||||||
if (!isSecureServerUrl(url)) {
|
if (!isSecureServerUrl(url)) {
|
||||||
showStatus('Bitte eine <strong>https://</strong>-Adresse verwenden (nur localhost darf http:// sein). Sonst würden Token und Passwörter unverschlüsselt übertragen.', false);
|
showStatus('Bitte eine <strong>https://</strong>-Adresse verwenden (nur localhost darf http:// sein). Sonst würden Zugangsdaten und Passwörter unverschlüsselt übertragen.', false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = { serverUrl: url };
|
chrome.storage.local.set({ serverUrl: url }, () => {
|
||||||
if (token && !token.startsWith('•')) data.apiToken = token;
|
|
||||||
|
|
||||||
chrome.storage.local.set(data, () => {
|
|
||||||
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
||||||
$('savedMsg').innerHTML = '<span class="save-ok">✓ Gespeichert</span>';
|
$('savedMsg').innerHTML = '<span class="save-ok">✓ Gespeichert</span>';
|
||||||
setTimeout(() => { $('savedMsg').textContent = ''; }, 2000);
|
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 = '<span class="fa-spin-sm"></span> 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 <strong>${esc(data.user)}</strong>`, 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) ────────────────────
|
// ── SSO-Anmeldung (OAuth 2.0 + PKCE via chrome.identity) ────────────────────
|
||||||
function b64url(bytes) {
|
function b64url(bytes) {
|
||||||
let s = btoa(String.fromCharCode.apply(null, new Uint8Array(bytes)));
|
let s = btoa(String.fromCharCode.apply(null, new Uint8Array(bytes)));
|
||||||
|
|
@ -168,7 +112,6 @@ async function loginWithSso() {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok || !data.access_token) { ssoMsg('Token konnte nicht ausgestellt werden.', false); return; }
|
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 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 });
|
await ssoSet('session', { accessToken: data.access_token, accessExpiresAt: Date.now() + (data.expires_in || 0) * 1000 });
|
||||||
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
||||||
ssoMsg('Angemeldet.', true);
|
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() {
|
function loadConnStatus() {
|
||||||
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, data => {
|
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, data => {
|
||||||
if (data && data.ok) {
|
if (data && data.ok) {
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,14 @@ body {
|
||||||
.ne-pass-row .panel-input { flex: 1; }
|
.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 { 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; }
|
.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; }
|
.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 ────────────────────────────────────────── */
|
/* ── Lock Screen ────────────────────────────────────────── */
|
||||||
|
|
@ -204,7 +212,7 @@ body {
|
||||||
<!-- New Entry Panel (initially hidden) -->
|
<!-- New Entry Panel (initially hidden) -->
|
||||||
<div id="newEntryPanel">
|
<div id="newEntryPanel">
|
||||||
<div class="panel-hd">
|
<div class="panel-hd">
|
||||||
<span class="panel-hd-title">Neuen Eintrag anlegen</span>
|
<span class="panel-hd-title" id="panelTitle">Neuen Eintrag anlegen</span>
|
||||||
<button class="panel-close" id="btnCloseNew" title="Schließen">✕</button>
|
<button class="panel-close" id="btnCloseNew" title="Schließen">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
|
|
@ -219,6 +227,19 @@ body {
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="gen-opts">
|
||||||
|
<label class="gen-opt" for="genLen">Länge
|
||||||
|
<select class="gen-select" id="genLen">
|
||||||
|
<option>12</option>
|
||||||
|
<option>16</option>
|
||||||
|
<option selected>20</option>
|
||||||
|
<option>24</option>
|
||||||
|
<option>32</option>
|
||||||
|
<option>48</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="gen-opt"><input type="checkbox" id="genSymbols" checked> Sonderzeichen</label>
|
||||||
|
</div>
|
||||||
<input class="panel-input" id="neUrl" type="url" placeholder="URL (https://…)" autocomplete="off">
|
<input class="panel-input" id="neUrl" type="url" placeholder="URL (https://…)" autocomplete="off">
|
||||||
<textarea class="panel-input" id="neNotes" placeholder="Notizen" rows="2" style="resize:vertical;font-family:inherit;"></textarea>
|
<textarea class="panel-input" id="neNotes" placeholder="Notizen" rows="2" style="resize:vertical;font-family:inherit;"></textarea>
|
||||||
<button class="panel-save" id="btnSaveNew">Speichern</button>
|
<button class="panel-save" id="btnSaveNew">Speichern</button>
|
||||||
|
|
@ -248,9 +269,6 @@ body {
|
||||||
<div class="field-lbl">Benutzername</div>
|
<div class="field-lbl">Benutzername</div>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<span class="field-val" id="detailUser"></span>
|
<span class="field-val" id="detailUser"></span>
|
||||||
<button class="field-btn" id="btnRevealUser" title="Anzeigen/Verbergen">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="field-btn" id="btnCopyUser" title="Kopieren">
|
<button class="field-btn" id="btnCopyUser" title="Kopieren">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -298,6 +316,16 @@ body {
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||||
Auf dieser Seite ausfüllen
|
Auf dieser Seite ausfüllen
|
||||||
</button>
|
</button>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="detail-action" id="btnDetailEdit">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button class="detail-action danger" id="btnDetailDelete">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -342,6 +370,7 @@ body {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="urlmatch.js"></script>
|
||||||
<script src="popup.js"></script>
|
<script src="popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -5,6 +5,7 @@ let pageMatches = [];
|
||||||
let entryIndex = {}; // id -> entry
|
let entryIndex = {}; // id -> entry
|
||||||
let detailState = null; // aktiver Eintrag im Detail-Panel
|
let detailState = null; // aktiver Eintrag im Detail-Panel
|
||||||
let selIndex = -1; // Tastatur-Auswahl in der Liste
|
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); }
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
|
@ -28,13 +29,14 @@ async function init() {
|
||||||
// Detail-Panel
|
// Detail-Panel
|
||||||
$('btnDetailBack').addEventListener('click', closeDetail);
|
$('btnDetailBack').addEventListener('click', closeDetail);
|
||||||
$('btnDetailClose').addEventListener('click', closeDetail);
|
$('btnDetailClose').addEventListener('click', closeDetail);
|
||||||
$('btnRevealUser').addEventListener('click', toggleRevealUser);
|
|
||||||
$('btnCopyUser').addEventListener('click', () => copySecret($('detailUser').dataset.value || '', 'Benutzername kopiert'));
|
$('btnCopyUser').addEventListener('click', () => copySecret($('detailUser').dataset.value || '', 'Benutzername kopiert'));
|
||||||
$('btnRevealPass').addEventListener('click', toggleRevealPass);
|
$('btnRevealPass').addEventListener('click', toggleRevealPass);
|
||||||
$('btnCopyPass').addEventListener('click', copyDetailPassword);
|
$('btnCopyPass').addEventListener('click', copyDetailPassword);
|
||||||
$('btnCopyTotp').addEventListener('click', () => { const c = $('detailTotp').dataset.code || ''; if (c) copySecret(c, 'TOTP kopiert'); });
|
$('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);
|
$('btnDetailFill').addEventListener('click', fillActiveTab);
|
||||||
|
$('btnDetailEdit').addEventListener('click', openEditPanel);
|
||||||
|
$('btnDetailDelete').addEventListener('click', deleteCurrentEntry);
|
||||||
|
|
||||||
// Lock-Screen
|
// Lock-Screen
|
||||||
$('lockSubmit').addEventListener('click', submitPin);
|
$('lockSubmit').addEventListener('click', submitPin);
|
||||||
|
|
@ -211,6 +213,9 @@ function setSel(items, idx) {
|
||||||
|
|
||||||
// ── New Entry ─────────────────────────────────────────────────────────────
|
// ── New Entry ─────────────────────────────────────────────────────────────
|
||||||
function openNewPanel() {
|
function openNewPanel() {
|
||||||
|
editingId = null;
|
||||||
|
$('panelTitle').textContent = 'Neuen Eintrag anlegen';
|
||||||
|
$('nePassword').placeholder = 'Passwort';
|
||||||
$('listWrap').style.display = 'none';
|
$('listWrap').style.display = 'none';
|
||||||
$('search').closest('.search-wrap').style.display = 'none';
|
$('search').closest('.search-wrap').style.display = 'none';
|
||||||
$('detailPanel').style.display = 'none';
|
$('detailPanel').style.display = 'none';
|
||||||
|
|
@ -232,74 +237,126 @@ function openNewPanel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeNewPanel() {
|
function closeNewPanel() {
|
||||||
|
editingId = null;
|
||||||
$('newEntryPanel').style.display = 'none';
|
$('newEntryPanel').style.display = 'none';
|
||||||
$('listWrap').style.display = '';
|
$('listWrap').style.display = '';
|
||||||
$('search').closest('.search-wrap').style.display = '';
|
$('search').closest('.search-wrap').style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function generatePassword() {
|
// Gleichverteilte Zufallszahl aus [0, max) – verwirft die Werte des obersten,
|
||||||
const len = 20;
|
// unvollständigen Blocks, damit kein Rest-Modulo einzelne Zeichen bevorzugt.
|
||||||
const sets = [
|
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',
|
'abcdefghijkmnopqrstuvwxyz',
|
||||||
'ABCDEFGHJKLMNPQRSTUVWXYZ',
|
'ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||||
'23456789',
|
'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('');
|
const all = sets.join('');
|
||||||
const buf = new Uint32Array(len);
|
|
||||||
crypto.getRandomValues(buf);
|
// Je Satz ein Zeichen garantieren, den Rest frei ziehen …
|
||||||
let out = [];
|
const out = sets.map(s => s[randomBelow(s.length)]);
|
||||||
// Mindestens ein Zeichen je Set
|
while (out.length < len) out.push(all[randomBelow(all.length)]);
|
||||||
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]);
|
// … und danach mischen, damit die garantierten Zeichen nicht vorne stehen.
|
||||||
// mischen
|
// Der Zufall dafür wird frisch gezogen und nicht aus der Zeichenwahl wiederverwendet.
|
||||||
for (let i = out.length - 1; i > 0; i--) {
|
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]];
|
[out[i], out[j]] = [out[j], out[i]];
|
||||||
}
|
}
|
||||||
|
|
||||||
$('nePassword').value = out.join('');
|
$('nePassword').value = out.join('');
|
||||||
$('nePassword').type = 'text';
|
$('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();
|
const title = $('neTitle').value.trim();
|
||||||
if (!title) { $('newEntryMsg').textContent = 'Titel ist erforderlich.'; return; }
|
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').disabled = true;
|
||||||
$('btnSaveNew').textContent = '...';
|
$('btnSaveNew').textContent = '...';
|
||||||
$('newEntryMsg').textContent = '';
|
$('newEntryMsg').textContent = '';
|
||||||
|
|
||||||
const fd = new FormData();
|
const entry = {
|
||||||
fd.append('title', title);
|
title: title,
|
||||||
fd.append('username', $('neUsername').value.trim());
|
username: $('neUsername').value.trim(),
|
||||||
fd.append('password', $('nePassword').value);
|
password: $('nePassword').value,
|
||||||
fd.append('url', $('neUrl').value.trim());
|
url: $('neUrl').value.trim(),
|
||||||
fd.append('notes', $('neNotes').value.trim());
|
notes: $('neNotes').value.trim(),
|
||||||
|
};
|
||||||
try {
|
const msg = editingId
|
||||||
const res = await fetch(cfg.serverUrl + '/api/vault/extension/entries', {
|
? { type: 'UPDATE_ENTRY', id: editingId, entry }
|
||||||
method: 'POST',
|
: { type: 'CREATE_ENTRY', entry };
|
||||||
headers: { 'Authorization': 'Bearer ' + cfg.apiToken },
|
const wasEditing = !!editingId;
|
||||||
body: fd,
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.ok) {
|
|
||||||
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
|
||||||
closeNewPanel();
|
|
||||||
reload(true);
|
|
||||||
showToast('Eintrag gespeichert');
|
|
||||||
} else {
|
|
||||||
$('newEntryMsg').textContent = data.error || 'Fehler beim Speichern.';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
$('newEntryMsg').textContent = 'Verbindungsfehler: ' + e.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
chrome.runtime.sendMessage(msg, resp => {
|
||||||
$('btnSaveNew').disabled = false;
|
$('btnSaveNew').disabled = false;
|
||||||
$('btnSaveNew').textContent = 'Speichern';
|
$('btnSaveNew').textContent = 'Speichern';
|
||||||
|
if (resp?.ok) {
|
||||||
|
closeNewPanel();
|
||||||
|
reload(true);
|
||||||
|
showToast(wasEditing ? 'Eintrag aktualisiert' : 'Eintrag gespeichert');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resp?.locked) { showLockScreen(); return; }
|
||||||
|
$('newEntryMsg').textContent = resp?.error || 'Fehler beim Speichern.';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Liste (Klick öffnet Detailansicht) ─────────────────────────────────────
|
// ── Liste (Klick öffnet Detailansicht) ─────────────────────────────────────
|
||||||
|
|
@ -364,7 +421,7 @@ function openDetail(id) {
|
||||||
const e = entryIndex[String(id)];
|
const e = entryIndex[String(id)];
|
||||||
if (!e) return;
|
if (!e) return;
|
||||||
closeDetailTimers();
|
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';
|
$('listWrap').style.display = 'none';
|
||||||
$('search').closest('.search-wrap').style.display = 'none';
|
$('search').closest('.search-wrap').style.display = 'none';
|
||||||
|
|
@ -399,20 +456,17 @@ function openDetail(id) {
|
||||||
urlLink.style.display = 'none';
|
urlLink.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Benutzername (standardmäßig sichtbar; Auge schaltet Maskierung)
|
// Benutzername – kein Geheimnis, daher immer im Klartext.
|
||||||
const uval = e.username || '';
|
const uval = e.username || '';
|
||||||
const uEl = $('detailUser');
|
const uEl = $('detailUser');
|
||||||
uEl.dataset.value = uval;
|
uEl.dataset.value = uval;
|
||||||
detailState.revealUser = true;
|
|
||||||
if (uval) {
|
if (uval) {
|
||||||
uEl.classList.remove('empty');
|
uEl.classList.remove('empty');
|
||||||
uEl.textContent = uval;
|
uEl.textContent = uval;
|
||||||
$('btnRevealUser').style.display = '';
|
|
||||||
$('btnCopyUser').style.display = '';
|
$('btnCopyUser').style.display = '';
|
||||||
} else {
|
} else {
|
||||||
uEl.classList.add('empty');
|
uEl.classList.add('empty');
|
||||||
uEl.textContent = 'Kein Benutzername';
|
uEl.textContent = 'Kein Benutzername';
|
||||||
$('btnRevealUser').style.display = 'none';
|
|
||||||
$('btnCopyUser').style.display = 'none';
|
$('btnCopyUser').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -447,14 +501,6 @@ function closeDetail() {
|
||||||
$('search').closest('.search-wrap').style.display = '';
|
$('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) {
|
async function ensurePassword(id) {
|
||||||
if (detailState && detailState.password !== null) return detailState.password;
|
if (detailState && detailState.password !== null) return detailState.password;
|
||||||
const pw = await new Promise(resolve => {
|
const pw = await new Promise(resolve => {
|
||||||
|
|
@ -484,6 +530,12 @@ async function copyDetailPassword() {
|
||||||
else showToast('Kein Passwort');
|
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) {
|
function loadDetailTotp(id) {
|
||||||
const codeEl = $('detailTotp');
|
const codeEl = $('detailTotp');
|
||||||
const secsEl = $('detailTotpSecs');
|
const secsEl = $('detailTotpSecs');
|
||||||
|
|
@ -492,33 +544,55 @@ function loadDetailTotp(id) {
|
||||||
codeEl.dataset.code = '';
|
codeEl.dataset.code = '';
|
||||||
secsEl.textContent = '';
|
secsEl.textContent = '';
|
||||||
|
|
||||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, resp => {
|
let deadline = 0;
|
||||||
if (!detailState || detailState.id !== String(id)) return;
|
let refetching = false;
|
||||||
if (!resp?.code) { codeEl.textContent = '—'; return; }
|
|
||||||
|
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) => {
|
const apply = (code, remaining) => {
|
||||||
codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
|
codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
|
||||||
codeEl.dataset.code = code;
|
codeEl.dataset.code = code;
|
||||||
secsEl.textContent = remaining + 's';
|
// Der Server liefert die Restsekunden des laufenden Zeitfensters; daraus wird
|
||||||
if (barEl) barEl.style.width = Math.round(remaining / 30 * 100) + '%';
|
// 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);
|
apply(resp.code, resp.remaining);
|
||||||
|
|
||||||
let secs = resp.remaining;
|
|
||||||
detailState.totpInterval = setInterval(() => {
|
detailState.totpInterval = setInterval(() => {
|
||||||
secs--;
|
if (!stillOpen()) return;
|
||||||
if (secs <= 0) {
|
if (render() === 0) refetch();
|
||||||
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);
|
}, 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -560,30 +634,12 @@ async function fillActiveTab() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helfer ────────────────────────────────────────────────────────────────
|
// ── Helfer ────────────────────────────────────────────────────────────────
|
||||||
function hostOf(u) {
|
function hostOf(u) { return VaultUrl.host(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 –
|
// True, wenn eine der (mehrzeiligen) Eintrags-URLs zur Seiten-Domain passt –
|
||||||
// oder wenn im Eintrag gar keine URL hinterlegt ist (dann keine Warnung).
|
// oder wenn im Eintrag gar keine URL hinterlegt ist (dann keine Warnung).
|
||||||
function fillDomainMatches(entryUrls, pageUrl) {
|
function fillDomainMatches(entryUrls, pageUrl) { return VaultUrl.matchesOrUnset(entryUrls, pageUrl); }
|
||||||
const pageHost = hostOf(pageUrl);
|
// Alles, was aus einem Eintrag kommt, gilt als Geheimnis – Notizen enthalten in
|
||||||
const list = String(entryUrls || '').split('\n').map(s => s.trim()).filter(Boolean);
|
// der Praxis ebenso oft Wiederherstellungscodes wie das Passwortfeld selbst.
|
||||||
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) {
|
function copySecret(text, msg) {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
showToast(msg);
|
showToast(msg);
|
||||||
|
|
@ -597,6 +653,6 @@ function showToast(msg) {
|
||||||
setTimeout(() => t.classList.remove('show'), 1800);
|
setTimeout(() => t.classList.remove('show'), 1800);
|
||||||
}
|
}
|
||||||
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
function escAttr(s) { return String(s||'').replace(/"/g,'"'); }
|
function escAttr(s) { return esc(s).replace(/"/g, '"').replace(/'/g, '''); }
|
||||||
|
|
||||||
init();
|
init();
|
||||||
63
extension/urlmatch.js
Normal file
63
extension/urlmatch.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gemeinsame Zuordnung Eintrag ↔ Seite.
|
||||||
|
*
|
||||||
|
* Wird in allen drei Kontexten geladen (Service Worker via importScripts,
|
||||||
|
* Popup via <script>, Seiten via content_scripts) – damit Vorschlagsliste und
|
||||||
|
* Sicherheitswarnung dieselbe Regel anwenden.
|
||||||
|
*
|
||||||
|
* Regel: Ein Eintrag passt zu einer Seite, wenn deren Host dem hinterlegten Host
|
||||||
|
* entspricht oder eine Subdomain davon ist. Die Gegenrichtung gilt bewusst nicht –
|
||||||
|
* ein Eintrag für `vpn.firma.de` passt nicht zu `firma.de`.
|
||||||
|
*/
|
||||||
|
var VaultUrl = (function () {
|
||||||
|
/**
|
||||||
|
* Host einer Adresse in vergleichbarer Form (klein, ohne `www.`).
|
||||||
|
* @param {string} raw Adresse mit oder ohne Schema
|
||||||
|
* @return {string} Host oder '' wenn nicht bestimmbar
|
||||||
|
*/
|
||||||
|
function host(raw) {
|
||||||
|
const s = String(raw || '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
try {
|
||||||
|
return new URL(s.includes('://') ? s : 'https://' + s).hostname.replace(/^www\./, '').toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return s.replace(/^www\./, '').toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hinterlegte Adressen eines Eintrags (mehrzeilig) als Liste. */
|
||||||
|
function list(entryUrls) {
|
||||||
|
if (Array.isArray(entryUrls)) return entryUrls.map(u => String(u || '').trim()).filter(Boolean);
|
||||||
|
return String(entryUrls || '').split('\n').map(u => u.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passt einer der hinterlegten Hosts zur Seite?
|
||||||
|
* @param {string|string[]} entryUrls Adressen des Eintrags
|
||||||
|
* @param {string} pageUrl Adresse der Seite
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
function matches(entryUrls, pageUrl) {
|
||||||
|
const pageHost = host(pageUrl);
|
||||||
|
if (!pageHost) return false;
|
||||||
|
return list(entryUrls).some(raw => {
|
||||||
|
let eh = host(raw);
|
||||||
|
if (eh.startsWith('*.')) eh = eh.slice(2);
|
||||||
|
if (!eh) return false;
|
||||||
|
return pageHost === eh || pageHost.endsWith('.' + eh);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie `matches`, wertet einen Eintrag ohne hinterlegte Adresse aber als passend. */
|
||||||
|
function matchesOrUnset(entryUrls, pageUrl) {
|
||||||
|
if (!list(entryUrls).length) return true;
|
||||||
|
return matches(entryUrls, pageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { host, list, matches, matchesOrUnset };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Im Service Worker steht `self` zur Verfügung, im Popup/Content-Script `window`.
|
||||||
|
if (typeof self !== 'undefined') self.VaultUrl = VaultUrl;
|
||||||
|
|
@ -45,7 +45,7 @@ SICHERHEIT & DATENSCHUTZ
|
||||||
• Passwörter werden erst im Moment der Nutzung abgerufen und nicht dauerhaft gespeichert
|
• Passwörter werden erst im Moment der Nutzung abgerufen und nicht dauerhaft gespeichert
|
||||||
|
|
||||||
EINRICHTUNG
|
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.
|
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
|
• Passwords are fetched only at the moment of use and never stored permanently
|
||||||
|
|
||||||
SETUP
|
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.
|
OpenNIT Vault requires a running OpenNIT instance.
|
||||||
```
|
```
|
||||||
|
|
@ -93,7 +93,7 @@ Deutsch (primär), Englisch
|
||||||
|
|
||||||
## Datenschutz-Angaben (Data usage im Dashboard)
|
## 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).
|
Autofill (verlassen den Browser nicht).
|
||||||
- Nicht verkauft/weitergegeben. Nicht für Werbung/Bonität genutzt.
|
- Nicht verkauft/weitergegeben. Nicht für Werbung/Bonität genutzt.
|
||||||
- Verschlüsselte Übertragung (HTTPS).
|
- Verschlüsselte Übertragung (HTTPS).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue