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:
@@ -308,9 +308,20 @@ adapter is pure-exec Go, so it is unit-validated cross-platform via an injected
|
|||||||
- **Phase B (now):** `pwgen` + `pwstore` (interface + Bitwarden/1Password/KeePassXC in-place +
|
- **Phase B (now):** `pwgen` + `pwstore` (interface + Bitwarden/1Password/KeePassXC in-place +
|
||||||
Chrome/Firefox CSV ingest-and-shred) + the staged-list propagation engine + `passwords`
|
Chrome/Firefox CSV ingest-and-shred) + the staged-list propagation engine + `passwords`
|
||||||
commands. Human does the site change + MFA; incredigo does everything else.
|
commands. Human does the site change + MFA; incredigo does everything else.
|
||||||
- **Phase A-tier-1:** deterministic `go-rod`/`playwright-go` driving the curated change-URL
|
- **Phase A-tier-1 (engine built):** `internal/browserrot` — a deterministic `go-rod` driver
|
||||||
table for a handful of high-frequency, no-MFA sites, with verify-before-commit. Measure the
|
(behind a browser-agnostic `Driver`/`Session` interface so `playwright-go` and a Tier-2 AI
|
||||||
real success rate before promising anything.
|
driver are swap-ins) that discovers the change page via `internal/links` (curated table +
|
||||||
|
RFC 8615), injects OLD + a freshly-minted NEW password over CDP with **no model in the loop**,
|
||||||
|
submits, and **re-logs-in to verify the new password before commit** (hard rule 2). A `Rotator`
|
||||||
|
here implements `rotate.Rotator`, so `rotate.Snapshot` (backup), the verify-before-revoke
|
||||||
|
ordering, and the proof gate all apply unchanged; `RevokeOld` is a no-op because the site
|
||||||
|
invalidates the old password itself. Proof-as-data per `Site`: the **ENGINE is LIVE-VM**
|
||||||
|
(real headless Chromium against a real local change-password form —
|
||||||
|
`lab-provision-browserrot.sh` / `TestIntegration_realBrowserRotation`); a **specific real-site
|
||||||
|
selector table stays UNPROVEN** until run against that real site, and no production `rotate`
|
||||||
|
wiring auto-enables it yet (still gated behind per-credential authorization). Still to do:
|
||||||
|
build out the curated selector table for real high-frequency no-MFA sites and measure the real
|
||||||
|
success rate before promising anything.
|
||||||
- **Phase A-tier-2:** Computer-Use / AI-in-browser vision fallback for DOM variance, with
|
- **Phase A-tier-2:** Computer-Use / AI-in-browser vision fallback for DOM variance, with
|
||||||
**secrets kept out of the model loop** (the model decides navigation; the Go harness injects
|
**secrets kept out of the model loop** (the model decides navigation; the Go harness injects
|
||||||
the actual password via CDP, redacted from the screenshot stream). Opt-in, measured.
|
the actual password via CDP, redacted from the screenshot stream). Opt-in, measured.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ require (
|
|||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/go-rod/rod v0.116.2 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
@@ -33,6 +34,11 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
github.com/ysmood/fetchup v0.2.3 // indirect
|
||||||
|
github.com/ysmood/goob v0.4.0 // indirect
|
||||||
|
github.com/ysmood/got v0.40.0 // indirect
|
||||||
|
github.com/ysmood/gson v0.7.3 // indirect
|
||||||
|
github.com/ysmood/leakless v0.9.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNE
|
|||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
||||||
|
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
@@ -51,6 +53,17 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
|||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
|
||||||
|
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
|
||||||
|
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
|
||||||
|
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
|
||||||
|
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
|
||||||
|
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
|
||||||
|
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
|
||||||
|
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
|
||||||
|
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
|
||||||
|
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
|
||||||
|
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
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) }
|
||||||
@@ -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)
|
||||||
@@ -56,6 +56,7 @@ multipass exec incredigo-sbx -- bash -lc '
|
|||||||
| `lab-provision-keepass.sh` | KeePassXC (`keepassxc-cli`) |
|
| `lab-provision-keepass.sh` | KeePassXC (`keepassxc-cli`) |
|
||||||
| `lab-provision-bitwarden.sh` + `vw-register.py` | Vaultwarden + `bw` CLI |
|
| `lab-provision-bitwarden.sh` + `vw-register.py` | Vaultwarden + `bw` CLI |
|
||||||
| `lab-provision-browsercsv.sh` + `csv-commit-probe.py` | Chrome/Firefox CSV (staging only) |
|
| `lab-provision-browsercsv.sh` + `csv-commit-probe.py` | Chrome/Firefox CSV (staging only) |
|
||||||
|
| `lab-provision-browserrot.sh` | Phase A-tier-1 site-side rotation engine (`internal/browserrot`) — real headless Chromium against a throwaway local change-password form (fake cred) |
|
||||||
| `tui-probe.py` | drives the `guide` Bubble Tea TUI under a pty |
|
| `tui-probe.py` | drives the `guide` Bubble Tea TUI under a pty |
|
||||||
|
|
||||||
## Custody / smoke
|
## Custody / smoke
|
||||||
|
|||||||
Executable
+55
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# LIVE-VM proof for the Phase A-tier-1 site-side rotation ENGINE (internal/browserrot),
|
||||||
|
# inside the sandbox VM. FAKE credential only, against a throwaway in-process website —
|
||||||
|
# nothing real is touched.
|
||||||
|
#
|
||||||
|
# What this proves is the deterministic browser-drive machinery that would run against a
|
||||||
|
# real change-password page:
|
||||||
|
#
|
||||||
|
# * discover the change page via RFC 8615 /.well-known/change-password (real redirect)
|
||||||
|
# * inject the OLD + a freshly-minted NEW password into the form over CDP, with NO
|
||||||
|
# language model in the loop
|
||||||
|
# * submit and read the site's SUCCESS marker
|
||||||
|
# * VERIFY the new password authenticates by re-logging-in (hard rule 2), and confirm
|
||||||
|
# the OLD password no longer works (the rotation is real, not a no-op)
|
||||||
|
#
|
||||||
|
# The engine is exercised by the CHROME_BIN-gated integration test
|
||||||
|
# (TestIntegration_realBrowserRotation) driving a REAL headless Chromium. This is what
|
||||||
|
# earns browserrot's LIVE-VM proof level for the ENGINE; a specific real-site selector
|
||||||
|
# table stays UNPROVEN until it is run against that real site.
|
||||||
|
#
|
||||||
|
# It does NOT rotate any real account: only the user's own accounts, with explicit
|
||||||
|
# per-credential authorization, may ever be driven against a real provider.
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH=/usr/local/bin:$PATH
|
||||||
|
|
||||||
|
# Locate a usable, NON-snap Chromium (snap chromium fights CDP + can't read /tmp).
|
||||||
|
find_chrome() {
|
||||||
|
for c in \
|
||||||
|
"${CHROME_BIN:-}" \
|
||||||
|
"$HOME"/.cache/ms-playwright/chromium-*/chrome-linux64/chrome \
|
||||||
|
"$HOME"/.cache/ms-playwright/chromium-*/chrome-linux/chrome \
|
||||||
|
/usr/bin/google-chrome /usr/bin/chromium /opt/google/chrome/chrome ; do
|
||||||
|
[ -n "$c" ] && [ -x "$c" ] && { echo "$c"; return 0; }
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
CHROME_BIN="$(find_chrome || true)"
|
||||||
|
if [ -z "${CHROME_BIN:-}" ]; then
|
||||||
|
echo "FAIL: no usable Chromium found. Install Chrome-for-Testing / google-chrome, or"
|
||||||
|
echo " set CHROME_BIN to a chromium executable (a Playwright-managed one is ideal)."
|
||||||
|
echo " hint: npx playwright install chromium"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "== using Chromium: $CHROME_BIN"
|
||||||
|
"$CHROME_BIN" --version 2>/dev/null | sed 's/^/ /' || true
|
||||||
|
|
||||||
|
# Run from the repo root (the dir containing go.mod).
|
||||||
|
cd "$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "$(dirname "$0")/..")"
|
||||||
|
|
||||||
|
echo "== running the LIVE-VM browser-rotation proof =="
|
||||||
|
CHROME_BIN="$CHROME_BIN" go test -run TestIntegration_realBrowserRotation -v ./internal/browserrot/
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "LIVE-VM BROWSER-ROTATION ENGINE PROOF PASSED"
|
||||||
Reference in New Issue
Block a user