index.ts

17.2 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 setNativeValue(el: HTMLInputElement, value: string): void {114  const nativeSetter = Object.getOwnPropertyDescriptor(115    HTMLInputElement.prototype, "value"116  )!.set!;117  nativeSetter.call(el, value);118  el.dispatchEvent(new Event("input", { bubbles: true }));119  el.dispatchEvent(new Event("change", { bubbles: true }));120}121122function findTOTPField(): HTMLInputElement | null {123  const inputs = document.querySelectorAll<HTMLInputElement>("input");124  for (const input of inputs) {125    if (!isVisible(input)) continue;126    const autocomplete = input.autocomplete.toLowerCase();127    const nameId = (input.name + input.id).toLowerCase();128    const nameIdPlaceholder = nameId + input.placeholder.toLowerCase();129    if (130      autocomplete === "one-time-code" ||131      /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(nameIdPlaceholder)132    ) {133      return input;134    }135    const labelEl = input.closest("label");136    if (labelEl && /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(labelEl.textContent?.toLowerCase() ?? "")) {137      return input;138    }139  }140  return null;141}142143async function autofillTOTP(totpField: HTMLInputElement, totpSecret: string): Promise<void> {144  await new Promise((r) => setTimeout(r, 400));145  if (totpField.value === "" || totpField.value.trim() === "") {146    const { code } = generateTOTP(totpSecret);147    setNativeValue(totpField, code);148    showToast("TOTP autofilled");149  }150}151152153async function handleAutofill(154  field: HTMLInputElement,155  usernameEl: HTMLInputElement | null,156  passwordEl: HTMLInputElement,157  entry: VaultEntry158): Promise<void> {159  if (!entry.username && !entry.password) return;160  const pageDomain = getPageDomain();161  const form = passwordEl.closest("form") as HTMLFormElement | null;162  fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);163164  if (entry.canary) {165    chrome.runtime.sendMessage({166      type: "FIRE_CANARY_PING",167      payload: {168        webhookUrl: entry.canary.webhookUrl,169        entryTitle: entry.title,170        trigger: "autofilled",171      },172    }).catch(() => {});173    chrome.runtime.sendMessage({174      type: "UPDATE_ENTRY",175      payload: {176        id: entry.id,177        patch: {178          canary: {179            ...entry.canary,180            lastPinged: Date.now(),181            pingCount: (entry.canary.pingCount ?? 0) + 1,182          },183        },184      },185    }).catch(() => {});186  }187188  if (entry.totp) {189    const { code } = generateTOTP(entry.totp);190    const response = await chrome.runtime.sendMessage({191      type: "COPY",192      payload: { text: code, label: "TOTP" },193    });194    if ((response as { ok?: boolean }).ok) {195      showToast("TOTP copied · " + code);196    }197    const totpField = findTOTPField();198    if (totpField) {199      autofillTOTP(totpField, entry.totp);200    }201  }202}203204async function fetchAutofillEntries(205  domain: string,206  includeAll = false207): Promise<{ entries: SelectorEntry[]; locked: boolean }> {208  const response = await chrome.runtime.sendMessage({209    type: "AUTOFILL_QUERY",210    payload: { domain, includeAll },211  });212  const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };213  if (!res.ok && res.error === "VAULT_LOCKED") {214    return { entries: [], locked: true };215  }216  return { entries: res.data ?? [], locked: false };217}218219220function resolveAutofillFieldType(221  el: HTMLInputElement,222  predictedType?: FieldPrediction["type"] | null223): "password" | "username" | "email" | null {224  if (225    predictedType === "password" ||226    predictedType === "username" ||227    predictedType === "email"228  ) {229    return predictedType;230  }231232  const detected = detectLoginFields().find((field) => field.el === el)?.type;233  if (detected && AUTOFILL_FIELD_TYPES.has(detected)) {234    return detected;235  }236237  const htmlType = el.type.toLowerCase();238  if (htmlType === "password") return "password";239  if (htmlType === "email") return "email";240  return null;241}242243async function openAutofillSelector(244  anchorField: HTMLInputElement,245  usernameEl: HTMLInputElement | null,246  passwordEl: HTMLInputElement,247  domainEntries: SelectorEntry[],248  searchableEntries: SelectorEntry[],249  domain: string250): Promise<void> {251  currentDismiss?.();252  currentAnchorField = anchorField;253  currentDismiss = showAutofillSelector(254    anchorField,255    domainEntries,256    async (id: string) => {257      currentDismiss = null;258      currentAnchorField = null;259      const entryResponse = await chrome.runtime.sendMessage({260        type: "GET_ENTRY_FOR_FILL",261        payload: { id },262      });263      const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;264      if (entry) {265        await handleAutofill(anchorField, usernameEl, passwordEl, entry);266        // Offer to set page domain as default for this entry when it differs.267        const pageDomain = location.hostname.replace(/^www\./, "");268        const entryDomain = (() => {269          try { return new URL(entry.url ?? "").hostname.replace(/^www\./, ""); } catch { return ""; }270        })();271        if (entryDomain && pageDomain && entryDomain !== pageDomain) {272          showDomainConfirmToast(pageDomain, entry.id);273        }274      }275    },276    {277      domain,278      searchableEntries,279      onDismiss: () => { lastDismissedField = anchorField; },280    }281  );282}283284async function openSignupSelector(285  usernameEl: HTMLInputElement,286  passwordEl: HTMLInputElement | null,287  domainEntries: SelectorEntry[],288  allEntries: SelectorEntry[],289  domain: string290): Promise<void> {291  currentDismiss?.();292  currentAnchorField = usernameEl;293  currentDismiss = showAutofillSelector(294    usernameEl,295    domainEntries,296    () => {}, // identity-click handled via onFillFields297    {298      domain,299      searchableEntries: allEntries,300      isSignup: true,301      onFillFields: (username, password) => {302        if (username) setNativeValue(usernameEl, username);303        if (password && passwordEl) setNativeValue(passwordEl, password);304      },305      onDismiss: () => { lastDismissedField = usernameEl; },306    }307  );308}309310311async function handleFieldInteraction(anchorField?: HTMLInputElement | null): Promise<void> {312  const field = anchorField ?? getDeepActiveInput();313  if (!field) return;314  if (!isVisible(field)) return;315316  // If a selector is already open for this exact field, don't flicker it.317  if (currentDismiss && currentAnchorField === field) return;318319  // If focus moved to a different field, the previous dismiss lock no longer applies.320  if (field !== lastDismissedField) lastDismissedField = null;321  // The user explicitly dismissed this field's selector — don't reopen it until322  // focus has genuinely moved away and come back.323  if (field === lastDismissedField) return;324325  // Prevent two concurrent calls for the same field (e.g. focusin + icon click326  // firing simultaneously — the second call would dismiss the first's selector).327  if (pendingInteractions.has(field)) return;328  pendingInteractions.add(field);329330  try {331    const fieldType = field.type.toLowerCase();332333    const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {334      return preds[0] ?? null;335    });336337    const mlPrediction = await new Promise<FieldPrediction | null>((resolve) => {338      const timeout = setTimeout(() => resolve(null), 150);339      mlPromise.then((pred) => {340        clearTimeout(timeout);341        resolve(pred);342      });343    });344345    if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {346      return;347    }348349    const autofillFieldType = resolveAutofillFieldType(field, mlPrediction?.type);350    if (autofillFieldType) {351      reattachListeners(field, autofillFieldType);352    }353354    let fields = detectLoginFields();355    const loginForm = isLoginForm(fields);356    const signupForm = isSignupForm(fields);357    if (!loginForm && !signupForm) {358      return;359    }360361    const domain = getPageDomain();362    const firstResult = await fetchAutofillEntries(domain);363364    // Vault is locked — show an inline unlock prompt.365    // Re-triggering after unlock is handled by the VAULT_UNLOCKED message so366    // there is no double-trigger race between onUnlocked and the broadcast.367    if (firstResult.locked) {368      currentDismiss?.();369      currentAnchorField = field;370      currentDismiss = showAutofillSelector(371        field,372        [],373        () => {},374        { domain, locked: true, onDismiss: () => { lastDismissedField = field; } }375      );376      return;377    }378379    const entries = firstResult.entries;380    const allEntries = entries.length === 0381      ? (await fetchAutofillEntries(domain, true)).entries382      : entries;383384    let usernameEl: HTMLInputElement | null = null;385    const userField = fields.find((f) => f.type === "username" || f.type === "email");386    if (userField) usernameEl = userField.el;387    const pwField = fields.find((f) => f.type === "password");388    if (!pwField) return;389    const passwordEl = pwField.el;390391    if (signupForm && usernameEl && field === usernameEl) {392      await openSignupSelector(usernameEl, passwordEl, entries, allEntries, domain);393      return;394    }395396    await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain);397  } finally {398    pendingInteractions.delete(field);399  }400}401402async function onFocusIn(): Promise<void> {403  await handleFieldInteraction();404}405406function reattachListeners(407  el: HTMLInputElement,408  detectedType?: "password" | "username" | "email",409  contextFields?: ReturnType<typeof detectLoginFields>410): void {411  if (processedInputs.has(el)) return;412  if (!resolveAutofillFieldType(el, detectedType)) return;413  // Only inject icon when a real login/signup form is present on the page414  const fields = contextFields ?? detectLoginFields();415  if (!isLoginForm(fields) && !isSignupForm(fields)) return;416417  const cleanup = injectFieldIcon(el, () => {418    currentDismiss?.();419    currentDismiss = null;420    lastDismissedField = null; // icon click is always an explicit user open — clear the dismiss lock421    el.focus({ preventScroll: true });422    void handleFieldInteraction(el);423  });424  processedInputs.set(el, cleanup);425}426427const debouncedReattach = debounce(() => {428  const allFields = detectLoginFields();429  // Don't inject anything if there's no real login/signup form on this page430  if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;431  const newFields = allFields.filter(432    (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)433  );434  for (const f of newFields) {435    reattachListeners(f.el, f.type, allFields);436  }437}, 200);438439function setupMutationObserver(): void {440  const observer = new MutationObserver(debouncedReattach);441  observer.observe(document.body, {442    childList: true,443    subtree: true,444    attributes: true,445    attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"],446  });447}448449function initialScan() {450  const allFields = detectLoginFields();451  if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;452  const newFields = allFields.filter(453    (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)454  );455  for (const f of newFields) {456    reattachListeners(f.el, f.type, allFields);457  }458}459460document.addEventListener("focusin", onFocusIn);461462setupMutationObserver();463setupSaveDetection();464465setTimeout(initialScan, 0);466setTimeout(initialScan, 500);467setTimeout(initialScan, 1500);468469chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {470  // Ignore anything not originating from our own background/service worker.471  if (sender.id !== chrome.runtime.id) return;472  if (msg?.type === "GET_SITE_PASSWORD_POLICY") {473    const policy = detectPasswordPolicy();474    sendResponse({ ok: true, data: policy });475    return true;476  }477  if (msg?.type === "PING") {478    sendResponse({ ok: true });479    return true;480  }481  if (msg?.type === "SUMMON_AUTOFILL") {482    // Cmd+Shift+P should open the same unified inline selector as clicking483    // a field — not a separate palette. Find the best anchor.484    const activeInput = getDeepActiveInput();485    const fields = detectLoginFields();486    const hasForm = isLoginForm(fields) || isSignupForm(fields);487    if (hasForm) {488      // Prefer the currently focused field if it's a login field, otherwise489      // fall back to the first password field, then username/email.490      const inForm = activeInput && fields.some(f => f.el === activeInput);491      const target = inForm492        ? activeInput493        : (fields.find(f => f.type === "password")?.el494            ?? fields.find(f => f.type === "username" || f.type === "email")?.el);495      if (target) void handleFieldInteraction(target);496    }497    // No login form on this page — nothing to show inline; the background498    // falls back to opening the popup window.499    sendResponse({ ok: hasForm });500    return true;501  }502  if (msg?.type === "VAULT_UNLOCKED") {503    // Close any open selector (e.g. the locked-state prompt) and re-open504    // with fresh entry data. Use the saved anchor field so we reopen exactly505    // where the user was — covers both inline unlock and popup unlock.506    const savedField = currentAnchorField;507    if (currentDismiss) {508      currentDismiss();509      currentDismiss = null;510      currentAnchorField = null;511    }512    void handleFieldInteraction(savedField ?? undefined);513    sendResponse({ ok: true });514    return true;515  }516  return true;517});