c2f08d1a21
Extends the Phase A-tier-1 site-side rotation engine with an optional Node browser-sidecar and a deterministic self-heal tier, all proven LIVE-VM against the local lab change-password form. go-rod stays the default; the sidecar is a swap-in Driver behind the same Session/Driver/Rotator interfaces — no spine change, backup + verify-new-before-revoke-old + proof gates all apply unchanged. M-B1 sidecar parity: internal/browserrot/sidecar/ (index.js JSON-RPC over stdio, patchright real-fingerprint Chromium, playwright-core fallback) + sidecar.go Driver/Session. A shared test harness runs go-rod and the sidecar through the identical live rotation proof. M-B2 stealth: patchright removes navigator.webdriver; humanized input (Bezier mouse paths + per-key typing jitter, no ML trajectory generation per plan §5). M-B3 self-heal: Healer interface + heal-retry fill/click/text wrappers; on a rotted selector, heal.js relocates the field from DOM structure ONLY (never a value) and the engine rewrites + persists the recipe (recipes.go RecipeBook) so the next run is Tier-1 again. Deterministic heuristic is the proven Tier-2; Stagehand LLM strategy is opt-in + UNPROVEN. Leak-check asserts no secret ever reaches a heal request (redacted IPC transcript). Secret seam documented honestly: fill carries base64 secret bytes across exactly one local stdio boundary, typed and wiped, never logged/persisted/sent to a model. MFA/CAPTCHA still degrade to the human worklist (Hard Rule 5). 14 tests green, -race clean; lab/lab-provision-browserrot.sh proves both drivers. M-B4 (first REAL self-owned site) deliberately NOT started — needs per-credential authorization (safe-candidate ladder). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
358 lines
15 KiB
Go
358 lines
15 KiB
Go
// Package browserrot is incredigo's Phase A-tier-1 site-side rotation engine: it
|
|
// drives a real browser to change a password AT A WEBSITE, then verifies the new
|
|
// password authenticates before the safe spine commits it (docs/BROWSER-ROTATION.md
|
|
// §9). It is the deterministic tier — NO language model is in this loop; a curated
|
|
// per-site selector table tells the harness exactly which fields to fill, and the
|
|
// harness injects the secret directly over CDP. The AI/vision fallback (Tier-2) is
|
|
// a separate future Driver, kept out of this deterministic path (CLAUDE.md rule).
|
|
//
|
|
// It plugs into the SAME safety spine as the API/DB rotation drivers: a Rotator
|
|
// here implements rotate.Rotator, so rotate.Snapshot (mandatory backup), the
|
|
// verify-new-before-revoke-old ordering in rotate.Execute, and the proof gate all
|
|
// apply unchanged. Website password change is inherently "verify then the site
|
|
// invalidates the old itself", so RevokeOld is a no-op (see below).
|
|
//
|
|
// SECURITY SEAMS (documented honestly, mirroring the pwstore argv caveats):
|
|
// - A browser field is filled with a plaintext value. The vault hands out bytes;
|
|
// the ONE unavoidable plaintext-as-string moment is at the CDP boundary inside
|
|
// the Driver implementation (rod.go), localized there and never logged, never
|
|
// written to disk, never sent to a model.
|
|
// - MFA / CAPTCHA / confirmation-email are NEVER bypassed. A site that presents
|
|
// one makes SuccessText not match, Rotate fails cleanly, and the old secret is
|
|
// kept — the human is handed the change-URL by the existing worklist.
|
|
package browserrot
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"incredigo/internal/discover"
|
|
"incredigo/internal/links"
|
|
"incredigo/internal/pwgen"
|
|
"incredigo/internal/rotate"
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// Session drives one browser tab against one site. Secrets cross this interface as
|
|
// []byte and are converted to the browser's required string only inside the Driver
|
|
// implementation, so the core engine never holds a secret as a Go string. A Session
|
|
// MUST NOT log field values.
|
|
type Session interface {
|
|
// Goto navigates to url (following redirects, e.g. RFC 8615
|
|
// /.well-known/change-password → the real change page) and returns the final URL.
|
|
Goto(ctx context.Context, url string) (finalURL string, err error)
|
|
// Fill sets the value of the element matched by selector. secret is treated as
|
|
// ephemeral and never logged.
|
|
Fill(ctx context.Context, selector string, secret []byte) error
|
|
// Click activates the element matched by selector.
|
|
Click(ctx context.Context, selector string) error
|
|
// Text waits for the element matched by selector to be present and returns its
|
|
// visible text — used to read the site's success/error signal.
|
|
Text(ctx context.Context, selector string) (string, error)
|
|
// Close releases the tab/browser.
|
|
Close() error
|
|
}
|
|
|
|
// Driver opens browser sessions. go-rod is the default (rod.go); playwright-go and
|
|
// a Tier-2 AI driver are swap-ins that satisfy the same interface.
|
|
type Driver interface {
|
|
Name() string
|
|
Open(ctx context.Context) (Session, error)
|
|
}
|
|
|
|
// Healer is the OPTIONAL Tier-2 capability a Session may implement: relocate a field the
|
|
// deterministic recipe could not find, by NATURAL-LANGUAGE description, and return a fresh
|
|
// selector. Note the signature — Heal takes only a description and returns a selector, so
|
|
// a secret CANNOT be passed to the healer (and thus never reaches a model). The engine
|
|
// calls Heal only when a Tier-1 selector misses, then re-persists the healed selector so
|
|
// the next run is deterministic again ("learn once"). A Session that does not implement
|
|
// Healer simply fails closed on a selector miss (Tier-1 only).
|
|
type Healer interface {
|
|
Heal(ctx context.Context, description string) (selector string, err error)
|
|
}
|
|
|
|
// Form describes a site's change-password form: the CSS selectors to fill and the
|
|
// element whose text signals the outcome.
|
|
type Form struct {
|
|
CurrentSel string // selector for the "current password" field
|
|
NewSel string // selector for the "new password" field
|
|
ConfirmSel string // selector for a "confirm new password" field ("" if none)
|
|
SubmitSel string // selector for the submit control
|
|
SuccessSel string // selector for the element carrying the result text
|
|
SuccessText string // substring in SuccessSel's text that means the change succeeded
|
|
}
|
|
|
|
// Login is an OPTIONAL re-login flow used to VERIFY the new password actually
|
|
// authenticates (hard rule 2). A zero Login (URL == "") disables browser verify —
|
|
// in that case Verify refuses, so the spine keeps the old value and flags the human
|
|
// rather than committing an unverified rotation.
|
|
type Login struct {
|
|
URL string
|
|
UserSel string // "" for a password-only verify form
|
|
PassSel string
|
|
SubmitSel string
|
|
SuccessSel string
|
|
SuccessText string
|
|
}
|
|
|
|
// Site is one curated target: its host, the change form, an optional verify login,
|
|
// and how the ENGINE against this site has been validated (proof-as-data, mirroring
|
|
// rotate/proofs.go). A real provider's selector table stays ProofUnproven until it
|
|
// has actually been run against that real site; the local-form lab target is
|
|
// ProofLiveVM because a real browser + real HTTP form proved the mechanism.
|
|
type Site struct {
|
|
Host string
|
|
// ChangeURL overrides the derived change-password URL. Leave "" to use the
|
|
// curated/well-known URL from internal/links (the normal path); set it for a
|
|
// site whose change page is not derivable, or for a local-form proof.
|
|
ChangeURL string
|
|
Form Form
|
|
Login Login
|
|
Proof rotate.ProofLevel
|
|
}
|
|
|
|
// Rotator drives one Site. Unlike the stateless API drivers, a browser Rotator is
|
|
// created PER credential: Rotate records the account's username so the later Verify
|
|
// can re-login. Construct one with New.
|
|
type Rotator struct {
|
|
site Site
|
|
drv Driver
|
|
policy pwgen.Policy
|
|
|
|
username string // captured in Rotate, used by Verify's login (non-secret)
|
|
healed bool // set true if a Tier-2 heal rewrote any selector this run
|
|
}
|
|
|
|
// New builds a Rotator for a Site backed by drv, minting new passwords with policy.
|
|
func New(site Site, drv Driver, policy pwgen.Policy) *Rotator {
|
|
return &Rotator{site: site, drv: drv, policy: policy}
|
|
}
|
|
|
|
// Name identifies the driver in plans/audit: "browser:<host>".
|
|
func (r *Rotator) Name() string { return "browser:" + r.site.Host }
|
|
|
|
// Proof exposes this Site's validation level so callers can apply the same
|
|
// execute-time proof gate the API drivers use (rotate.MinExecuteProof).
|
|
func (r *Rotator) Proof() rotate.ProofLevel { return r.site.Proof }
|
|
|
|
// Detect reports whether this credential's derived web host matches the Site.
|
|
func (r *Rotator) Detect(c discover.Credential) bool {
|
|
return links.HostFor(c) == r.site.Host && r.site.Host != ""
|
|
}
|
|
|
|
// Rotate opens the change-password page (via the curated/well-known URL), fills the
|
|
// OLD secret and a freshly minted NEW secret over CDP, submits, and confirms the
|
|
// site reported success. It returns the new secret's vault handle. It does NOT
|
|
// revoke the old credential (the Rotator contract) and does NOT touch disk.
|
|
func (r *Rotator) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
|
|
r.username = c.Meta["username"] // non-secret; "" is fine for password-only verify
|
|
|
|
changeURL := r.site.ChangeURL
|
|
if changeURL == "" {
|
|
changeURL = links.For(r.site.Host).URL
|
|
}
|
|
if changeURL == "" {
|
|
return nil, fmt.Errorf("browserrot: no change-password URL for %q", r.site.Host)
|
|
}
|
|
|
|
sess, err := r.drv.Open(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("browserrot: open browser: %w", err)
|
|
}
|
|
defer sess.Close()
|
|
|
|
if _, err := sess.Goto(ctx, changeURL); err != nil {
|
|
return nil, fmt.Errorf("browserrot: goto change page: %w", err)
|
|
}
|
|
|
|
// Mint the new secret in the vault BEFORE filling, so both old and new stay as
|
|
// vault-backed bytes for the duration of the fills.
|
|
newH, err := pwgen.Generate(v, r.policy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("browserrot: generate: %w", err)
|
|
}
|
|
|
|
if err := r.fillForm(ctx, sess, c.Secret, newH, v); err != nil {
|
|
v.Forget(newH) // never keep a new secret whose change did not land
|
|
return nil, err
|
|
}
|
|
|
|
txt, err := r.text(ctx, sess, &r.site.Form.SuccessSel, "the success or error status message")
|
|
if err != nil {
|
|
v.Forget(newH)
|
|
return nil, fmt.Errorf("browserrot: read result: %w", err)
|
|
}
|
|
if !strings.Contains(txt, r.site.Form.SuccessText) {
|
|
v.Forget(newH)
|
|
// This is also the MFA/CAPTCHA/confirmation wall: the success marker simply
|
|
// did not appear. We keep the old secret and let the human finish.
|
|
return nil, fmt.Errorf("browserrot: site did not confirm change (needs human / MFA?): got %q", strings.TrimSpace(txt))
|
|
}
|
|
return newH, nil
|
|
}
|
|
|
|
// fillForm injects the old + new secrets into the form fields. Both secrets are read
|
|
// straight from the vault as bytes and handed to the Driver; they are never copied
|
|
// into a Go string in this package. Each field goes through fill/click, which self-heal
|
|
// on a selector miss (Tier-2) and rewrite r.site.Form so the healed recipe can persist.
|
|
func (r *Rotator) fillForm(ctx context.Context, sess Session, oldH, newH *vault.Handle, v *vault.Vault) error {
|
|
oldBuf, err := v.Open(oldH)
|
|
if err != nil {
|
|
return fmt.Errorf("browserrot: open old secret: %w", err)
|
|
}
|
|
if err := r.fill(ctx, sess, &r.site.Form.CurrentSel, "the current password input field", oldBuf.Bytes()); err != nil {
|
|
return err
|
|
}
|
|
newBuf, err := v.Open(newH)
|
|
if err != nil {
|
|
return fmt.Errorf("browserrot: open new secret: %w", err)
|
|
}
|
|
if err := r.fill(ctx, sess, &r.site.Form.NewSel, "the new password input field", newBuf.Bytes()); err != nil {
|
|
return err
|
|
}
|
|
if r.site.Form.ConfirmSel != "" {
|
|
if err := r.fill(ctx, sess, &r.site.Form.ConfirmSel, "the confirm-new-password input field", newBuf.Bytes()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := r.click(ctx, sess, &r.site.Form.SubmitSel, "the submit / save-changes button"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fill sets *selPtr's field to secret; on a selector miss it asks the session's Healer
|
|
// (Tier-2) to relocate the field by desc, retries, and rewrites *selPtr in place so the
|
|
// healed recipe persists. The secret is never handed to the healer (see Healer).
|
|
func (r *Rotator) fill(ctx context.Context, sess Session, selPtr *string, desc string, secret []byte) error {
|
|
if err := sess.Fill(ctx, *selPtr, secret); err == nil {
|
|
return nil
|
|
} else {
|
|
newSel, herr := r.heal(ctx, sess, desc, err)
|
|
if herr != nil {
|
|
return herr
|
|
}
|
|
if err2 := sess.Fill(ctx, newSel, secret); err2 != nil {
|
|
return fmt.Errorf("browserrot: fill %q after heal to %q: %w", desc, newSel, err2)
|
|
}
|
|
*selPtr = newSel
|
|
r.healed = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// click activates *selPtr; heals + rewrites on a miss, same contract as fill.
|
|
func (r *Rotator) click(ctx context.Context, sess Session, selPtr *string, desc string) error {
|
|
if err := sess.Click(ctx, *selPtr); err == nil {
|
|
return nil
|
|
} else {
|
|
newSel, herr := r.heal(ctx, sess, desc, err)
|
|
if herr != nil {
|
|
return herr
|
|
}
|
|
if err2 := sess.Click(ctx, newSel); err2 != nil {
|
|
return fmt.Errorf("browserrot: click %q after heal to %q: %w", desc, newSel, err2)
|
|
}
|
|
*selPtr = newSel
|
|
r.healed = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// text reads *selPtr's visible text; heals + rewrites on a miss, same contract as fill.
|
|
func (r *Rotator) text(ctx context.Context, sess Session, selPtr *string, desc string) (string, error) {
|
|
if txt, err := sess.Text(ctx, *selPtr); err == nil {
|
|
return txt, nil
|
|
} else {
|
|
newSel, herr := r.heal(ctx, sess, desc, err)
|
|
if herr != nil {
|
|
return "", herr
|
|
}
|
|
txt2, err2 := sess.Text(ctx, newSel)
|
|
if err2 != nil {
|
|
return "", fmt.Errorf("browserrot: read %q after heal to %q: %w", desc, newSel, err2)
|
|
}
|
|
*selPtr = newSel
|
|
r.healed = true
|
|
return txt2, nil
|
|
}
|
|
}
|
|
|
|
// heal asks the session's Healer (if any) to relocate a field by natural-language desc.
|
|
// If the session cannot heal, the original miss is returned unchanged (fail closed).
|
|
func (r *Rotator) heal(ctx context.Context, sess Session, desc string, cause error) (string, error) {
|
|
h, ok := sess.(Healer)
|
|
if !ok {
|
|
return "", fmt.Errorf("browserrot: selector for %s missed and driver cannot self-heal: %w", desc, cause)
|
|
}
|
|
newSel, err := h.Heal(ctx, desc)
|
|
if err != nil {
|
|
return "", fmt.Errorf("browserrot: heal %s failed: %v (original miss: %w)", desc, err, cause)
|
|
}
|
|
if newSel == "" {
|
|
return "", fmt.Errorf("browserrot: heal %s returned no selector (original miss: %w)", desc, cause)
|
|
}
|
|
return newSel, nil
|
|
}
|
|
|
|
// Healed reports whether a Tier-2 heal rewrote any selector during the last Rotate/Verify.
|
|
// When true, the caller should persist HealedSite() so the next run is deterministic.
|
|
func (r *Rotator) Healed() bool { return r.healed }
|
|
|
|
// HealedSite returns the Site with any healed selectors folded in — the recipe to persist.
|
|
func (r *Rotator) HealedSite() Site { return r.site }
|
|
|
|
// Verify proves the new secret authenticates by re-logging-in through the Site's
|
|
// Login flow (hard rule 2: verify-new-before-revoke-old). If the Site has no Login
|
|
// flow, Verify REFUSES — the spine then keeps the old secret and flags the human,
|
|
// never committing an unverified website rotation.
|
|
func (r *Rotator) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
|
|
if r.site.Login.URL == "" {
|
|
return errors.New("browserrot: no browser verify path for this site — keeping old (hard rule 2)")
|
|
}
|
|
sess, err := r.drv.Open(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("browserrot: open browser (verify): %w", err)
|
|
}
|
|
defer sess.Close()
|
|
|
|
if _, err := sess.Goto(ctx, r.site.Login.URL); err != nil {
|
|
return fmt.Errorf("browserrot: goto login: %w", err)
|
|
}
|
|
if r.site.Login.UserSel != "" {
|
|
if err := r.fill(ctx, sess, &r.site.Login.UserSel, "the username or email input field", []byte(r.username)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
buf, err := v.Open(newSecret)
|
|
if err != nil {
|
|
return fmt.Errorf("browserrot: open new secret (verify): %w", err)
|
|
}
|
|
if err := r.fill(ctx, sess, &r.site.Login.PassSel, "the password input field", buf.Bytes()); err != nil {
|
|
return err
|
|
}
|
|
if err := r.click(ctx, sess, &r.site.Login.SubmitSel, "the log-in button"); err != nil {
|
|
return err
|
|
}
|
|
txt, err := r.text(ctx, sess, &r.site.Login.SuccessSel, "the post-login status message")
|
|
if err != nil {
|
|
return fmt.Errorf("browserrot: read login result: %w", err)
|
|
}
|
|
if !strings.Contains(txt, r.site.Login.SuccessText) {
|
|
return fmt.Errorf("browserrot: new password did NOT authenticate: got %q", strings.TrimSpace(txt))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RevokeOld is a no-op for website passwords: the site itself invalidates the old
|
|
// password the instant the new one is set (we do not control that ordering — see
|
|
// docs/BROWSER-ROTATION.md §3, rule 2). Nothing to retire, so this always succeeds.
|
|
func (r *Rotator) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
|
|
return nil
|
|
}
|
|
|
|
// Ensure a Rotator satisfies the shared rotation contract at compile time.
|
|
var _ rotate.Rotator = (*Rotator)(nil)
|