Files
Teknium d9ca9c974d feat(vault): two-factor codes — automatic from a saved authenticator key, otherwise asked for in the user's UI
Follow-up to #106480. Sites that ask for a code after the password stopped
the agent cold: the login classifier excludes one-time-code fields on
purpose (a password must never land in an OTP box) and there was no tool
for the second step, so the only move was to ask in chat.

browser_vault_enter_code
  Fills the one-time code the current page asks for. Two sources, same
  invariant as passwords (the code goes to the page over the supervisor
  socket and never enters model context):
  - a TOTP seed on the login: local vault `otp_secret` (RFC 6238, stdlib,
    verified against the RFC test vectors), 1Password `op item get --otp`,
    Bitwarden `bw get totp`. Nobody is asked.
  - no seed: the surface prompts "Verification code for {site}"; the user
    types what their phone/email/app shows. Enter on empty / Skip declines
    and the tool returns code_declined ("do not ask again this turn").
  no_code_field tells the model the site wants a passkey / hardware key /
  app approval: hand it to the user's device and wait for navigation.
  Per-digit OTP boxes (maxlength=1 pattern) get one digit each in DOM order.

Surfaces
  CLI: sudo-style panel, code shown as typed (not a secret worth masking,
  typos must be visible), Enter submits, ESC/empty skips.
  Desktop: "Verification code for {site}" card via vault.code.request /
  vault.code.respond (gateway), owner-routed like the other vault prompts.
  Settings → Passwords & Logins: optional "Authenticator key" field on the
  add form (base32 or otpauth:// link); items with one show a "2FA auto"
  badge. `hermes vault add` asks for the same optional key.
  browser_vault_fill's result now says what to do next ("if the site asks
  for a verification code, call browser_vault_enter_code with this handle").
  Six locales.

Verified live (real model, local 2FA site that checks the TOTP; CLI PTY):
  A. login saved with authenticator key → signed in through 2FA, zero
     prompts, code/password absent from the transcript
  B. login without key → code panel → user types code → signed in
  C. panel dismissed → agent stops and explains, never asks in chat
Unit: RFC 6238 vectors, seed normalisation, mint-without-asking, per-digit
spread, decline, no-code-field; Desktop card test (owner routing, trim, Skip).
2026-09-10 11:48:01 -07:00

140 lines
5.7 KiB
Python

"""Login-backend contract + registry for the browser credential vault.
A ``LoginBackend`` lists login metadata (never secrets) and resolves ONE
password at fill time. External managers (1Password, Bitwarden) additionally
need a per-session unlock; ``resolve_password`` raises ``UnlockRequired``
while locked so the tool can ask the surface to prompt. Handles are
namespaced by ``prefix`` so ``backend_for_handle`` needs no lookup table.
"""
from __future__ import annotations
import subprocess
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Dict, List, Optional, Sequence
from agent.vault_store import VaultItemMeta
class UnlockRequired(Exception):
"""The backend is locked for this session; the surface must prompt for the master password."""
def __init__(self, backend: "LoginBackend"):
super().__init__(f"{backend.display_name} is locked")
self.backend = backend
class LoginBackend(ABC):
name: str # config key: local | onepassword | bitwarden
display_name: str # user-facing
prefix: str # handle prefix ("vault_", "op:", "bw:")
needs_unlock: bool = False
def owns(self, handle: str) -> bool:
return handle.startswith(self.prefix)
def is_unlocked(self) -> bool:
return True
@abstractmethod
def list_items(self) -> List[VaultItemMeta]:
"""Metadata only. Locked external backends return [] (the agent sees a lock hint instead)."""
@abstractmethod
def get_meta(self, handle: str) -> Optional[VaultItemMeta]: ...
@abstractmethod
def resolve_password(self, handle: str) -> str:
"""Server-side only; raises ``UnlockRequired`` when locked."""
def resolve_otp(self, handle: str) -> Optional[str]:
"""Current one-time code for a login that stores a TOTP seed, else None (the user is asked).
Server-side only, like resolve_password."""
return None
def resolve_secret(self, handle: str) -> Dict[str, str]:
"""Full payload of a payment/address item (server-side only). External managers list only
logins, so the base returns the password-only shape."""
return {"password": self.resolve_password(handle)}
def run_with_stdin_secret(argv: Sequence[str], *, env: Dict[str, str], secret: str, timeout: float,
label: str) -> subprocess.CompletedProcess:
"""Run a manager CLI feeding *secret* on stdin (never argv, never env). Spawn/timeout → RuntimeError."""
try:
return subprocess.run( # noqa: S603 — argv list, no shell
list(argv), env=env, input=secret + "\n", capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"{label} unlock timed out after {timeout:.0f}s") from exc
except OSError as exc:
raise RuntimeError(f"failed to invoke {label}: {exc}") from exc
def run_with_secret_env(argv: Sequence[str], *, env: Dict[str, str], secret_env: str, secret: str, timeout: float,
label: str) -> subprocess.CompletedProcess:
"""Run a manager CLI whose non-interactive contract reads the secret from a named env var.
The variable is set on the child's environment only (never argv, never our process)."""
child_env = dict(env)
child_env[secret_env] = secret
try:
return subprocess.run( # noqa: S603 — argv list, no shell
list(argv), env=child_env, stdin=subprocess.DEVNULL, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"{label} unlock timed out after {timeout:.0f}s") from exc
except OSError as exc:
raise RuntimeError(f"failed to invoke {label}: {exc}") from exc
def _cfg() -> Dict:
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly().get("vault") or {}
return cfg if isinstance(cfg, dict) else {}
def external_backend_classes():
from agent.vault_backends.bitwarden import BitwardenLoginBackend
from agent.vault_backends.onepassword import OnePasswordLoginBackend
return (OnePasswordLoginBackend, BitwardenLoginBackend)
def is_installed(name: str) -> bool:
"""Is the manager CLI reachable — honouring a configured ``binary_path`` over PATH."""
import shutil
section = _cfg().get(name) or {}
explicit = str(section.get("binary_path") or "") if isinstance(section, dict) else ""
if explicit:
return Path(explicit).is_file()
if name == "onepassword":
from agent.secret_sources.onepassword import find_op
return find_op() is not None
return shutil.which("bw") is not None
def is_enabled(name: str) -> bool:
"""An installed manager is a login source unless the user opted out (``vault.<name>.enabled: false``).
Zero-config on purpose: a user with ``bw``/``op`` on PATH should never have to discover a toggle."""
section = _cfg().get(name) or {}
if isinstance(section, dict) and section.get("enabled") is False:
return False
return is_installed(name)
def enabled_backends() -> List[LoginBackend]:
"""Local first (always on), then every detected external manager the user has not turned off."""
from agent.vault_backends.local import LocalLoginBackend
cfg = _cfg()
out: List[LoginBackend] = [LocalLoginBackend()]
for cls in external_backend_classes():
if is_enabled(cls.name):
section = cfg.get(cls.name) or {}
out.append(cls(section if isinstance(section, dict) else {}))
return out
def backend_for_handle(handle: str) -> Optional[LoginBackend]:
return next((b for b in enabled_backends() if b.owns(handle)), None)