Compare commits

..

7 commits

Author SHA1 Message Date
OpenNIT Extension Sync
a032dd5b13 Store-Build automatisch aus OpenNIT aktualisiert 2026-08-02 11:04:36 +00:00
Claude
baf13c865f 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 15:55:37 +02:00
Claude
1b3ed44e6e 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 15:55:37 +02:00
Claude
07a5785580 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 15:55:37 +02:00
Claude
26c01a4fc3 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 15:55:37 +02:00
Claude
19051bbacd 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 15:55:37 +02:00
Claude
ea5cd8d623 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 15:55:37 +02:00
8 changed files with 295 additions and 487 deletions

17
extension/LICENSE.txt Normal file
View file

@ -0,0 +1,17 @@
OpenNIT Vault Browser-Erweiterung
Copyright (C) 2025 Friederich Loheide
Dieses Programm ist freie Software: Sie koennen es unter den Bedingungen
der GNU Affero General Public License, Version 3, weitergeben und/oder
veraendern.
Die Veroeffentlichung erfolgt in der Hoffnung, dass es nuetzlich ist,
jedoch OHNE JEDE GEWAEHRLEISTUNG - sogar ohne die implizite Gewaehr-
leistung der Marktreife oder der Eignung fuer einen bestimmten Zweck.
Einzelheiten regelt die GNU Affero General Public License.
Lizenztext: https://www.gnu.org/licenses/agpl-3.0.html
Quelltext: https://opennit.de
Der vollstaendige Lizenztext liegt der OpenNIT-Installation als Datei
LICENSE bei und kann ueber die oben genannte Adresse bezogen werden.

View file

@ -1,7 +1,5 @@
'use strict';
importScripts('urlmatch.js');
// ── Cache ──────────────────────────────────────────────────────────────────
let cachedEntries = null;
let cacheTime = 0;
@ -15,13 +13,13 @@ async function getServerUrl() {
return c.serverUrl || null;
}
// ── Zugangstoken beschaffen (SSO) ──────────────────────────────────────────
// Kurzlebiger Access-Token aus der Session; ist er abgelaufen, wird er über den
// rotierenden Refresh-Token erneuert.
// ── Zugangstoken beschaffen (SSO-Refresh oder manueller Token) ──────────────
// Reihenfolge: manueller Token (Erweitert) > SSO-Access-Token (mit Auto-Refresh).
async function getAccessToken() {
const cfg = await new Promise(r => chrome.storage.local.get(
['serverUrl', 'apiRefreshToken', 'apiRefreshExpiresAt'], r));
['serverUrl', 'apiToken', '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) {
@ -102,15 +100,11 @@ 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) {
@ -153,67 +147,6 @@ 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 {
@ -255,28 +188,21 @@ async function fetchFavicon(entryId) {
} catch (e) { return null; }
}
// ── 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 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; }
});
}
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) {
@ -285,22 +211,19 @@ async function scheduleClipClear(text) {
pendingClip = text || '';
chrome.alarms.create('clipClear', { delayInMinutes: 0.5 });
}
// 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) {
async function clearClipboard() {
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: 'Zugangsdaten in die Zwischenablage legen und nach kurzer Zeit wieder entfernen.',
justification: 'Zwischenablage nach dem Kopieren von Zugangsdaten leeren.',
});
}
await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: text || '' });
await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: '' });
} catch (e) { /* ignore */ }
}
async function clearClipboard() { await writeClipboard(''); }
// ── Status prüfen (Bearer) ──────────────────────────────────────────────────
async function checkStatus() {
@ -324,14 +247,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
if (msg.type === 'GET_MATCHING_ENTRIES') {
fetchEntries().then(r => {
const matched = (r.entries || []).filter(e => VaultUrl.matches(e.url, msg.url));
const matched = (r.entries || []).filter(e => matchUrl(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; }
@ -341,26 +261,11 @@ 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,9 +14,6 @@ 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;
@ -31,11 +28,7 @@ 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) {
// 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 */ }
});
navigator.clipboard.writeText(text).catch(() => {});
try { chrome.runtime.sendMessage({ type: 'SCHEDULE_CLIP_CLEAR', text: text }); } catch (e) { /* ignore */ }
}
function attr(el, n) { try { return el.getAttribute(n) || ''; } catch { return ''; } }
@ -124,28 +117,6 @@ 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';
@ -207,12 +178,31 @@ 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);
@ -237,44 +227,27 @@ function fillFromPopup(msg) {
if (userField && msg.username) setFieldValue(userField, msg.username);
if (passField && msg.password) setFieldValue(passField, msg.password);
else if (msg.password) chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: msg.id, pw: msg.password, user: msg.username || '' });
else if (msg.password) chrome.storage.local.set({ __pendingFill: { id: msg.id, pw: msg.password, user: msg.username || '', ts: Date.now() } });
if (msg.has_totp && msg.id != null) {
armTotp(msg.id);
deliverTotp(msg.id, findOtpFields(passField || userField || document.body));
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);
});
}
}
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 onFocusIn(e) { 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);
}
@ -317,7 +290,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 => VaultUrl.matches(e.url, location.href));
let entries = (resp && resp.entries || []).filter(e => matchUrl(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);
@ -470,66 +443,17 @@ async function fillEntry(entry, focused) {
if (userField && entry.username) setFieldValue(userField, entry.username);
if (passField) setFieldValue(passField, pw);
else chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: entry.id, pw: pw, user: entry.username || '' });
else chrome.storage.local.set({ __pendingFill: { id: entry.id, pw: pw, user: entry.username || '', ts: Date.now() } });
if (entry.has_totp) {
// 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();
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);
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) {
@ -555,7 +479,6 @@ function distributeOtp(fields, code) {
}
function setFieldValue(field, value) {
fillingInProgress = true;
try {
field.focus({ preventScroll: true });
const proto = (typeof HTMLTextAreaElement !== 'undefined' && field instanceof HTMLTextAreaElement)
@ -568,47 +491,26 @@ function setFieldValue(field, value) {
field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
field.dispatchEvent(new Event('blur', { bubbles: true }));
} catch {}
fillingInProgress = false;
}
// ── Mehrstufiger Login: Passwort/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;
// ── 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;
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);
@ -629,20 +531,18 @@ 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:' + Math.min(100, remaining / TOTP_PERIOD * 100) + '%;transition:width 1s linear;"></div></div>'
+ '<div id="__vault_totp_bar" style="height:100%;background:#28a745;width:' + (remaining / 30 * 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);
// 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;
let secs = remaining;
const iv = setInterval(() => {
const secs = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
secs--;
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 = Math.min(100, secs / TOTP_PERIOD * 100) + '%'; if (secs <= 10) bar.style.background = '#dc3545'; }
if (bar) { bar.style.width = (secs / 30 * 100) + '%'; if (secs < 10) bar.style.background = '#dc3545'; }
}, 1000);
notif.addEventListener('click', () => { clearInterval(iv); notif.remove(); });
}

