Store-Build automatisch aus OpenNIT aktualisiert

This commit is contained in:
OpenNIT Extension Sync 2026-08-02 11:04:36 +00:00
parent baf13c865f
commit a032dd5b13
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>
<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>
<span class="form-text">Empfohlen: Anmeldung wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert.</span>
<div id="statusMsg"></div>
<!-- Erweitert: manueller Token -->
<details style="margin-top:1rem;">
<summary style="cursor:pointer;font-size:.82rem;color:#6c757d;font-weight:600;">Erweitert: manueller Token</summary>
<div style="margin-top:.75rem;">
<div class="form-group">
<label class="control-label" for="apiToken">API-Token</label>
<input type="password" class="form-control" id="apiToken"
placeholder="Token aus dem Vault einf&uuml;gen&hellip;">
<span class="form-text">Alternative ohne SSO (z. B. Kiosk/Headless): Vault &rarr; &bdquo;Extension&ldquo; &rarr; Token generieren.</span>
</div>
<div class="btn-group">
<button class="btn btn-default btn-sm" id="btnSave">Token speichern</button>
<button class="btn btn-default btn-sm" id="btnTest">Verbindung testen</button>
<span id="savedMsg"></span>
</div>
</div>
</details>
</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 => {
$('lockDuration').value = (cfg.lockDuration && cfg.lockDuration !== 'off') ? cfg.lockDuration : '15';
$('clipClear').checked = cfg.clipClear !== false; // Standard: an
$('totpAutoCopy').checked = cfg.totpAutoCopy !== false; // Standard: an
chrome.storage.local.get(['lockDuration', 'clipClear'], cfg => {
$('lockDuration').value = (cfg.lockDuration && cfg.lockDuration !== 'off') ? cfg.lockDuration : '15';
$('clipClear').checked = cfg.clipClear !== 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);
@ -38,20 +56,58 @@ function isSecureServerUrl(url) {
}
$('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 (!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 = [
'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 len = 20;
const sets = [
'abcdefghijkmnopqrstuvwxyz',
'ABCDEFGHJKLMNPQRSTUVWXYZ',
'23456789',
'!@#$%^&*()-_=+[]{}',
];
const all = sets.join('');
const buf = new Uint32Array(len);
crypto.getRandomValues(buf);
let out = [];
// Mindestens ein Zeichen je Set
sets.forEach((s, i) => { out.push(s[buf[i] % s.length]); });
for (let i = sets.length; i < len; i++) out.push(all[buf[i] % all.length]);
// mischen
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,18 +399,21 @@ 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;
$('btnCopyUser').style.display = '';
$('btnRevealUser').style.display = '';
$('btnCopyUser').style.display = '';
} else {
uEl.classList.add('empty');
uEl.textContent = 'Kein Benutzername';
$('btnCopyUser').style.display = 'none';
$('btnRevealUser').style.display = 'none';
$('btnCopyUser').style.display = 'none';
}
// Passwort (standardmäßig maskiert)
@ -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;
};
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();
};
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 (!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;
secsEl.textContent = remaining + 's';
if (barEl) barEl.style.width = Math.round(remaining / 30 * 100) + '%';
};
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();