From 07a57855801e8816dbe54385dfa912ad4e1e5f4d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:17:23 +0000 Subject: [PATCH] Sechs Review-Punkte: Speicherort, Domain-Pruefung, Rechte, Generator, Notizen, Bearbeiten 1. Passwort mehrstufiger Logins nicht mehr auf der Platte: Der Auftrag lag als __pendingFill in chrome.storage.local, also im Klartext auf der Festplatte. Die 30-Sekunden-Pruefung verhinderte nur die Verwendung, nicht die Speicherung - wurde der zweite Schritt nie erreicht, blieb das Passwort liegen. Er liegt jetzt ausschliesslich im Speicher des Service Workers, je Tab, und wird beim Abholen verbraucht, nach 30 s verworfen, beim Sperren geleert und beim Schliessen des Tabs entfernt. Reste frueherer Versionen raeumt onInstalled ab. 2. Domain-Warnung: fillDomainMatches akzeptierte mit eh.endsWith('.' + pageHost) auch die Gegenrichtung - ein Eintrag fuer vpn.firma.de galt auf firma.de als passend und die Warnung blieb aus. Diese Klausel entfaellt. 3. scripting und activeTab werden nicht mehr angefordert; beide waren unbenutzt (das Content-Script laeuft ueber content_scripts, der Tab-Zugriff ueber host_permissions). PERMISSIONS.md begruendete scripting mit dem nativen Value-Setter, was nichts damit zu tun hat. 4. Passwort-Generator: buf lieferte dieselben Werte fuer Zeichenwahl und Mischreihenfolge, wodurch die Permutation mit dem Inhalt korrelierte. Beides zieht jetzt getrennt ueber randomBelow(), das den obersten, unvollstaendigen Block verwirft (gleichverteilt statt Rest-Modulo). Laenge (12-48) und Sonderzeichen sind waehlbar. 5. Notizen laufen ueber copySecret und werden damit ebenfalls aus der Zwischenablage entfernt; sie enthalten in der Praxis oft Wiederherstellungscodes. copyToClipboard entfaellt. 6. urlmatch.js buendelt die drei abweichenden matchUrl-Fassungen zu einer Regel, geladen in Service Worker, Popup und Seiten. escAttr escapt jetzt auch & < > und Apostroph, traegt also in jedem Attributkontext. Eintraege lassen sich im Popup bearbeiten und loeschen; beim Bearbeiten bedeutet ein leeres Passwortfeld unveraendert, sodass das Passwort das Popup nicht verlaesst. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1 --- CHANGELOG.md | 20 ++++++ README.md | 1 + docs/ARCHITECTURE.md | 12 +++- docs/PERMISSIONS.md | 13 ++-- extension/background.js | 95 +++++++++++++++++++++------- extension/content.js | 38 +++--------- extension/manifest.json | 3 +- extension/popup.html | 34 +++++++++- extension/popup.js | 133 +++++++++++++++++++++++++++------------- extension/urlmatch.js | 63 +++++++++++++++++++ 10 files changed, 305 insertions(+), 107 deletions(-) create mode 100644 extension/urlmatch.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5db7b..e0998d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,32 @@ Die Erweiterungsversion (`manifest.json`) ist unabhängig von der OpenNIT-Server ## [Unreleased] ### Hinzugefügt +- **Einträge bearbeiten und löschen:** In der Detailansicht gibt es jetzt „Bearbeiten" und „Löschen". + Beim Bearbeiten bleibt das Passwortfeld leer – wer es leer lässt, behält das gespeicherte Passwort; + 2FA-Secret und Ordner bleiben unangetastet. Für Team-Einträge greift das Schreibrecht des Teams. +- **Passwort-Generator einstellbar:** Länge (12–48) und Sonderzeichen lassen sich beim Anlegen wählen – + hilfreich bei Seiten, die Sonderzeichen ablehnen. - **2FA-Code wird für den nächsten Schritt bereitgelegt:** Nach dem Ausfüllen eines Eintrags mit 2FA landet beim nächsten 2FA-Feld automatisch ein **frischer** Code in der Zwischenablage – auch dann, wenn die Abfrage erst auf einer Folgeseite kommt (z. B. Microsoft-Anmeldung). Einfügen genügt mit Strg + V; der bisherige Inhalt der Zwischenablage wird dabei überschrieben und der Code nach 30 Sekunden wieder entfernt. Abschaltbar unter „Sicherheit → 2FA-Code beim Anmelden bereitlegen". +### Sicherheit +- **Passwort für mehrstufige Logins nicht mehr auf der Festplatte:** Verteilt eine Anmeldung Benutzername + und Passwort auf zwei Schritte, wurde das Passwort bisher im lokalen Speicher der Erweiterung abgelegt – + und blieb dort liegen, wenn der zweite Schritt nie erreicht wurde. Es wird jetzt nur noch im Arbeits- + speicher gehalten, an den jeweiligen Tab gebunden und nach 30 Sekunden bzw. beim Sperren verworfen. +- **Warnung bei fremder Domain greift zuverlässiger:** Ein Eintrag für `vpn.firma.de` galt auf `firma.de` + fälschlich als passend, sodass die Warnung ausblieb. Zuordnung und Warnung nutzen jetzt dieselbe Regel: + nur die hinterlegte Adresse selbst und deren Unteradressen gelten als passend. +- **Weniger Berechtigungen:** `scripting` und `activeTab` werden nicht mehr angefordert – beide wurden + nicht benötigt. +- **Passwort-Generator:** Der Zufall für die Mischreihenfolge wird jetzt getrennt von der Zeichenauswahl + gezogen, und die Zeichenwahl ist gleichverteilt. +- Notizen werden beim Kopieren wie andere Geheimnisse behandelt und nach 30 Sekunden aus der + Zwischenablage entfernt. + ### Geändert - **Vorschläge weichen der Seite:** Sobald in ein Feld getippt wird, verschwindet die Vault-Liste – darunter erscheint typischerweise die Suche der Seite selbst. Bei Feldern, die erkennbar eine eigene diff --git a/README.md b/README.md index 8522e70..f88f24d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ deiner selbst gehosteten OpenNIT-Instanz und bietet: - 👁️ **Detailansicht** mit Anzeigen/Kopieren von Zugangsdaten und 2FA-Code mit Countdown - ➕ **Neue Einträge anlegen** inkl. **Passwort-Generator** - 🔒 **PIN-Sperre** mit demselben PIN wie der Web-Tresor (Dauer frei wählbar, bis „bis Browser schließt") +- ✏️ **Anlegen, Bearbeiten und Löschen** von Einträgen direkt im Popup - 🧹 **Zwischenablage-Auto-Clear** nach dem Kopieren von Geheimnissen - 📋 **2FA-Code bereitgelegt:** nach dem Ausfüllen landet beim nächsten 2FA-Feld – auch auf einer Folgeseite – automatisch ein frischer Code in der Zwischenablage (Strg + V genügt) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3388e7b..1b05bf7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,7 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht └───────────────┘ └──────────┬───────────┘ │ CLIP_WRITE ┌────────▼─────────┐ - │ offscreen.html │ (Zwischenablage leeren) + │ offscreen.html │ (Zwischenablage schreiben/leeren) └──────────────────┘ ``` @@ -29,6 +29,7 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht | `popup.html` / `popup.js` | Toolbar-Popup: Liste, Suche, Detailansicht, Anlegen + Generator, PIN-Schirm. | | `options.html` / `options.js` | Einstellungen: Server-URL, SSO-Anmeldung, PIN-Sperrdauer, Zwischenablage. | | `offscreen.html` / `offscreen.js` | Minimaldokument, das ausschließlich die Zwischenablage beschreibt bzw. leert (MV3-konform). | +| `urlmatch.js` | Gemeinsame Zuordnung Eintrag ↔ Seite (`VaultUrl`), geladen in allen drei Kontexten – damit Vorschlagsliste und Sicherheitswarnung dieselbe Regel anwenden. | ## Nachrichten (Auszug) @@ -38,7 +39,8 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht | `GET_LOCK` / `DO_UNLOCK` / `LOCK_NOW` | popup → bg | PIN-Sperre abfragen/entsperren/sperren | | `GET_ENTRIES` / `GET_MATCHING_ENTRIES` | popup/content → bg | Einträge (alle / passend zur URL) | | `GET_PASSWORD` / `GET_TOTP` | popup/content → bg | Secret **on demand** | -| `CREATE_ENTRY` | popup → bg | Neuen Eintrag anlegen | +| `CREATE_ENTRY` / `UPDATE_ENTRY` / `DELETE_ENTRY` | popup → bg | Eintrag anlegen / ändern / löschen | +| `SET_PENDING_FILL` / `TAKE_PENDING_FILL` | content → bg | Passwort für den zweiten Login-Schritt hinterlegen bzw. abholen (nur im Speicher, je Tab) | | `GET_FAVICON` | popup/content → bg | Favicon als Data-URL (serverseitig gecacht) | | `VAULT_FILL` | popup → content | Aktives Tab-Formular ausfüllen | | `SCHEDULE_CLIP_CLEAR` | popup/content → bg | Zwischenablage-Leerung planen | @@ -53,6 +55,8 @@ Alle Endpunkte unter `/api/vault/extension/` mit `Authorization: Bearer ` - `GET /entries/{id}/totp` – aktueller TOTP-Code + Restsekunden - `GET /entries/{id}/favicon?fetch=1` – gecachtes Favicon (bei Bedarf serverseitig geholt) - `POST /entries` – neuen Eintrag anlegen +- `POST /entries/{id}` – Eintrag ändern (leeres Passwortfeld = unverändert) +- `POST /entries/{id}/delete` – Eintrag löschen - `GET /status` – Token gültig? + `pin_enabled` / `pin_lock_secs` - `POST /unlock` – Tresor-PIN verifizieren + serverseitiges Entsperr-Fenster für den Token setzen - `POST /lock` – Token sofort wieder sperren (Entsperr-Fenster zurücksetzen) @@ -76,5 +80,7 @@ Fensterdauer fest; „Bis der Browser geschlossen wird" nutzt ein langes Serverf - **Kein Remote-Code** – alle Skripte im Paket (MV3-CSP-konform, keine Inline-Skripte). - **Secrets on demand** – Passwörter/TOTP erst bei Nutzung, nie in der Liste. -- **Kein persistentes Secret** – nur URL, Sitzungstoken, Einstellungen in `chrome.storage`. +- **Kein persistentes Secret** – nur URL, Sitzungstoken, Einstellungen in `chrome.storage`. Das Passwort + für einen mehrstufigen Login liegt ausschließlich im Speicher des Service Workers (je Tab, 30 s), + nie in `chrome.storage`, das auf die Festplatte geschrieben würde. - **Server-seitige Krypto** – Ver-/Entschlüsselung im OpenNIT-Server, nicht im Browser. diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 08d428b..1262ee6 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -16,15 +16,6 @@ Browser nicht. Es findet kein Tracking und keine Analyse statt. **Warum:** Das In-Seite-Dropdown mit Vorschlägen und die Felderkennung laufen als Content-Script. Notwendig für Autofill und die 2FA-Erkennung (inkl. Shadow-DOM und mehrstufiger Logins). -## `scripting` - -**Warum:** Werte werden über den nativen Value-Setter gesetzt und Events ausgelöst, damit auch -React/Vue/Angular-Formulare die Eingaben übernehmen. - -## `activeTab` - -**Warum:** Zugriff auf den aktiven Tab beim Ausfüllen aus dem Popup („Auf dieser Seite ausfüllen"). - ## `storage` **Warum:** Lokale Speicherung von Server-URL, Zugriffstoken (per SSO) und Einstellungen; Entsperr-Status in @@ -49,6 +40,10 @@ OpenNIT-Login-Seite und empfängt die Weiterleitung an `https://.c ## Bewusst NICHT angefordert +- **`scripting` / `activeTab`** – nicht nötig: Das Content-Script wird über `content_scripts` geladen, + das Ausfüllen aus dem Popup läuft über eine Nachricht an das bereits laufende Script. Der Zugriff auf + den aktiven Tab ist bereits durch `host_permissions` abgedeckt. + - **`tabs`** – entfällt: Die aktive Tab-Adresse ist bereits über `host_permissions` verfügbar. Dadurch erscheint **keine** „Browserverlauf lesen"-Warnung. - Keine `cookies`, `history`, `webRequest`, `downloads`, `notifications` o. Ä. diff --git a/extension/background.js b/extension/background.js index 6e6928c..86113c5 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,5 +1,7 @@ 'use strict'; +importScripts('urlmatch.js'); + // ── Cache ────────────────────────────────────────────────────────────────── let cachedEntries = null; let cacheTime = 0; @@ -100,13 +102,15 @@ async function setUnlockedLocal(dur) { } async function clearUnlocked() { await chrome.storage.session.remove('unlock'); - await chrome.storage.local.remove(['__armedTotp', '__pendingFill']); + await chrome.storage.local.remove('__armedTotp'); + clearPendingFills(); cachedEntries = null; cacheTime = 0; faviconCache.clear(); try { await apiFetch('/api/vault/extension/lock', { method: 'POST' }); } catch (e) { /* ignore */ } } async function onServerLocked() { await chrome.storage.session.remove('unlock'); - await chrome.storage.local.remove(['__armedTotp', '__pendingFill']); + await chrome.storage.local.remove('__armedTotp'); + clearPendingFills(); cachedEntries = null; cacheTime = 0; } async function doUnlock(pin) { @@ -153,8 +157,7 @@ async function fetchEntries(force = false) { * Legt einen persönlichen Eintrag im Tresor an. * * Läuft bewusst über `apiFetch`, damit derselbe Zugang wie für alle übrigen - * Aufrufe gilt: SSO-Access-Token (inkl. automatischer Erneuerung) oder – falls - * gesetzt – der manuelle Token. + * Aufrufe gilt, inklusive automatischer Erneuerung des Access-Tokens. * * @param {{title?:string,username?:string,password?:string,url?:string,notes?:string}} fields * @return {Promise<{ok:boolean,id?:number,locked?:boolean,error?:string}>} @@ -163,11 +166,42 @@ async function createEntry(fields) { if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' }; const body = new URLSearchParams(); ['title', 'username', 'password', 'url', 'notes'].forEach(k => body.append(k, fields?.[k] ?? '')); + return writeEntry('/api/vault/extension/entries', body.toString()); +} + +/** + * Ändert einen bestehenden Eintrag. Ein leeres Passwortfeld lässt das gespeicherte + * Passwort unangetastet; 2FA-Secret und Ordner bleiben serverseitig erhalten. + * + * @param {number|string} entryId + * @param {{title?:string,username?:string,password?:string,url?:string,notes?:string}} fields + * @return {Promise<{ok:boolean,locked?:boolean,error?:string}>} + */ +async function updateEntry(entryId, fields) { + if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' }; + const body = new URLSearchParams(); + ['title', 'username', 'password', 'url', 'notes'].forEach(k => body.append(k, fields?.[k] ?? '')); + return writeEntry(`/api/vault/extension/entries/${entryId}`, body.toString()); +} + +/** + * Löscht einen Eintrag (persönlich oder Team, sofern Schreibrecht besteht). + * + * @param {number|string} entryId + * @return {Promise<{ok:boolean,locked?:boolean,error?:string}>} + */ +async function deleteEntry(entryId) { + if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' }; + return writeEntry(`/api/vault/extension/entries/${entryId}/delete`, ''); +} + +// Gemeinsame Auswertung der schreibenden Endpunkte. +async function writeEntry(path, body) { try { - const res = await apiFetch('/api/vault/extension/entries', { + const res = await apiFetch(path, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: body.toString(), + body: body, }); if (!res) return { ok: false, error: 'Nicht konfiguriert.' }; if (res.status === 423) { await onServerLocked(); return { ok: false, locked: true, error: 'Tresor gesperrt.' }; } @@ -221,21 +255,28 @@ async function fetchFavicon(entryId) { } catch (e) { return null; } } -function matchUrl(entryUrl, pageUrl) { - if (!entryUrl) return false; - let pHost; - try { pHost = new URL(pageUrl).hostname.replace(/^www\./, ''); } catch { return false; } - return String(entryUrl).split('\n').some(line => { - const raw = line.trim(); - if (!raw) return false; - try { - const eu = raw.includes('://') ? raw : 'https://' + raw; - let eHost = new URL(eu).hostname.replace(/^www\./, ''); - if (eHost.startsWith('*.')) eHost = eHost.slice(2); - return pHost === eHost || pHost.endsWith('.' + eHost); - } catch { return false; } - }); + +// ── Ausstehendes Ausfüllen (mehrstufiger Login) ───────────────────────────── +// Bei Logins, die Benutzername und Passwort auf zwei Schritte verteilen, muss das +// Passwort den Seitenwechsel überdauern. Es bleibt dafür ausschließlich im +// Speicher des Service Workers – niemals in `chrome.storage`, das auf die +// Festplatte geschrieben wird. Je Tab ein Auftrag, mit harter Verfallszeit. +const PENDING_FILL_TTL = 30 * 1000; +const pendingFills = new Map(); // tabId -> { id, pw, user, ts } + +function setPendingFill(tabId, data) { + if (tabId == null) return; + pendingFills.set(tabId, Object.assign({ ts: Date.now() }, data)); } +function takePendingFill(tabId) { + if (tabId == null) return null; + const p = pendingFills.get(tabId); + if (!p) return null; + pendingFills.delete(tabId); + return (Date.now() - p.ts > PENDING_FILL_TTL) ? null : p; +} +function clearPendingFills() { pendingFills.clear(); } +chrome.tabs.onRemoved.addListener(tabId => pendingFills.delete(tabId)); // ── Zwischenablage automatisch leeren (Offscreen) ─────────────────────────── async function scheduleClipClear(text) { @@ -283,12 +324,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } if (msg.type === 'GET_MATCHING_ENTRIES') { fetchEntries().then(r => { - const matched = (r.entries || []).filter(e => matchUrl(e.url, msg.url)); + const matched = (r.entries || []).filter(e => VaultUrl.matches(e.url, msg.url)); sendResponse({ entries: matched, locked: r.locked }); }); return true; } if (msg.type === 'CREATE_ENTRY') { createEntry(msg.entry || {}).then(sendResponse); return true; } + if (msg.type === 'UPDATE_ENTRY') { updateEntry(msg.id, msg.entry || {}).then(sendResponse); return true; } + if (msg.type === 'DELETE_ENTRY') { deleteEntry(msg.id).then(sendResponse); return true; } if (msg.type === 'GET_PASSWORD') { fetchPassword(msg.id).then(password => sendResponse({ password })); return true; } if (msg.type === 'GET_TOTP') { fetchTotp(msg.id).then(result => sendResponse(result)); return true; } if (msg.type === 'GET_FAVICON') { fetchFavicon(msg.id).then(dataUrl => sendResponse({ dataUrl })); return true; } @@ -298,6 +341,12 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } if (msg.type === 'DO_UNLOCK') { doUnlock(msg.pin || '').then(sendResponse); return true; } if (msg.type === 'LOCK_NOW') { clearUnlocked().then(() => sendResponse({ ok: true })); return true; } + if (msg.type === 'SET_PENDING_FILL') { + setPendingFill(sender.tab?.id, { id: msg.id, pw: msg.pw || '', user: msg.user || '' }); + sendResponse({ ok: true }); + return true; + } + if (msg.type === 'TAKE_PENDING_FILL') { sendResponse({ fill: takePendingFill(sender.tab?.id) }); return true; } if (msg.type === 'CLIP_WRITE') { writeClipboard(msg.text || '').then(() => sendResponse({ ok: true })); return true; } if (msg.type === 'SCHEDULE_CLIP_CLEAR') { scheduleClipClear(msg.text || ''); sendResponse({ ok: true }); return true; } if (msg.type === 'CHECK_STATUS') { checkStatus().then(sendResponse); return true; } @@ -306,8 +355,10 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { // Der manuell eingetragene API-Token wird nicht mehr unterstützt; ein aus einer // früheren Version übernommener Wert wird beim Update aus dem Speicher entfernt. +// Der manuelle API-Token entfaellt, und ein ausstehender Fuellauftrag liegt nicht +// mehr im Speicher auf der Platte – Reste frueherer Versionen hier entfernen. chrome.runtime.onInstalled.addListener(() => { - chrome.storage.local.remove('apiToken'); + chrome.storage.local.remove(['apiToken', '__pendingFill']); }); // Cache alle 5 Minuten leeren; Zwischenablage-Clear nach Timeout. diff --git a/extension/content.js b/extension/content.js index 04f6bc3..7687cde 100644 --- a/extension/content.js +++ b/extension/content.js @@ -16,6 +16,7 @@ let currentField = null; let showGen = 0; let typedField = null; // Feld, in das der Nutzer zuletzt selbst getippt hat let fillingInProgress = false; // unterdrückt die Tipp-Erkennung beim Autofill +let pendingFillInFlight = false; // der Auftrag wird beim Abholen verbraucht – nur einmal gleichzeitig // ── Heuristik-Muster ──────────────────────────────────────────────────────── const RE_USER = /(user(name|id)?|login|logon|sign[-_ ]?in|account|konto|benutzer|kennung|anmeld|e[-_ ]?mail|email|mail|uid|userid|handle|identifier|ident\b|loginid)/i; @@ -206,26 +207,6 @@ function findOtpFields(ref) { return collectInputs(scopeOf(ref)).filter(el => isVisible(el) && isOtpField(el)); } -// ── URL-Matching ──────────────────────────────────────────────────────────── -function normalizeHost(raw) { - if (!raw) return ''; - try { - const s = raw.includes('://') ? raw : 'https://' + raw; - return new URL(s).hostname.replace(/^www\./, '').toLowerCase(); - } catch { return lc(raw).replace(/^www\./, ''); } -} -function matchUrl(entryUrls, pageUrl) { - const pageHost = normalizeHost(pageUrl); - if (!pageHost) return false; - const urls = typeof entryUrls === 'string' ? entryUrls.split('\n') : [entryUrls]; - return urls.some(u => { - let eh = normalizeHost((u || '').trim()); - if (!eh) return false; - if (eh.startsWith('*.')) eh = eh.slice(2); - return pageHost === eh || pageHost.endsWith('.' + eh); - }); -} - // ── Events ────────────────────────────────────────────────────────────────── function init() { document.addEventListener('focusin', onFocusIn, true); @@ -256,7 +237,7 @@ function fillFromPopup(msg) { if (userField && msg.username) setFieldValue(userField, msg.username); if (passField && msg.password) setFieldValue(passField, msg.password); - else if (msg.password) chrome.storage.local.set({ __pendingFill: { id: msg.id, pw: msg.password, user: msg.username || '', ts: Date.now() } }); + else if (msg.password) chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: msg.id, pw: msg.password, user: msg.username || '' }); if (msg.has_totp && msg.id != null) { armTotp(msg.id); @@ -336,7 +317,7 @@ function showSuggestions(field) { const mode = fieldKind(field) === 'otp' ? 'otp' : 'login'; chrome.runtime.sendMessage({ type: 'GET_MATCHING_ENTRIES', url: location.href }, resp => { if (gen !== showGen) return; - let entries = (resp && resp.entries || []).filter(e => matchUrl(e.url, location.href)); + let entries = (resp && resp.entries || []).filter(e => VaultUrl.matches(e.url, location.href)); if (mode === 'otp') entries = entries.filter(e => e.has_totp); if (!entries.length) { hideDrop(); return; } if (document.contains(field) && isVisible(field)) renderDrop(field, entries, mode); @@ -489,7 +470,7 @@ async function fillEntry(entry, focused) { if (userField && entry.username) setFieldValue(userField, entry.username); if (passField) setFieldValue(passField, pw); - else chrome.storage.local.set({ __pendingFill: { id: entry.id, pw: pw, user: entry.username || '', ts: Date.now() } }); + else chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: entry.id, pw: pw, user: entry.username || '' }); if (entry.has_totp) { // Der 2FA-Schritt folgt oft erst nach dem Absenden – ggf. auf der nächsten @@ -603,16 +584,17 @@ function scanForPendingWork() { if (otps.length) serveArmedTotp(otps, true); const pw = inputs.filter(isPasswordField); - if (!pw.length) return; - chrome.storage.local.get(['__pendingFill'], result => { - const p = result.__pendingFill; - if (!p || Date.now() - p.ts > 30000) return; + if (!pw.length || pendingFillInFlight) return; + pendingFillInFlight = true; + chrome.runtime.sendMessage({ type: 'TAKE_PENDING_FILL' }, resp => { + pendingFillInFlight = false; + const p = resp && resp.fill; + if (!p) return; pw.forEach(f => setFieldValue(f, p.pw)); if (p.user) { const uf = findUsernameField(pw[0]); if (uf && !uf.value) setFieldValue(uf, p.user); } - chrome.storage.local.remove('__pendingFill'); }); } diff --git a/extension/manifest.json b/extension/manifest.json index cae0877..a7cdc20 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -5,8 +5,6 @@ "description": "OpenNIT Vault – Passwort-Manager mit Autofill für Benutzer-, Passwort- und 2FA-Felder direkt im Browser.", "permissions": [ "storage", - "activeTab", - "scripting", "alarms", "offscreen", "identity" @@ -31,6 +29,7 @@ "" ], "js": [ + "urlmatch.js", "content.js" ], "run_at": "document_idle" diff --git a/extension/popup.html b/extension/popup.html index c6f1a77..1f9290e 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -147,6 +147,14 @@ body { .ne-pass-row .panel-input { flex: 1; } .ne-icon-btn { flex-shrink: 0; width: 36px; height: 36px; border: 1.5px solid var(--line); background: var(--card); border-radius: 9px; cursor: pointer; color: #5b6478; display: inline-flex; align-items: center; justify-content: center; transition: background .12s, color .12s, border-color .12s; } .ne-icon-btn:hover { background: var(--brand); border-color: var(--brand); color: #fff; } +.gen-opts { display: flex; align-items: center; gap: 14px; margin: -2px 0 2px; font-size: 11px; color: var(--muted); } +.gen-opt { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; } +.gen-select { border: 1.5px solid var(--line); border-radius: 7px; padding: 2px 4px; font-size: 11px; font-family: inherit; background: var(--card); color: var(--ink); cursor: pointer; } +.detail-actions { display: flex; gap: 8px; margin-top: 8px; } +.detail-action { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; background: var(--card); border: 1.5px solid var(--line); border-radius: 9px; padding: 8px 10px; font-size: 11.5px; font-weight: 600; font-family: inherit; color: #5b6478; cursor: pointer; transition: background .12s, color .12s, border-color .12s; } +.detail-action:hover { background: var(--brand); border-color: var(--brand); color: #fff; } +.detail-action.danger:hover { background: #dc3545; border-color: #dc3545; } +.detail-action:disabled { opacity: .55; cursor: default; } .field-notes { font-size: 12px; color: var(--ink); white-space: pre-wrap; word-break: break-word; max-height: 90px; overflow-y: auto; margin-top: 2px; } /* ── Lock Screen ────────────────────────────────────────── */ @@ -204,7 +212,7 @@ body {
- Neuen Eintrag anlegen + Neuen Eintrag anlegen
@@ -219,6 +227,19 @@ body {
+
+ + +
@@ -298,6 +319,16 @@ body { Auf dieser Seite ausfüllen +
+ + +
@@ -342,6 +373,7 @@ body {
+ \ No newline at end of file diff --git a/extension/popup.js b/extension/popup.js index 2f09f3a..104ea9d 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -5,6 +5,7 @@ let pageMatches = []; let entryIndex = {}; // id -> entry let detailState = null; // aktiver Eintrag im Detail-Panel let selIndex = -1; // Tastatur-Auswahl in der Liste +let editingId = null; // gesetzt, solange das Panel einen bestehenden Eintrag bearbeitet function $(id) { return document.getElementById(id); } @@ -33,8 +34,10 @@ async function init() { $('btnRevealPass').addEventListener('click', toggleRevealPass); $('btnCopyPass').addEventListener('click', copyDetailPassword); $('btnCopyTotp').addEventListener('click', () => { const c = $('detailTotp').dataset.code || ''; if (c) copySecret(c, 'TOTP kopiert'); }); - $('btnCopyNotes').addEventListener('click', () => copyToClipboard($('detailNotes').dataset.value || '', 'Notiz kopiert')); + $('btnCopyNotes').addEventListener('click', () => copySecret($('detailNotes').dataset.value || '', 'Notiz kopiert')); $('btnDetailFill').addEventListener('click', fillActiveTab); + $('btnDetailEdit').addEventListener('click', openEditPanel); + $('btnDetailDelete').addEventListener('click', deleteCurrentEntry); // Lock-Screen $('lockSubmit').addEventListener('click', submitPin); @@ -211,6 +214,9 @@ function setSel(items, idx) { // ── New Entry ───────────────────────────────────────────────────────────── function openNewPanel() { + editingId = null; + $('panelTitle').textContent = 'Neuen Eintrag anlegen'; + $('nePassword').placeholder = 'Passwort'; $('listWrap').style.display = 'none'; $('search').closest('.search-wrap').style.display = 'none'; $('detailPanel').style.display = 'none'; @@ -232,35 +238,92 @@ function openNewPanel() { } function closeNewPanel() { + editingId = null; $('newEntryPanel').style.display = 'none'; $('listWrap').style.display = ''; $('search').closest('.search-wrap').style.display = ''; } +// Gleichverteilte Zufallszahl aus [0, max) – verwirft die Werte des obersten, +// unvollständigen Blocks, damit kein Rest-Modulo einzelne Zeichen bevorzugt. +function randomBelow(max) { + const limit = Math.floor(0x100000000 / max) * max; + const buf = new Uint32Array(1); + do { crypto.getRandomValues(buf); } while (buf[0] >= limit); + return buf[0] % max; +} + +/** Zeichensätze ohne optisch verwechselbare Zeichen (l/I/1, O/0). */ +const GEN_SETS = [ + 'abcdefghijkmnopqrstuvwxyz', + 'ABCDEFGHJKLMNPQRSTUVWXYZ', + '23456789', +]; +const GEN_SYMBOLS = '!@#$%^&*()-_=+[]{}'; + function generatePassword() { - const len = 20; - const sets = [ - 'abcdefghijkmnopqrstuvwxyz', - 'ABCDEFGHJKLMNPQRSTUVWXYZ', - '23456789', - '!@#$%^&*()-_=+[]{}', - ]; - const all = sets.join(''); - const buf = new Uint32Array(len); - crypto.getRandomValues(buf); - let out = []; - // Mindestens ein Zeichen je Set - sets.forEach((s, i) => { out.push(s[buf[i] % s.length]); }); - for (let i = sets.length; i < len; i++) out.push(all[buf[i] % all.length]); - // mischen + const len = Math.max(8, parseInt($('genLen').value, 10) || 20); + const sets = $('genSymbols').checked ? GEN_SETS.concat(GEN_SYMBOLS) : GEN_SETS.slice(); + const all = sets.join(''); + + // Je Satz ein Zeichen garantieren, den Rest frei ziehen … + const out = sets.map(s => s[randomBelow(s.length)]); + while (out.length < len) out.push(all[randomBelow(all.length)]); + + // … und danach mischen, damit die garantierten Zeichen nicht vorne stehen. + // Der Zufall dafür wird frisch gezogen und nicht aus der Zeichenwahl wiederverwendet. for (let i = out.length - 1; i > 0; i--) { - const j = buf[i] % (i + 1); + const j = randomBelow(i + 1); [out[i], out[j]] = [out[j], out[i]]; } + $('nePassword').value = out.join(''); $('nePassword').type = 'text'; } +// Bestehenden Eintrag im selben Panel bearbeiten. Das Passwortfeld bleibt leer – +// leer bedeutet serverseitig „unverändert", sodass das Passwort das Popup nie verlässt. +function openEditPanel() { + if (!detailState) return; + const e = entryIndex[detailState.id]; + if (!e) return; + + editingId = detailState.id; + closeDetail(); + + $('panelTitle').textContent = 'Eintrag bearbeiten'; + $('neTitle').value = e.title || ''; + $('neUsername').value = e.username || ''; + $('nePassword').value = ''; + $('nePassword').type = 'password'; + $('nePassword').placeholder = 'Passwort (leer = unverändert)'; + $('neUrl').value = e.url || ''; + $('neNotes').value = e.notes || ''; + $('newEntryMsg').textContent = ''; + + $('listWrap').style.display = 'none'; + $('search').closest('.search-wrap').style.display = 'none'; + $('detailPanel').style.display = 'none'; + $('newEntryPanel').style.display = 'block'; + $('neTitle').focus(); +} + +function deleteCurrentEntry() { + if (!detailState) return; + const e = entryIndex[detailState.id]; + if (!e) return; + if (!window.confirm('Eintrag „' + (e.title || '') + '" wirklich löschen?')) return; + + const btn = $('btnDetailDelete'); + btn.disabled = true; + chrome.runtime.sendMessage({ type: 'DELETE_ENTRY', id: detailState.id }, resp => { + btn.disabled = false; + if (resp?.ok) { closeDetail(); reload(true); showToast('Eintrag gelöscht'); return; } + if (resp?.locked) { showLockScreen(); return; } + showToast(resp?.error || 'Löschen fehlgeschlagen'); + }); +} + // Speichern läuft – wie alle anderen Aufrufe – über den Background-Service-Worker, // der den gültigen Zugang beisteuert. function saveNewEntry() { @@ -278,14 +341,18 @@ function saveNewEntry() { url: $('neUrl').value.trim(), notes: $('neNotes').value.trim(), }; + const msg = editingId + ? { type: 'UPDATE_ENTRY', id: editingId, entry } + : { type: 'CREATE_ENTRY', entry }; + const wasEditing = !!editingId; - chrome.runtime.sendMessage({ type: 'CREATE_ENTRY', entry }, resp => { + chrome.runtime.sendMessage(msg, resp => { $('btnSaveNew').disabled = false; $('btnSaveNew').textContent = 'Speichern'; if (resp?.ok) { closeNewPanel(); reload(true); - showToast('Eintrag gespeichert'); + showToast(wasEditing ? 'Eintrag aktualisiert' : 'Eintrag gespeichert'); return; } if (resp?.locked) { showLockScreen(); return; } @@ -579,30 +646,12 @@ async function fillActiveTab() { } // ── Helfer ──────────────────────────────────────────────────────────────── -function hostOf(u) { - try { - const s = String(u || ''); - return new URL(s.includes('://') ? s : 'https://' + s).hostname.replace(/^www\./, '').toLowerCase(); - } catch { return ''; } -} +function hostOf(u) { return VaultUrl.host(u); } // True, wenn eine der (mehrzeiligen) Eintrags-URLs zur Seiten-Domain passt – // oder wenn im Eintrag gar keine URL hinterlegt ist (dann keine Warnung). -function fillDomainMatches(entryUrls, pageUrl) { - const pageHost = hostOf(pageUrl); - const list = String(entryUrls || '').split('\n').map(s => s.trim()).filter(Boolean); - if (!list.length) return true; - if (!pageHost) return false; - return list.some(u => { - let eh = hostOf(u); - if (eh.startsWith('*.')) eh = eh.slice(2); - return eh && (pageHost === eh || pageHost.endsWith('.' + eh) || eh.endsWith('.' + pageHost)); - }); -} -function copyToClipboard(text, msg) { - navigator.clipboard.writeText(text).then(() => showToast(msg)).catch(() => showToast('Fehler')); -} -// Wie copyToClipboard, plant aber zusätzlich das automatische Leeren der -// Zwischenablage (für Zugangsdaten/2FA). +function fillDomainMatches(entryUrls, pageUrl) { return VaultUrl.matchesOrUnset(entryUrls, pageUrl); } +// Alles, was aus einem Eintrag kommt, gilt als Geheimnis – Notizen enthalten in +// der Praxis ebenso oft Wiederherstellungscodes wie das Passwortfeld selbst. function copySecret(text, msg) { navigator.clipboard.writeText(text).then(() => { showToast(msg); @@ -616,6 +665,6 @@ function showToast(msg) { setTimeout(() => t.classList.remove('show'), 1800); } function esc(s) { return String(s||'').replace(/&/g,'&').replace(//g,'>'); } -function escAttr(s) { return String(s||'').replace(/"/g,'"'); } +function escAttr(s) { return esc(s).replace(/"/g, '"').replace(/'/g, '''); } init(); \ No newline at end of file diff --git a/extension/urlmatch.js b/extension/urlmatch.js new file mode 100644 index 0000000..c835b08 --- /dev/null +++ b/extension/urlmatch.js @@ -0,0 +1,63 @@ +'use strict'; + +/* + * Gemeinsame Zuordnung Eintrag ↔ Seite. + * + * Wird in allen drei Kontexten geladen (Service Worker via importScripts, + * Popup via