View file

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

View file

@ -229,13 +229,29 @@ 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>
<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>
</details>
</div>
</div>
@ -259,10 +275,6 @@ 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>
@ -277,9 +289,9 @@ label.control-label {
</div>
<div class="card-body">
<ul class="steps-list">
<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">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">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,22 +3,21 @@
function $(id) { return document.getElementById(id); }
// Gespeicherte Werte laden (überschreibt das vorausgefüllte Feld nur wenn bereits gespeichert)
chrome.storage.local.get(['serverUrl'], cfg => {
chrome.storage.local.get(['serverUrl', 'apiToken'], cfg => {
if (cfg.serverUrl) $('serverUrl').value = cfg.serverUrl;
if (cfg.apiToken) $('apiToken').value = '••••••••';
});
// Sicherheits-Einstellungen laden
chrome.storage.local.get(['lockDuration', 'clipClear', 'totpAutoCopy'], cfg => {
chrome.storage.local.get(['lockDuration', 'clipClear'], 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>';
@ -26,8 +25,27 @@ $('btnSaveSec').addEventListener('click', () => {
});
});
// HTTPS erzwingen (außer localhost) sonst gingen Zugangsdaten und Passwörter
// im Klartext über die Leitung.
// App-Name und Verbindungsstatus laden (falls Token bereits gesetzt)
chrome.storage.local.get(['serverUrl', 'apiToken'], async cfg => {
if (!cfg.serverUrl || !cfg.apiToken) return;
try {
const res = await fetch(`${cfg.serverUrl}/api/vault/extension/status`, {
headers: { 'Authorization': `Bearer ${cfg.apiToken}` }
});
const data = await res.json();
if (data.ok) {
// Name der Erweiterung bleibt fest „OpenNIT Vault"; die Instanz wird
// beim angemeldeten Nutzer zur Orientierung angezeigt.
if (data.user) {
$('headerUser').textContent = data.app_name ? (data.user + ' · ' + data.app_name) : data.user;
$('headerStatus').style.display = '';
}
}
} catch { /* ignore */ }
});
// HTTPS erzwingen (außer localhost) sonst gingen Token und Passwörter im
// Klartext über die Leitung.
function isSecureServerUrl(url) {
try {
const u = new URL(url);
@ -39,19 +57,57 @@ 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 Zugangsdaten und Passwörter unverschlüsselt übertragen.', false);
showStatus('Bitte eine <strong>https://</strong>-Adresse verwenden (nur localhost darf http:// sein). Sonst würden Token und Passwörter unverschlüsselt übertragen.', false);
return;
}
chrome.storage.local.set({ serverUrl: url }, () => {
const data = { serverUrl: url };
if (token && !token.startsWith('•')) data.apiToken = token;
chrome.storage.local.set(data, () => {
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)));
@ -112,6 +168,7 @@ 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);
@ -146,7 +203,7 @@ function reflectAuthState() {
});
}
// Verbindungsstatus über den Background (nutzt den SSO-Access-Token)
// Verbindungsstatus über den Background (nutzt SSO-Access-Token oder manuellen Token)
function loadConnStatus() {
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, data => {
if (data && data.ok) {

View file

@ -147,14 +147,6 @@ 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 ────────────────────────────────────────── */
@ -212,7 +204,7 @@ body {
<!-- New Entry Panel (initially hidden) -->
<div id="newEntryPanel">
<div class="panel-hd">
<span class="panel-hd-title" id="panelTitle">Neuen Eintrag anlegen</span>
<span class="panel-hd-title">Neuen Eintrag anlegen</span>
<button class="panel-close" id="btnCloseNew" title="Schließen">&#x2715;</button>
</div>
<div class="panel-body">
@ -227,19 +219,6 @@ 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>
@ -269,6 +248,9 @@ 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>
@ -316,16 +298,6 @@ 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>
@ -370,7 +342,6 @@ body {
</div>
<div class="toast" id="toast"></div>
<script src="urlmatch.js"></script>
<script src="popup.js"></script>
</body>
</html>

View file

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