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:
@@ -0,0 +1,121 @@
|
||||
// go-rod implementation of the browserrot Driver — the default deterministic
|
||||
// backend (docs/BROWSER-ROTATION.md §13 chosen stack). It talks to a real Chromium
|
||||
// over CDP. This file is the ONLY place a secret becomes a Go string: Fill converts
|
||||
// the vault-supplied bytes to the string CDP's Input requires, at the last moment.
|
||||
// The value is never logged, never persisted, never handed to a model.
|
||||
package browserrot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
)
|
||||
|
||||
// RodDriver launches a real headless Chromium per session. Bin points at a Chromium
|
||||
// executable (e.g. the Playwright-managed chrome-for-testing) so we avoid a network
|
||||
// download and, on snap systems, snap confinement. Timeout bounds each browser op.
|
||||
type RodDriver struct {
|
||||
Bin string // chromium binary; "" lets go-rod find/download one
|
||||
Headless bool // default true in production; false to watch it locally
|
||||
Timeout time.Duration // per-operation timeout; defaults to 30s
|
||||
}
|
||||
|
||||
// Name identifies the backend in logs/plans.
|
||||
func (d *RodDriver) Name() string { return "go-rod" }
|
||||
|
||||
// Open launches an isolated Chromium and returns a single-tab Session. Each Open is
|
||||
// a fresh browser + fresh profile, so no cookies/state leak between accounts.
|
||||
func (d *RodDriver) Open(ctx context.Context) (Session, error) {
|
||||
l := launcher.New().Headless(d.Headless).
|
||||
Set("no-sandbox").Set("disable-dev-shm-usage")
|
||||
if d.Bin != "" {
|
||||
l = l.Bin(d.Bin)
|
||||
}
|
||||
u, err := l.Launch()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("go-rod: launch chromium: %w", err)
|
||||
}
|
||||
b := rod.New().ControlURL(u)
|
||||
if err := b.Connect(); err != nil {
|
||||
l.Cleanup()
|
||||
return nil, fmt.Errorf("go-rod: connect: %w", err)
|
||||
}
|
||||
page, err := b.Page(proto.TargetCreateTarget{})
|
||||
if err != nil {
|
||||
b.MustClose()
|
||||
l.Cleanup()
|
||||
return nil, fmt.Errorf("go-rod: new page: %w", err)
|
||||
}
|
||||
to := d.Timeout
|
||||
if to <= 0 {
|
||||
to = 30 * time.Second
|
||||
}
|
||||
return &rodSession{browser: b, launcher: l, page: page, to: to}, nil
|
||||
}
|
||||
|
||||
type rodSession struct {
|
||||
browser *rod.Browser
|
||||
launcher *launcher.Launcher
|
||||
page *rod.Page
|
||||
to time.Duration
|
||||
}
|
||||
|
||||
func (s *rodSession) Goto(ctx context.Context, url string) (string, error) {
|
||||
p := s.page.Timeout(s.to)
|
||||
if err := p.Navigate(url); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := p.WaitStable(300 * time.Millisecond); err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := p.Info()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return info.URL, nil
|
||||
}
|
||||
|
||||
func (s *rodSession) Fill(ctx context.Context, selector string, secret []byte) error {
|
||||
el, err := s.page.Timeout(s.to).Element(selector)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The one plaintext-as-string moment: CDP Input needs a string. Localized here.
|
||||
return el.Input(string(secret))
|
||||
}
|
||||
|
||||
func (s *rodSession) Click(ctx context.Context, selector string) error {
|
||||
el, err := s.page.Timeout(s.to).Element(selector)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return el.Click(proto.InputMouseButtonLeft, 1)
|
||||
}
|
||||
|
||||
func (s *rodSession) Text(ctx context.Context, selector string) (string, error) {
|
||||
el, err := s.page.Timeout(s.to).Element(selector)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := el.WaitVisible(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return el.Text()
|
||||
}
|
||||
|
||||
func (s *rodSession) Close() error {
|
||||
if s.browser != nil {
|
||||
_ = s.browser.Close()
|
||||
}
|
||||
if s.launcher != nil {
|
||||
s.launcher.Cleanup()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check that RodDriver is a Driver.
|
||||
var _ Driver = (*RodDriver)(nil)
|
||||
Reference in New Issue
Block a user