'use strict';
/*
* OpenNIT Vault – Content-Script
* Robuste Erkennung von Passwort-, Benutzer-/E-Mail- und TOTP-Feldern
* inkl. Shadow-DOM, dynamischen Formularen, mehrstufigen Logins und
* segmentierten OTP-Eingaben. Autofill via nativem Value-Setter + Events
* (framework-kompatibel: React/Vue/Angular).
*/
if (!window.__vaultInjected) {
window.__vaultInjected = true;
const DROPDOWN_ID = '__vault_dropdown__';
let appLabel = 'Vault';
let currentField = null;
let showGen = 0;
// ── 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_NEG = /(search|suche|query|coupon|promo|voucher|gift|zip|postal|plz|phone|tel|mobile|firstname|lastname|first[-_ ]?name|last[-_ ]?name|vorname|nachname|street|strasse|address|adresse|city|stadt|country|land|company|firma|captcha|amount|menge|quantity|qty)/i;
const RE_PASS = /(pass(word|wort)?|pwd|passwd|kennwort|passphrase)/i;
const RE_PASS_NEG = /(hint|frage|question|reminder|recovery|forgot|vergessen)/i;
const RE_OTP = /(otp|totp|2fa|mfa|one[-_ ]?time|einmal|verification|verify|verifizier|authenticat|auth[-_ ]?code|security[-_ ]?code|sms[-_ ]?code|passcode|one_?time_?code|2[-_ ]?step|two[-_ ]?factor|bestätigungscode|einmalkennwort|einmalpasswort)/i;
const RE_CODEONLY = /(\b|_)(code|pin|token)(\b|_)/i;
// ── kleine Helfer ───────────────────────────────────────────────────────────
function lc(s) { return String(s || '').toLowerCase(); }
function esc(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>'); }
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) {
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 ''; } }
function ac(el) { return lc(attr(el, 'autocomplete')); }
function isVisible(el) {
if (!el) return false;
if (el.disabled || el.readOnly) return false;
if (lc(el.type) === 'hidden') return false;
const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) return false;
const s = getComputedStyle(el);
if (s.display === 'none' || s.visibility === 'hidden' || s.visibility === 'collapse') return false;
if (parseFloat(s.opacity || '1') === 0) return false;
return true;
}
function labelText(el) {
const parts = [];
try {
if (el.id) {
const sel = (window.CSS && CSS.escape) ? CSS.escape(el.id) : el.id;
const l = document.querySelector('label[for="' + sel + '"]');
if (l) parts.push(l.textContent);
}
} catch {}
const wrap = el.closest ? el.closest('label') : null;
if (wrap) parts.push(wrap.textContent);
const lb = attr(el, 'aria-labelledby');
if (lb) lb.split(/\s+/).forEach(id => { const n = document.getElementById(id); if (n) parts.push(n.textContent); });
return parts.join(' ').slice(0, 200);
}
function sig(el) {
return lc([
el.name, el.id, attr(el, 'autocomplete'), el.placeholder,
attr(el, 'aria-label'), el.title, attr(el, 'data-testid'),
attr(el, 'ng-model'), el.className, labelText(el),
].join(' '));
}
function isTextLike(el) {
if (!el || el.tagName !== 'INPUT') return false;
return ['text', 'email', 'tel', 'search', 'url', 'number', ''].includes(lc(el.type || 'text'));
}
// ── Feld-Klassifikation ─────────────────────────────────────────────────────
function isPasswordField(el) {
if (!el || el.tagName !== 'INPUT') return false;
if (lc(el.type) === 'password') return true;
const a = ac(el);
if (a.includes('current-password') || a.includes('new-password')) return true;
// sichtbar geschaltetes Passwortfeld (type=text)
if (isTextLike(el)) {
const s = sig(el);
if (RE_PASS.test(s) && !RE_PASS_NEG.test(s) && !RE_USER.test(lc(el.name + ' ' + el.id))) return true;
}
return false;
}
function isOtpField(el) {
if (!el || el.tagName !== 'INPUT') return false;
const t = lc(el.type);
if (['password', 'checkbox', 'radio', 'submit', 'button', 'file', 'hidden', 'range', 'color', 'date'].includes(t)) return false;
if (ac(el).includes('one-time-code')) return true;
const s = sig(el);
const ml = parseInt(attr(el, 'maxlength') || '0', 10);
const pat = lc(attr(el, 'pattern'));
const numeric = lc(el.inputMode || '') === 'numeric' || pat.includes('0-9') || pat.includes('\\d') || t === 'number' || t === 'tel';
if (RE_OTP.test(s)) return true;
if (RE_CODEONLY.test(s) && (numeric || (ml > 0 && ml <= 8))) return true;
// segmentierte OTP-Eingabe (mehrere 1-Zeichen-Felder)
if (ml === 1 && numeric) return segmentGroup(el).length >= 4;
return false;
}
function isUsernameField(el) {
if (!el || el.tagName !== 'INPUT') return false;
const t = lc(el.type || 'text');
if (['password', 'submit', 'button', 'hidden', 'checkbox', 'radio', 'file', 'image', 'range', 'color', 'date', 'datetime-local', 'month', 'week', 'time'].includes(t)) return false;
if (isOtpField(el)) return false;
const a = ac(el);
if (a.includes('username') || a === 'email') return true;
if (t === 'email') return true;
const s = sig(el);
return RE_USER.test(s) && !RE_USER_NEG.test(s);
}
function isLoginField(el) { return isPasswordField(el) || isUsernameField(el) || isOtpField(el); }
function fieldKind(el) {
if (isPasswordField(el)) return 'password';
if (isOtpField(el)) return 'otp';
if (isUsernameField(el)) return 'username';
return null;
}
// ── Shadow-DOM-fähige Feldsammlung ──────────────────────────────────────────
function collectInputs(container) {
const out = [];
const visit = (root) => {
let nodes;
try { nodes = root.querySelectorAll('input, textarea'); } catch { nodes = []; }
nodes.forEach(n => out.push(n));
let all;
try { all = root.querySelectorAll('*'); } catch { all = []; }
all.forEach(n => { if (n.shadowRoot) visit(n.shadowRoot); });
};
visit(container || document);
return out;
}
function scopeOf(field) {
const form = field.closest ? field.closest('form') : null;
if (form) return form;
const root = field.getRootNode ? field.getRootNode() : null;
if (root && root.host && root.host.closest) {
const f = root.host.closest('form');
if (f) return f;
}
return document.body;
}
function segmentGroup(el) {
const parent = el.parentElement;
if (!parent) return [el];
const sibs = [...parent.querySelectorAll('input')].filter(i => parseInt(attr(i, 'maxlength') || '0', 10) === 1);
return sibs.length >= 4 ? sibs : [el];
}
function findUsernameField(ref) {
const inputs = collectInputs(scopeOf(ref)).filter(isVisible);
const idx = inputs.indexOf(ref);
for (let i = idx - 1; i >= 0; i--) if (isUsernameField(inputs[i])) return inputs[i];
for (let i = idx + 1; i < inputs.length; i++) if (isUsernameField(inputs[i])) return inputs[i];
// positionaler Fallback: Textfeld direkt vor dem Passwort
for (let i = idx - 1; i >= 0; i--) if (isTextLike(inputs[i]) && !isOtpField(inputs[i])) return inputs[i];
return null;
}
function findPasswordField(ref) {
const inputs = collectInputs(scopeOf(ref));
const vis = inputs.filter(isVisible);
return vis.find(isPasswordField) || inputs.find(isPasswordField) || null;
}
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('keydown', onKeyDown, true);
document.addEventListener('click', onDocClick, true);
window.addEventListener('scroll', repositionDrop, true);
window.addEventListener('resize', repositionDrop, true);
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg && msg.type === 'VAULT_FILL') {
fillFromPopup(msg);
sendResponse({ ok: true });
return true;
}
});
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, resp => { if (resp && resp.app_name) appLabel = resp.app_name; });
}
// Vom Popup angestoßenes Ausfüllen (ohne fokussiertes Feld): bestes
// Passwort-/Benutzerfeld der Seite suchen und befüllen.
function fillFromPopup(msg) {
const inputs = collectInputs(document).filter(isVisible);
const passField = inputs.find(isPasswordField) || null;
let userField = passField ? findUsernameField(passField) : null;
if (!userField) userField = inputs.find(isUsernameField) || null;
if (userField && msg.username) setFieldValue(userField, msg.username);
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() } });
if (msg.has_totp && msg.id != null) {
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) { maybeShow(e.target); }
function onPointerDown(e) {
const drop = document.getElementById(DROPDOWN_ID);
if (drop && drop.contains(e.target)) return;
maybeShow(e.target);
}
function maybeShow(el) {
if (!isLoginField(el) || !isVisible(el)) return;
currentField = el;
showSuggestions(el);
}
function onFocusOut(e) {
const blurred = e.target;
setTimeout(() => {
const active = document.activeElement;
const drop = document.getElementById(DROPDOWN_ID);
if (active === blurred || active === currentField || (drop && drop.contains(active))) return;
hideDrop();
currentField = null;
}, 200);
}
function onDocClick(e) {
const drop = document.getElementById(DROPDOWN_ID);
if (drop && drop.contains(e.target)) return;
if (e.target === currentField) return;
hideDrop();
}
function onKeyDown(e) {
const drop = document.getElementById(DROPDOWN_ID);
if (!drop) return;
const items = [...drop.querySelectorAll('.vi')];
if (!items.length) return;
let idx = items.findIndex(i => i.classList.contains('selected'));
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(items, idx + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(items, idx - 1); }
else if (e.key === 'Enter' && idx >= 0) { e.preventDefault(); items[idx].click(); }
else if (e.key === 'Escape') { hideDrop(); currentField = null; }
}
function setSelected(items, idx) {
items.forEach(i => i.classList.remove('selected'));
const next = items[Math.max(0, Math.min(idx, items.length - 1))];
if (next) { next.classList.add('selected'); next.scrollIntoView({ block: 'nearest' }); }
}
// ── Vorschläge ──────────────────────────────────────────────────────────────
function showSuggestions(field) {
const gen = ++showGen;
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 => 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);
});
}
function repositionDrop() {
const drop = document.getElementById(DROPDOWN_ID);
if (!drop || !currentField) return;
const r = currentField.getBoundingClientRect();
if (r.width === 0) { hideDrop(); return; }
drop.style.top = (r.bottom + 2) + 'px';
drop.style.left = r.left + 'px';
drop.style.width = Math.max(r.width, 300) + 'px';
}
function renderDrop(field, entries, mode) {
hideDrop();
const rect = field.getBoundingClientRect();
if (rect.width === 0) return;
const drop = document.createElement('div');
drop.id = DROPDOWN_ID;
Object.assign(drop.style, {
position: 'fixed', top: (rect.bottom + 4) + 'px', left: rect.left + 'px',
width: Math.max(rect.width, 300) + 'px', background: '#fff', border: '1px solid #e3e6ef',
borderRadius: '12px', boxShadow: '0 10px 32px rgba(31,35,48,.20)', zIndex: '2147483647',
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif', fontSize: '13px',
overflow: 'hidden', maxHeight: '320px', overflowY: 'auto', color: '#1f2330',
});
// Hover-/Auswahl-Highlight (scoped auf unser Dropdown – page-safe)
const styleEl = document.createElement('style');
styleEl.textContent = '#' + DROPDOWN_ID + ' .vi:hover,#' + DROPDOWN_ID + ' .vi.selected{background:#f5f6fb !important;}';
drop.appendChild(styleEl);
const hd = document.createElement('div');
Object.assign(hd.style, {
padding: '9px 13px', background: 'linear-gradient(135deg,#4f46e5 0%,#5b6ee8 45%,#3c8dbc 100%)',
color: '#fff', fontWeight: '700', fontSize: '10.5px',
display: 'flex', alignItems: 'center', gap: '7px', letterSpacing: '.05em', textTransform: 'uppercase',
});
hd.innerHTML = ' '
+ esc(appLabel) + (mode === 'otp' ? ' · 2FA' : ' · Vault');
drop.appendChild(hd);
entries.forEach(entry => {
const item = document.createElement('div');
item.className = 'vi';
item.dataset.id = entry.id;
Object.assign(item.style, {
padding: '9px 13px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '10px',
borderBottom: '1px solid #f1f3f5', background: '#fff', transition: 'background .1s',
});
const _hue = vaultHue(entry.title || '?');
const favSpan = document.createElement('span');
Object.assign(favSpan.style, {
width: '22px', height: '22px', borderRadius: '6px', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', fontSize: '11px', fontWeight: '700', flexShrink: '0', overflow: 'hidden',
background: 'hsl(' + _hue + ',52%,90%)', color: 'hsl(' + _hue + ',55%,38%)',
});
favSpan.textContent = (entry.title || '?').charAt(0).toUpperCase();
// Serverseitig gecachtes Favicon nachladen (kein externer Call)
if (entry.has_favicon) {
chrome.runtime.sendMessage({ type: 'GET_FAVICON', id: entry.id }, r => {
if (r && r.dataUrl) {
favSpan.style.background = '#eef0f7';
favSpan.innerHTML = '';
}
});
}
const team = entry.team_name
? '' + esc(entry.team_name) + ''
: '';
const sub = mode === 'otp'
? '2FA-Code einfügen'
: '