Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
Claude
d1e4472241
Version 2.5.0
Changelog-Abschnitt [Unreleased] geschnitten und manifest.json von 2.4.1
auf 2.5.0 gezogen. Der Store weist einen Upload mit bereits
veroeffentlichter Versionsnummer ab, 2.4.1 ist die aktuell
veroeffentlichte Version.

Minor, weil der Stand neue Funktionen enthaelt (Eintraege bearbeiten und
loeschen, einstellbarer Generator, 2FA-Code bereitlegen) und nicht nur
Korrekturen.

Das Build-Verzeichnis dist/ gehoert nicht ins Repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1
2026-07-31 13:49:44 +00:00
Claude
5da80df416
Benutzername in der Detailansicht immer im Klartext
Das Auge neben dem Benutzernamen schaltete eine Maskierung, die
standardmaessig ohnehin aus war. Ein Benutzername ist kein Geheimnis, das
Umschalten also ohne Nutzen. Button, Handler und der zugehoerige Zustand
entfallen; der Kopieren-Button bleibt. Das Passwort bleibt maskiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1
2026-07-31 13:23:55 +00:00
Claude
65a07fbc18
Sechs Review-Punkte: Speicherort, Domain-Pruefung, Rechte, Generator, Notizen, Bearbeiten
1. Passwort mehrstufiger Logins nicht mehr auf der Platte: Der Auftrag lag
   als __pendingFill in chrome.storage.local, also im Klartext auf der
   Festplatte. Die 30-Sekunden-Pruefung verhinderte nur die Verwendung,
   nicht die Speicherung - wurde der zweite Schritt nie erreicht, blieb
   das Passwort liegen. Er liegt jetzt ausschliesslich im Speicher des
   Service Workers, je Tab, und wird beim Abholen verbraucht, nach 30 s
   verworfen, beim Sperren geleert und beim Schliessen des Tabs entfernt.
   Reste frueherer Versionen raeumt onInstalled ab.

2. Domain-Warnung: fillDomainMatches akzeptierte mit
   eh.endsWith('.' + pageHost) auch die Gegenrichtung - ein Eintrag fuer
   vpn.firma.de galt auf firma.de als passend und die Warnung blieb aus.
   Diese Klausel entfaellt.

3. scripting und activeTab werden nicht mehr angefordert; beide waren
   unbenutzt (das Content-Script laeuft ueber content_scripts, der
   Tab-Zugriff ueber host_permissions). PERMISSIONS.md begruendete
   scripting mit dem nativen Value-Setter, was nichts damit zu tun hat.

4. Passwort-Generator: buf lieferte dieselben Werte fuer Zeichenwahl und
   Mischreihenfolge, wodurch die Permutation mit dem Inhalt korrelierte.
   Beides zieht jetzt getrennt ueber randomBelow(), das den obersten,
   unvollstaendigen Block verwirft (gleichverteilt statt Rest-Modulo).
   Laenge (12-48) und Sonderzeichen sind waehlbar.

5. Notizen laufen ueber copySecret und werden damit ebenfalls aus der
   Zwischenablage entfernt; sie enthalten in der Praxis oft
   Wiederherstellungscodes. copyToClipboard entfaellt.

6. urlmatch.js buendelt die drei abweichenden matchUrl-Fassungen zu einer
   Regel, geladen in Service Worker, Popup und Seiten. escAttr escapt jetzt
   auch & < > und Apostroph, traegt also in jedem Attributkontext.
   Eintraege lassen sich im Popup bearbeiten und loeschen; beim Bearbeiten
   bedeutet ein leeres Passwortfeld unveraendert, sodass das Passwort das
   Popup nicht verlaesst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1
2026-07-31 13:17:23 +00:00
Claude
32cc4de8be
Vorschlaege weichen Seiten-Comboboxen, 2FA-Code bereitlegen
Vorschlagsliste: Bisher galt jedes E-Mail-Feld als Anmeldefeld
(isUsernameField gibt bei type=email bedingungslos true zurueck), und das
Dropdown liegt mit maximalem z-index direkt unter dem Feld. Auf Seiten
mit eigener Auswahlliste - etwa einem Benutzer-Picker - verdeckte es
deren Treffer, die dadurch nicht mehr anklickbar waren.

Zwei Regeln davor: Felder mit eigener Vorschlagsliste (ARIA-Combobox mit
aria-autocomplete/aria-controls/aria-expanded oder <input list>) bekommen
keine Vault-Vorschlaege mehr, sofern autocomplete sie nicht ausdruecklich
als Anmeldefeld ausweist. Und sobald der Nutzer selbst tippt, blendet
sich die Liste aus und bleibt es, bis das Feld wieder leer ist.

2FA-Code bereitlegen: Nach dem Ausfuellen eines Eintrags mit 2FA bleibt
dieser fuenf Minuten vorgemerkt. Taucht danach ein 2FA-Feld auf oder wird
es fokussiert - auch auf einer Folgeseite -, wird ein frischer Code
geholt und in die Zwischenablage gelegt, statt wie bisher nur einmal zum
Fuellzeitpunkt (wo er bis zum 2FA-Schritt laengst rotiert waere).
Automatisch wird ausschliesslich kopiert; ins Feld geschrieben wird ein
Code weiterhin nur bei ausdruecklicher Auswahl. Geschrieben wird zuerst
ueber die Seite und, falls diese keinen Zugriff bekommt, ueber das
Offscreen-Dokument des Hintergrunds. Abschaltbar in den Einstellungen.

Beim Sperren des Tresors werden Vormerkung und ausstehender Fuellauftrag
verworfen. Der Seiten-Scan des MutationObservers laeuft jetzt gedrosselt
statt bei jeder einzelnen Mutation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1
2026-07-31 13:05:30 +00:00
Claude
18f68d68ea
2FA-Restlaufzeit korrigiert, manuellen API-Token entfernt
Countdown des 2FA-Codes: Die Anzeige zaehlte blind im Sekundentakt
herunter und lief dadurch aus dem Takt. Am Ende eines Zeitfensters wurde
zwar ein neuer Code angefordert, der Zaehler aber nicht angehalten - bei
langsamer oder fehlschlagender Antwort lief er ins Negative und stiess
jede Sekunde eine weitere Anfrage an (inkl. Audit-Eintrag je Abruf),
waehrend die Anzeige auf dem alten Wert stehenblieb.

Die Restlaufzeit wird jetzt aus einem festen Ablaufzeitpunkt berechnet,
sodass gedrosselte oder ausgefallene Ticks sie nicht verschieben. Pro
Ablauf wird genau einmal nachgeladen; liefert der Server keinen Code
mehr (gesperrt/offline), stoppt der Timer und die Anzeige wird geleert.
Dieselbe Rechnung gilt fuer die Benachrichtigung im Seiteninhalt; die
Periode steckt dort wie im Popup in einer Konstanten statt als 30 im
Ausdruck.

Manueller API-Token: Eingabefeld, "Token speichern" und "Verbindung
testen" sind aus den Optionen entfernt; die Server-URL laesst sich
weiterhin separat speichern. getAccessToken() kennt nur noch den
SSO-Weg, und ein aus einer frueheren Version uebernommener Token wird
bei onInstalled aus chrome.storage.local geloescht. Doku und
Store-Listing entsprechend angepasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1
2026-07-31 12:53:34 +00:00
Claude
d1647e7b58
Fix: Eintrag anlegen scheiterte nach SSO-Anmeldung
Das Speichern eines neuen Eintrags im Popup las den Token direkt aus
chrome.storage.local und verlangte den manuellen apiToken. Nach einer
SSO-Anmeldung wird dieser Schluessel jedoch geloescht (nur Refresh-Token
plus kurzlebiger Access-Token in storage.session), sodass das Anlegen
immer mit "Nicht konfiguriert." abbrach.

Das Anlegen laeuft jetzt wie alle uebrigen Aufrufe ueber den
Background-Service-Worker (apiFetch): SSO-Access-Token mit automatischer
Erneuerung oder, falls gesetzt, manueller Token. Eine serverseitige
Sperre (423) fuehrt zur PIN-Abfrage statt zu einer Fehlermeldung, und der
Eintrags-Cache wird nach dem Anlegen verworfen.

Ausserdem entfaellt in den Optionen ein redundanter Statusabruf, der
ebenfalls nur den manuellen Token beruecksichtigte; loadConnStatus()
deckt beide Anmeldewege bereits ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1
2026-07-31 12:44:03 +00:00
15 changed files with 646 additions and 310 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
dist/

View file

