browserrot(gitea): M-B4 LIVE-REAL — first real site-side password rotation
ci / build-test (push) Waiting to run
ci / build-test (push) Waiting to run
Rotated the self-owned Gitea login password end-to-end via the new lab/rung4-gitea-browser harness: backup-gated (Hard Rule 1) -> pre-change login(OLD) -> change-form submit -> new stored to gopass -> verified by browser re-login AND independent API re-auth. GiteaSite promoted to ProofLiveReal (proof-as-data). Three real-site fixes surfaced by the live-fire: - sites.go: Gitea login + "Update Password" buttons carry no type=submit ATTRIBUTE (only the default DOM property), so CSS [type=submit] misses; select by class / the account form's sole button instead. - rod.go: Click now waits for the post-submit navigation to settle before the success text is read (a remote round-trip, unlike the instant in-process test) — else Text() resolves against the pre-submit page. - rod.go: new RodDriver.Insecure (--ignore-certificate-errors) for the self-signed / Tailscale-fronted host (browser analog of curl -k). Suite green + -race clean; docs (BROWSER-ROTATION.md §9, BROWSERROT-PLAN.md) and OVERSEER-STATUS updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
// Command rung4-gitea-browser is the M-B4 live-fire harness: it drives the
|
||||
// browserrot engine against a REAL, self-owned Gitea account to rotate the login
|
||||
// PASSWORD in the browser (not an API PAT — that was rung-3). It is deliberately a
|
||||
// dedicated one-shot program because browserrot is not wired into the CLI `rotate`
|
||||
// command yet; this run is exactly the run that PROMOTES the GiteaSite recipe from
|
||||
// UNPROVEN to LIVE-REAL, so it cannot go through the proof gate that would skip it.
|
||||
//
|
||||
// It honors the same safety spine as the API drivers:
|
||||
// - Hard Rule 1: rotate.Snapshot (verified sealed backup) runs BEFORE any change.
|
||||
// - Hard Rule 2 (adapted for browser): a website invalidates the OLD password the
|
||||
// instant the new one is set — RevokeOld is a no-op and "revoke" happens AT the
|
||||
// change, not after. So the only lockout window is between form-submit and the
|
||||
// gopass store. We therefore STORE the new secret IMMEDIATELY after Rotate
|
||||
// succeeds, then Verify by re-login. (The API-spine order verify->store is for
|
||||
// creds whose old value stays valid until an explicit revoke; that guarantee
|
||||
// does not exist for a browser password change.)
|
||||
// - Hard Rule 3: no plaintext on disk — secrets cross as vault []byte only.
|
||||
// - Hard Rule 5: self-owned account, one credential, no MFA bypass (if a second
|
||||
// factor appears the success marker won't match and we fail SAFE, old kept).
|
||||
//
|
||||
// Secrets come from gopass/env ONLY, never argv. Two modes (env MODE):
|
||||
// probe (default) — read-only: log in with the OLD password to validate the login
|
||||
// selectors + success marker against the live instance. No writes,
|
||||
// no backup, no change. Run this first.
|
||||
// live — the irreversible rotation: backup -> Rotate -> store -> Verify.
|
||||
//
|
||||
// Env:
|
||||
// INCREDIGO_PASSPHRASE seal passphrase for the backup gate (live mode) [required in live]
|
||||
// CHROME_BIN path to a Chromium binary [required]
|
||||
// GITEA_BASE scheme+host (default https://100.117.177.50:3030)
|
||||
// GITEA_USER account login (default trill-technician)
|
||||
// GITEA_STORE_PATH gopass entry (default gitea/login-creds)
|
||||
// GITEA_INSECURE=1 accept the self-signed cert (Tailscale host) [set for 100.x]
|
||||
// MODE probe | live (default probe)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"incredigo/internal/browserrot"
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/pwgen"
|
||||
"incredigo/internal/rotate"
|
||||
"incredigo/internal/sink"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\n✗ %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
mode := env("MODE", "probe")
|
||||
base := env("GITEA_BASE", "https://100.117.177.50:3030")
|
||||
user := env("GITEA_USER", "trill-technician")
|
||||
storePath := env("GITEA_STORE_PATH", "gitea/login-creds")
|
||||
chrome := os.Getenv("CHROME_BIN")
|
||||
insecure := os.Getenv("GITEA_INSECURE") == "1"
|
||||
if chrome == "" {
|
||||
return fmt.Errorf("CHROME_BIN is required (a Chromium binary)")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
|
||||
gp := &sink.Gopass{}
|
||||
if !gp.Available() {
|
||||
return fmt.Errorf("gopass not found on PATH")
|
||||
}
|
||||
|
||||
// Load the OLD password (line 1 of the entry) into the vault as a handle.
|
||||
oldH, err := gp.Show(ctx, v, storePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load old secret from %q: %w", storePath, err)
|
||||
}
|
||||
cred := discover.Credential{
|
||||
Source: "browserrot-rung4",
|
||||
Identity: user + " @ " + base,
|
||||
Location: storePath,
|
||||
Secret: oldH,
|
||||
Meta: map[string]string{"username": user},
|
||||
}
|
||||
|
||||
site := browserrot.GiteaSite(base, user)
|
||||
drv := &browserrot.RodDriver{Bin: chrome, Headless: true, Insecure: insecure, Timeout: 45 * time.Second}
|
||||
rot := browserrot.New(site, drv, pwgen.DefaultPolicy())
|
||||
|
||||
fmt.Fprintf(os.Stderr, "target : %s (user %s)\n", base, user)
|
||||
fmt.Fprintf(os.Stderr, "driver : %s insecure=%v\n", drv.Name(), insecure)
|
||||
fmt.Fprintf(os.Stderr, "mode : %s\n\n", mode)
|
||||
|
||||
switch mode {
|
||||
case "probe":
|
||||
// Read-only: a login with the OLD password. Verify(old) opens a session,
|
||||
// runs the Login flow, and checks Login.SuccessText. No writes, no change.
|
||||
fmt.Fprintln(os.Stderr, "PROBE: logging in with the OLD password (read-only, no writes)…")
|
||||
if err := rot.Verify(ctx, oldH, v); err != nil {
|
||||
return fmt.Errorf("probe login failed — fix login selectors/marker before live: %w", err)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "✓ PROBE OK: login selectors + success marker validated against the live instance.")
|
||||
fmt.Fprintln(os.Stderr, " Re-run with MODE=live to perform the irreversible rotation.")
|
||||
return nil
|
||||
|
||||
case "live":
|
||||
return live(ctx, gp, v, rot, cred, storePath)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown MODE %q (want probe|live)", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func live(ctx context.Context, gp *sink.Gopass, v *vault.Vault, rot *browserrot.Rotator, cred discover.Credential, storePath string) error {
|
||||
// Hard Rule 1 — MANDATORY verified backup gate, BEFORE any change.
|
||||
passEnv := os.Getenv("INCREDIGO_PASSPHRASE")
|
||||
if passEnv == "" {
|
||||
return fmt.Errorf("INCREDIGO_PASSPHRASE required for the backup gate in live mode")
|
||||
}
|
||||
passH := v.Store([]byte(passEnv))
|
||||
passBuf, err := v.Open(passH)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open passphrase: %w", err)
|
||||
}
|
||||
|
||||
dir := ".backups"
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
backupOut := filepath.Join(dir, "gitea-rung4-browser-"+time.Now().UTC().Format("2006-01-02T15-04-05Z")+".age")
|
||||
n, err := rotate.Snapshot(ctx, gp, "gitea", &sink.Age{}, passBuf, backupOut)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup gate failed — refusing to rotate: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "✓ backup gate: %d gitea entr(ies) sealed + verified -> %s\n\n", n, backupOut)
|
||||
|
||||
// Rotate: pre-change login(OLD) -> change form -> site confirms. On success the
|
||||
// OLD password is now DEAD at the site and newH holds the working new secret.
|
||||
fmt.Fprintln(os.Stderr, "ROTATE: driving the browser change-password flow…")
|
||||
newH, err := rot.Rotate(ctx, cred, v)
|
||||
if err != nil {
|
||||
// Rotate forgets newH itself on any internal failure; if the fill/submit
|
||||
// never landed the OLD password is still valid (safe). If it DID land but the
|
||||
// success marker missed, see the recovery note below.
|
||||
return fmt.Errorf("rotate failed (old secret kept if change did not submit): %w", err)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "✓ site confirmed the password change.")
|
||||
|
||||
// STORE IMMEDIATELY — close the lockout window before doing anything else. The
|
||||
// new secret is now the only working credential; persist it first, verify second.
|
||||
if err := gp.InsertAt(ctx, v, storePath, newH, true); err != nil {
|
||||
// Worst case: new password active at the site but the store failed. Print the
|
||||
// recovery path loudly. (We never print the secret.)
|
||||
return fmt.Errorf("STORE FAILED after a successful change — the new password is ACTIVE at %s but NOT saved to gopass %q. Recover via the tea API token or the Gitea server CLI, then re-run store: %w", cred.Identity, storePath, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "✓ new password stored at gopass %q (metadata lines dropped — re-add username/url/notes if desired).\n", storePath)
|
||||
|
||||
// Verify: re-login with the new secret (from vault), then re-read from gopass and
|
||||
// re-login again to prove the stored bytes are the working secret.
|
||||
fmt.Fprintln(os.Stderr, "VERIFY: re-login with the new password…")
|
||||
if err := rot.Verify(ctx, newH, v); err != nil {
|
||||
v.Forget(newH)
|
||||
return fmt.Errorf("verify(new) failed but the new password IS stored — check the account manually: %w", err)
|
||||
}
|
||||
v.Forget(newH)
|
||||
|
||||
rh, err := gp.Show(ctx, v, storePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-read stored secret: %w", err)
|
||||
}
|
||||
if err := rot.Verify(ctx, rh, v); err != nil {
|
||||
v.Forget(rh)
|
||||
return fmt.Errorf("verify(stored) failed: %w", err)
|
||||
}
|
||||
v.Forget(rh)
|
||||
|
||||
// RevokeOld is a no-op for a website password (the site already invalidated old).
|
||||
if err := rot.RevokeOld(ctx, cred, v); err != nil {
|
||||
return fmt.Errorf("revoke-old: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n✓✓ LIVE-REAL rotation complete: %s\n", cred.Identity)
|
||||
fmt.Fprintf(os.Stderr, " backup: %s\n", backupOut)
|
||||
fmt.Fprintln(os.Stderr, " Promote GiteaSite recipe to ProofLiveReal.")
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user