OpenNIT-Vault-Extension/extension/background.js
Claude 19051bbacd 2FA-Restlaufzeit korrigiert, manuellen API-Token entfernt
Countdown des 2FA-Codes: Die Anzeige zaehlte blind im Sekundentakt
herunter und lief dadurch aus dem Takt. Am Ende eines Zeitfensters wurde
zwar ein neuer Code angefordert, der Zaehler aber nicht angehalten - bei
langsamer oder fehlschlagender Antwort lief er ins Negative und stiess
jede Sekunde eine weitere Anfrage an (inkl. Audit-Eintrag je Abruf),
waehrend die Anzeige auf dem alten Wert stehenblieb.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1
2026-07-31 15:55:37 +02:00

312 lines
No EOL
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// ── Cache ──────────────────────────────────────────────────────────────────
let cachedEntries = null;
let cacheTime = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 Minuten
const faviconCache = new Map();
let pendingClip = null;
let refreshInFlight = null; // Single-Flight: verhindert parallele Refresh-Aufrufe (Rotation-Race)
async function getServerUrl() {
const c = await new Promise(r => chrome.storage.local.get(['serverUrl'], r));
return c.serverUrl || null;
}
// ── Zugangstoken beschaffen (SSO) ──────────────────────────────────────────
// Kurzlebiger Access-Token aus der Session; ist er abgelaufen, wird er über den
// rotierenden Refresh-Token erneuert.
async function getAccessToken() {
const cfg = await new Promise(r => chrome.storage.local.get(
['serverUrl', 'apiRefreshToken', 'apiRefreshExpiresAt'], r));
if (!cfg.serverUrl) return null;
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) {
return sess.accessToken;
}
// Nur EINEN Refresh gleichzeitig ausführen; parallele Aufrufer warten mit.
if (!refreshInFlight) {
refreshInFlight = refreshAccessToken(cfg).finally(() => { refreshInFlight = null; });
}
return await refreshInFlight;
}
async function refreshAccessToken(cfg) {
try {
const body = new URLSearchParams();
body.append('grant_type', 'refresh_token');
body.append('refresh_token', cfg.apiRefreshToken);
const res = await fetch(`${cfg.serverUrl}/api/vault/extension/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
// ungültig / abgelaufen / Reuse-Detection → SSO-Tokens verwerfen (Re-Login nötig)
await chrome.storage.local.remove(['apiRefreshToken', 'apiRefreshExpiresAt']);
await chrome.storage.session.remove(['accessToken', 'accessExpiresAt']);
return null;
}
const data = await res.json();
// Rotation: den NEUEN Refresh-Token speichern.
await chrome.storage.local.set({
apiRefreshToken: data.refresh_token,
apiRefreshExpiresAt: Date.now() + (data.refresh_expires_in || 0) * 1000,
});
await chrome.storage.session.set({
accessToken: data.access_token,
accessExpiresAt: Date.now() + (data.expires_in || 0) * 1000,
});
return data.access_token;
} catch (e) { return null; }
}
// Zentraler API-Aufruf: hängt Server-URL + gültigen Bearer an. null = nicht verfügbar.
async function apiFetch(path, opts = {}) {
const serverUrl = await getServerUrl();
const token = await getAccessToken();
if (!serverUrl || !token) return null;
const headers = Object.assign({}, opts.headers, { 'Authorization': 'Bearer ' + token });
return fetch(serverUrl + path, Object.assign({}, opts, { headers }));
}
// ── Lock-Gate (serverseitig erzwungen; Client spiegelt nur) ─────────────────
async function lockSettings() {
return new Promise(resolve => chrome.storage.local.get(['lockDuration', 'lockEnabled'], resolve));
}
function lockRequired(s) { return !!s.lockEnabled; }
function durationSecs(dur) {
switch (String(dur)) {
case '5': return 300;
case '60': return 3600;
case 'session': return 43200;
case 'off': return 900;
default: return 900;
}
}
async function isUnlocked() {
const s = await lockSettings();
if (!lockRequired(s)) return true;
const sess = await chrome.storage.session.get(['unlock']);
const u = sess.unlock;
if (!u) return false;
if (u.sticky) return true;
return !!u.until && Date.now() < u.until;
}
async function setUnlockedLocal(dur) {
const unlock = (String(dur) === 'session') ? { sticky: true } : { until: Date.now() + durationSecs(dur) * 1000 };
await chrome.storage.session.set({ unlock });
}
async function clearUnlocked() {
await chrome.storage.session.remove('unlock');
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');
cachedEntries = null; cacheTime = 0;
}
async function doUnlock(pin) {
const s = await lockSettings();
const dur = s.lockDuration || '15';
try {
const body = new URLSearchParams();
body.append('pin', pin);
body.append('duration_secs', String(durationSecs(dur)));
const res = await apiFetch('/api/vault/extension/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res) return { ok: false, error: 'Nicht konfiguriert.' };
const data = await res.json().catch(() => ({}));
if (data.ok) { await setUnlockedLocal(dur); return { ok: true }; }
return { ok: false, error: data.error || 'PIN falsch.', lockSecs: data.lock_secs || 0 };
} catch (e) { return { ok: false, error: 'Verbindungsfehler.' }; }
}
// ── Daten ───────────────────────────────────────────────────────────────────
async function fetchEntries(force = false) {
if (!(await isUnlocked())) return { entries: null, locked: true };
const now = Date.now();
if (!force && cachedEntries && (now - cacheTime) < CACHE_TTL) {
return { entries: cachedEntries, locked: false };
}
try {
const res = await apiFetch('/api/vault/extension/entries');
if (!res) return { entries: null, locked: false };
if (res.status === 423) { await onServerLocked(); return { entries: null, locked: true }; }
if (!res.ok) { cachedEntries = null; return { entries: null, locked: false }; }
const data = await res.json();
if (data.ok) {
cachedEntries = data.entries; cacheTime = Date.now();
return { entries: cachedEntries, locked: false };
}
} catch (e) { /* ignore */ }
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: SSO-Access-Token (inkl. automatischer Erneuerung) oder falls
* gesetzt der manuelle Token.
*
* @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] ?? ''));
try {
const res = await apiFetch('/api/vault/extension/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
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 {
const res = await apiFetch(`/api/vault/extension/entries/${entryId}/password`);
if (!res) return null;
if (res.status === 423) { await onServerLocked(); return null; }
if (!res.ok) return null;
const data = await res.json();
return data.ok ? data.password : null;
} catch (e) { return null; }
}
async function fetchTotp(entryId) {
if (!(await isUnlocked())) return null;
try {
const res = await apiFetch(`/api/vault/extension/entries/${entryId}/totp`);
if (!res) return null;
if (res.status === 423) { await onServerLocked(); return null; }
if (!res.ok) return null;
const data = await res.json();
return data.ok ? { code: data.code, remaining: data.remaining } : null;
} catch (e) { return null; }
}
async function fetchFavicon(entryId) {
if (faviconCache.has(entryId)) return faviconCache.get(entryId);
try {
const res = await apiFetch(`/api/vault/extension/entries/${entryId}/favicon?fetch=1`);
if (!res || !res.ok) { faviconCache.set(entryId, null); return null; }
const blob = await res.blob();
const dataUrl = await new Promise(resolve => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = () => resolve(null);
fr.readAsDataURL(blob);
});
faviconCache.set(entryId, dataUrl);
return dataUrl;
} catch (e) { return null; }
}
function matchUrl(entryUrl, pageUrl) {
if (!entryUrl) return false;
let pHost;
try { pHost = new URL(pageUrl).hostname.replace(/^www\./, ''); } catch { return false; }
return String(entryUrl).split('\n').some(line => {
const raw = line.trim();
if (!raw) return false;
try {
const eu = raw.includes('://') ? raw : 'https://' + raw;
let eHost = new URL(eu).hostname.replace(/^www\./, '');
if (eHost.startsWith('*.')) eHost = eHost.slice(2);
return pHost === eHost || pHost.endsWith('.' + eHost);
} catch { return false; }
});
}
// ── Zwischenablage automatisch leeren (Offscreen) ───────────────────────────
async function scheduleClipClear(text) {
const cfg = await new Promise(r => chrome.storage.local.get(['clipClear'], r));
if (cfg.clipClear === false) return;
pendingClip = text || '';
chrome.alarms.create('clipClear', { delayInMinutes: 0.5 });
}
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: 'Zwischenablage nach dem Kopieren von Zugangsdaten leeren.',
});
}
await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: '' });
} catch (e) { /* ignore */ }
}
// ── Status prüfen (Bearer) ──────────────────────────────────────────────────
async function checkStatus() {
const res = await apiFetch('/api/vault/extension/status');
if (!res) return { ok: false, reason: 'not_configured' };
try {
const data = await res.json();
if (data && typeof data.pin_enabled !== 'undefined') {
await new Promise(r => chrome.storage.local.set({ lockEnabled: !!data.pin_enabled }, r));
}
return data;
} catch { return { ok: false, reason: 'network_error' }; }
}
// ── Message Handler ──────────────────────────────────────────────────────────
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (!msg || msg.target === 'offscreen') return;
if (msg.type === 'GET_ENTRIES') {
fetchEntries(msg.force).then(r => sendResponse({ entries: r.entries, locked: r.locked }));
return true;
}
if (msg.type === 'GET_MATCHING_ENTRIES') {
fetchEntries().then(r => {
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 === '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; }
if (msg.type === 'GET_LOCK') {
Promise.all([lockSettings(), isUnlocked()]).then(([s, unlocked]) => sendResponse({ required: lockRequired(s), unlocked }));
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 === '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.
chrome.runtime.onInstalled.addListener(() => {
chrome.storage.local.remove('apiToken');
});
// Cache alle 5 Minuten leeren; Zwischenablage-Clear nach Timeout.
chrome.alarms.create('clearCache', { periodInMinutes: 5 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'clearCache') { cachedEntries = null; cacheTime = 0; faviconCache.clear(); return; }
if (alarm.name === 'clipClear') { await clearClipboard(); pendingClip = null; }
});