OpenNIT-Vault-Extension/extension/background.js
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

369 lines
No EOL
17 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';
importScripts('urlmatch.js');
// ── 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');
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) {
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, 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 {
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; }
}
// ── Ausstehendes Ausfüllen (mehrstufiger Login) ─────────────────────────────
// Bei Logins, die Benutzername und Passwort auf zwei Schritte verteilen, muss das
// Passwort den Seitenwechsel überdauern. Es bleibt dafür ausschließlich im
// Speicher des Service Workers niemals in `chrome.storage`, das auf die
// Festplatte geschrieben wird. Je Tab ein Auftrag, mit harter Verfallszeit.
const PENDING_FILL_TTL = 30 * 1000;
const pendingFills = new Map(); // tabId -> { id, pw, user, ts }
function setPendingFill(tabId, data) {
if (tabId == null) return;
pendingFills.set(tabId, Object.assign({ ts: Date.now() }, data));
}
function takePendingFill(tabId) {
if (tabId == null) return null;
const p = pendingFills.get(tabId);
if (!p) return null;
pendingFills.delete(tabId);
return (Date.now() - p.ts > PENDING_FILL_TTL) ? null : p;
}
function clearPendingFills() { pendingFills.clear(); }
chrome.tabs.onRemoved.addListener(tabId => pendingFills.delete(tabId));
// ── Zwischenablage automatisch leeren (Offscreen) ───────────────────────────
async function scheduleClipClear(text) {
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 });
}
// 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 {
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.',
});
}
await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: text || '' });
} catch (e) { /* ignore */ }
}
async function clearClipboard() { await writeClipboard(''); }
// ── 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 => VaultUrl.matches(e.url, msg.url));
sendResponse({ entries: matched, locked: r.locked });
});
return true;
}
if (msg.type === 'CREATE_ENTRY') { createEntry(msg.entry || {}).then(sendResponse); return true; }
if (msg.type === 'UPDATE_ENTRY') { updateEntry(msg.id, msg.entry || {}).then(sendResponse); return true; }
if (msg.type === 'DELETE_ENTRY') { deleteEntry(msg.id).then(sendResponse); return true; }
if (msg.type === 'GET_PASSWORD') { fetchPassword(msg.id).then(password => sendResponse({ password })); return true; }
if (msg.type === 'GET_TOTP') { fetchTotp(msg.id).then(result => sendResponse(result)); return true; }
if (msg.type === 'GET_FAVICON') { fetchFavicon(msg.id).then(dataUrl => sendResponse({ dataUrl })); return true; }
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 === '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) => {
if (alarm.name === 'clearCache') { cachedEntries = null; cacheTime = 0; faviconCache.clear(); return; }
if (alarm.name === 'clipClear') { await clearClipboard(); pendingClip = null; }
});