@ -3,6 +3,61 @@
Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
Die Erweiterungsversion (`manifest.json`) ist unabhängig von der OpenNIT-Serverversion.
## [Unreleased]
## [2.5.0] - 2026-07-31
### Hinzugefügt
- **Einträge bearbeiten und löschen:** In der Detailansicht gibt es jetzt „Bearbeiten" und „Löschen".
Beim Bearbeiten bleibt das Passwortfeld leer wer es leer lässt, behält das gespeicherte Passwort;
2FA-Secret und Ordner bleiben unangetastet. Für Team-Einträge greift das Schreibrecht des Teams.
- **Passwort-Generator einstellbar:** Länge (1248) und Sonderzeichen lassen sich beim Anlegen wählen
hilfreich bei Seiten, die Sonderzeichen ablehnen.
- **2FA-Code wird für den nächsten Schritt bereitgelegt:** Nach dem Ausfüllen eines Eintrags mit 2FA
landet beim nächsten 2FA-Feld automatisch ein **frischer** Code in der Zwischenablage auch dann,
wenn die Abfrage erst auf einer Folgeseite kommt (z. B. Microsoft-Anmeldung). Einfügen genügt mit
Strg + V; der bisherige Inhalt der Zwischenablage wird dabei überschrieben und der Code nach 30
Sekunden wieder entfernt. Abschaltbar unter „Sicherheit → 2FA-Code beim Anmelden bereitlegen".
### Sicherheit
- **Passwort für mehrstufige Logins nicht mehr auf der Festplatte:** Verteilt eine Anmeldung Benutzername
und Passwort auf zwei Schritte, wurde das Passwort bisher im lokalen Speicher der Erweiterung abgelegt
und blieb dort liegen, wenn der zweite Schritt nie erreicht wurde. Es wird jetzt nur noch im Arbeits-
speicher gehalten, an den jeweiligen Tab gebunden und nach 30 Sekunden bzw. beim Sperren verworfen.
- **Warnung bei fremder Domain greift zuverlässiger:** Ein Eintrag für `vpn.firma.de` galt auf `firma.de`
fälschlich als passend, sodass die Warnung ausblieb. Zuordnung und Warnung nutzen jetzt dieselbe Regel:
nur die hinterlegte Adresse selbst und deren Unteradressen gelten als passend.
- **Weniger Berechtigungen:** `scripting` und `activeTab` werden nicht mehr angefordert beide wurden
nicht benötigt.
- **Passwort-Generator:** Der Zufall für die Mischreihenfolge wird jetzt getrennt von der Zeichenauswahl
gezogen, und die Zeichenwahl ist gleichverteilt.
- Notizen werden beim Kopieren wie andere Geheimnisse behandelt und nach 30 Sekunden aus der
Zwischenablage entfernt.
### Geändert
- **Benutzername immer sichtbar:** Das Auge zum Ausblenden des Benutzernamens in der Detailansicht ist
entfallen ein Benutzername ist kein Geheimnis. Das Passwort bleibt weiterhin maskiert.
- **Vorschläge weichen der Seite:** Sobald in ein Feld getippt wird, verschwindet die Vault-Liste
darunter erscheint typischerweise die Suche der Seite selbst. Bei Feldern, die erkennbar eine eigene
Auswahlliste öffnen (etwa Benutzer-Auswahlfelder), erscheinen gar keine Vault-Vorschläge mehr.
Ausdrücklich als Anmeldefeld ausgezeichnete Felder sind davon ausgenommen.
- **Anmeldung nur noch per SSO:** Das Feld für den manuell erzeugten API-Token samt „Verbindung testen"
ist aus den Einstellungen entfernt. Die Verbindung entsteht ausschließlich über „Mit OpenNIT anmelden".
Ein aus einer früheren Version übernommener Token wird beim Update aus dem lokalen Speicher gelöscht;
wer bisher nur damit verbunden war, meldet sich einmal per SSO an.
### Behoben
- **Restlaufzeit des 2FA-Codes wird wieder korrekt angezeigt:** Der Countdown blieb am Ende eines
Zeitfensters stehen und lief mit der Zeit aus dem Takt. Er rechnet nun gegen den tatsächlichen
Ablaufzeitpunkt und holt den neuen Code beim Wechsel genau einmal nach. Lässt sich kein neuer Code
laden (Tresor gesperrt oder Verbindung weg), zeigt die Anzeige das an, statt auf einem abgelaufenen
Wert stehen zu bleiben.
- **Neuen Eintrag anlegen funktioniert wieder nach SSO-Anmeldung:** Das Speichern eines im Popup
erzeugten Passworts brach bisher mit „Nicht konfiguriert." ab, wenn die Erweiterung über
„Mit OpenNIT anmelden" verbunden war es wurde ausschließlich der manuelle Token akzeptiert.
Das Anlegen nutzt nun denselben Zugang wie alle übrigen Aufrufe (inkl. automatischer
Token-Erneuerung). Ist der Tresor gesperrt, erscheint die PIN-Abfrage statt einer Fehlermeldung.
## [2.4.0] - 2026-07-01
### Hinzugefügt

View file

