browserrot: Phase A-tier-1 site-side rotation engine (go-rod)

Deterministic, headless browser-driven password rotation:
discover change page via RFC 8615 / links, inject old+new over CDP
(no model in the loop), submit, and re-login to verify the new
password before commit. Implements rotate.Rotator so the mandatory
backup gate, verify-before-revoke ordering, and proof gate apply
unchanged; RevokeOld is a no-op (the site invalidates the old pw).

Proof-as-data per Site: the engine is LIVE-VM (real headless
Chromium vs a real local change-password form, via
lab-provision-browserrot.sh / TestIntegration_realBrowserRotation);
real-site selector tables stay UNPROVEN and nothing auto-registers
into production rotate yet. 14 packages, -race clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-18 12:43:56 -07:00
parent 6e3b39b393
commit 7a06d57bb3
8 changed files with 798 additions and 3 deletions
+263
View File
@@ -0,0 +1,263 @@
// 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)
}
// 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)
}
// 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 := sess.Text(ctx, r.site.Form.SuccessSel)
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.
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 := sess.Fill(ctx, r.site.Form.CurrentSel, oldBuf.Bytes()); err != nil {
return fmt.Errorf("browserrot: fill current: %w", err)
}
newBuf, err := v.Open(newH)
if err != nil {
return fmt.Errorf("browserrot: open new secret: %w", err)
}
if err := sess.Fill(ctx, r.site.Form.NewSel, newBuf.Bytes()); err != nil {
return fmt.Errorf("browserrot: fill new: %w", err)
}
if r.site.Form.ConfirmSel != "" {
if err := sess.Fill(ctx, r.site.Form.ConfirmSel, newBuf.Bytes()); err != nil {
return fmt.Errorf("browserrot: fill confirm: %w", err)
}
}
if err := sess.Click(ctx, r.site.Form.SubmitSel); err != nil {
return fmt.Errorf("browserrot: submit: %w", err)
}
return nil
}
// 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 := sess.Fill(ctx, r.site.Login.UserSel, []byte(r.username)); err != nil {
return fmt.Errorf("browserrot: fill username: %w", err)
}
}
buf, err := v.Open(newSecret)
if err != nil {
return fmt.Errorf("browserrot: open new secret (verify): %w", err)
}
if err := sess.Fill(ctx, r.site.Login.PassSel, buf.Bytes()); err != nil {
return fmt.Errorf("browserrot: fill password (verify): %w", err)
}
if err := sess.Click(ctx, r.site.Login.SubmitSel); err != nil {
return fmt.Errorf("browserrot: submit login: %w", err)
}
txt, err := sess.Text(ctx, r.site.Login.SuccessSel)
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)