Files
hermes-agent/agent/vault_login_classifier.py
Teknium f98cb00a8b fix(vault): 2FA review follow-ups — per-digit fill only for an unmistakable maxlength=1 widget; honour otpauth digits/period/algorithm
Reviewer findings on #107585:
- build_otp_fills split any >=4 code-like controls into digits. A page with
  promo/zip/referral 'code' inputs next to the real OTP box would have had a
  digit sprayed across unrelated fields. Split now requires exactly len(code)
  controls that are all maxlength=1, same form, adjacent in DOM order
  (inspection JS exports maxLength); anything else fills ONE field, the
  best-scoring one. Verified on the real Browser Use stack: 6-box widget gets
  one digit each; scattered page fills only the one-time-code input.
- normalize_otp_secret dropped digits/period/algorithm from otpauth:// URIs,
  so an 8-digit or SHA-256 authenticator would get wrong codes. Non-default
  parameters are now stored as seed|digits|period|algo and honoured (RFC 6238
  SHA-256 8-digit vector added); hotp:// is rejected explicitly.
- rebase on main (prompts.ts conflict) + prettier.
2026-09-10 11:48:01 -07:00

334 lines
16 KiB
Python

"""Login / checkout form control classifier for vault autofill.
Python port (~170 LOC) of Merit-Systems/OpenInstinct's
``lib/manager/server/kernel-login-autofill.ts`` (MIT). Classifies visible
input controls on a page into login-autofill tokens. The vault fill path
uses the classification to select the single best current-password control
(the identifier is agent-visible metadata and is typed by the agent
itself via normal input tools).
Scoring:
- exact autocomplete-token match ................ 100
- type=password (not new/confirm/create/repeat) .. 90
- type=email / type=tel .......................... 85
- label/name regex heuristics .................. 70-75
Hard exclusions: autocomplete ``new-password`` / ``one-time-code``, and
label/name text matching ``(new|confirm|create|repeat)\\s*password``.
"""
from __future__ import annotations
import json
import re
import unicodedata
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
LOGIN_AUTOFILL_TOKENS = ("username", "email", "tel", "current-password")
# Payment / address autocomplete tokens (WHATWG) the checkout fill targets. ``cc-exp`` (combined
# MM/YY) is derived at fill time from exp_month + exp_year. Field-name/label heuristics below back
# up sites that omit autocomplete attributes.
PAYMENT_AUTOFILL_TOKENS = ("cc-number", "cc-name", "cc-exp", "cc-exp-month", "cc-exp-year", "cc-csc")
ADDRESS_AUTOFILL_TOKENS = ("address-line1", "address-line2", "address-level2", "address-level1",
"postal-code", "country-name", "country")
_CHECKOUT_HEURISTICS = (
(re.compile(r"\b(?:card\s*number|cardnumber|ccnumber|cc\s*num|pan)\b"), "cc-number"),
(re.compile(r"\b(?:name\s*on\s*card|cardholder|cc\s*name|ccname)\b"), "cc-name"),
(re.compile(r"\b(?:cvc|cvv|csc|security\s*code|card\s*code)\b"), "cc-csc"),
(re.compile(r"\b(?:exp(?:iry|iration)?\s*month|exp\s*mm|ccmonth)\b"), "cc-exp-month"),
(re.compile(r"\b(?:exp(?:iry|iration)?\s*year|exp\s*yy(?:yy)?|ccyear)\b"), "cc-exp-year"),
(re.compile(r"\b(?:exp(?:iry|iration)?(?:\s*date)?|mm\s*yy|valid\s*thru)\b"), "cc-exp"),
(re.compile(r"\b(?:address\s*(?:line\s*)?2|apt|suite|unit)\b"), "address-line2"),
(re.compile(r"\b(?:address(?:\s*line\s*1)?|street)\b"), "address-line1"),
(re.compile(r"\b(?:city|town|locality)\b"), "address-level2"),
(re.compile(r"\b(?:state|province|region|county)\b"), "address-level1"),
(re.compile(r"\b(?:zip|postal|postcode)\b"), "postal-code"),
(re.compile(r"\b(?:country)\b"), "country-name"),
)
_EXCLUDED_AUTOCOMPLETE = {"new-password", "one-time-code"}
_RE_EXCLUDED_PASSWORD = re.compile(r"\b(?:new|confirm|create|repeat)\s*password\b")
_RE_EMAIL = re.compile(r"\b(?:e[\s-]?mail|email address)\b")
_RE_TEL = re.compile(r"\b(?:phone|telephone|mobile)\b")
_RE_USERNAME = re.compile(
r"\b(?:user\s*name|username|login|account|member|membership|mileageplus)\b"
)
def _normalize_text(value: str) -> str:
value = unicodedata.normalize("NFKD", value).lower()
return re.sub(r"[^a-z0-9]+", " ", value).strip()
@dataclass(frozen=True)
class LoginControl:
"""Descriptor of a visible input control, as inspected in the page."""
autocomplete: str
form_index: Optional[int]
index: int
label: str
name: str
type: str
max_length: Optional[int] = None
@classmethod
def from_dict(cls, raw: Dict[str, Any]) -> "LoginControl":
form_index = raw.get("formIndex", raw.get("form_index"))
max_length = raw.get("maxLength", raw.get("max_length"))
return cls(
autocomplete=str(raw.get("autocomplete") or ""),
form_index=int(form_index) if form_index is not None else None,
index=int(raw.get("index") or 0),
label=str(raw.get("label") or ""),
name=str(raw.get("name") or ""),
type=str(raw.get("type") or ""),
max_length=int(max_length) if max_length is not None else None,
)
@dataclass(frozen=True)
class ClassifiedLoginControl:
control: LoginControl
score: int
token: str
def classify_login_control(control: LoginControl) -> Optional[ClassifiedLoginControl]:
"""Classify one control, or return None if it is not a login fill target."""
autocomplete_tokens = [
t for t in control.autocomplete.lower().split() if t
]
if any(t in _EXCLUDED_AUTOCOMPLETE for t in autocomplete_tokens):
return None
for token in LOGIN_AUTOFILL_TOKENS:
if token in autocomplete_tokens:
return ClassifiedLoginControl(control, 100, token)
searchable = _normalize_text(
" ".join(part for part in (control.name, control.label) if part)
)
if _RE_EXCLUDED_PASSWORD.search(searchable):
return None
if control.type == "password":
return ClassifiedLoginControl(control, 90, "current-password")
if control.type == "email":
return ClassifiedLoginControl(control, 85, "email")
if control.type == "tel":
return ClassifiedLoginControl(control, 85, "tel")
if _RE_EMAIL.search(searchable):
return ClassifiedLoginControl(control, 75, "email")
if _RE_TEL.search(searchable):
return ClassifiedLoginControl(control, 75, "tel")
if _RE_USERNAME.search(searchable):
return ClassifiedLoginControl(control, 70, "username")
return None
_RE_OTP = re.compile(
r"\b(?:one[\s-]?time|verification|security|auth(?:entication|enticator)?|2fa|two[\s-]?factor|mfa|totp|otp|"
r"passcode|sms)\b.*\b(?:code|pin|token)\b|\b(?:otp|totp|2fa|mfa|verification\s*code|passcode)\b"
)
def classify_otp_controls(controls: List[LoginControl]) -> List[ClassifiedLoginControl]:
"""The controls that take a second-factor code. ``autocomplete=one-time-code`` is authoritative;
otherwise a text/tel/number input whose name/label says code/OTP/2FA/verification. Some sites split
the code into one input per digit (``maxlength=1`` boxes): they are returned in DOM order and the
fill spreads the code across them."""
out: List[ClassifiedLoginControl] = []
for c in controls:
tokens = c.autocomplete.lower().split()
if "one-time-code" in tokens:
out.append(ClassifiedLoginControl(c, 100, "one-time-code"))
continue
if c.type not in ("text", "tel", "number", "password", ""):
continue
if _RE_OTP.search(_normalize_text(" ".join(p for p in (c.name, c.label) if p))):
out.append(ClassifiedLoginControl(c, 70, "one-time-code"))
return out
def select_password_fill(
classified: List[ClassifiedLoginControl],
password: str,
) -> List[Dict[str, Any]]:
"""Select the single best current-password control to fill.
The vault fill path is password-only: the identifier is agent-visible
metadata and is typed by the agent via normal input tools. This picks
the highest-scoring ``current-password`` control (ties broken by DOM
order) and returns ``[{"index": int, "token": "current-password",
"value": password}]`` or ``[]`` when no password field exists.
"""
passwords = [c for c in classified if c.token == "current-password"]
if not passwords or not password:
return []
best_password = sorted(
passwords, key=lambda c: (-c.score, c.control.index)
)[0]
return [
{
"index": best_password.control.index,
"token": "current-password",
"value": password,
}
]
def classify_checkout_control(control: LoginControl) -> Optional[ClassifiedLoginControl]:
"""Classify one control as a payment/address fill target (autocomplete token exact match 100,
label/name heuristic 70), or None. Password/email inputs are never checkout targets."""
tokens = [t for t in control.autocomplete.lower().split() if t]
for token in PAYMENT_AUTOFILL_TOKENS + ADDRESS_AUTOFILL_TOKENS:
if token in tokens:
return ClassifiedLoginControl(control, 100, "country-name" if token == "country" else token)
if control.type in ("password", "email"):
return None
searchable = _normalize_text(" ".join(part for part in (control.name, control.label) if part))
for pattern, token in _CHECKOUT_HEURISTICS:
if pattern.search(searchable):
return ClassifiedLoginControl(control, 70, token)
return None
def select_checkout_fills(classified: List[ClassifiedLoginControl], secret: Dict[str, str],
field_tokens: Dict[str, str]) -> List[Dict[str, Any]]:
"""Map a payment/address secret payload onto the best control per autocomplete token.
``field_tokens`` is ``PAYMENT_FIELDS`` / ``ADDRESS_FIELDS`` (agent/vault_store.py). A combined
``cc-exp`` control gets ``MM/YY`` from exp_month + exp_year and then suppresses the separate
month/year fills. Returns ``[{"index", "token", "value"}]``: one control per token, highest
score then DOM order.
"""
values: Dict[str, str] = {tok: secret[f] for f, tok in field_tokens.items() if secret.get(f)}
if "cc-exp-month" in values and "cc-exp-year" in values:
values["cc-exp"] = f"{values['cc-exp-month'].zfill(2)}/{values['cc-exp-year'][-2:]}"
fills: List[Dict[str, Any]] = []
for token, value in values.items():
candidates = sorted((c for c in classified if c.token == token), key=lambda c: (-c.score, c.control.index))
if candidates:
fills.append({"index": candidates[0].control.index, "token": token, "value": value})
if any(f["token"] == "cc-exp" for f in fills):
fills = [f for f in fills if f["token"] not in ("cc-exp-month", "cc-exp-year")]
return fills
# JS expression evaluated in the page to inspect candidate input controls.
# Ported from OpenInstinct's nativeLoginControlInspectionExpression.
# Inspection stamps every input with ``<nonce>:<index>`` under a per-inspection attribute; the fill
# script resolves targets by the stamp of ITS OWN inspection instead of re-querying by position, so
# neither a DOM reflow nor a second inspection in between can redirect the password into another field.
INSPECTION_STAMP_ATTR = "data-hermes-vault-slot"
def build_otp_fills(otp_controls: List[ClassifiedLoginControl], code: str) -> List[Dict[str, Any]]:
"""One fill per box. Default: the single best-scoring code field takes the whole code.
Per-digit entry only when the page unmistakably uses it: exactly len(code) OTP controls that are all
``maxlength=1``, all in the same form, and adjacent in DOM order (the classic N-box widget). Anything
looser (several code-like inputs scattered over a page) gets ONE field, never a digit sprayed across
unrelated inputs."""
best = max(otp_controls, key=lambda c: c.score)
boxes = sorted((c for c in otp_controls if c.control.max_length == 1), key=lambda c: c.control.index)
if (len(boxes) == len(code)
and len({b.control.form_index for b in boxes}) == 1
and all(b.control.index - a.control.index == 1 for a, b in zip(boxes, boxes[1:]))):
return [{"index": b.control.index, "token": "one-time-code", "value": ch} for b, ch in zip(boxes, code)]
return [{"index": best.control.index, "token": "one-time-code", "value": code}]
def build_inspection_js(nonce: str) -> str:
return _LOGIN_CONTROL_INSPECTION_JS_TEMPLATE.replace("__NONCE__", json.dumps(nonce))
_LOGIN_CONTROL_INSPECTION_JS_TEMPLATE = """(() => {
const nonce = __NONCE__;
const elements = Array.from(document.querySelectorAll("input, select"));
const forms = Array.from(document.forms);
elements.forEach((element, index) => element.setAttribute("data-hermes-vault-slot", nonce + ":" + index));
const out = elements.flatMap((element, index) => {
if (element.disabled || element.readOnly) return [];
if (["hidden", "submit", "button", "reset", "file", "image", "checkbox", "radio"].includes(element.type)) return [];
const style = getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden" || element.getClientRects().length === 0) return [];
const labels = element.labels ? Array.from(element.labels, (l) => l.textContent || "") : [];
const ariaText = (element.getAttribute("aria-labelledby") || "")
.split(/\\s+/).filter(Boolean)
.map((id) => { const n = document.getElementById(id); return n ? (n.textContent || "") : ""; })
.join(" ");
const resolvedFormIndex = element.form ? forms.indexOf(element.form) : -1;
return [{
autocomplete: element.autocomplete || "",
formIndex: resolvedFormIndex >= 0 ? resolvedFormIndex : null,
index,
maxLength: element.maxLength > 0 ? element.maxLength : null,
label: [
...labels,
element.getAttribute("aria-label") || "",
ariaText,
element.getAttribute("placeholder") || "",
element.getAttribute("title") || "",
].join(" "),
name: [element.name, element.id].join(" "),
type: element.tagName === "SELECT" ? "select" : (element.type || ""),
}];
});
return JSON.stringify(out);
})()"""
def build_fill_js(fills: List[Dict[str, Any]], expected_origin: str, nonce: str = "") -> str:
"""Build a JS expression that fills the selected controls and reports only a count. The
returned expression never echoes the values back.
``expected_origin`` is asserted against ``window.location.origin`` synchronously inside the SAME
evaluated script, immediately before any write. If the page navigated between inspection and fill
(TOCTOU), the script writes nothing and returns ``{"refused": "origin_changed", "found": <actual>}``:
proof scope equals mutation scope (#88706). Targets resolve by the ``<nonce>:<index>`` stamp of
THIS inspection; a ``current-password`` fill additionally requires ``type=password``; ``<select>``
controls (country, state, expiry month) match an option by value or visible text. No marker is
left on filled controls so later model-driven DOM reads cannot address them deterministically.
"""
payload = json.dumps(
[{"index": f["index"], "token": f.get("token", "current-password"), "value": f["value"]} for f in fills]
)
return (_FILL_JS_TEMPLATE.replace("__EXPECTED_ORIGIN__", json.dumps(expected_origin))
.replace("__FILLS__", payload).replace("__NONCE__", json.dumps(nonce)))
_FILL_JS_TEMPLATE = """(() => {
const expectedOrigin = __EXPECTED_ORIGIN__;
if (window.location.origin !== expectedOrigin) {
return JSON.stringify({ refused: "origin_changed", found: window.location.origin });
}
const fills = __FILLS__;
const nonce = __NONCE__;
let filled = 0;
const norm = (t) => String(t || "").trim().toLowerCase();
for (const f of fills) {
const el = document.querySelector('[data-hermes-vault-slot="' + nonce + ':' + f.index + '"]');
if (!el || (f.token === "current-password" && el.type !== "password")) continue;
try {
if (el.tagName === "SELECT") {
const want = norm(f.value);
const opt = Array.from(el.options).find((o) => [o.value, o.textContent].some((t) => norm(t) === want || norm(t) === want.replace(/^0/, "")));
if (opt) { el.value = opt.value; el.dispatchEvent(new Event("change", { bubbles: true })); filled += 1; }
continue;
}
el.focus();
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
// one-time-code split into single-character boxes: f.value is the slice for THIS box (see build_otp_fills)
if (setter && setter.set) { setter.set.call(el, f.value); } else { el.value = f.value; }
el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
el.dispatchEvent(new Event("change", { bubbles: true }));
if (el.value.length > 0) filled += 1;
} catch (e) { /* skip */ }
}
document.querySelectorAll("[data-hermes-vault-slot]").forEach((n) => n.removeAttribute("data-hermes-vault-slot"));
return JSON.stringify({ filled });
})()"""