@ -17,7 +17,7 @@ technisches Client-Werkzeug ohne eigenen Server.
| Datum | Zweck | Speicherort |
|-------|-------|-------------|
| **Server-URL** | Adresse deiner OpenNIT-Instanz | lokal (`chrome.storage.local`) |
| **API-Token** | Authentifizierung gegenüber deiner Instanz | lokal (`chrome.storage.local`) |
| **Sitzungstoken** | Authentifizierung gegenüber deiner Instanz (per SSO ausgestellt) | lokal (`chrome.storage.local`) |
| **Einstellungen** (PIN-Sperrdauer, Zwischenablage-Option) | Verhalten der Erweiterung | lokal (`chrome.storage.local`) |
| **Entsperr-Status** | Merkt, ob die PIN-Sperre entsperrt ist | Sitzungsspeicher (`chrome.storage.session`, wird beim Schließen des Browsers gelöscht) |
| **Tresor-Einträge** (Titel, Benutzername, URL, Notizen, ob 2FA vorhanden) | Anzeige & Suche | flüchtig im Arbeitsspeicher (max. 5 Min zwischengespeichert) |
@ -30,7 +30,7 @@ Die Erweiterung erstellt **keine** Nutzungsprofile und protokolliert **kein** Su
- Es werden Daten **ausschließlich** an die von dir eingetragene **OpenNIT-Server-URL** übertragen
(verschlüsselt via HTTPS, sofern deine Instanz HTTPS nutzt).
- Übertragen werden: der API-Token (zur Authentifizierung), die aktuelle Seiten-Adresse (zum Finden
- Übertragen werden: der Sitzungstoken (zur Authentifizierung), die aktuelle Seiten-Adresse (zum Finden
passender Einträge), sowie beim Anlegen neuer Einträge die von dir eingegebenen Felder.
- Es erfolgt **keine** Übermittlung an Anthropic, Google (außer der Chrome-Sync deines eigenen Browsers,
falls du ihn aktiviert hast) oder sonstige Dritte.
@ -42,7 +42,7 @@ Siehe [`docs/PERMISSIONS.md`](docs/PERMISSIONS.md). Kurz:
- **Zugriff auf alle Websites** (`host_permissions: <all_urls>`): nötig, um Login-Felder auf beliebigen
Seiten zu erkennen und auf Wunsch auszufüllen. Es werden **keine** Seiteninhalte gelesen oder
übertragen, außer den zum Ausfüllen nötigen Formularfeldern und diese verlassen den Browser nicht.
- **storage**: lokale Speicherung von URL, Token und Einstellungen.
- **storage**: lokale Speicherung von URL, Sitzungstoken und Einstellungen.
- **scripting / activeTab**: Einfügen der Zugangsdaten in das aktive Tab-Formular.
- **alarms / offscreen**: automatisches Leeren der Zwischenablage nach dem Kopieren.
@ -53,7 +53,7 @@ Zwischenablage entfernt.
## 6. Speicherdauer
- Server-URL, Token und Einstellungen bleiben lokal gespeichert, bis du sie änderst oder die Erweiterung
- Server-URL, Sitzungstoken und Einstellungen bleiben lokal gespeichert, bis du sie änderst oder die Erweiterung
entfernst.
- Zwischengespeicherte Einträge werden spätestens nach 5 Minuten bzw. beim Sperren verworfen.
- Passwörter/2FA-Codes werden nicht dauerhaft gespeichert.
@ -62,7 +62,7 @@ Zwischenablage entfernt.
Da die eigentliche Datenverarbeitung in deiner OpenNIT-Instanz stattfindet, richten sich Auskunfts-,
Lösch- und Berichtigungsrechte an deren Betreiber. Lokale Daten der Erweiterung entfernst du durch
Deinstallieren der Erweiterung oder Löschen des Tokens in den Einstellungen.
Deinstallieren der Erweiterung oder „Abmelden" in den Einstellungen.
## 8. Kontakt
@ -78,7 +78,7 @@ eine erreichbare Kontaktadresse ergänzen.
**Controller.** The operator of the OpenNIT instance you connect to. The extension itself has no server.
**Data processed.** Server URL, API token and settings (stored locally); unlock state (session storage,
**Data processed.** Server URL, session tokens and settings (stored locally); unlock state (session storage,
cleared when the browser closes); vault entry metadata (title, username, URL, notes, whether 2FA exists
cached in memory for up to 5 minutes); passwords/2FA codes (fetched **only** at the moment of use, never
stored permanently); the active tab URL (to find matching entries).
@ -96,4 +96,4 @@ browser. See [`docs/PERMISSIONS.md`](docs/PERMISSIONS.md).
within 5 minutes; secrets are never stored persistently.
**Your rights.** Because processing happens in your OpenNIT instance, direct data-subject requests to its
operator. Remove local extension data by uninstalling the extension or clearing the token.
operator. Remove local extension data by uninstalling the extension or signing out in the settings.

View file

@ -22,7 +22,10 @@ deiner selbst gehosteten OpenNIT-Instanz und bietet:
- 👁️ **Detailansicht** mit Anzeigen/Kopieren von Zugangsdaten und 2FA-Code mit Countdown
- **Neue Einträge anlegen** inkl. **Passwort-Generator**
- 🔒 **PIN-Sperre** mit demselben PIN wie der Web-Tresor (Dauer frei wählbar, bis „bis Browser schließt")
- ✏️ **Anlegen, Bearbeiten und Löschen** von Einträgen direkt im Popup
- 🧹 **Zwischenablage-Auto-Clear** nach dem Kopieren von Geheimnissen
- 📋 **2FA-Code bereitgelegt:** nach dem Ausfüllen landet beim nächsten 2FA-Feld auch auf einer
Folgeseite automatisch ein frischer Code in der Zwischenablage (Strg + V genügt)
- 🖼️ **Favicons** der hinterlegten Seiten (serverseitig gecacht keine externen Aufrufe)
- 🌙 **Heller & dunkler Modus** (folgt dem System)
@ -37,19 +40,18 @@ Die Erweiterung ist ein reiner **Client** zu deiner OpenNIT-Instanz. Sie enthäl
und **keinen** Remote-Code alle Skripte sind im Paket enthalten. Kommuniziert wird ausschließlich mit dem
von dir konfigurierten OpenNIT-Server über dessen REST-API (`/api/vault/extension/...`) per **Bearer-Token**.
- Die Anmeldung erfolgt per **SSO** („Mit OpenNIT anmelden"). Die Erweiterung erhält dabei automatisch
ein kurzlebiges, rotierendes Zugriffstoken ein manuelles Erzeugen von Tokens im Web-Tresor entfällt.
- Die Anmeldung erfolgt ausschließlich per **SSO** („Mit OpenNIT anmelden"). Die Erweiterung erhält dabei
ein kurzlebiges, rotierendes Zugriffstoken ein manuelles Erzeugen von Tokens entfällt.
- Passwörter werden **serverseitig** ver-/entschlüsselt; die Erweiterung fordert das Klartext-Passwort
eines Eintrags erst **im Moment des Ausfüllens/Kopierens** an nicht beim Laden der Liste.
- Es findet **keine** Ende-zu-Ende-Entschlüsselung im Browser statt; die Erweiterung speichert keine
Passwörter dauerhaft (nur Server-URL, Token und Einstellungen in `chrome.storage`).
Passwörter dauerhaft (nur Server-URL, Sitzungstoken und Einstellungen in `chrome.storage`).
Details: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · Berechtigungen: [`docs/PERMISSIONS.md`](docs/PERMISSIONS.md)
> **Anmeldung per SSO (Standard):** „Mit OpenNIT anmelden" (OAuth 2.0 + PKCE) Anmeldung wie an OpenNIT
> (lokal + 2FA / M365 / Keycloak), automatische Token-Erneuerung mit Rotation. Ein manuell erzeugter Token
> ist nur noch als „Erweitert"-Fallback in den Erweiterungs-Einstellungen vorgesehen; OpenNIT bietet dafür
> im Frontend keine Token-Erzeugung mehr an. Konzept/Details: [`docs/SSO-PLAN.md`](docs/SSO-PLAN.md).
> **Anmeldung per SSO:** „Mit OpenNIT anmelden" (OAuth 2.0 + PKCE) Anmeldung wie an OpenNIT
> (lokal + 2FA / M365 / Keycloak), automatische Token-Erneuerung mit Rotation. Einen manuell eingetragenen
> API-Token unterstützt die Erweiterung nicht mehr.
## Installation
@ -80,9 +82,10 @@ Repository gedacht.
| Einstellung | Beschreibung |
|--------------------|--------------|
| **Server-URL** | Adresse deiner OpenNIT-Instanz, ohne abschließenden Slash (`https://…`). |
| **Anmeldung** | Per **SSO** („Mit OpenNIT anmelden"). Ein manueller API-Token ist nur als „Erweitert"-Fallback vorgesehen. |
| **Anmeldung** | Ausschließlich per **SSO** („Mit OpenNIT anmelden"). |
| **PIN-Sperre** | Aus / 5 Min / 15 Min / 1 Std / bis der Browser geschlossen wird. Nutzt den **Tresor-PIN**. |
| **Zwischenablage** | Nach 30 s automatisch leeren (Standard: an). |
| **2FA bereitlegen** | Frischen 2FA-Code beim nächsten 2FA-Feld in die Zwischenablage legen (Standard: an). |
## Build / Paketierung

View file

@ -16,7 +16,7 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht
└───────────────┘ └──────────┬───────────┘
│ CLIP_WRITE
┌────────▼─────────┐
│ offscreen.html │ (Zwischenablage leeren)
│ offscreen.html │ (Zwischenablage schreiben/leeren)
└──────────────────┘
```
@ -27,8 +27,9 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht
| `background.js` | Zentrale Logik: API-Aufrufe, 5-Minuten-Cache, **Lock-Gate**, Favicon-Cache (Data-URLs), Zwischenablage-Timer. Alle Secrets fließen hier durch. |
| `content.js` | Wird auf jeder Seite ausgeführt. Erkennt Benutzer-/Passwort-/OTP-Felder (inkl. Shadow-DOM, mehrstufige Logins, segmentierte OTP-Felder), zeigt das Vorschlags-Dropdown und füllt Felder framework-kompatibel (React/Vue/Angular). |
| `popup.html` / `popup.js` | Toolbar-Popup: Liste, Suche, Detailansicht, Anlegen + Generator, PIN-Schirm. |
| `options.html` / `options.js` | Einstellungen: Server-URL, Token, PIN-Sperrdauer, Zwischenablage. |
| `offscreen.html` / `offscreen.js` | Minimaldokument, das ausschließlich die Zwischenablage leert (MV3-konform). |
| `options.html` / `options.js` | Einstellungen: Server-URL, SSO-Anmeldung, PIN-Sperrdauer, Zwischenablage. |
| `offscreen.html` / `offscreen.js` | Minimaldokument, das ausschließlich die Zwischenablage beschreibt bzw. leert (MV3-konform). |
| `urlmatch.js` | Gemeinsame Zuordnung Eintrag ↔ Seite (`VaultUrl`), geladen in allen drei Kontexten damit Vorschlagsliste und Sicherheitswarnung dieselbe Regel anwenden. |
## Nachrichten (Auszug)
@ -38,9 +39,12 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht
| `GET_LOCK` / `DO_UNLOCK` / `LOCK_NOW` | popup → bg | PIN-Sperre abfragen/entsperren/sperren |
| `GET_ENTRIES` / `GET_MATCHING_ENTRIES` | popup/content → bg | Einträge (alle / passend zur URL) |
| `GET_PASSWORD` / `GET_TOTP` | popup/content → bg | Secret **on demand** |
| `CREATE_ENTRY` / `UPDATE_ENTRY` / `DELETE_ENTRY` | popup → bg | Eintrag anlegen / ändern / löschen |
| `SET_PENDING_FILL` / `TAKE_PENDING_FILL` | content → bg | Passwort für den zweiten Login-Schritt hinterlegen bzw. abholen (nur im Speicher, je Tab) |
| `GET_FAVICON` | popup/content → bg | Favicon als Data-URL (serverseitig gecacht) |
| `VAULT_FILL` | popup → content | Aktives Tab-Formular ausfüllen |
| `SCHEDULE_CLIP_CLEAR` | popup/content → bg | Zwischenablage-Leerung planen |
| `CLIP_WRITE` | content → bg | In die Zwischenablage schreiben, wenn die Seite selbst keinen Zugriff bekommt |
## Server-API (in OpenNIT)
@ -51,6 +55,8 @@ Alle Endpunkte unter `/api/vault/extension/` mit `Authorization: Bearer <token>`
- `GET /entries/{id}/totp` aktueller TOTP-Code + Restsekunden
- `GET /entries/{id}/favicon?fetch=1` gecachtes Favicon (bei Bedarf serverseitig geholt)
- `POST /entries` neuen Eintrag anlegen
- `POST /entries/{id}` Eintrag ändern (leeres Passwortfeld = unverändert)
- `POST /entries/{id}/delete` Eintrag löschen
- `GET /status` Token gültig? + `pin_enabled` / `pin_lock_secs`
- `POST /unlock` Tresor-PIN verifizieren + serverseitiges Entsperr-Fenster für den Token setzen
- `POST /lock` Token sofort wieder sperren (Entsperr-Fenster zurücksetzen)
@ -74,5 +80,7 @@ Fensterdauer fest; „Bis der Browser geschlossen wird" nutzt ein langes Serverf
- **Kein Remote-Code** alle Skripte im Paket (MV3-CSP-konform, keine Inline-Skripte).
- **Secrets on demand** Passwörter/TOTP erst bei Nutzung, nie in der Liste.
- **Kein persistentes Secret** nur URL, Token, Einstellungen in `chrome.storage`.
- **Kein persistentes Secret** nur URL, Sitzungstoken, Einstellungen in `chrome.storage`. Das Passwort
für einen mehrstufigen Login liegt ausschließlich im Speicher des Service Workers (je Tab, 30 s),
nie in `chrome.storage`, das auf die Festplatte geschrieben würde.
- **Server-seitige Krypto** Ver-/Entschlüsselung im OpenNIT-Server, nicht im Browser.

View file

@ -16,15 +16,6 @@ Browser nicht. Es findet kein Tracking und keine Analyse statt.
**Warum:** Das In-Seite-Dropdown mit Vorschlägen und die Felderkennung laufen als Content-Script.
Notwendig für Autofill und die 2FA-Erkennung (inkl. Shadow-DOM und mehrstufiger Logins).
## `scripting`
**Warum:** Werte werden über den nativen Value-Setter gesetzt und Events ausgelöst, damit auch
React/Vue/Angular-Formulare die Eingaben übernehmen.
## `activeTab`
**Warum:** Zugriff auf den aktiven Tab beim Ausfüllen aus dem Popup („Auf dieser Seite ausfüllen").
## `storage`
**Warum:** Lokale Speicherung von Server-URL, Zugriffstoken (per SSO) und Einstellungen; Entsperr-Status in
@ -38,7 +29,8 @@ Einträge-Caches (5 Min).
## `offscreen`
**Warum:** In Manifest V3 hat der Service Worker keinen DOM-Zugriff. Zum programmatischen Leeren der
Zwischenablage wird ein kurzlebiges Offscreen-Dokument (Reason `CLIPBOARD`) genutzt.
Zwischenablage wird ein kurzlebiges Offscreen-Dokument (Reason `CLIPBOARD`) genutzt sowohl zum
Bereitlegen eines 2FA-Codes als auch zum anschließenden Leeren.
## `identity`
@ -48,6 +40,10 @@ OpenNIT-Login-Seite und empfängt die Weiterleitung an `https://<extension-id>.c
## Bewusst NICHT angefordert
- **`scripting` / `activeTab`** nicht nötig: Das Content-Script wird über `content_scripts` geladen,
das Ausfüllen aus dem Popup läuft über eine Nachricht an das bereits laufende Script. Der Zugriff auf
den aktiven Tab ist bereits durch `host_permissions` abgedeckt.
- **`tabs`** entfällt: Die aktive Tab-Adresse ist bereits über `host_permissions` verfügbar. Dadurch
erscheint **keine** „Browserverlauf lesen"-Warnung.
- Keine `cookies`, `history`, `webRequest`, `downloads`, `notifications` o. Ä.

View file

@ -1,5 +1,7 @@
'use strict';
importScripts('urlmatch.js');
// ── Cache ──────────────────────────────────────────────────────────────────
let cachedEntries = null;
let cacheTime = 0;
@ -13,13 +15,13 @@ async function getServerUrl() {
return c.serverUrl || null;
}
// ── Zugangstoken beschaffen (SSO-Refresh oder manueller Token) ──────────────
// Reihenfolge: manueller Token (Erweitert) > SSO-Access-Token (mit Auto-Refresh).
// ── Zugangstoken beschaffen (SSO) ──────────────────────────────────────────
// Kurzlebiger Access-Token aus der Session; ist er abgelaufen, wird er über den
// rotierenden Refresh-Token erneuert.
async function getAccessToken() {
const cfg = await new Promise(r => chrome.storage.local.get(
['serverUrl', 'apiToken', 'apiRefreshToken', 'apiRefreshExpiresAt'], r));
['serverUrl', 'apiRefreshToken', 'apiRefreshExpiresAt'], r));
if (!cfg.serverUrl) return null;
if (cfg.apiToken) return cfg.apiToken; // manueller Token
if (!cfg.apiRefreshToken) return null; // nicht angemeldet
const sess = await chrome.storage.session.get(['accessToken', 'accessExpiresAt']);
if (sess.accessToken && sess.accessExpiresAt && Date.now() < sess.accessExpiresAt - 30000) {
@ -100,11 +102,15 @@ async function setUnlockedLocal(dur) {
}
async function clearUnlocked() {
await chrome.storage.session.remove('unlock');
await chrome.storage.local.remove('__armedTotp');
clearPendingFills();
cachedEntries = null; cacheTime = 0; faviconCache.clear();
try { await apiFetch('/api/vault/extension/lock', { method: 'POST' }); } catch (e) { /* ignore */ }
}
async function onServerLocked() {
await chrome.storage.session.remove('unlock');
await chrome.storage.local.remove('__armedTotp');
clearPendingFills();
cachedEntries = null; cacheTime = 0;
}
async function doUnlock(pin) {
@ -147,6 +153,67 @@ async function fetchEntries(force = false) {
return { entries: null, locked: false };
}
/**
* Legt einen persönlichen Eintrag im Tresor an.
*
* Läuft bewusst über `apiFetch`, damit derselbe Zugang wie für alle übrigen
* Aufrufe gilt, inklusive automatischer Erneuerung des Access-Tokens.
*
* @param {{title?:string,username?:string,password?:string,url?:string,notes?:string}} fields
* @return {Promise<{ok:boolean,id?:number,locked?:boolean,error?:string}>}
*/
async function createEntry(fields) {
if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' };
const body = new URLSearchParams();
['title', 'username', 'password', 'url', 'notes'].forEach(k => body.append(k, fields?.[k] ?? ''));
return writeEntry('/api/vault/extension/entries', body.toString());
}
/**
* Ändert einen bestehenden Eintrag. Ein leeres Passwortfeld lässt das gespeicherte
* Passwort unangetastet; 2FA-Secret und Ordner bleiben serverseitig erhalten.
*
* @param {number|string} entryId
* @param {{title?:string,username?:string,password?:string,url?:string,notes?:string}} fields
* @return {Promise<{ok:boolean,locked?:boolean,error?:string}>}
*/
async function updateEntry(entryId, fields) {
if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' };
const body = new URLSearchParams();
['title', 'username', 'password', 'url', 'notes'].forEach(k => body.append(k, fields?.[k] ?? ''));
return writeEntry(`/api/vault/extension/entries/${entryId}`, body.toString());
}
/**
* Löscht einen Eintrag (persönlich oder Team, sofern Schreibrecht besteht).
*
* @param {number|string} entryId
* @return {Promise<{ok:boolean,locked?:boolean,error?:string}>}
*/
async function deleteEntry(entryId) {
if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' };
return writeEntry(`/api/vault/extension/entries/${entryId}/delete`, '');
}
// Gemeinsame Auswertung der schreibenden Endpunkte.
async function writeEntry(path, body) {
try {
const res = await apiFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body,
});
if (!res) return { ok: false, error: 'Nicht konfiguriert.' };
if (res.status === 423) { await onServerLocked(); return { ok: false, locked: true, error: 'Tresor gesperrt.' }; }
const data = await res.json().catch(() => ({}));
if (data.ok) {
cachedEntries = null; cacheTime = 0;
return { ok: true, id: data.id };
}
return { ok: false, error: data.error || 'Fehler beim Speichern.' };
} catch (e) { return { ok: false, error: 'Verbindungsfehler.' }; }
}
async function fetchPassword(entryId) {
if (!(await isUnlocked())) return null;
try {
@ -188,21 +255,28 @@ async function fetchFavicon(entryId) {
} catch (e) { return null; }
}
function matchUrl(entryUrl, pageUrl) {
if (!entryUrl) return false;
let pHost;
try { pHost = new URL(pageUrl).hostname.replace(/^www\./, ''); } catch { return false; }
return String(entryUrl).split('\n').some(line => {
const raw = line.trim();
if (!raw) return false;
try {
const eu = raw.includes('://') ? raw : 'https://' + raw;
let eHost = new URL(eu).hostname.replace(/^www\./, '');
if (eHost.startsWith('*.')) eHost = eHost.slice(2);
return pHost === eHost || pHost.endsWith('.' + eHost);
} catch { return false; }
});
// ── Ausstehendes Ausfüllen (mehrstufiger Login) ─────────────────────────────
// Bei Logins, die Benutzername und Passwort auf zwei Schritte verteilen, muss das
// Passwort den Seitenwechsel überdauern. Es bleibt dafür ausschließlich im
// Speicher des Service Workers niemals in `chrome.storage`, das auf die
// Festplatte geschrieben wird. Je Tab ein Auftrag, mit harter Verfallszeit.
const PENDING_FILL_TTL = 30 * 1000;
const pendingFills = new Map(); // tabId -> { id, pw, user, ts }
function setPendingFill(tabId, data) {
if (tabId == null) return;
pendingFills.set(tabId, Object.assign({ ts: Date.now() }, data));
}
function takePendingFill(tabId) {
if (tabId == null) return null;
const p = pendingFills.get(tabId);
if (!p) return null;
pendingFills.delete(tabId);
return (Date.now() - p.ts > PENDING_FILL_TTL) ? null : p;
}
function clearPendingFills() { pendingFills.clear(); }
chrome.tabs.onRemoved.addListener(tabId => pendingFills.delete(tabId));
// ── Zwischenablage automatisch leeren (Offscreen) ───────────────────────────
async function scheduleClipClear(text) {
@ -211,19 +285,22 @@ async function scheduleClipClear(text) {
pendingClip = text || '';
chrome.alarms.create('clipClear', { delayInMinutes: 0.5 });
}
async function clearClipboard() {
// Schreibt über das Offscreen-Dokument. Dieser Weg funktioniert auch dann, wenn
// die Seite selbst keinen Zugriff bekommt etwa ohne frische Nutzerinteraktion.
async function writeClipboard(text) {
try {
if (!chrome.offscreen) return;
const has = chrome.offscreen.hasDocument ? await chrome.offscreen.hasDocument() : false;
if (!has) {
await chrome.offscreen.createDocument({
url: 'offscreen.html', reasons: ['CLIPBOARD'],
justification: 'Zwischenablage nach dem Kopieren von Zugangsdaten leeren.',
justification: 'Zugangsdaten in die Zwischenablage legen und nach kurzer Zeit wieder entfernen.',
});
}
await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: '' });
await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: text || '' });
} catch (e) { /* ignore */ }
}
async function clearClipboard() { await writeClipboard(''); }
// ── Status prüfen (Bearer) ──────────────────────────────────────────────────
async function checkStatus() {
@ -247,11 +324,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
if (msg.type === 'GET_MATCHING_ENTRIES') {
fetchEntries().then(r => {
const matched = (r.entries || []).filter(e => matchUrl(e.url, msg.url));
const matched = (r.entries || []).filter(e => VaultUrl.matches(e.url, msg.url));
sendResponse({ entries: matched, locked: r.locked });
});
return true;
}
if (msg.type === 'CREATE_ENTRY') { createEntry(msg.entry || {}).then(sendResponse); return true; }
if (msg.type === 'UPDATE_ENTRY') { updateEntry(msg.id, msg.entry || {}).then(sendResponse); return true; }
if (msg.type === 'DELETE_ENTRY') { deleteEntry(msg.id).then(sendResponse); return true; }
if (msg.type === 'GET_PASSWORD') { fetchPassword(msg.id).then(password => sendResponse({ password })); return true; }
if (msg.type === 'GET_TOTP') { fetchTotp(msg.id).then(result => sendResponse(result)); return true; }
if (msg.type === 'GET_FAVICON') { fetchFavicon(msg.id).then(dataUrl => sendResponse({ dataUrl })); return true; }
@ -261,11 +341,26 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
if (msg.type === 'DO_UNLOCK') { doUnlock(msg.pin || '').then(sendResponse); return true; }
if (msg.type === 'LOCK_NOW') { clearUnlocked().then(() => sendResponse({ ok: true })); return true; }
if (msg.type === 'SET_PENDING_FILL') {
setPendingFill(sender.tab?.id, { id: msg.id, pw: msg.pw || '', user: msg.user || '' });
sendResponse({ ok: true });
return true;
}
if (msg.type === 'TAKE_PENDING_FILL') { sendResponse({ fill: takePendingFill(sender.tab?.id) }); return true; }
if (msg.type === 'CLIP_WRITE') { writeClipboard(msg.text || '').then(() => sendResponse({ ok: true })); return true; }
if (msg.type === 'SCHEDULE_CLIP_CLEAR') { scheduleClipClear(msg.text || ''); sendResponse({ ok: true }); return true; }
if (msg.type === 'CHECK_STATUS') { checkStatus().then(sendResponse); return true; }
if (msg.type === 'CLEAR_CACHE') { cachedEntries = null; cacheTime = 0; faviconCache.clear(); sendResponse({ ok: true }); return true; }
});
// Der manuell eingetragene API-Token wird nicht mehr unterstützt; ein aus einer
// früheren Version übernommener Wert wird beim Update aus dem Speicher entfernt.
// Der manuelle API-Token entfaellt, und ein ausstehender Fuellauftrag liegt nicht
// mehr im Speicher auf der Platte Reste frueherer Versionen hier entfernen.
chrome.runtime.onInstalled.addListener(() => {
chrome.storage.local.remove(['apiToken', '__pendingFill']);
});
// Cache alle 5 Minuten leeren; Zwischenablage-Clear nach Timeout.
chrome.alarms.create('clearCache', { periodInMinutes: 5 });
chrome.alarms.onAlarm.addListener(async (alarm) => {

View file

@ -14,6 +14,9 @@ const DROPDOWN_ID = '__vault_dropdown__';
let appLabel = 'Vault';
let currentField = null;
let showGen = 0;
let typedField = null; // Feld, in das der Nutzer zuletzt selbst getippt hat
let fillingInProgress = false; // unterdrückt die Tipp-Erkennung beim Autofill
let pendingFillInFlight = false; // der Auftrag wird beim Abholen verbraucht nur einmal gleichzeitig
// ── Heuristik-Muster ────────────────────────────────────────────────────────
const RE_USER = /(user(name|id)?|login|logon|sign[-_ ]?in|account|konto|benutzer|kennung|anmeld|e[-_ ]?mail|email|mail|uid|userid|handle|identifier|ident\b|loginid)/i;
@ -28,7 +31,11 @@ function lc(s) { return String(s || '').toLowerCase(); }
function esc(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function vaultHue(s) { s = String(s || '?'); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }
function vaultClipCopy(text) {
navigator.clipboard.writeText(text).catch(() => {});
// Ohne frische Nutzerinteraktion verweigert der Browser den direkten Zugriff;
// dann schreibt der Hintergrund über das Offscreen-Dokument.
navigator.clipboard.writeText(text).catch(() => {
try { chrome.runtime.sendMessage({ type: 'CLIP_WRITE', text: text }); } catch (e) { /* ignore */ }
});
try { chrome.runtime.sendMessage({ type: 'SCHEDULE_CLIP_CLEAR', text: text }); } catch (e) { /* ignore */ }
}
function attr(el, n) { try { return el.getAttribute(n) || ''; } catch { return ''; } }
@ -117,6 +124,28 @@ function isUsernameField(el) {
return RE_USER.test(s) && !RE_USER_NEG.test(s);
}
// Felder, die eine eigene Vorschlagsliste unter sich aufklappen (ARIA-Combobox,
// <input list>). Dort verdeckt unser Dropdown die Treffer der Seite.
function hasOwnSuggestionList(el) {
if (!el) return false;
if (attr(el, 'list')) return true; // datalist
const isCombo = lc(attr(el, 'role')) === 'combobox'
|| !!(el.closest && el.closest('[role="combobox"]'));
if (!isCombo) return false;
const auto = lc(attr(el, 'aria-autocomplete'));
if (auto === 'list' || auto === 'both') return true;
// Verweist die Combobox auf ein eigenes Listenelement bzw. meldet ihren
// Auf-/Zuklapp-Zustand, hat sie eine eigene Trefferliste.
return !!(attr(el, 'aria-controls') || attr(el, 'aria-owns') || attr(el, 'aria-expanded'));
}
// Vom Seitenautor ausdrücklich als Login-Feld ausgezeichnet wiegt schwerer als
// die Combobox-Erkennung, damit echte Anmeldeformulare weiter Vorschläge bekommen.
function isDeclaredLoginField(el) {
const a = ac(el);
return a.includes('username') || a.includes('password') || a.includes('one-time-code');
}
function isLoginField(el) { return isPasswordField(el) || isUsernameField(el) || isOtpField(el); }
function fieldKind(el) {
if (isPasswordField(el)) return 'password';
@ -178,31 +207,12 @@ function findOtpFields(ref) {
return collectInputs(scopeOf(ref)).filter(el => isVisible(el) && isOtpField(el));
}
// ── URL-Matching ────────────────────────────────────────────────────────────
function normalizeHost(raw) {
if (!raw) return '';
try {
const s = raw.includes('://') ? raw : 'https://' + raw;
return new URL(s).hostname.replace(/^www\./, '').toLowerCase();
} catch { return lc(raw).replace(/^www\./, ''); }
}
function matchUrl(entryUrls, pageUrl) {
const pageHost = normalizeHost(pageUrl);
if (!pageHost) return false;
const urls = typeof entryUrls === 'string' ? entryUrls.split('\n') : [entryUrls];
return urls.some(u => {
let eh = normalizeHost((u || '').trim());
if (!eh) return false;
if (eh.startsWith('*.')) eh = eh.slice(2);
return pageHost === eh || pageHost.endsWith('.' + eh);
});
}
// ── Events ──────────────────────────────────────────────────────────────────
function init() {
document.addEventListener('focusin', onFocusIn, true);
document.addEventListener('focusout', onFocusOut, true);
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('input', onInput, true);
document.addEventListener('keydown', onKeyDown, true);
document.addEventListener('click', onDocClick, true);
window.addEventListener('scroll', repositionDrop, true);
@ -227,27 +237,44 @@ function fillFromPopup(msg) {
if (userField && msg.username) setFieldValue(userField, msg.username);
if (passField && msg.password) setFieldValue(passField, msg.password);
else if (msg.password) chrome.storage.local.set({ __pendingFill: { id: msg.id, pw: msg.password, user: msg.username || '', ts: Date.now() } });
else if (msg.password) chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: msg.id, pw: msg.password, user: msg.username || '' });
if (msg.has_totp && msg.id != null) {
const otps = findOtpFields(passField || userField || document.body);
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: msg.id }, t => {
if (!t || !t.code) return;
if (otps.length) distributeOtp(otps, t.code);
vaultClipCopy(t.code);
showTotpNotification(t.code, t.remaining);
});
armTotp(msg.id);
deliverTotp(msg.id, findOtpFields(passField || userField || document.body));
}
}
function onFocusIn(e) { maybeShow(e.target); }
function onFocusIn(e) {
// Beim Betreten des 2FA-Feldes einen frischen Code bereitlegen.
if (isOtpField(e.target) && isVisible(e.target)) serveArmedTotp([e.target], false);
maybeShow(e.target);
}
function onPointerDown(e) {
const drop = document.getElementById(DROPDOWN_ID);
if (drop && drop.contains(e.target)) return;
maybeShow(e.target);
}
// Sobald der Nutzer selbst tippt, gehört der Platz unter dem Feld der Seite
// dort erscheint typischerweise deren eigene Suche. Das Feld bleibt bis zum
// Leeren gesperrt, damit ein erneuter Klick das Dropdown nicht zurückholt.
function onInput(e) {
const el = e.target;
if (fillingInProgress || !el || !el.value) {
if (el && !el.value && typedField === el) typedField = null;
return;
}
typedField = el;
if (el === currentField) hideDrop();
}
function isTypingSuppressed(el) { return el === typedField && !!el.value; }
function maybeShow(el) {
if (!isLoginField(el) || !isVisible(el)) return;
// Eigene Vorschlagsliste der Seite hat Vorrang.
if (hasOwnSuggestionList(el) && !isDeclaredLoginField(el)) return;
if (isTypingSuppressed(el)) return;
currentField = el;
showSuggestions(el);
}
@ -290,7 +317,7 @@ function showSuggestions(field) {
const mode = fieldKind(field) === 'otp' ? 'otp' : 'login';
chrome.runtime.sendMessage({ type: 'GET_MATCHING_ENTRIES', url: location.href }, resp => {
if (gen !== showGen) return;
let entries = (resp && resp.entries || []).filter(e => matchUrl(e.url, location.href));
let entries = (resp && resp.entries || []).filter(e => VaultUrl.matches(e.url, location.href));
if (mode === 'otp') entries = entries.filter(e => e.has_totp);
if (!entries.length) { hideDrop(); return; }
if (document.contains(field) && isVisible(field)) renderDrop(field, entries, mode);
@ -443,17 +470,66 @@ async function fillEntry(entry, focused) {
if (userField && entry.username) setFieldValue(userField, entry.username);
if (passField) setFieldValue(passField, pw);
else chrome.storage.local.set({ __pendingFill: { id: entry.id, pw: pw, user: entry.username || '', ts: Date.now() } });
else chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: entry.id, pw: pw, user: entry.username || '' });
if (entry.has_totp) {
const otps = findOtpFields(passField || userField || focused);
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entry.id }, t => {
if (!t || !t.code) return;
if (otps.length) distributeOtp(otps, t.code);
// Der 2FA-Schritt folgt oft erst nach dem Absenden ggf. auf der nächsten
// Seite. Deshalb den Eintrag vormerken, damit dort ein frischer Code
// bereitgestellt werden kann.
armTotp(entry.id);
deliverTotp(entry.id, findOtpFields(passField || userField || focused));
}
}
// ── 2FA-Code für den nächsten Schritt bereitlegen ───────────────────────────
// Nach dem Ausfüllen eines Eintrags mit 2FA bleibt der Eintrag kurz vorgemerkt.
// Taucht danach ein 2FA-Feld auf auf derselben oder einer Folgeseite , landet
// ein frischer Code in der Zwischenablage, sodass Strg+V genügt.
const TOTP_ARM_TTL = 5 * 60 * 1000; // Vormerkung nach dem Ausfüllen
const TOTP_RECOPY_MS = 5000; // Mindestabstand zwischen zwei Kopiervorgängen
let lastTotpCopy = 0;
let totpRequestPending = false; // Fokus und Seiten-Scan können gleichzeitig auslösen
const totpServedFields = new WeakSet();
function armTotp(entryId) {
try { chrome.storage.local.set({ __armedTotp: { id: entryId, ts: Date.now() } }); } catch (e) { /* ignore */ }
}
function deliverTotp(entryId, fields, done) {
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entryId }, t => {
if (t && t.code) {
if (fields && fields.length) distributeOtp(fields, t.code);
lastTotpCopy = Date.now();
vaultClipCopy(t.code);
showTotpNotification(t.code, t.remaining);
});
}
if (done) done();
});
}
/**
* Automatischer Weg: nur kopieren und anzeigen. Ins Feld geschrieben wird ein Code
* ausschließlich, wenn der Nutzer den Eintrag selbst ausgewählt hat.
*
* @param {Element[]} fields erkannte 2FA-Felder
* @param {boolean} oncePerField true beim automatischen Auftauchen (je Feld einmal),
* false beim Fokussieren dort darf nach Ablauf des
* Codes erneut ein frischer kopiert werden.
*/
function serveArmedTotp(fields, oncePerField) {
const relevant = oncePerField ? fields.filter(f => !totpServedFields.has(f)) : fields;
if (!relevant.length || totpRequestPending) return;
if (Date.now() - lastTotpCopy < TOTP_RECOPY_MS) return;
totpRequestPending = true;
chrome.storage.local.get(['__armedTotp', 'totpAutoCopy'], res => {
const armed = res.__armedTotp;
if (res.totpAutoCopy === false || !armed || Date.now() - armed.ts > TOTP_ARM_TTL) {
totpRequestPending = false;
return;
}
relevant.forEach(f => totpServedFields.add(f));
deliverTotp(armed.id, null, () => { totpRequestPending = false; });
});
}
function fillOtp(field, entry) {
@ -479,6 +555,7 @@ function distributeOtp(fields, code) {
}
function setFieldValue(field, value) {
fillingInProgress = true;
try {
field.focus({ preventScroll: true });
const proto = (typeof HTMLTextAreaElement !== 'undefined' && field instanceof HTMLTextAreaElement)
@ -491,26 +568,47 @@ function setFieldValue(field, value) {
field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
field.dispatchEvent(new Event('blur', { bubbles: true }));
} catch {}
fillingInProgress = false;
}
// ── Mehrstufiger Login: Passwort/User nach dem Erscheinen befüllen ───────────
const _obs = new MutationObserver(() => {
chrome.storage.local.get(['__pendingFill'], result => {
const p = result.__pendingFill;
if (!p || Date.now() - p.ts > 30000) return;
const pw = collectInputs(document).filter(f => isVisible(f) && isPasswordField(f));
if (!pw.length) return;
// ── Mehrstufiger Login: Passwort/2FA nach dem Erscheinen bedienen ────────────
// Der Scan ist entkoppelt vom Mutations-Takt: eine Seite kann pro Sekunde
// hunderte Mutationen erzeugen, die Feldsuche läuft davon unabhängig gedrosselt.
const SCAN_DEBOUNCE_MS = 250;
let scanTimer = null;
function scanForPendingWork() {
const inputs = collectInputs(document).filter(isVisible);
const otps = inputs.filter(isOtpField);
if (otps.length) serveArmedTotp(otps, true);
const pw = inputs.filter(isPasswordField);
if (!pw.length || pendingFillInFlight) return;
pendingFillInFlight = true;
chrome.runtime.sendMessage({ type: 'TAKE_PENDING_FILL' }, resp => {
pendingFillInFlight = false;
const p = resp && resp.fill;
if (!p) return;
pw.forEach(f => setFieldValue(f, p.pw));
if (p.user) {
const uf = findUsernameField(pw[0]);
if (uf && !uf.value) setFieldValue(uf, p.user);
}
chrome.storage.local.remove('__pendingFill');
});
}
const _obs = new MutationObserver(() => {
if (scanTimer) return;
scanTimer = setTimeout(() => { scanTimer = null; scanForPendingWork(); }, SCAN_DEBOUNCE_MS);
});
try { _obs.observe(document.documentElement, { childList: true, subtree: true }); } catch {}
// Bei einem Seitenwechsel steht das 2FA-Feld oft schon im ersten Rendering.
scanForPendingWork();
// ── TOTP-Benachrichtigung (unten rechts) ────────────────────────────────────
const TOTP_PERIOD = 30; // Sekunden pro Code (RFC 6238, Serverseite nutzt denselben Wert)
function showTotpNotification(code, remaining) {
const ID = '__vault_totp_notif__';
const old = document.getElementById(ID);
@ -531,18 +629,20 @@ function showTotpNotification(code, remaining) {
+ '<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="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>';
document.documentElement.appendChild(notif);
let secs = remaining;
// Gegen einen festen Ablaufzeitpunkt rechnen, damit die Anzeige auch nach
// gedrosselten oder ausgefallenen Timer-Ticks stimmt.
const deadline = Date.now() + (Number(remaining) > 0 ? Number(remaining) : TOTP_PERIOD) * 1000;
const iv = setInterval(() => {
secs--;
const secs = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
const t = document.getElementById('__vault_totp_t');
const bar = document.getElementById('__vault_totp_bar');
if (secs <= 0 || !t) { clearInterval(iv); notif.style.transition = 'opacity .4s'; notif.style.opacity = '0'; setTimeout(() => notif.remove(), 400); return; }
if (secs === 0 || !t) { clearInterval(iv); notif.style.transition = 'opacity .4s'; notif.style.opacity = '0'; setTimeout(() => notif.remove(), 400); return; }
t.textContent = secs + 's';
if (bar) { bar.style.width = (secs / 30 * 100) + '%'; if (secs < 10) bar.style.background = '#dc3545'; }
if (bar) { bar.style.width = Math.min(100, secs / TOTP_PERIOD * 100) + '%'; if (secs <= 10) bar.style.background = '#dc3545'; }
}, 1000);
notif.addEventListener('click', () => { clearInterval(iv); notif.remove(); });
}

View file

@ -1,12 +1,10 @@
{
"manifest_version": 3,
"name": "OpenNIT Vault",
"version": "2.4.1",
"version": "2.5.0",
"description": "OpenNIT Vault Passwort-Manager mit Autofill für Benutzer-, Passwort- und 2FA-Felder direkt im Browser.",
"permissions": [
"storage",
"activeTab",
"scripting",
"alarms",
"offscreen",
"identity"
@ -31,6 +29,7 @@
"<all_urls>"
],
"js": [
"urlmatch.js",
"content.js"
],
"run_at": "document_idle"

View file

@ -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>
Mit OpenNIT anmelden
</button>
<button class="btn btn-default" id="btnSave">URL speichern</button>
<button class="btn btn-default" id="btnLogout" style="display:none;">Abmelden</button>
<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&uuml;gen&hellip;">
<span class="form-text">Alternative ohne SSO (z. B. Kiosk/Headless): Vault &rarr; &bdquo;Extension&ldquo; &rarr; 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>
</div>
</div>
</details>
<span class="form-text">Die Anmeldung erfolgt wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert und l&auml;sst sich jederzeit im Vault widerrufen.</span>
<div id="statusMsg"></div>
</div>
</div>
@ -275,6 +259,10 @@ label.control-label {
<label class="control-label"><input type="checkbox" id="clipClear" checked> Zwischenablage nach 30&nbsp;Sekunden leeren</label>
<span class="form-text">Kopierte Passwörter und 2FA-Codes werden nach kurzer Zeit automatisch aus der Zwischenablage entfernt.</span>
</div>
<div class="form-group">
<label class="control-label"><input type="checkbox" id="totpAutoCopy" checked> 2FA-Code beim Anmelden bereitlegen</label>
<span class="form-text">Nach dem Ausf&uuml;llen eines Eintrags mit 2FA wird beim n&auml;chsten 2FA-Feld &ndash; auch auf einer Folgeseite &ndash; automatisch ein frischer Code in die Zwischenablage gelegt. Einf&uuml;gen gen&uuml;gt dann mit <strong>Strg&nbsp;+&nbsp;V</strong>.</span>
</div>
<div class="btn-group">
<button class="btn btn-primary btn-sm" id="btnSaveSec">Speichern</button>
<span id="savedSecMsg"></span>
@ -289,9 +277,9 @@ label.control-label {
</div>
<div class="card-body">
<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">2</span><span>Klicken Sie auf <strong>&bdquo;Extension&ldquo;</strong> in der Toolbar und generieren Sie einen API-Token.</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">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>&bdquo;Mit OpenNIT anmelden&ldquo;</strong> und melden Sie sich wie gewohnt an.</span></li>
<li><span class="step-badge">3</span><span>Best&auml;tigen Sie den Zugriff auf den <strong>Passwort-Vault</strong> &ndash; die Erweiterung ist damit verbunden.</span></li>
<li><span class="step-badge">4</span><span>Fokussieren Sie ein Login-Feld auf einer Website &ndash; Vault-Vorschl&auml;ge erscheinen automatisch.</span></li>
</ul>
</div>

View file

@ -3,21 +3,22 @@
function $(id) { return document.getElementById(id); }
// Gespeicherte Werte laden (überschreibt das vorausgefüllte Feld nur wenn bereits gespeichert)
chrome.storage.local.get(['serverUrl', 'apiToken'], cfg => {
chrome.storage.local.get(['serverUrl'], cfg => {
if (cfg.serverUrl) $('serverUrl').value = cfg.serverUrl;
if (cfg.apiToken) $('apiToken').value = '••••••••';
});
// Sicherheits-Einstellungen laden
chrome.storage.local.get(['lockDuration', 'clipClear'], cfg => {
chrome.storage.local.get(['lockDuration', 'clipClear', 'totpAutoCopy'], cfg => {
$('lockDuration').value = (cfg.lockDuration && cfg.lockDuration !== 'off') ? cfg.lockDuration : '15';
$('clipClear').checked = cfg.clipClear !== false; // Standard: an
$('totpAutoCopy').checked = cfg.totpAutoCopy !== false; // Standard: an
});
$('btnSaveSec').addEventListener('click', () => {
chrome.storage.local.set({
lockDuration: $('lockDuration').value,
clipClear: $('clipClear').checked,
totpAutoCopy: $('totpAutoCopy').checked,
}, () => {
chrome.runtime.sendMessage({ type: 'LOCK_NOW' });
$('savedSecMsg').innerHTML = '<span class="save-ok">&#10003; Gespeichert</span>';
@ -25,27 +26,8 @@ $('btnSaveSec').addEventListener('click', () => {
});
});
// App-Name und Verbindungsstatus laden (falls Token bereits gesetzt)
chrome.storage.local.get(['serverUrl', 'apiToken'], async cfg => {
if (!cfg.serverUrl || !cfg.apiToken) return;
try {
const res = await fetch(`${cfg.serverUrl}/api/vault/extension/status`, {
headers: { 'Authorization': `Bearer ${cfg.apiToken}` }
});
const data = await res.json();
if (data.ok) {
// Name der Erweiterung bleibt fest „OpenNIT Vault"; die Instanz wird
// beim angemeldeten Nutzer zur Orientierung angezeigt.
if (data.user) {
$('headerUser').textContent = data.app_name ? (data.user + ' · ' + data.app_name) : data.user;
$('headerStatus').style.display = '';
}
}
} catch { /* ignore */ }
});
// HTTPS erzwingen (außer localhost) sonst gingen Token und Passwörter im
// Klartext über die Leitung.
// HTTPS erzwingen (außer localhost) sonst gingen Zugangsdaten und Passwörter
// im Klartext über die Leitung.
function isSecureServerUrl(url) {
try {
const u = new URL(url);
@ -57,57 +39,19 @@ function isSecureServerUrl(url) {
$('btnSave').addEventListener('click', () => {
const url = $('serverUrl').value.trim().replace(/\/$/, '');
const token = $('apiToken').value.trim();
if (!url) { showStatus('Server-URL darf nicht leer sein.', false); return; }
if (!isSecureServerUrl(url)) {
showStatus('Bitte eine <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;
}
const data = { serverUrl: url };
if (token && !token.startsWith('•')) data.apiToken = token;
chrome.storage.local.set(data, () => {
chrome.storage.local.set({ serverUrl: url }, () => {
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
$('savedMsg').innerHTML = '<span class="save-ok">&#10003; Gespeichert</span>';
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&#8230;';
$('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) ────────────────────
function b64url(bytes) {
let s = btoa(String.fromCharCode.apply(null, new Uint8Array(bytes)));
@ -168,7 +112,6 @@ async function loginWithSso() {
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.access_token) { ssoMsg('Token konnte nicht ausgestellt werden.', false); return; }
await ssoSet('local', { serverUrl: url, apiRefreshToken: data.refresh_token, apiRefreshExpiresAt: Date.now() + (data.refresh_expires_in || 0) * 1000 });
await ssoRemove('local', ['apiToken']);
await ssoSet('session', { accessToken: data.access_token, accessExpiresAt: Date.now() + (data.expires_in || 0) * 1000 });
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
ssoMsg('Angemeldet.', true);
@ -203,7 +146,7 @@ function reflectAuthState() {
});
}
// Verbindungsstatus über den Background (nutzt SSO-Access-Token oder manuellen Token)
// Verbindungsstatus über den Background (nutzt den SSO-Access-Token)
function loadConnStatus() {
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, data => {
if (data && data.ok) {

View file

@ -147,6 +147,14 @@ body {
.ne-pass-row .panel-input { flex: 1; }
.ne-icon-btn { flex-shrink: 0; width: 36px; height: 36px; border: 1.5px solid var(--line); background: var(--card); border-radius: 9px; cursor: pointer; color: #5b6478; display: inline-flex; align-items: center; justify-content: center; transition: background .12s, color .12s, border-color .12s; }
.ne-icon-btn:hover { background: var(--brand); border-color: var(--brand); color: #fff; }
.gen-opts { display: flex; align-items: center; gap: 14px; margin: -2px 0 2px; font-size: 11px; color: var(--muted); }
.gen-opt { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
.gen-select { border: 1.5px solid var(--line); border-radius: 7px; padding: 2px 4px; font-size: 11px; font-family: inherit; background: var(--card); color: var(--ink); cursor: pointer; }
.detail-actions { display: flex; gap: 8px; margin-top: 8px; }
.detail-action { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; background: var(--card); border: 1.5px solid var(--line); border-radius: 9px; padding: 8px 10px; font-size: 11.5px; font-weight: 600; font-family: inherit; color: #5b6478; cursor: pointer; transition: background .12s, color .12s, border-color .12s; }
.detail-action:hover { background: var(--brand); border-color: var(--brand); color: #fff; }
.detail-action.danger:hover { background: #dc3545; border-color: #dc3545; }
.detail-action:disabled { opacity: .55; cursor: default; }
.field-notes { font-size: 12px; color: var(--ink); white-space: pre-wrap; word-break: break-word; max-height: 90px; overflow-y: auto; margin-top: 2px; }
/* ── Lock Screen ────────────────────────────────────────── */
@ -204,7 +212,7 @@ body {
<!-- New Entry Panel (initially hidden) -->
<div id="newEntryPanel">
<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">&#x2715;</button>
</div>
<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>
</button>
</div>
<div class="gen-opts">
<label class="gen-opt" for="genLen">L&auml;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">
<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>
@ -248,9 +269,6 @@ body {
<div class="field-lbl">Benutzername</div>
<div class="field-row">
<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">
<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>
@ -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>
Auf dieser Seite ausf&uuml;llen
</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&ouml;schen
</button>
</div>
</div>
</div>
@ -342,6 +370,7 @@ body {
</div>
<div class="toast" id="toast"></div>
<script src="urlmatch.js"></script>
<script src="popup.js"></script>
</body>
</html>

View file

@ -5,6 +5,7 @@ let pageMatches = [];
let entryIndex = {}; // id -> entry
let detailState = null; // aktiver Eintrag im Detail-Panel
let selIndex = -1; // Tastatur-Auswahl in der Liste
let editingId = null; // gesetzt, solange das Panel einen bestehenden Eintrag bearbeitet
function $(id) { return document.getElementById(id); }
@ -28,13 +29,14 @@ async function init() {
// Detail-Panel
$('btnDetailBack').addEventListener('click', closeDetail);
$('btnDetailClose').addEventListener('click', closeDetail);
$('btnRevealUser').addEventListener('click', toggleRevealUser);
$('btnCopyUser').addEventListener('click', () => copySecret($('detailUser').dataset.value || '', 'Benutzername kopiert'));
$('btnRevealPass').addEventListener('click', toggleRevealPass);
$('btnCopyPass').addEventListener('click', copyDetailPassword);
$('btnCopyTotp').addEventListener('click', () => { const c = $('detailTotp').dataset.code || ''; if (c) copySecret(c, 'TOTP kopiert'); });
$('btnCopyNotes').addEventListener('click', () => copyToClipboard($('detailNotes').dataset.value || '', 'Notiz kopiert'));
$('btnCopyNotes').addEventListener('click', () => copySecret($('detailNotes').dataset.value || '', 'Notiz kopiert'));
$('btnDetailFill').addEventListener('click', fillActiveTab);
$('btnDetailEdit').addEventListener('click', openEditPanel);
$('btnDetailDelete').addEventListener('click', deleteCurrentEntry);
// Lock-Screen
$('lockSubmit').addEventListener('click', submitPin);
@ -211,6 +213,9 @@ function setSel(items, idx) {
// ── New Entry ─────────────────────────────────────────────────────────────
function openNewPanel() {
editingId = null;
$('panelTitle').textContent = 'Neuen Eintrag anlegen';
$('nePassword').placeholder = 'Passwort';
$('listWrap').style.display = 'none';
$('search').closest('.search-wrap').style.display = 'none';
$('detailPanel').style.display = 'none';
@ -232,74 +237,126 @@ function openNewPanel() {
}
function closeNewPanel() {
editingId = null;
$('newEntryPanel').style.display = 'none';
$('listWrap').style.display = '';
$('search').closest('.search-wrap').style.display = '';
}
function generatePassword() {
const len = 20;
const sets = [
// Gleichverteilte Zufallszahl aus [0, max) verwirft die Werte des obersten,
// unvollständigen Blocks, damit kein Rest-Modulo einzelne Zeichen bevorzugt.
function randomBelow(max) {
const limit = Math.floor(0x100000000 / max) * max;
const buf = new Uint32Array(1);
do { crypto.getRandomValues(buf); } while (buf[0] >= limit);
return buf[0] % max;
}
/** Zeichensätze ohne optisch verwechselbare Zeichen (l/I/1, O/0). */
const GEN_SETS = [
'abcdefghijkmnopqrstuvwxyz',
'ABCDEFGHJKLMNPQRSTUVWXYZ',
'23456789',
'!@#$%^&*()-_=+[]{}',
];
];
const GEN_SYMBOLS = '!@#$%^&*()-_=+[]{}';
function generatePassword() {
const len = Math.max(8, parseInt($('genLen').value, 10) || 20);
const sets = $('genSymbols').checked ? GEN_SETS.concat(GEN_SYMBOLS) : GEN_SETS.slice();
const all = sets.join('');
const buf = new Uint32Array(len);
crypto.getRandomValues(buf);
let out = [];
// Mindestens ein Zeichen je Set
sets.forEach((s, i) => { out.push(s[buf[i] % s.length]); });
for (let i = sets.length; i < len; i++) out.push(all[buf[i] % all.length]);
// mischen
// Je Satz ein Zeichen garantieren, den Rest frei ziehen …
const out = sets.map(s => s[randomBelow(s.length)]);
while (out.length < len) out.push(all[randomBelow(all.length)]);
// … und danach mischen, damit die garantierten Zeichen nicht vorne stehen.
// Der Zufall dafür wird frisch gezogen und nicht aus der Zeichenwahl wiederverwendet.
for (let i = out.length - 1; i > 0; i--) {
const j = buf[i] % (i + 1);
const j = randomBelow(i + 1);
[out[i], out[j]] = [out[j], out[i]];
}
$('nePassword').value = out.join('');
$('nePassword').type = 'text';
}
async function saveNewEntry() {
// Bestehenden Eintrag im selben Panel bearbeiten. Das Passwortfeld bleibt leer
// leer bedeutet serverseitig „unverändert", sodass das Passwort das Popup nie verlässt.
function openEditPanel() {
if (!detailState) return;
const e = entryIndex[detailState.id];
if (!e) return;
editingId = detailState.id;
closeDetail();
$('panelTitle').textContent = 'Eintrag bearbeiten';
$('neTitle').value = e.title || '';
$('neUsername').value = e.username || '';
$('nePassword').value = '';
$('nePassword').type = 'password';
$('nePassword').placeholder = 'Passwort (leer = unverändert)';
$('neUrl').value = e.url || '';
$('neNotes').value = e.notes || '';
$('newEntryMsg').textContent = '';
$('listWrap').style.display = 'none';
$('search').closest('.search-wrap').style.display = 'none';
$('detailPanel').style.display = 'none';
$('newEntryPanel').style.display = 'block';
$('neTitle').focus();
}
function deleteCurrentEntry() {
if (!detailState) return;
const e = entryIndex[detailState.id];
if (!e) return;
if (!window.confirm('Eintrag „' + (e.title || '') + '" wirklich löschen?')) return;
const btn = $('btnDetailDelete');
btn.disabled = true;
chrome.runtime.sendMessage({ type: 'DELETE_ENTRY', id: detailState.id }, resp => {
btn.disabled = false;
if (resp?.ok) { closeDetail(); reload(true); showToast('Eintrag gelöscht'); return; }
if (resp?.locked) { showLockScreen(); return; }
showToast(resp?.error || 'Löschen fehlgeschlagen');
});
}
// Speichern läuft wie alle anderen Aufrufe über den Background-Service-Worker,
// der den gültigen Zugang beisteuert.
function saveNewEntry() {
const title = $('neTitle').value.trim();
if (!title) { $('newEntryMsg').textContent = 'Titel ist erforderlich.'; return; }
const cfg = await new Promise(r => chrome.storage.local.get(['serverUrl', 'apiToken'], r));
if (!cfg.serverUrl || !cfg.apiToken) { $('newEntryMsg').textContent = 'Nicht konfiguriert.'; return; }
$('btnSaveNew').disabled = true;
$('btnSaveNew').textContent = '...';
$('newEntryMsg').textContent = '';
const fd = new FormData();
fd.append('title', title);
fd.append('username', $('neUsername').value.trim());
fd.append('password', $('nePassword').value);
fd.append('url', $('neUrl').value.trim());
fd.append('notes', $('neNotes').value.trim());
try {
const res = await fetch(cfg.serverUrl + '/api/vault/extension/entries', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + cfg.apiToken },
body: fd,
});
const data = await res.json();
if (data.ok) {
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
closeNewPanel();
reload(true);
showToast('Eintrag gespeichert');
} else {
$('newEntryMsg').textContent = data.error || 'Fehler beim Speichern.';
}
} catch (e) {
$('newEntryMsg').textContent = 'Verbindungsfehler: ' + e.message;
}
const entry = {
title: title,
username: $('neUsername').value.trim(),
password: $('nePassword').value,
url: $('neUrl').value.trim(),
notes: $('neNotes').value.trim(),
};
const msg = editingId
? { type: 'UPDATE_ENTRY', id: editingId, entry }
: { type: 'CREATE_ENTRY', entry };
const wasEditing = !!editingId;
chrome.runtime.sendMessage(msg, resp => {
$('btnSaveNew').disabled = false;
$('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) ─────────────────────────────────────
@ -364,7 +421,7 @@ function openDetail(id) {
const e = entryIndex[String(id)];
if (!e) return;
closeDetailTimers();
detailState = { id: String(id), password: null, revealUser: true, revealPass: false, totpInterval: null };
detailState = { id: String(id), password: null, revealPass: false, totpInterval: null };
$('listWrap').style.display = 'none';
$('search').closest('.search-wrap').style.display = 'none';
@ -399,20 +456,17 @@ function openDetail(id) {
urlLink.style.display = 'none';
}
// Benutzername (standardmäßig sichtbar; Auge schaltet Maskierung)
// Benutzername kein Geheimnis, daher immer im Klartext.
const uval = e.username || '';
const uEl = $('detailUser');
uEl.dataset.value = uval;
detailState.revealUser = true;
if (uval) {
uEl.classList.remove('empty');
uEl.textContent = uval;
$('btnRevealUser').style.display = '';
$('btnCopyUser').style.display = '';
} else {
uEl.classList.add('empty');
uEl.textContent = 'Kein Benutzername';
$('btnRevealUser').style.display = 'none';
$('btnCopyUser').style.display = 'none';
}
@ -447,14 +501,6 @@ function closeDetail() {
$('search').closest('.search-wrap').style.display = '';
}
function toggleRevealUser() {
const uEl = $('detailUser');
const val = uEl.dataset.value || '';
if (!val) return;
detailState.revealUser = !detailState.revealUser;
uEl.textContent = detailState.revealUser ? val : '•'.repeat(Math.min(val.length, 14));
}
async function ensurePassword(id) {
if (detailState && detailState.password !== null) return detailState.password;
const pw = await new Promise(resolve => {
@ -484,6 +530,12 @@ async function copyDetailPassword() {
else showToast('Kein Passwort');
}
const TOTP_PERIOD = 30; // Sekunden pro Code (RFC 6238, Serverseite nutzt denselben Wert)
// Restlaufzeit des 2FA-Codes. Die Anzeige rechnet gegen einen festen Ablaufzeitpunkt
// statt blind herunterzuzählen so bleibt sie korrekt, wenn der Timer gedrosselt wird
// oder ein Tick ausfällt. Nachgeladen wird erst, wenn der Code abgelaufen ist, und pro
// Ablauf nur einmal.
function loadDetailTotp(id) {
const codeEl = $('detailTotp');
const secsEl = $('detailTotpSecs');
@ -492,33 +544,55 @@ function loadDetailTotp(id) {
codeEl.dataset.code = '';
secsEl.textContent = '';
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, resp => {
if (!detailState || detailState.id !== String(id)) return;
if (!resp?.code) { codeEl.textContent = '—'; return; }
let deadline = 0;
let refetching = false;
const stillOpen = () => detailState && detailState.id === String(id);
const render = () => {
const secs = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
secsEl.textContent = secs + 's';
if (barEl) {
barEl.style.width = Math.min(100, Math.round(secs / TOTP_PERIOD * 100)) + '%';
barEl.style.background = secs <= 10 ? '#dc3545' : '#34d399';
}
return secs;
};
const apply = (code, remaining) => {
codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
codeEl.dataset.code = code;
secsEl.textContent = remaining + 's';
if (barEl) barEl.style.width = Math.round(remaining / 30 * 100) + '%';
// Der Server liefert die Restsekunden des laufenden Zeitfensters; daraus wird
// ein Ablaufzeitpunkt, gegen den die Anzeige lokal rechnet.
deadline = Date.now() + (Number(remaining) > 0 ? Number(remaining) : TOTP_PERIOD) * 1000;
render();
};
const refetch = () => {
if (refetching) return;
refetching = true;
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, r => {
refetching = false;
if (!stillOpen()) return;
if (r?.code) { apply(r.code, r.remaining); return; }
// Kein Code mehr (gesperrt oder Verbindung weg) Anzeige zurücksetzen
// statt weiter auf einem abgelaufenen Wert stehen zu bleiben.
closeDetailTimers();
codeEl.textContent = '—';
codeEl.dataset.code = '';
secsEl.textContent = '';
if (barEl) barEl.style.width = '0%';
});
};
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, resp => {
if (!stillOpen()) return;
if (!resp?.code) { codeEl.textContent = '—'; return; }
apply(resp.code, resp.remaining);
let secs = resp.remaining;
detailState.totpInterval = setInterval(() => {
secs--;
if (secs <= 0) {
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, r2 => {
if (!detailState || detailState.id !== String(id)) return;
if (r2?.code) { secs = r2.remaining; apply(r2.code, r2.remaining); }
});
return;
}
secsEl.textContent = secs + 's';
if (barEl) {
barEl.style.width = Math.round(secs / 30 * 100) + '%';
barEl.style.background = secs < 10 ? '#dc3545' : '#34d399';
}
if (!stillOpen()) return;
if (render() === 0) refetch();
}, 1000);
});
}
@ -560,30 +634,12 @@ async function fillActiveTab() {
}
// ── Helfer ────────────────────────────────────────────────────────────────
function hostOf(u) {
try {
const s = String(u || '');
return new URL(s.includes('://') ? s : 'https://' + s).hostname.replace(/^www\./, '').toLowerCase();
} catch { return ''; }
}
function hostOf(u) { return VaultUrl.host(u); }
// True, wenn eine der (mehrzeiligen) Eintrags-URLs zur Seiten-Domain passt
// oder wenn im Eintrag gar keine URL hinterlegt ist (dann keine Warnung).
function fillDomainMatches(entryUrls, pageUrl) {
const pageHost = hostOf(pageUrl);
const list = String(entryUrls || '').split('\n').map(s => s.trim()).filter(Boolean);
if (!list.length) return true;
if (!pageHost) return false;
return list.some(u => {
let eh = hostOf(u);
if (eh.startsWith('*.')) eh = eh.slice(2);
return eh && (pageHost === eh || pageHost.endsWith('.' + eh) || eh.endsWith('.' + pageHost));
});
}
function copyToClipboard(text, msg) {
navigator.clipboard.writeText(text).then(() => showToast(msg)).catch(() => showToast('Fehler'));
}
// Wie copyToClipboard, plant aber zusätzlich das automatische Leeren der
// Zwischenablage (für Zugangsdaten/2FA).
function fillDomainMatches(entryUrls, pageUrl) { return VaultUrl.matchesOrUnset(entryUrls, pageUrl); }
// Alles, was aus einem Eintrag kommt, gilt als Geheimnis Notizen enthalten in
// der Praxis ebenso oft Wiederherstellungscodes wie das Passwortfeld selbst.
function copySecret(text, msg) {
navigator.clipboard.writeText(text).then(() => {
showToast(msg);
@ -597,6 +653,6 @@ function showToast(msg) {
setTimeout(() => t.classList.remove('show'), 1800);
}
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escAttr(s) { return String(s||'').replace(/"/g,'&quot;'); }
function escAttr(s) { return esc(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
init();

63
extension/urlmatch.js Normal file
View 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;

View file

@ -45,7 +45,7 @@ SICHERHEIT & DATENSCHUTZ
• Passwörter werden erst im Moment der Nutzung abgerufen und nicht dauerhaft gespeichert
EINRICHTUNG
Server-URL eintragen, im OpenNIT-Tresor einen API-Token erzeugen und einfügen fertig.
Server-URL eintragen, auf „Mit OpenNIT anmelden" klicken und den Zugriff bestätigen fertig.
OpenNIT Vault benötigt eine laufende OpenNIT-Instanz.
```
@ -71,7 +71,7 @@ SECURITY & PRIVACY
• Passwords are fetched only at the moment of use and never stored permanently
SETUP
Enter your server URL, generate an API token in the OpenNIT vault, paste it — done.
Enter your server URL, click "Sign in with OpenNIT" and approve access — done.
OpenNIT Vault requires a running OpenNIT instance.
```
@ -93,7 +93,7 @@ Deutsch (primär), Englisch
## Datenschutz-Angaben (Data usage im Dashboard)
- Erhobene Datentypen: **Authentifizierungsinformationen** (API-Token), **Website-Inhalte** nur für
- Erhobene Datentypen: **Authentifizierungsinformationen** (Sitzungstoken), **Website-Inhalte** nur für
Autofill (verlassen den Browser nicht).
- Nicht verkauft/weitergegeben. Nicht für Werbung/Bonität genutzt.
- Verschlüsselte Übertragung (HTTPS).