Compare commits

..

6 commits

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

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

Das Build-Verzeichnis dist/ gehoert nicht ins Repository.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

@ -229,29 +229,13 @@ label.control-label {
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px;"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px;"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
Mit OpenNIT anmelden Mit OpenNIT anmelden
</button> </button>
<button class="btn btn-default" id="btnSave">URL speichern</button>
<button class="btn btn-default" id="btnLogout" style="display:none;">Abmelden</button> <button class="btn btn-default" id="btnLogout" style="display:none;">Abmelden</button>
<span id="ssoMsg"></span> <span id="ssoMsg"></span>
</div>
<span class="form-text">Empfohlen: Anmeldung wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert.</span>
<div id="statusMsg"></div>
<!-- Erweitert: manueller Token -->
<details style="margin-top:1rem;">
<summary style="cursor:pointer;font-size:.82rem;color:#6c757d;font-weight:600;">Erweitert: manueller Token</summary>
<div style="margin-top:.75rem;">
<div class="form-group">
<label class="control-label" for="apiToken">API-Token</label>
<input type="password" class="form-control" id="apiToken"
placeholder="Token aus dem Vault einf&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> <span id="savedMsg"></span>
</div> </div>
</div> <span class="form-text">Die Anmeldung erfolgt wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert und l&auml;sst sich jederzeit im Vault widerrufen.</span>
</details> <div id="statusMsg"></div>
</div> </div>
</div> </div>
@ -275,6 +259,10 @@ label.control-label {
<label class="control-label"><input type="checkbox" id="clipClear" checked> Zwischenablage nach 30&nbsp;Sekunden leeren</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> <span class="form-text">Kopierte Passwörter und 2FA-Codes werden nach kurzer Zeit automatisch aus der Zwischenablage entfernt.</span>
</div> </div>
<div class="form-group">
<label class="control-label"><input type="checkbox" id="totpAutoCopy" checked> 2FA-Code beim Anmelden bereitlegen</label>
<span class="form-text">Nach dem Ausf&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"> <div class="btn-group">
<button class="btn btn-primary btn-sm" id="btnSaveSec">Speichern</button> <button class="btn btn-primary btn-sm" id="btnSaveSec">Speichern</button>
<span id="savedSecMsg"></span> <span id="savedSecMsg"></span>
@ -289,9 +277,9 @@ label.control-label {
</div> </div>
<div class="card-body"> <div class="card-body">
<ul class="steps-list"> <ul class="steps-list">
<li><span class="step-badge">1</span><span>Öffnen Sie den <strong>Passwort-Vault</strong> in Ihrer OpenNIT-Anwendung.</span></li> <li><span class="step-badge">1</span><span>Tragen Sie oben die <strong>Server-URL</strong> Ihrer OpenNIT-Instanz ein (nur <strong>https://</strong>).</span></li>
<li><span class="step-badge">2</span><span>Klicken Sie auf <strong>&bdquo;Extension&ldquo;</strong> in der Toolbar und generieren Sie einen API-Token.</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>Fügen Sie den Token oben in das Token-Feld ein und klicken Sie auf <strong>Speichern</strong>.</span></li> <li><span class="step-badge">3</span><span>Best&auml;tigen Sie den Zugriff auf den <strong>Passwort-Vault</strong> &ndash; die Erweiterung ist damit verbunden.</span></li>
<li><span class="step-badge">4</span><span>Fokussieren Sie ein Login-Feld auf einer Website &ndash; Vault-Vorschl&auml;ge erscheinen automatisch.</span></li> <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> </ul>
</div> </div>

View file

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

View file

@ -147,6 +147,14 @@ body {
.ne-pass-row .panel-input { flex: 1; } .ne-pass-row .panel-input { flex: 1; }
.ne-icon-btn { flex-shrink: 0; width: 36px; height: 36px; border: 1.5px solid var(--line); background: var(--card); border-radius: 9px; cursor: pointer; color: #5b6478; display: inline-flex; align-items: center; justify-content: center; transition: background .12s, color .12s, border-color .12s; } .ne-icon-btn { flex-shrink: 0; width: 36px; height: 36px; border: 1.5px solid var(--line); background: var(--card); border-radius: 9px; cursor: pointer; color: #5b6478; display: inline-flex; align-items: center; justify-content: center; transition: background .12s, color .12s, border-color .12s; }
.ne-icon-btn:hover { background: var(--brand); border-color: var(--brand); color: #fff; } .ne-icon-btn:hover { background: var(--brand); border-color: var(--brand); color: #fff; }
.gen-opts { display: flex; align-items: center; gap: 14px; margin: -2px 0 2px; font-size: 11px; color: var(--muted); }
.gen-opt { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
.gen-select { border: 1.5px solid var(--line); border-radius: 7px; padding: 2px 4px; font-size: 11px; font-family: inherit; background: var(--card); color: var(--ink); cursor: pointer; }
.detail-actions { display: flex; gap: 8px; margin-top: 8px; }
.detail-action { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; background: var(--card); border: 1.5px solid var(--line); border-radius: 9px; padding: 8px 10px; font-size: 11.5px; font-weight: 600; font-family: inherit; color: #5b6478; cursor: pointer; transition: background .12s, color .12s, border-color .12s; }
.detail-action:hover { background: var(--brand); border-color: var(--brand); color: #fff; }
.detail-action.danger:hover { background: #dc3545; border-color: #dc3545; }
.detail-action:disabled { opacity: .55; cursor: default; }
.field-notes { font-size: 12px; color: var(--ink); white-space: pre-wrap; word-break: break-word; max-height: 90px; overflow-y: auto; margin-top: 2px; } .field-notes { font-size: 12px; color: var(--ink); white-space: pre-wrap; word-break: break-word; max-height: 90px; overflow-y: auto; margin-top: 2px; }
/* ── Lock Screen ────────────────────────────────────────── */ /* ── Lock Screen ────────────────────────────────────────── */
@ -204,7 +212,7 @@ body {
<!-- New Entry Panel (initially hidden) --> <!-- New Entry Panel (initially hidden) -->
<div id="newEntryPanel"> <div id="newEntryPanel">
<div class="panel-hd"> <div class="panel-hd">
<span class="panel-hd-title">Neuen Eintrag anlegen</span> <span class="panel-hd-title" id="panelTitle">Neuen Eintrag anlegen</span>
<button class="panel-close" id="btnCloseNew" title="Schließen">&#x2715;</button> <button class="panel-close" id="btnCloseNew" title="Schließen">&#x2715;</button>
</div> </div>
<div class="panel-body"> <div class="panel-body">
@ -219,6 +227,19 @@ body {
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</button> </button>
</div> </div>
<div class="gen-opts">
<label class="gen-opt" for="genLen">L&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"> <input class="panel-input" id="neUrl" type="url" placeholder="URL (https://…)" autocomplete="off">
<textarea class="panel-input" id="neNotes" placeholder="Notizen" rows="2" style="resize:vertical;font-family:inherit;"></textarea> <textarea class="panel-input" id="neNotes" placeholder="Notizen" rows="2" style="resize:vertical;font-family:inherit;"></textarea>
<button class="panel-save" id="btnSaveNew">Speichern</button> <button class="panel-save" id="btnSaveNew">Speichern</button>
@ -248,9 +269,6 @@ body {
<div class="field-lbl">Benutzername</div> <div class="field-lbl">Benutzername</div>
<div class="field-row"> <div class="field-row">
<span class="field-val" id="detailUser"></span> <span class="field-val" id="detailUser"></span>
<button class="field-btn" id="btnRevealUser" title="Anzeigen/Verbergen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="field-btn" id="btnCopyUser" title="Kopieren"> <button class="field-btn" id="btnCopyUser" title="Kopieren">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button> </button>
@ -298,6 +316,16 @@ body {
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
Auf dieser Seite ausf&uuml;llen Auf dieser Seite ausf&uuml;llen
</button> </button>
<div class="detail-actions">
<button class="detail-action" id="btnDetailEdit">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>
Bearbeiten
</button>
<button class="detail-action danger" id="btnDetailDelete">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
L&ouml;schen
</button>
</div>
</div> </div>
</div> </div>
@ -342,6 +370,7 @@ body {
</div> </div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="urlmatch.js"></script>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>
</html> </html>

View file

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