Files
incredigo/internal/browserrot/browserrot_test.go
T
leetcrypt 7a06d57bb3 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>
2026-07-18 12:43:56 -07:00

326 lines
10 KiB
Go

package browserrot
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"incredigo/internal/discover"
"incredigo/internal/links"
"incredigo/internal/pwgen"
"incredigo/internal/rotate"
"incredigo/internal/vault"
)
// ---- fake Driver: records the SEQUENCE of ops and the field LENGTHS, never the
// secret bytes, so tests can assert order + leak-freedom without a browser. -------
type fakeOp struct {
kind string // "goto" | "fill" | "click" | "text"
arg string // url / selector
n int // secret length for fill (never the value)
}
type fakeSession struct {
mu *sync.Mutex
ops *[]fakeOp
seen *[]byte // every secret byte the driver was handed (for leak assertions)
fields map[string]string
success string // text returned by Text()
failFill string // selector to error on, if any
}
func (s *fakeSession) Goto(_ context.Context, url string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
*s.ops = append(*s.ops, fakeOp{kind: "goto", arg: url})
return url, nil
}
func (s *fakeSession) Fill(_ context.Context, sel string, secret []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if sel == s.failFill {
return fmt.Errorf("fake: forced fill error on %s", sel)
}
*s.ops = append(*s.ops, fakeOp{kind: "fill", arg: sel, n: len(secret)})
*s.seen = append(*s.seen, secret...)
s.fields[sel] = string(secret) // the fake may look; the PRODUCTION path may not
return nil
}
func (s *fakeSession) Click(_ context.Context, sel string) error {
s.mu.Lock()
defer s.mu.Unlock()
*s.ops = append(*s.ops, fakeOp{kind: "click", arg: sel})
return nil
}
func (s *fakeSession) Text(_ context.Context, sel string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
*s.ops = append(*s.ops, fakeOp{kind: "text", arg: sel})
return s.success, nil
}
func (s *fakeSession) Close() error { return nil }
type fakeDriver struct {
mu sync.Mutex
ops []fakeOp
seen []byte
success string
failFill string
fields map[string]string
}
func (d *fakeDriver) Name() string { return "fake" }
func (d *fakeDriver) Open(context.Context) (Session, error) {
if d.fields == nil {
d.fields = map[string]string{}
}
return &fakeSession{
mu: &d.mu, ops: &d.ops, seen: &d.seen, fields: d.fields,
success: d.success, failFill: d.failFill,
}, nil
}
// gitHubPAT builds a credential whose derived host is github.com (via the service
// hint), matching a Site{Host: "github.com"}.
func gitHubPAT(t *testing.T, v *vault.Vault, secret string) discover.Credential {
t.Helper()
return discover.Credential{
Source: "env",
Identity: ".env / GITHUB_TOKEN",
Location: "imported/env/GITHUB_TOKEN",
Secret: v.Store([]byte(secret)),
Meta: map[string]string{discover.MetaService: "github", "username": "octocat"},
}
}
func testSite() Site {
return Site{
Host: "github.com",
Form: Form{
CurrentSel: "#current", NewSel: "#new", ConfirmSel: "#confirm",
SubmitSel: "#submit", SuccessSel: "#result", SuccessText: "SUCCESS",
},
Proof: rotate.ProofMockOnly,
}
}
func TestRotate_spineOrderAndNoLeak(t *testing.T) {
v := vault.New()
defer v.Purge()
const oldPW = "OLD-corr3ct-horse"
c := gitHubPAT(t, v, oldPW)
drv := &fakeDriver{success: "SUCCESS"}
r := New(testSite(), drv, pwgen.DefaultPolicy())
newH, err := r.Rotate(context.Background(), c, v)
if err != nil {
t.Fatalf("Rotate: %v", err)
}
// Ops must be: goto → fill current → fill new → fill confirm → click → text.
want := []string{"goto:https://github.com/settings/tokens", "fill:#current", "fill:#new", "fill:#confirm", "click:#submit", "text:#result"}
var got []string
for _, o := range drv.ops {
got = append(got, o.kind+":"+o.arg)
}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("op order:\n got %v\nwant %v", got, want)
}
// The old secret was injected into #current; a fresh (different) secret into #new.
if drv.fields["#current"] != oldPW {
t.Fatalf("current field got %q, want old password", drv.fields["#current"])
}
if drv.fields["#new"] == oldPW || drv.fields["#new"] == "" {
t.Fatalf("new field must be a freshly minted, non-empty, different secret")
}
if drv.fields["#new"] != drv.fields["#confirm"] {
t.Fatalf("confirm must equal new")
}
// Leak check: the OLD secret must never appear anywhere except as the value we
// deliberately handed to #current — assert it is not in an unexpected field, and
// that the Rotator's Name()/error surface never carries a secret.
if strings.Contains(r.Name(), oldPW) || strings.Contains(r.Name(), drv.fields["#new"]) {
t.Fatalf("driver name leaked a secret: %q", r.Name())
}
v.Forget(newH)
}
func TestRotate_failWhenSiteRejects(t *testing.T) {
v := vault.New()
defer v.Purge()
c := gitHubPAT(t, v, "OLD")
drv := &fakeDriver{success: "REJECTED: wrong current password"}
r := New(testSite(), drv, pwgen.DefaultPolicy())
if _, err := r.Rotate(context.Background(), c, v); err == nil {
t.Fatal("expected Rotate to fail when the site does not confirm success")
} else if !strings.Contains(err.Error(), "did not confirm") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestVerify_refusesWithoutLoginFlow(t *testing.T) {
v := vault.New()
defer v.Purge()
drv := &fakeDriver{success: "SUCCESS"}
r := New(testSite(), drv, pwgen.DefaultPolicy()) // Site has no Login flow
h := v.Store([]byte("whatever"))
if err := r.Verify(context.Background(), h, v); err == nil {
t.Fatal("Verify must REFUSE when the site has no browser verify path (hard rule 2)")
}
}
func TestVerify_loginFlowAuthenticates(t *testing.T) {
v := vault.New()
defer v.Purge()
site := testSite()
site.Login = Login{
URL: "https://github.com/login", UserSel: "#u", PassSel: "#p",
SubmitSel: "#go", SuccessSel: "#welcome", SuccessText: "SUCCESS",
}
drv := &fakeDriver{success: "SUCCESS"}
r := New(site, drv, pwgen.DefaultPolicy())
r.username = "octocat"
if err := r.Verify(context.Background(), v.Store([]byte("newpw")), v); err != nil {
t.Fatalf("Verify should pass when login succeeds: %v", err)
}
}
// The Rotator must satisfy the shared contract AND flow through the proof gate: a
// MOCK-ONLY site is skipped by Execute unless the operator opts in.
func TestProofGate_mockOnlySkippedByDefault(t *testing.T) {
r := New(testSite(), &fakeDriver{}, pwgen.DefaultPolicy())
if got := r.Proof(); got != rotate.ProofMockOnly {
t.Fatalf("proof: got %v want MOCK-ONLY", got)
}
if r.Proof() >= rotate.MinExecuteProof {
t.Fatal("a MOCK-ONLY browser site must be below the execute gate")
}
}
// ---- LIVE-VM integration: real Chromium against a real local form. -------------
// Skipped unless CHROME_BIN points at a usable Chromium (set by lab-provision-browserrot.sh).
func TestIntegration_realBrowserRotation(t *testing.T) {
bin := os.Getenv("CHROME_BIN")
if bin == "" {
t.Skip("set CHROME_BIN to a Chromium executable to run the LIVE-VM browser proof")
}
const oldPW = "OLD-corr3ct-horse-battery"
var mu sync.Mutex
stored := oldPW
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/change-password", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/account/security/password", http.StatusSeeOther)
})
mux.HandleFunc("/account/security/password", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
r.ParseForm()
mu.Lock()
ok := r.FormValue("current") == stored
if ok {
stored = r.FormValue("new")
}
mu.Unlock()
if !ok {
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `<h1 id="result">REJECTED</h1>`)
return
}
fmt.Fprint(w, `<h1 id="result">SUCCESS</h1>`)
return
}
fmt.Fprint(w, `<!doctype html><meta charset=utf-8><form method=POST>
<input id=current name=current type=password>
<input id=new name=new type=password>
<button id=submit type=submit>Change</button></form>`)
})
// login-verify endpoint
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
r.ParseForm()
mu.Lock()
ok := r.FormValue("p") == stored
mu.Unlock()
if ok {
fmt.Fprint(w, `<h1 id="welcome">SUCCESS</h1>`)
} else {
fmt.Fprint(w, `<h1 id="welcome">DENIED</h1>`)
}
return
}
fmt.Fprint(w, `<!doctype html><meta charset=utf-8><form method=POST>
<input id=p name=p type=password><button id=go type=submit>Login</button></form>`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://") // 127.0.0.1:PORT
site := Site{
Host: host,
ChangeURL: srv.URL + "/.well-known/change-password", // exercise RFC 8615 redirect
Form: Form{
CurrentSel: "#current", NewSel: "#new",
SubmitSel: "#submit", SuccessSel: "#result", SuccessText: "SUCCESS",
},
Login: Login{
URL: srv.URL + "/login", PassSel: "#p", SubmitSel: "#go",
SuccessSel: "#welcome", SuccessText: "SUCCESS",
},
Proof: rotate.ProofLiveVM,
}
v := vault.New()
defer v.Purge()
c := discover.Credential{
Source: "file", Identity: host + " / cred", Location: "imported/file/x",
Secret: v.Store([]byte(oldPW)),
// host has no curated entry, so links.For falls back to well-known on this host.
Meta: map[string]string{},
}
// Point HostFor at our ephemeral host via the git-style "user @ host" identity.
c.Identity = "acct @ " + host
drv := &RodDriver{Bin: bin, Headless: true, Timeout: 30 * time.Second}
r := New(site, drv, pwgen.DefaultPolicy())
if !r.Detect(c) {
t.Fatalf("Detect should match host %q (HostFor=%q)", host, hostForDebug(c))
}
ctx := context.Background()
newH, err := r.Rotate(ctx, c, v)
if err != nil {
t.Fatalf("Rotate against real browser: %v", err)
}
if err := r.Verify(ctx, newH, v); err != nil {
t.Fatalf("Verify (new pw must authenticate): %v", err)
}
// old must no longer authenticate — prove the rotation was real, not a no-op.
mu.Lock()
oldStillWorks := stored == oldPW
mu.Unlock()
if oldStillWorks {
t.Fatal("old password still stored — rotation did not take effect")
}
if err := r.RevokeOld(ctx, c, v); err != nil {
t.Fatalf("RevokeOld: %v", err)
}
t.Log("LIVE-VM browser rotation proof passed: discover→inject→submit→verify(new)→old-rejected")
}
func hostForDebug(c discover.Credential) string { return links.HostFor(c) }