index.ts

23.4 KB · raw

1import {2  detectLoginFields,3  getDeepActiveInput,4  getPageDomain,5  isLoginForm,6  isSignupForm,7  isVisible,8} from "./detect";9import {10  injectFieldIcon,11  observeAnchorPosition,12  SelectorEntry,13  showAutofillSelector,14  showDomainConfirmToast,15} from "./inject";16import { fillFields } from "./fill";17import { setupSaveDetection } from "./save";18import { contentMLDetector } from "./content-ml-detector";19import type { FieldPrediction } from "../shared/ml";20import { generateTOTP } from "../crypto/totp";21import type { PasswordPolicy, VaultEntry } from "../shared/types";2223const processedInputs = new WeakMap<HTMLInputElement, () => void>();24const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);2526function detectPasswordPolicy(doc: Document = document): PasswordPolicy {27  const policy: PasswordPolicy = {};2829  const passwordField = doc.querySelector<HTMLInputElement>(30    'input[autocomplete="new-password"], input[type="password"]'31  );32  const maxLen = passwordField?.maxLength ?? 0;33  if (maxLen > 0) policy.maxLength = maxLen;34  if (passwordField?.pattern) {35    const pat = passwordField.pattern;36    const minMatch = pat.match(/\{(\d+),/);37    const maxMatch = pat.match(/,(\d+)\}/);38    if (minMatch) policy.minLength = parseInt(minMatch[1]);39    if (maxMatch) policy.maxLength = parseInt(maxMatch[1]);40  }4142  const requirementText = extractNearbyText(passwordField);43  const minMatch = requirementText.match(/(?:at least|minimum|min)[^\d]*(\d+)/i);44  const maxMatch = requirementText.match(/(?:at most|maximum|max|up to)[^\d]*(\d+)/i);45  if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]);46  if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]);4748  if (/no\s+(?:special|symbol)/i.test(requirementText)) policy.forbidsSymbols = true;49  if (/(?:special|symbol|punctuation)/i.test(requirementText)) policy.requiresSymbols = true;50  if (/(?:uppercase|capital)/i.test(requirementText)) policy.requiresUppercase = true;51  if (/number|digit/i.test(requirementText)) policy.requiresNumbers = true;5253  return policy;54}5556function extractNearbyText(el: HTMLInputElement | null): string {57  if (!el) return "";58  const describedBy = el.getAttribute("aria-describedby");59  if (describedBy) {60    const desc = document.getElementById(describedBy);61    if (desc) return desc.textContent ?? "";62  }63  let node: Element | null = el;64  for (let i = 0; i < 3; i++) {65    node = node?.parentElement ?? null;66    if (!node) break;67    const hints = node.querySelectorAll("p, span, li, small, .hint, .requirement, .password-rule");68    if (hints.length > 0) return Array.from(hints).map((h) => h.textContent).join(" ");69  }70  return "";71}7273let currentDismiss: (() => void) | null = null;74// Field whose selector is currently open — used to avoid dismiss+reopen flicker75let currentAnchorField: HTMLInputElement | null = null;76// Prevents two concurrent handleFieldInteraction calls for the same field77const pendingInteractions = new WeakSet<HTMLInputElement>();78// Tracks the last field the user explicitly dismissed (Escape / click-outside / Tab).79// Suppresses spurious reopens until focus moves to a different field.80let lastDismissedField: HTMLInputElement | null = null;8182function debounce<T extends (...args: unknown[]) => void>(83  fn: T,84  ms: number85): (...args: Parameters<T>) => void {86  let timer: ReturnType<typeof setTimeout>;87  return (...args: Parameters<T>) => {88    clearTimeout(timer);89    timer = setTimeout(() => fn(...args), ms);90  };91}9293function showToast(message: string): void {94  const toast = document.createElement("div");95  toast.style.cssText = `96    position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);97    background: #131109; color: #F0E8D8; padding: 8px 16px;98    border-radius: 8px; font-family: 'IBM Plex Mono', monospace;99    font-size: 11px; z-index: 2147483647; opacity: 0;100    transition: opacity 200ms ease;101    border: 1px solid rgba(240,232,216,0.07);102    pointer-events: none;103  `;104  toast.textContent = message;105  document.body.appendChild(toast);106  requestAnimationFrame(() => { toast.style.opacity = "1"; });107  setTimeout(() => {108    toast.style.opacity = "0";109    setTimeout(() => toast.remove(), 200);110  }, 2000);111}112113function showSearchPalette(): void {114  const host = document.createElement("div");115  host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";116  document.body.appendChild(host);117  const shadow = host.attachShadow({ mode: "closed" });118119  const style = document.createElement("style");120  style.textContent = `121    .p{background:#131109;border:1px solid rgba(240,232,216,.12);border-radius:12px;122      font-family:'IBM Plex Mono',monospace;font-size:12px;color:#F0E8D8;123      box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden}124    .h{padding:10px 12px 8px;border-bottom:1px solid rgba(240,232,216,.08);125      font-size:11px;color:rgba(240,232,216,.5);display:flex;justify-content:space-between;align-items:center}126    .x{background:none;border:none;color:rgba(240,232,216,.4);cursor:pointer;font-size:14px;padding:0;line-height:1}127    .s{padding:8px 12px}128    .s input{width:100%;box-sizing:border-box;background:rgba(240,232,216,.06);129      border:1px solid rgba(240,232,216,.12);border-radius:6px;color:#F0E8D8;130      padding:6px 8px;font-family:inherit;font-size:12px;outline:none}131    .l{max-height:220px;overflow-y:auto}132    .e{padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;gap:2px}133    .e:hover{background:rgba(240,232,216,.06)}134    .et{font-size:12px}135    .eu{font-size:10px;color:rgba(240,232,216,.45)}136    .em{padding:12px;color:rgba(240,232,216,.4);text-align:center}137  `;138139  let allEntries: SelectorEntry[] = [];140  let query = "";141142  const panel = document.createElement("div");143  panel.className = "p";144145  const hdr = document.createElement("div");146  hdr.className = "h";147  const hdrText = document.createElement("span");148  hdrText.textContent = "No form detected — search your vault";149  const closeBtn = document.createElement("button");150  closeBtn.className = "x";151  closeBtn.textContent = "✕";152  hdr.appendChild(hdrText);153  hdr.appendChild(closeBtn);154155  const searchWrap = document.createElement("div");156  searchWrap.className = "s";157  const searchInput = document.createElement("input");158  searchInput.placeholder = "search...";159  searchWrap.appendChild(searchInput);160161  const list = document.createElement("div");162  list.className = "l";163164  panel.appendChild(hdr);165  panel.appendChild(searchWrap);166  panel.appendChild(list);167  shadow.appendChild(style);168  shadow.appendChild(panel);169170  function close() { host.remove(); }171172  closeBtn.onclick = close;173174  function onKeyDown(e: KeyboardEvent) {175    if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKeyDown, true); }176  }177  document.addEventListener("keydown", onKeyDown, true);178179  function render() {180    const q = query.toLowerCase();181    const filtered = q182      ? allEntries.filter(e => e.title.toLowerCase().includes(q) || (e.username ?? "").toLowerCase().includes(q))183      : allEntries;184    list.innerHTML = "";185    if (filtered.length === 0) {186      const em = document.createElement("div");187      em.className = "em";188      em.textContent = query ? "no matches" : "vault is empty";189      list.appendChild(em);190      return;191    }192    for (const entry of filtered.slice(0, 20)) {193      const row = document.createElement("div");194      row.className = "e";195      const title = document.createElement("div");196      title.className = "et";197      title.textContent = entry.title;198      row.appendChild(title);199      if (entry.username) {200        const user = document.createElement("div");201        user.className = "eu";202        user.textContent = entry.username;203        row.appendChild(user);204      }205      row.onclick = async () => {206        const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } });207        const r = resp as { ok: boolean; data?: { password?: string } };208        if (r.ok && r.data?.password) {209          await chrome.runtime.sendMessage({ type: "COPY", payload: { text: r.data.password, label: "Password" } });210          showToast("Password copied");211        } else {212          showToast("Could not copy password");213        }214        close();215        document.removeEventListener("keydown", onKeyDown, true);216      };217      list.appendChild(row);218    }219  }220221  searchInput.oninput = () => { query = searchInput.value; render(); };222223  fetchAutofillEntries(getPageDomain(), true).then(result => {224    if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }225    allEntries = result.entries;226    render();227    searchInput.focus();228  });229}230231function setNativeValue(el: HTMLInputElement, value: string): void {232  const nativeSetter = Object.getOwnPropertyDescriptor(233    HTMLInputElement.prototype, "value"234  )!.set!;235  nativeSetter.call(el, value);236  el.dispatchEvent(new Event("input", { bubbles: true }));237  el.dispatchEvent(new Event("change", { bubbles: true }));238}239240function findTOTPField(): HTMLInputElement | null {241  const inputs = document.querySelectorAll<HTMLInputElement>("input");242  for (const input of inputs) {243    if (!isVisible(input)) continue;244    const autocomplete = input.autocomplete.toLowerCase();245    const nameId = (input.name + input.id).toLowerCase();246    const nameIdPlaceholder = nameId + input.placeholder.toLowerCase();247    if (248      autocomplete === "one-time-code" ||249      /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(nameIdPlaceholder)250    ) {251      return input;252    }253    const labelEl = input.closest("label");254    if (labelEl && /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(labelEl.textContent?.toLowerCase() ?? "")) {255      return input;256    }257  }258  return null;259}260261async function autofillTOTP(totpField: HTMLInputElement, totpSecret: string): Promise<void> {262  await new Promise((r) => setTimeout(r, 400));263  if (totpField.value === "" || totpField.value.trim() === "") {264    const { code } = generateTOTP(totpSecret);265    setNativeValue(totpField, code);266    showToast("TOTP autofilled");267  }268}269270271async function handleAutofill(272  field: HTMLInputElement,273  usernameEl: HTMLInputElement | null,274  passwordEl: HTMLInputElement,275  entry: VaultEntry276): Promise<void> {277  if (!entry.username && !entry.password) return;278  const pageDomain = getPageDomain();279  const form = passwordEl.closest("form") as HTMLFormElement | null;280  fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);281282  if (entry.canary) {283    chrome.runtime.sendMessage({284      type: "FIRE_CANARY_PING",285      payload: {286        webhookUrl: entry.canary.webhookUrl,287        entryTitle: entry.title,288        trigger: "autofilled",289      },290    }).catch(() => {});291    chrome.runtime.sendMessage({292      type: "UPDATE_ENTRY",293      payload: {294        id: entry.id,295        patch: {296          canary: {297            ...entry.canary,298            lastPinged: Date.now(),299            pingCount: (entry.canary.pingCount ?? 0) + 1,300          },301        },302      },303    }).catch(() => {});304  }305306  if (entry.totp) {307    const { code } = generateTOTP(entry.totp);308    const response = await chrome.runtime.sendMessage({309      type: "COPY",310      payload: { text: code, label: "TOTP" },311    });312    if ((response as { ok?: boolean }).ok) {313      showToast("TOTP copied · " + code);314    }315    const totpField = findTOTPField();316    if (totpField) {317      autofillTOTP(totpField, entry.totp);318    }319  }320}321322async function fetchAutofillEntries(323  domain: string,324  includeAll = false325): Promise<{ entries: SelectorEntry[]; locked: boolean }> {326  const response = await chrome.runtime.sendMessage({327    type: "AUTOFILL_QUERY",328    payload: { domain, includeAll },329  });330  const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };331  if (!res.ok && res.error === "VAULT_LOCKED") {332    return { entries: [], locked: true };333  }334  return { entries: res.data ?? [], locked: false };335}336337338function resolveAutofillFieldType(339  el: HTMLInputElement,340  predictedType?: FieldPrediction["type"] | null341): "password" | "username" | "email" | null {342  if (343    predictedType === "password" ||344    predictedType === "username" ||345    predictedType === "email"346  ) {347    return predictedType;348  }349350  const detected = detectLoginFields().find((field) => field.el === el)?.type;351  if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {352    return detected;353  }354355  const htmlType = el.type.toLowerCase();356  if (htmlType === "password") return "password";357  if (htmlType === "email") return "email";358  return null;359}360361async function openAutofillSelector(362  anchorField: HTMLInputElement,363  usernameEl: HTMLInputElement | null,364  passwordEl: HTMLInputElement,365  domainEntries: SelectorEntry[],366  searchableEntries: SelectorEntry[],367  domain: string368): Promise<void> {369  currentDismiss?.();370  currentAnchorField = anchorField;371  currentDismiss = showAutofillSelector(372    anchorField,373    domainEntries,374    async (id: string) => {375      currentDismiss = null;376      currentAnchorField = null;377      const entryResponse = await chrome.runtime.sendMessage({378        type: "GET_ENTRY_FOR_FILL",379        payload: { id },380      });381      const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;382      if (entry) {383        await handleAutofill(anchorField, usernameEl, passwordEl, entry);384        // Offer to set page domain as default for this entry when it differs.385        const pageDomain = location.hostname.replace(/^www\./, "");386        const entryDomain = (() => {387          try { return new URL(entry.url ?? "").hostname.replace(/^www\./, ""); } catch { return ""; }388        })();389        if (entryDomain && pageDomain && entryDomain !== pageDomain) {390          showDomainConfirmToast(pageDomain, entry.id);391        }392      }393    },394    {395      domain,396      searchableEntries,397      onDismiss: () => { lastDismissedField = anchorField; },398    }399  );400}401402async function openSignupSelector(403  usernameEl: HTMLInputElement,404  passwordEl: HTMLInputElement | null,405  domainEntries: SelectorEntry[],406  allEntries: SelectorEntry[],407  domain: string408): Promise<void> {409  currentDismiss?.();410  currentAnchorField = usernameEl;411  currentDismiss = showAutofillSelector(412    usernameEl,413    domainEntries,414    () => {}, // identity-click handled via onFillFields415    {416      domain,417      searchableEntries: allEntries,418      isSignup: true,419      onFillFields: (username, password) => {420        if (username) setNativeValue(usernameEl, username);421        if (password && passwordEl) setNativeValue(passwordEl, password);422      },423      onDismiss: () => { lastDismissedField = usernameEl; },424    }425  );426}427428429async function handleFieldInteraction(anchorField?: HTMLInputElement | null): Promise<void> {430  const field = anchorField ?? getDeepActiveInput();431  if (!field) return;432  if (!isVisible(field)) return;433434  // If a selector is already open for this exact field, don't flicker it.435  if (currentDismiss && currentAnchorField === field) return;436437  // If focus moved to a different field, the previous dismiss lock no longer applies.438  if (field !== lastDismissedField) lastDismissedField = null;439  // The user explicitly dismissed this field's selector — don't reopen it until440  // focus has genuinely moved away and come back.441  if (field === lastDismissedField) return;442443  // Prevent two concurrent calls for the same field (e.g. focusin + icon click444  // firing simultaneously — the second call would dismiss the first's selector).445  if (pendingInteractions.has(field)) return;446  pendingInteractions.add(field);447448  try {449    const fieldType = field.type.toLowerCase();450451    const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {452      return preds[0] ?? null;453    });454455    const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {456      const timeout = setTimeout(() => resolve(null), 150);457      mlPromise.then((pred) => {458        clearTimeout(timeout);459        resolve(pred);460      });461    });462463    if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {464      return;465    }466467    const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);468    if (autofillFieldType) {469      reattachListeners(field, autofillFieldType);470    }471472    let fields = detectLoginFields();473    const loginForm = isLoginForm(fields);474    const signupForm = isSignupForm(fields);475    if (!loginForm && !signupForm) {476      return;477    }478479    const domain = getPageDomain();480    const firstResult = await fetchAutofillEntries(domain);481482    // Vault is locked — show an inline unlock prompt.483    // Re-triggering after unlock is handled by the VAULT_UNLOCKED message so484    // there is no double-trigger race between onUnlocked and the broadcast.485    if (firstResult.locked) {486      currentDismiss?.();487      currentAnchorField = field;488      currentDismiss = showAutofillSelector(489        field,490        [],491        () => {},492        { domain, locked: true, onDismiss: () => { lastDismissedField = field; } }493      );494      return;495    }496497    const entries = firstResult.entries;498    const allEntries = entries.length === 0499      ? (await fetchAutofillEntries(domain, true)).entries500      : entries;501502    let usernameEl: HTMLInputElement | null = null;503    const userField = fields.find((f) => f.type === "username" || f.type === "email");504    if (userField) usernameEl = userField.el;505    const pwField = fields.find((f) => f.type === "password");506    if (!pwField) return;507    const passwordEl = pwField.el;508509    if (signupForm && usernameEl && field === usernameEl) {510      await openSignupSelector(usernameEl, passwordEl, entries, allEntries, domain);511      return;512    }513514    await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain);515  } finally {516    pendingInteractions.delete(field);517  }518}519520async function onFocusIn(): Promise<void> {521  await handleFieldInteraction();522}523524function reattachListeners(525  el: HTMLInputElement,526  detectedType?: "password" | "username" | "email",527  contextFields?: ReturnType<typeof detectLoginFields>528): void {529  if (processedInputs.has(el)) return;530  if (!resolveAutofillFieldType(el, detectedType)) return;531  // Only inject icon when a real login/signup form is present on the page532  const fields = contextFields ?? detectLoginFields();533  if (!isLoginForm(fields) && !isSignupForm(fields)) return;534535  const cleanup = injectFieldIcon(el, () => {536    currentDismiss?.();537    currentDismiss = null;538    lastDismissedField = null; // icon click is always an explicit user open — clear the dismiss lock539    el.focus({ preventScroll: true });540    void handleFieldInteraction(el);541  });542  processedInputs.set(el, cleanup);543}544545const debouncedReattach = debounce(() => {546  const allFields = detectLoginFields();547  // Don't inject anything if there's no real login/signup form on this page548  if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;549  const newFields = allFields.filter(550    (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)551  );552  for (const f of newFields) {553    reattachListeners(f.el, f.type, allFields);554  }555}, 200);556557function setupMutationObserver(): void {558  const observer = new MutationObserver(debouncedReattach);559  observer.observe(document.body, {560    childList: true,561    subtree: true,562    attributes: true,563    attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],564  });565}566567function initialScan(aggressionLevel = 0, isFinalPass = false) {568  const allFields = detectLoginFields();569  const hasKnownForm = isLoginForm(allFields) || isSignupForm(allFields);570571  if (aggressionLevel === 0 && !hasKnownForm) return;572573  const newFields = allFields.filter(574    (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)575  );576577  for (const f of newFields) {578    reattachListeners(f.el, f.type, allFields);579  }580581  // Level 2, final pass: inject icon on any visible password field even if582  // the form-context gate in reattachListeners would normally skip it.583  if (aggressionLevel >= 2 && isFinalPass && newFields.length === 0) {584    const pwInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');585    for (const el of pwInputs) {586      if (isVisible(el) && !processedInputs.has(el)) {587        const cleanup = injectFieldIcon(el, () => {588          currentDismiss?.();589          currentDismiss = null;590          lastDismissedField = null;591          el.focus({ preventScroll: true });592          void handleFieldInteraction(el);593        });594        processedInputs.set(el, cleanup);595      }596    }597  }598}599600document.addEventListener("focusin", onFocusIn);601602setupMutationObserver();603setupSaveDetection();604605// Async init: check recall map before running the initial scans so we can606// apply the right aggression level for domains the user has needed before.607(async () => {608  let aggressionLevel = 0;609  try {610    const resp = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });611    const s = (resp as { ok: boolean; data?: { manualSummonDomains?: Record<string, { summonCount: number }> } }).data;612    const domain = getPageDomain();613    const record = domain ? s?.manualSummonDomains?.[domain] : undefined;614    aggressionLevel = !record ? 0 : record.summonCount >= 2 ? 2 : 1;615  } catch { /* SW may be starting up; proceed with normal scan */ }616617  setTimeout(() => initialScan(aggressionLevel, false), 0);618  setTimeout(() => initialScan(aggressionLevel, false), 500);619  setTimeout(() => initialScan(aggressionLevel, true), 1500);620})();621622chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {623  // Ignore anything not originating from our own background/service worker.624  if (sender.id !== chrome.runtime.id) return;625  if (msg?.type === "GET_SITE_PASSWORD_POLICY") {626    const policy = detectPasswordPolicy();627    sendResponse({ ok: true, data: policy });628    return true;629  }630  if (msg?.type === "PING") {631    sendResponse({ ok: true });632    return true;633  }634  if (msg?.type === "SUMMON_AUTOFILL") {635    const source = (msg as { type: string; source?: string }).source;636    const isShortcut = source === "shortcut";637638    const activeInput = getDeepActiveInput();639    const fields = detectLoginFields();640    const hasForm = isLoginForm(fields) || isSignupForm(fields);641642    if (hasForm) {643      // Prefer the currently focused field if it's a login field, otherwise644      // fall back to the first password field, then username/email.645      const inForm = activeInput && fields.some(f => f.el === activeInput);646      const target = inForm647        ? activeInput648        : (fields.find(f => f.type === "password")?.el649            ?? fields.find(f => f.type === "username" || f.type === "email")?.el);650      if (target) void handleFieldInteraction(target);651    } else if (isShortcut) {652      // Shortcut on a page with no login form — show clipboard-copy search mode.653      showSearchPalette();654    }655656    // Record this domain for the recall system when triggered via shortcut.657    if (isShortcut) {658      const domain = getPageDomain();659      if (domain) {660        chrome.runtime.sendMessage({ type: "RECORD_MANUAL_SUMMON", payload: { domain } }).catch(() => {});661      }662    }663664    sendResponse({ ok: hasForm || isShortcut });665    return true;666  }667  if (msg?.type === "VAULT_UNLOCKED") {668    // Close any open selector (e.g. the locked-state prompt) and re-open669    // with fresh entry data. Use the saved anchor field so we reopen exactly670    // where the user was — covers both inline unlock and popup unlock.671    const savedField = currentAnchorField;672    if (currentDismiss) {673      currentDismiss();674      currentDismiss = null;675      currentAnchorField = null;676    }677    void handleFieldInteraction(savedField ?? undefined);678    sendResponse({ ok: true });679    return true;680  }681  return true;682});