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,12 @@
|
||||
# OVERSEER-STATUS
|
||||
|
||||
## 2026-07-20 — M-B4 rung-1 (LAN Gitea) safe prep, pre-live-fire
|
||||
- Backup: `.backups/gitea-rung-2026-07-20.credrot.age` (age-sealed, 2 entries of `gitea/` prefix); **verified decrypts**; restore key custodied in gopass `incredigo/backups/gitea-rung-2026-07-20` (no plaintext on disk). Store confirmed intact.
|
||||
- Selectors (read-only, via Tailscale `100.117.177.50:3030`; LAN IP dead): login `#user_name` ✅ `#password` ✅ submit **fixed** `.ui.primary.button` ✅ (seed `[type=submit]` MISSED — corrected in sites.go). Change page 303→/user/login ✅ confirms `LoginBeforeChange`.
|
||||
- Unconfirmed (auth-gated, defer to live-fire; self-heal covers): change-form `#old_password`/`#password`/`#retype`/submit; login SuccessText.
|
||||
- Zero Gitea writes; no submit performed. **Blocked on Andre:** (1) push local commits `1db4134`+`e11d2f0`(+uncommitted sites.go selector fix); (2) explicit go for the live rotation.
|
||||
|
||||
## 2026-07-21 — M-B4 LIVE-FIRE DONE: first REAL browser password rotation (LIVE-REAL)
|
||||
- **Rotated** the self-owned Gitea login password (`trill-technician` @ `100.117.177.50:3030`, gopass `gitea/login-creds`) end-to-end via `lab/rung4-gitea-browser` (MODE=live): backup gate 3 gitea entries sealed+verified → pre-change login(OLD) → change-form submit → **site confirmed** → new stored to gopass → verified by browser re-login **and** independent API re-auth (HTTP 200). Old password now dead (confirmed). Metadata lines (username/url/notes) restored on the entry.
|
||||
- **Engine fixes from the live-fire (all tested green, 11 pass, -race clean):** (1) `sites.go` submit selectors drop `[type=submit]` (Gitea buttons have only the default DOM property, no attribute) — login `.ui.primary.button`, change `form[action$='/user/settings/account'] button`; (2) `RodDriver.Click` waits for post-submit navigation to settle before reading success text (remote round-trip vs instant in-process test); (3) new `RodDriver.Insecure` = `--ignore-certificate-errors` for the self-signed/Tailscale host.
|
||||
- **Proof-as-data:** `GiteaSite.Proof` promoted UNPROVEN → **ProofLiveReal**; docs (`BROWSER-ROTATION.md` §9, `BROWSERROT-PLAN.md`) updated. Safety held throughout: the two prior failed attempts (submit selector miss) left the password UNCHANGED (API 200 confirmed) — fail-safe worked. Seal passphrase custodied in gopass `incredigo/backups/gitea-rung4-browser-2026-07-20`. **Not yet committed/pushed.**
|
||||
@@ -318,10 +318,21 @@ adapter is pure-exec Go, so it is unit-validated cross-platform via an injected
|
||||
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.
|
||||
selector table stays UNPROVEN** until run against that real site. **First real-site promotion
|
||||
done: `GiteaSite` is LIVE-REAL** — it drove a real password rotation of a self-owned Gitea
|
||||
account (`trill-technician` @ `100.117.177.50:3030`) on 2026-07-21 via
|
||||
`lab/rung4-gitea-browser` (M-B4): backup-gated (Hard Rule 1, 3 entries sealed+verified),
|
||||
pre-change login with the OLD secret, change-form submit, then the NEW secret verified by
|
||||
browser re-login AND independent API re-auth (HTTP 200). Two real-site selector fixes fell out
|
||||
of the live-fire and are now in `sites.go`: (1) Gitea's login **and** "Update Password" buttons
|
||||
carry no `type=submit` *attribute* (only the default DOM property), so CSS `[type=submit]`
|
||||
misses — select by class / by the account form's sole button instead; (2) `RodDriver.Click`
|
||||
now waits for the post-submit navigation to settle (a remote round-trip, unlike the instant
|
||||
in-process test) before the success text is read. `RodDriver.Insecure` was added for the
|
||||
self-signed/Tailscale host (browser analog of `curl -k`). No production `rotate` wiring
|
||||
auto-enables browser rotation yet (still gated behind per-credential authorization; the
|
||||
live-fire is a dedicated harness). Still to do: build out the curated selector table for other
|
||||
real high-frequency no-MFA sites and measure the real success rate before promising anything.
|
||||
- **Phase A-tier-1.5 — Node stealth sidecar (built + LIVE-VM):** an optional swap-in
|
||||
`Driver` (`internal/browserrot/sidecar.go` + `internal/browserrot/sidecar/`) that speaks
|
||||
newline-delimited JSON-RPC over stdio to a small Node program driving a **patchright**
|
||||
|
||||
+14
-8
@@ -1,9 +1,13 @@
|
||||
# browserrot buildout plan — Node stealth sidecar + AI self-heal (Phase A tiers 1-3)
|
||||
|
||||
**Status (2026-07-19):** M-B1 ✅ M-B2 ✅ M-B3 ✅ (all LIVE-VM proven against the local lab
|
||||
form, `internal/browserrot/` suite green + `-race` clean). **M-B4 (first REAL self-owned
|
||||
site) deliberately NOT started** — it rotates a real credential and needs explicit
|
||||
per-credential authorization (safe-candidate ladder). M-B5 (vision) later. The Stagehand
|
||||
**Status (2026-07-21):** M-B1 ✅ M-B2 ✅ M-B3 ✅ **M-B4 ✅** (first REAL self-owned site).
|
||||
M-B1..B3 are LIVE-VM against the local lab form; **M-B4 is LIVE-REAL** — `GiteaSite` drove a
|
||||
real password rotation of a self-owned Gitea account (`trill-technician` @
|
||||
`100.117.177.50:3030`) on 2026-07-21 via `lab/rung4-gitea-browser`, backup-gated and verified
|
||||
by browser re-login + API re-auth (HTTP 200). Two real-site selector fixes landed from the
|
||||
live-fire (no `type=submit` attribute on Gitea buttons; `RodDriver.Click` now waits for the
|
||||
post-submit navigation), plus `RodDriver.Insecure` for the self-signed/Tailscale host. The
|
||||
`internal/browserrot/` suite is green + `-race` clean. M-B5 (vision) later. The Stagehand
|
||||
(LLM) heal strategy is wired but UNPROVEN/opt-in; the deterministic DOM-heuristic heal is
|
||||
the LIVE-VM Tier-2. See `docs/BROWSER-ROTATION.md` §9 for the proof-as-data record.
|
||||
**Extends, does not replace:** [`docs/BROWSER-ROTATION.md`](BROWSER-ROTATION.md) (the design
|
||||
@@ -142,10 +146,12 @@ Per-`Site` `Proof`:
|
||||
selector miss → rewrite + persist the recipe → next run is Tier-1. Test: seed a *broken*
|
||||
selector, assert one run heals it and the second run needs no LLM. Assert **no secret** in any
|
||||
heal request (leak-check the IPC transcript).
|
||||
- **M-B4 — record command + first REAL site.** `incredigo browser record`; then point the
|
||||
engine at ONE real self-owned account (low-stakes, no MFA), rotate + re-login-verify, promote
|
||||
that host to LIVE-REAL. New `lab/`/runbook entry, per-cred authorization (safe-candidate
|
||||
ladder discipline).
|
||||
- **M-B4 — record command + first REAL site. ✅ DONE (2026-07-21, rotation half).** Pointed the
|
||||
engine at ONE real self-owned account (self-hosted Gitea, no MFA), rotated + re-login-verified,
|
||||
promoted `GiteaSite` to LIVE-REAL. Harness: `lab/rung4-gitea-browser` (env-only secrets,
|
||||
isolated to the real gopass entry `gitea/login-creds`, `MODE=probe|live`, `GITEA_INSECURE=1`
|
||||
for the self-signed host). Backup-gated (Hard Rule 1). The `incredigo browser record` codegen
|
||||
command is still TODO — this run used the curated `GiteaSite` recipe, not a recorded one.
|
||||
- **M-B5 (optional/later) — vision fallback.** Tier-3 Computer Use for hostile markup; secrets
|
||||
injected locally, never to the model.
|
||||
|
||||
|
||||
@@ -352,9 +352,10 @@ func TestGiteaSite_recipeShape(t *testing.T) {
|
||||
if s.Login.URL == "" || s.Form.CurrentSel == "" || s.Form.NewSel == "" || s.Form.SubmitSel == "" {
|
||||
t.Fatalf("recipe has empty required selectors: %+v", s)
|
||||
}
|
||||
// A seed recipe is UNPROVEN until actually run against the real instance (proof-as-data).
|
||||
if s.Proof != rotate.ProofUnproven {
|
||||
t.Fatalf("a fresh curated recipe must be ProofUnproven, got %v", s.Proof)
|
||||
// Promoted to LIVE-REAL: the recipe drove a real self-owned Gitea password
|
||||
// rotation on 2026-07-21 (lab/rung4-gitea-browser, M-B4). Proof-as-data.
|
||||
if s.Proof != rotate.ProofLiveReal {
|
||||
t.Fatalf("GiteaSite recipe should be ProofLiveReal after the M-B4 live-fire, got %v", s.Proof)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,13 @@ 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
|
||||
// Insecure accepts self-signed / untrusted TLS certs (Chromium
|
||||
// --ignore-certificate-errors). Needed for self-hosted targets behind a private
|
||||
// CA or a Tailscale-fronted service (e.g. a personal Gitea on 100.x). This is the
|
||||
// browser analog of `curl -k` in the rung harnesses; the connection is still TLS,
|
||||
// we just don't verify the chain. Off by default — opt in per run for a host you
|
||||
// own and whose cert you already trust out-of-band.
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
// Name identifies the backend in logs/plans.
|
||||
@@ -32,6 +39,11 @@ func (d *RodDriver) Name() string { return "go-rod" }
|
||||
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.Insecure {
|
||||
// Accept the self-signed cert of a self-owned host. TLS still encrypts; only
|
||||
// chain verification is skipped. Scoped to this launched browser instance.
|
||||
l = l.Set("ignore-certificate-errors")
|
||||
}
|
||||
if d.Bin != "" {
|
||||
l = l.Bin(d.Bin)
|
||||
}
|
||||
@@ -93,7 +105,21 @@ func (s *rodSession) Click(ctx context.Context, selector string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return el.Click(proto.InputMouseButtonLeft, 1)
|
||||
// A submit/login click triggers a navigation (login POST -> 302 -> dashboard,
|
||||
// change-form POST -> flash). Against a remote host that round-trip has real
|
||||
// latency, so we must wait for the NEW document to commit before returning —
|
||||
// otherwise the next Text() read resolves against the PRE-submit page (the login
|
||||
// form still showing) and mis-reports the outcome. Arm the navigation wait BEFORE
|
||||
// clicking, then block until the network is almost idle. Bounded by the page
|
||||
// timeout; if a click does not navigate this returns when the timeout elapses.
|
||||
wait := s.page.Timeout(s.to).WaitNavigation(proto.PageLifecycleEventNameNetworkAlmostIdle)
|
||||
if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
wait()
|
||||
// Belt-and-suspenders: let any post-navigation DOM (flash render) settle.
|
||||
_ = s.page.Timeout(s.to).WaitStable(300 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *rodSession) Text(ctx context.Context, selector string) (string, error) {
|
||||
|
||||
@@ -19,9 +19,10 @@ import (
|
||||
// Gitea reaches the change-password controls only inside an authenticated session
|
||||
// (/user/settings/account 302s to /user/login otherwise), so LoginBeforeChange is on.
|
||||
// The password form on the account page carries old_password / password / retype; the
|
||||
// result renders in a flash message. These selectors match current Gitea markup but are
|
||||
// UNPROVEN against any specific instance/version — VERIFY on first run (self-heal will
|
||||
// rewrite any that drift). The SuccessText markers are the one part self-heal cannot fix
|
||||
// result renders in a flash message. Every selector here was VERIFIED against a live
|
||||
// Gitea instance on 2026-07-21 (login, the three change fields, and the submit button)
|
||||
// during the M-B4 live-fire; other instances/versions may drift, and self-heal will
|
||||
// rewrite any that do. The SuccessText markers are the one part self-heal cannot fix
|
||||
// (they are content, not selectors): a wrong marker only makes Rotate fail SAFE (old
|
||||
// secret kept, human flagged), never a silent bad rotation.
|
||||
func GiteaSite(baseURL, username string) Site {
|
||||
@@ -30,10 +31,15 @@ func GiteaSite(baseURL, username string) Site {
|
||||
Host: strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://"),
|
||||
ChangeURL: base + "/user/settings/account",
|
||||
Form: Form{
|
||||
CurrentSel: "#old_password",
|
||||
NewSel: "#password",
|
||||
ConfirmSel: "#retype",
|
||||
SubmitSel: "form[action$='/user/settings/account'] button[type=submit]",
|
||||
CurrentSel: "#old_password",
|
||||
NewSel: "#password",
|
||||
ConfirmSel: "#retype",
|
||||
// Gitea's "Update Password" button carries NO type=submit ATTRIBUTE — it is
|
||||
// a bare <button> whose DOM .type property defaults to "submit". A CSS
|
||||
// [type=submit] matches the attribute, not the property, so it MISSES here
|
||||
// (confirmed live 2026-07-21). Select the account-password form's only
|
||||
// button instead; the page has exactly one form posting to this action.
|
||||
SubmitSel: "form[action$='/user/settings/account'] button",
|
||||
SuccessSel: ".ui.positive.message, .flash-success",
|
||||
SuccessText: "has been updated",
|
||||
},
|
||||
@@ -52,6 +58,11 @@ func GiteaSite(baseURL, username string) Site {
|
||||
SuccessText: username,
|
||||
},
|
||||
LoginBeforeChange: true,
|
||||
Proof: rotate.ProofUnproven,
|
||||
// LIVE-REAL: this recipe drove a real password rotation of a self-owned Gitea
|
||||
// account (trill-technician @ 100.117.177.50:3030) on 2026-07-21 via
|
||||
// lab/rung4-gitea-browser (M-B4) — backup-gated, verified by API re-auth. Same
|
||||
// proof-as-data discipline as the API drivers (rotate/proofs.go): proven at the
|
||||
// mechanism level against a real instance, not a blanket "works on every Gitea".
|
||||
Proof: rotate.ProofLiveReal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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