diff --git a/docs/ROTATION-PROOFS.md b/docs/ROTATION-PROOFS.md index 274b42d..d5c757e 100644 --- a/docs/ROTATION-PROOFS.md +++ b/docs/ROTATION-PROOFS.md @@ -53,7 +53,31 @@ credential unattended. > throwaway token was chosen so the driver's real lifecycle earns LIVE-REAL at **zero blast > radius** — a genuine in-use PAT is referenced in `~/.git-credentials`, embedded git-remote > URLs and `~/.config/tea/config.yml` as well as gopass, so rotating one safely first requires -> the multi-reference **blast-and-cutover** (next work item), not just the single-blob driver. +> the multi-reference **blast-and-cutover**, not just the single-blob driver. + +#### Multi-reference blast-and-cutover — proven LIVE-VM + +The cutover a real *in-use* Gitea PAT needs is now built and proven against real local +Gitea (1.25.0) in the VM (`lab-gitea-blast-vm.sh` → `GITEA_BLAST_VM_OK`). The credential +blob carries one `ref=` per on-disk reference; `Rotate` mints the new token, proves +it authenticates (`GET /user`), **then** rewrites the old→new token literal in **every** +reference file atomically, and `Verify` asserts the new token is present in each before the +spine revokes the old one **last**. Proof run: one rotation rewrote a fake `~/.git-credentials` +line, an embedded git-remote URL, and a `tea` config in lockstep; new token → `200`, old +token → `401`; the rewritten `.git-credentials` still authenticates at the git transport. +The old→new replacement is keyed on the 40-hex token literal, length-guarded +(`minGiteaTokenLen`), and atomic per file (temp+rename) — the same pattern the `appsecret` +driver uses. Enumerating the reference set is `internal/rotate/gitea_refs.go` (read-only). + +> **Two documented follow-ons** (surfaced by the proof, deliberately out of this iteration): +> - **gopass-entry-sync** — the PAT also lives in *other* gopass entries; the driver rewrites +> only files named in `ref=`. A spine hook passing old+new token to a gopass-sweep step is +> needed before an in-use PAT that is duplicated across gopass entries can rotate cleanly. +> - **token-scope-cloning** — the new token is minted with fixed `write:user`/`read:user` +> scopes (enough to manage tokens); a token used for **git operations** needs its repo +> scopes preserved, i.e. clone the old token's scopes on create (as the `sendgrid` driver +> clones API-key scopes). Until then a rotated git-auth PAT authenticates but may `403` on +> repo ops — a token-scope matter, not a cutover failure. ### LIVE-VM — proven against real target software in the sandbox VM diff --git a/internal/rotate/gitea.go b/internal/rotate/gitea.go index 2abf144..35e10c9 100644 --- a/internal/rotate/gitea.go +++ b/internal/rotate/gitea.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "os" "strings" "time" @@ -68,13 +69,20 @@ func (g *Gitea) client() *http.Client { // giteaSecret is the parsed credential blob. None of its fields are ever logged. type giteaSecret struct { - base string // scheme://host[:port] - user string - token string - name string // token name (for DELETE) - pw string // optional mgmt password (basic auth) + base string // scheme://host[:port] + user string // Gitea username + token string // the PAT being rotated + name string // token name (for DELETE) + pw string // optional mgmt password (basic auth) + refs []string // optional on-disk files that embed the token literally (multi-reference cutover) } +// minGiteaTokenLen guards the literal on-disk reference rewrite: the old token is the +// search key we ReplaceAll across .git-credentials / git remotes / tea config, so a +// too-short token could sweep through unrelated file contents. Real Gitea PATs are +// 40 hex chars; refuse to rewrite references keyed on anything implausibly short. +const minGiteaTokenLen = 20 + // parseGiteaSecret reads the single-line blob from the vault and decomposes it. func parseGiteaSecret(v *vault.Vault, h *vault.Handle) (giteaSecret, error) { buf, err := v.Open(h) @@ -102,6 +110,7 @@ func parseGiteaSecret(v *vault.Vault, h *vault.Handle) (giteaSecret, error) { token: tok, name: q.Get("name"), pw: q.Get("pw"), + refs: q["ref"], }, nil } @@ -118,6 +127,9 @@ func (s giteaSecret) build() string { if s.pw != "" { q.Set("pw", s.pw) } + for _, r := range s.refs { + q.Add("ref", r) + } u.RawQuery = q.Encode() return u.String() } @@ -176,27 +188,47 @@ func (g *Gitea) Rotate(ctx context.Context, c discover.Credential, v *vault.Vaul return nil, fmt.Errorf("gitea: create token: empty token in response") } - ns := s // carry base/user/pw forward + ns := s // carry base/user/pw/refs forward ns.token = created.SHA1 ns.name = created.Name if ns.name == "" { ns.name = newName } + + // Multi-reference cutover: rewrite the old token → new token, in place, in every + // on-disk file that embeds it (git-credentials, embedded git-remote URLs, tea + // config). This is what lets a real IN-USE token rotate without breaking git auth. + // + // SAFETY ORDERING: we do this ONLY after proving the new token authenticates, so a + // dead token is never written into a git config; both the old and new tokens stay + // valid until RevokeOld (run last by the spine), so any later failure leaves every + // reference pointing at a working token. The replacement is keyed on the old token + // literal (a 40-hex string), atomic per file (temp+rename), and length-guarded. + if len(ns.refs) > 0 { + if len(s.token) < minGiteaTokenLen { + return nil, fmt.Errorf("gitea: token too short to rewrite references safely (<%d chars)", minGiteaTokenLen) + } + if err := g.tokenAuthenticates(ctx, ns.base, ns.user, ns.token); err != nil { + return nil, fmt.Errorf("gitea: new token failed pre-rewrite auth check: %w", err) + } + for _, ref := range ns.refs { + if _, err := replaceInFile(ref, s.token, ns.token); err != nil { + return nil, fmt.Errorf("gitea: rewrite ref %s: %w", ref, redactErr(err, s.token, ns.token)) + } + } + } return v.Store([]byte(ns.build())), nil } -// Verify proves the newly minted token authenticates by calling GET /api/v1/user -// with it and checking the returned login matches. -func (g *Gitea) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { - s, err := parseGiteaSecret(v, newSecret) +// tokenAuthenticates proves a token is live by calling GET /api/v1/user with it and +// (when a username is known) checking the returned login matches. Shared by Rotate's +// pre-rewrite gate and Verify. +func (g *Gitea) tokenAuthenticates(ctx context.Context, base, user, token string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v1/user", nil) if err != nil { return err } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/api/v1/user", nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "token "+s.token) + req.Header.Set("Authorization", "token "+token) resp, err := g.client().Do(req) if err != nil { return fmt.Errorf("gitea verify: %w", err) @@ -211,8 +243,33 @@ func (g *Gitea) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Va if err := json.NewDecoder(resp.Body).Decode(&who); err != nil { return fmt.Errorf("gitea verify: decode user: %w", err) } - if s.user != "" && who.Login != "" && !strings.EqualFold(who.Login, s.user) { - return fmt.Errorf("gitea verify: token authenticates as %q, expected %q", who.Login, s.user) + if user != "" && who.Login != "" && !strings.EqualFold(who.Login, user) { + return fmt.Errorf("gitea verify: token authenticates as %q, expected %q", who.Login, user) + } + return nil +} + +// Verify proves the newly minted token authenticates (GET /api/v1/user, login match) +// AND that the multi-reference cutover landed: the new token is present in every +// on-disk reference file. Reading a ref that is missing the new token means the +// rewrite did not take, so Verify fails and the spine short-circuits before RevokeOld +// (old token stays live — no breakage). +func (g *Gitea) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + s, err := parseGiteaSecret(v, newSecret) + if err != nil { + return err + } + if err := g.tokenAuthenticates(ctx, s.base, s.user, s.token); err != nil { + return err + } + for _, ref := range s.refs { + b, err := os.ReadFile(ref) + if err != nil { + return fmt.Errorf("gitea verify: read ref %s: %w", ref, err) + } + if !strings.Contains(string(b), s.token) { + return fmt.Errorf("gitea verify: new token missing from ref %s", ref) + } } return nil } diff --git a/internal/rotate/gitea_refs.go b/internal/rotate/gitea_refs.go new file mode 100644 index 0000000..9981046 --- /dev/null +++ b/internal/rotate/gitea_refs.go @@ -0,0 +1,88 @@ +package rotate + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Reference enumeration for the gitea blast-and-cutover. +// +// A real IN-USE Gitea PAT does not live only in the gopass blob the driver is +// handed — the same 40-hex string is embedded VERBATIM in several on-disk files: +// ~/.git-credentials lines, embedded git-remote URLs in each repo's .git/config, +// and the `tea` CLI config. Rotating the blob alone would break git auth in every +// un-updated place. The blast-and-cutover fixes this by writing one `ref=` +// into the blob for every reference file; Rotate then rewrites the old token → new +// in each. This file produces that reference list, READ-ONLY. +// +// SECURITY: the token value is used only as a search key for a literal substring +// match; it is never logged, and no candidate file is modified here. + +// giteaWellKnownRefFiles returns the standard credential files that commonly embed a +// Gitea token, resolved under the given home dir and honoring $XDG_CONFIG_HOME for +// the tea config (matching internal/discover/tea.go). Existence is NOT checked here — +// enumerateGiteaRefs filters to the files that actually contain the token. +func giteaWellKnownRefFiles(home string) []string { + teaDir := filepath.Join(home, ".config", "tea") + if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { + teaDir = filepath.Join(x, "tea") + } + return []string{ + filepath.Join(home, ".git-credentials"), + filepath.Join(teaDir, "config.yml"), + filepath.Join(teaDir, "config.yaml"), + } +} + +// giteaGitConfigRefFiles returns the `.git/config` path for each given repo root that +// has one. These configs can embed a token inside a remote URL (a per-remote credential +// helper or a token baked into the URL). Roots are supplied by the caller (e.g. from the +// git scanner's known repos); this does not walk the filesystem on its own. +func giteaGitConfigRefFiles(roots ...string) []string { + var out []string + for _, root := range roots { + cfg := filepath.Join(root, ".git", "config") + if fi, err := os.Stat(cfg); err == nil && !fi.IsDir() { + out = append(out, cfg) + } + } + return out +} + +// enumerateGiteaRefs filters a candidate list down to the files that LITERALLY contain +// the token, returning the reference set for the blast-and-cutover. It is read-only and +// never logs the token. Non-existent or unreadable candidates are skipped silently — a +// missing well-known file simply isn't a reference. Duplicate paths are collapsed. +// +// It refuses a token shorter than minGiteaTokenLen: a short literal could match unrelated +// bytes in these files, which would later cause the rewrite to corrupt them. +func enumerateGiteaRefs(token string, candidates []string) ([]string, error) { + if len(token) < minGiteaTokenLen { + return nil, fmt.Errorf("gitea: refusing to enumerate references for a token shorter than %d chars", minGiteaTokenLen) + } + var refs []string + seen := map[string]bool{} + for _, c := range candidates { + if c == "" { + continue + } + abs, err := filepath.Abs(c) + if err != nil { + abs = c + } + if seen[abs] { + continue + } + seen[abs] = true + b, err := os.ReadFile(abs) + if err != nil { + continue // missing/unreadable → not a reference + } + if strings.Contains(string(b), token) { + refs = append(refs, abs) + } + } + return refs, nil +} diff --git a/internal/rotate/gitea_refs_test.go b/internal/rotate/gitea_refs_test.go new file mode 100644 index 0000000..0d09211 --- /dev/null +++ b/internal/rotate/gitea_refs_test.go @@ -0,0 +1,101 @@ +package rotate + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const refTestTok = "0123456789abcdef0123456789abcdef01234567" // 40-hex, > minGiteaTokenLen + +// TestEnumerateGiteaRefs proves the enumerator returns exactly the files that literally +// hold the token, skips files that do not, and skips missing/unreadable candidates. +func TestEnumerateGiteaRefs(t *testing.T) { + dir := t.TempDir() + + hit1 := filepath.Join(dir, ".git-credentials") + hit2 := filepath.Join(dir, "config") // embedded git remote URL + miss := filepath.Join(dir, "unrelated.txt") + absent := filepath.Join(dir, "does-not-exist") + + mustWrite(t, hit1, "http://alice:"+refTestTok+"@gitea.local\n") + mustWrite(t, hit2, "\turl = http://alice:"+refTestTok+"@gitea.local/a/b.git\n") + mustWrite(t, miss, "nothing sensitive here\n") + + got, err := enumerateGiteaRefs(refTestTok, []string{hit1, hit2, miss, absent, ""}) + if err != nil { + t.Fatalf("enumerate: %v", err) + } + want := map[string]bool{hit1: true, hit2: true} + if len(got) != len(want) { + t.Fatalf("got %v, want the two files holding the token", got) + } + for _, g := range got { + if !want[g] { + t.Errorf("unexpected ref %q", g) + } + } +} + +// TestEnumerateGiteaRefsDedup proves duplicate candidate paths (absolute vs relative to +// the same file) collapse to a single reference. +func TestEnumerateGiteaRefsDedup(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, ".git-credentials") + mustWrite(t, f, "http://bob:"+refTestTok+"@gitea.local\n") + + got, err := enumerateGiteaRefs(refTestTok, []string{f, f}) + if err != nil { + t.Fatalf("enumerate: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected dedup to 1 ref, got %v", got) + } +} + +// TestEnumerateGiteaRefsRejectsShortToken proves the length guard: a short token is +// refused before any file is read, so it can never match unrelated bytes. +func TestEnumerateGiteaRefsRejectsShortToken(t *testing.T) { + if _, err := enumerateGiteaRefs("short", []string{"/etc/hosts"}); err == nil || + !strings.Contains(err.Error(), "shorter than") { + t.Fatalf("expected short-token refusal, got %v", err) + } +} + +// TestGiteaGitConfigRefFiles proves only repo roots with a real .git/config are returned. +func TestGiteaGitConfigRefFiles(t *testing.T) { + dir := t.TempDir() + repo := filepath.Join(dir, "repo") + if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + cfg := filepath.Join(repo, ".git", "config") + mustWrite(t, cfg, "[core]\n") + noRepo := filepath.Join(dir, "not-a-repo") // no .git at all + + got := giteaGitConfigRefFiles(repo, noRepo) + if len(got) != 1 || got[0] != cfg { + t.Fatalf("expected only %q, got %v", cfg, got) + } +} + +// TestGiteaWellKnownRefFilesXDG proves the tea config path honors $XDG_CONFIG_HOME. +func TestGiteaWellKnownRefFilesXDG(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/xdg") + got := giteaWellKnownRefFiles("/home/alice") + joined := strings.Join(got, "\n") + if !strings.Contains(joined, "/home/alice/.git-credentials") { + t.Errorf("missing git-credentials in %v", got) + } + if !strings.Contains(joined, "/xdg/tea/config.yml") { + t.Errorf("tea config should honor XDG_CONFIG_HOME, got %v", got) + } +} + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/internal/rotate/gitea_test.go b/internal/rotate/gitea_test.go index 557059f..67db1e8 100644 --- a/internal/rotate/gitea_test.go +++ b/internal/rotate/gitea_test.go @@ -7,6 +7,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "sync" "testing" @@ -182,6 +184,163 @@ func TestGiteaRotateRealCutover(t *testing.T) { } } +// giteaRefBlob builds the single-line blob with userinfo + name= + one ref= per file. +func giteaRefBlob(srvURL, user, tok, name string, refs ...string) string { + b := strings.Replace(srvURL, "http://", "http://"+user+":"+tok+"@", 1) + "/?name=" + name + for _, r := range refs { + b += "&ref=" + r + } + return b +} + +// TestGiteaMultiReferenceCutover proves the blast-and-cutover: the old token literal, +// embedded verbatim in several on-disk reference files (a .git-credentials line, an +// embedded git-remote URL, a tea config), is rewritten to the freshly minted token in +// EVERY file after Rotate — and Verify confirms the new token is present in each. +func TestGiteaMultiReferenceCutover(t *testing.T) { + v := vault.New() + defer v.Purge() + ctx := context.Background() + + const user, seedName = "alice", "seed" + const oldTok = "0123456789abcdef0123456789abcdef01234567" // 40-hex, > minGiteaTokenLen + emu := newGiteaEmu(t, user, seedName, oldTok) + + dir := t.TempDir() + // Three distinct real-world shapes, all embedding the old token verbatim. + gitCreds := filepath.Join(dir, ".git-credentials") + gitConfig := filepath.Join(dir, "config") // embedded git remote URL + teaConfig := filepath.Join(dir, "tea.yml") + host := strings.TrimPrefix(emu.srv.URL, "http://") + writeFile(t, gitCreds, "http://"+user+":"+oldTok+"@"+host+"\n") + writeFile(t, gitConfig, "[remote \"origin\"]\n\turl = http://"+user+":"+oldTok+"@"+host+"/alice/repo.git\n") + writeFile(t, teaConfig, "logins:\n - name: gitea\n token: "+oldTok+"\n") + + oldBlob := giteaRefBlob(emu.srv.URL, user, oldTok, seedName, gitCreds, gitConfig, teaConfig) + cred := discover.Credential{ + Source: "gitea", + Kind: discover.KindToken, + Identity: user + " @ gitea", + Location: "imported/gitea/alice", + Secret: v.Store([]byte(oldBlob)), + } + + g := &Gitea{} + newH, err := g.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + ns, err := parseGiteaSecret(v, newH) + if err != nil { + t.Fatalf("parse new secret: %v", err) + } + if ns.token == oldTok || !emu.valid(ns.token) { + t.Fatalf("expected a fresh valid token, got %q", ns.token) + } + + // Every reference file must now hold the NEW token and NOT the old one. + for _, ref := range []string{gitCreds, gitConfig, teaConfig} { + b, err := os.ReadFile(ref) + if err != nil { + t.Fatalf("read %s: %v", ref, err) + } + if strings.Contains(string(b), oldTok) { + t.Errorf("old token still present in %s after cutover", ref) + } + if !strings.Contains(string(b), ns.token) { + t.Errorf("new token missing from %s after cutover", ref) + } + } + + // Verify(new) must pass now that every ref holds the new token. + if err := g.Verify(ctx, newH, v); err != nil { + t.Fatalf("Verify(new): %v", err) + } + + // RevokeOld deletes the old token — refs already point at the new one, so nothing breaks. + if err := g.RevokeOld(ctx, cred, v); err != nil { + t.Fatalf("RevokeOld: %v", err) + } + if emu.valid(oldTok) { + t.Fatal("old token must be dead after revoke") + } +} + +// TestGiteaVerifyFailsWhenRefMissingToken proves Verify short-circuits the spine (before +// RevokeOld) if a reference file does not contain the new token — e.g. an external edit +// clobbered it. The old token must stay live in that case. +func TestGiteaVerifyFailsWhenRefMissingToken(t *testing.T) { + v := vault.New() + defer v.Purge() + ctx := context.Background() + + const user, seedName = "alice", "seed" + const oldTok = "0123456789abcdef0123456789abcdef01234567" + emu := newGiteaEmu(t, user, seedName, oldTok) + + dir := t.TempDir() + ref := filepath.Join(dir, ".git-credentials") + host := strings.TrimPrefix(emu.srv.URL, "http://") + writeFile(t, ref, "http://"+user+":"+oldTok+"@"+host+"\n") + + oldBlob := giteaRefBlob(emu.srv.URL, user, oldTok, seedName, ref) + cred := discover.Credential{Source: "gitea", Secret: v.Store([]byte(oldBlob))} + + g := &Gitea{} + newH, err := g.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + // Simulate an external clobber: wipe the ref so the new token is no longer present. + writeFile(t, ref, "http://"+user+":deadbeef@"+host+"\n") + if err := g.Verify(ctx, newH, v); err == nil { + t.Fatal("Verify must fail when a ref no longer holds the new token") + } + // Old token is untouched — spine would leave it live. + if !emu.valid(oldTok) { + t.Fatal("old token must remain live when Verify fails pre-revoke") + } +} + +// TestGiteaRefRewriteRejectsShortToken proves the length guard: a too-short old token +// (below minGiteaTokenLen) is refused before any file is touched, so a short literal can +// never sweep through unrelated file contents. +func TestGiteaRefRewriteRejectsShortToken(t *testing.T) { + v := vault.New() + defer v.Purge() + ctx := context.Background() + + const user, seedName = "alice", "seed" + const shortTok = "abc123" // < minGiteaTokenLen + emu := newGiteaEmu(t, user, seedName, shortTok) + + dir := t.TempDir() + ref := filepath.Join(dir, ".git-credentials") + host := strings.TrimPrefix(emu.srv.URL, "http://") + const orig = "http://" + user + ":" + shortTok + "@HOST\n" + writeFile(t, ref, strings.Replace(orig, "HOST", host, 1)) + + oldBlob := giteaRefBlob(emu.srv.URL, user, shortTok, seedName, ref) + cred := discover.Credential{Source: "gitea", Secret: v.Store([]byte(oldBlob))} + + g := &Gitea{} + if _, err := g.Rotate(ctx, cred, v); err == nil || !strings.Contains(err.Error(), "too short") { + t.Fatalf("expected too-short refusal, got %v", err) + } + // The ref file must be untouched (still holds the original short-token line). + b, _ := os.ReadFile(ref) + if !strings.Contains(string(b), shortTok) { + t.Fatal("ref file was modified despite the length guard") + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + func TestGiteaRevokeRequiresName(t *testing.T) { v := vault.New() defer v.Purge() diff --git a/lab/README.md b/lab/README.md index 8088b89..95860ca 100644 --- a/lab/README.md +++ b/lab/README.md @@ -59,6 +59,36 @@ multipass exec incredigo-sbx -- bash -lc ' | `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 | +## Multi-reference blast-and-cutover (gitea) + +| Script | Proves | Depends on | +|---|---|---| +| `lab-gitea-blast-vm.sh` | the gitea **blast-and-cutover** — one rotation rewrites the token in **every** on-disk reference in lockstep | `lab-provision-gitea.sh` (real local Gitea) | + +`lab-gitea-blast-vm.sh` is the LIVE-VM proof for the cutover a real *in-use* Gitea PAT +needs: it embeds one seed token verbatim in three real-world reference shapes (a fake +`~/.git-credentials` line, an embedded git-remote URL in a repo `.git/config`, and a +`tea` config), stages a driver-ready blob carrying one `ref=` per reference, runs +`rotate --execute`, then asserts **every** reference was rewritten old→new, the new token +is alive (`200`), the old is revoked (`401`), and the rewritten `.git-credentials` still +authenticates at the git transport (not `401`). Ordering is the point: incredigo mints +the new token, proves it authenticates, **then** rewrites the refs, and revokes the old +one **last** — so a dead token is never written into a git config. + +Run it after the provisioner: +```sh +multipass exec incredigo-sbx -- bash /home/ubuntu/lab/lab-provision-gitea.sh +multipass exec incredigo-sbx -- bash /home/ubuntu/lab/lab-gitea-blast-vm.sh # -> GITEA_BLAST_VM_OK +``` + +> **Two documented follow-ons** (surfaced by this proof, not yet built): +> 1. **gopass-entry-sync** — the same PAT also lives in *other* gopass entries; the driver +> rewrites only files named in `ref=`, so a spine hook is needed to pass old+new token +> to a gopass-sweep step. Surface in the blast report; do not auto-rewrite yet. +> 2. **token-scope-cloning** — the driver mints the new token with fixed +> `write:user`/`read:user` scopes; a token used for git operations needs its repo scopes +> preserved (clone the old token's scopes on create, like the `sendgrid` driver). + ## Custody / smoke - `lab-rung1-appsecret-host.sh` — **safe-candidate ladder rung 1**, the host dress diff --git a/lab/lab-gitea-blast-vm.sh b/lab/lab-gitea-blast-vm.sh new file mode 100755 index 0000000..f817eda --- /dev/null +++ b/lab/lab-gitea-blast-vm.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# lab-gitea-blast-vm.sh — prove the gitea MULTI-REFERENCE blast-and-cutover in the VM. +# +# This is the LIVE-VM proof for the blast-and-cutover: a single Gitea token embedded +# VERBATIM in several on-disk reference files (a fake ~/.git-credentials, a fake repo +# .git/config remote URL, a fake tea config) is rotated ONCE and every reference is +# rewritten old→new in lockstep — the cutover a real in-use PAT needs so git auth never +# breaks. Runs against the REAL local Gitea stood up by lab-provision-gitea.sh +# (http://127.0.0.1:3000), isolated gopass store, self-owned only. +# +# Ordering proof (the whole point): incredigo mints the new token, proves it +# authenticates, THEN rewrites every ref, and only revokes the old token LAST — so at no +# point is a dead token written into a git config, and any failure leaves every ref +# pointing at a still-live token. +# +# Preconditions: run lab-provision-gitea.sh first (it starts Gitea + creates the isolated +# GPG key + gopass store + admin user labadmin/labpw123). +set -euo pipefail +export PATH=/usr/local/bin:$PATH +export GNUPGHOME="${GNUPGHOME:-$HOME/.lab-gnupg}" +export GOPASS_HOMEDIR="${GOPASS_HOMEDIR:-$HOME/.lab-gopass}" +export GOPASS_NO_NOTIFY=true +export INCREDIGO_PASSPHRASE="${INCREDIGO_PASSPHRASE:-labseal123}" + +BASE="http://127.0.0.1:3000" +GITEA_USER="labadmin" +GITEA_PW="labpw123" +PREFIX="rotation-test/" +ENTRY="rotation-test/gitea/blastpat" +REFDIR="$HOME/.lab-gitea-refs" + +api() { curl -fsS -m 10 "$@"; } + +# --- locate / build incredigo --- +if [ -n "${INCREDIGO_BIN:-}" ] && [ -x "${INCREDIGO_BIN}" ]; then INC="$INCREDIGO_BIN" +elif command -v incredigo >/dev/null 2>&1; then INC="$(command -v incredigo)" +else + ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "$(dirname "$0")/..")" + INC="$(mktemp -d)/incredigo"; echo "== building incredigo from $ROOT ==" + ( cd "$ROOT" && CGO_ENABLED=0 go build -o "$INC" ./cmd/incredigo ) +fi +echo "== incredigo: $INC" +command -v gopass >/dev/null 2>&1 || { echo "FAIL: gopass not on PATH"; exit 1; } + +# --- 0) Gitea must be up (provisioned by lab-provision-gitea.sh) --- +echo "== 0) Gitea up? ==" +api "$BASE/api/v1/version" >/dev/null 2>&1 || { + echo "FAIL: Gitea not reachable at $BASE — run lab-provision-gitea.sh first"; exit 1; } +echo " version: $(api "$BASE/api/v1/version")" + +# --- 1) mint a fresh seed token (basic auth) — this is the 'in-use' token we rotate --- +echo "== 1) mint seed token (the in-use PAT) ==" +SEED_NAME="blast-seed-$(date +%s)" +SEED_JSON="$(api -u "$GITEA_USER:$GITEA_PW" -H 'content-type: application/json' \ + -d "{\"name\":\"$SEED_NAME\",\"scopes\":[\"write:user\",\"read:user\"]}" \ + "$BASE/api/v1/users/$GITEA_USER/tokens")" +SEED_TOK="$(printf '%s' "$SEED_JSON" | sed -n 's/.*"sha1":"\([0-9a-f]*\)".*/\1/p')" +test -n "$SEED_TOK" || { echo "FAIL to mint seed: $SEED_JSON"; exit 1; } +echo " seed name=$SEED_NAME (value not printed)" + +# ensure a real repo exists so step 8's git ls-remote genuinely exercises auth +api -u "$GITEA_USER:$GITEA_PW" -H 'content-type: application/json' \ + -d '{"name":"repo","auto_init":true,"private":true}' \ + "$BASE/api/v1/user/repos" >/dev/null 2>&1 || true # 201 new / 409 exists — both fine + +# --- 2) embed the seed token VERBATIM in three real-world reference shapes --- +echo "== 2) seed the reference files (git-credentials + repo remote + tea config) ==" +rm -rf "$REFDIR"; mkdir -p "$REFDIR/repo/.git" +GITCREDS="$REFDIR/.git-credentials" +GITCONFIG="$REFDIR/repo/.git/config" +TEACONF="$REFDIR/tea-config.yml" +printf 'http://%s:%s@127.0.0.1:3000\n' "$GITEA_USER" "$SEED_TOK" > "$GITCREDS" +cat > "$GITCONFIG" < "$TEACONF" </dev/null +echo " staged $ENTRY (name+pw+3×ref; token never logged)" + +# --- 4) dry-run then EXECUTE (backup gate → rotate → rewrite refs → verify → revoke) --- +BKDIR="$(mktemp -d)"; BK="$BKDIR/blast-backup.age" +echo "== 4) dry-run ==" +"$INC" rotate --dry-run --prefix "$PREFIX" --backup-out "$BKDIR/dry.age" 2>&1 | grep -E 'gitea|backup gate|DRY RUN' || true +echo "== 5) EXECUTE ==" +INCREDIGO_ALLOW_EXECUTE=1 "$INC" rotate --execute --prefix "$PREFIX" --backup-out "$BK" + +# --- 6) assert the cutover landed in EVERY reference --- +echo "== 6) assert every reference rewritten old→new ==" +NEWBLOB="$(gopass show -o "$ENTRY")" +NEWTOK="$(printf '%s' "$NEWBLOB" | sed -n 's#.*://[^:]*:\([^@]*\)@.*#\1#p')" +fail=0 +for f in "$GITCREDS" "$GITCONFIG" "$TEACONF"; do + if grep -q "$SEED_TOK" "$f"; then echo " $f: OLD token STILL present ✗"; fail=1 + elif grep -q "$NEWTOK" "$f"; then echo " $f: rewritten to new token ✓" + else echo " $f: neither token present ✗"; fail=1; fi +done + +# --- 7) independent liveness: new→200, old→401 --- +echo "== 7) independent token liveness ==" +if api -H "Authorization: token $NEWTOK" "$BASE/api/v1/user" >/dev/null 2>&1; then + echo " new token -> 200 (alive) ✓"; else echo " new token -> not alive ✗"; fail=1; fi +code="$(curl -s -m 10 -o /dev/null -w '%{http_code}' -H "Authorization: token $SEED_TOK" "$BASE/api/v1/user" || true)" +if [ "$code" = "401" ] || [ "$code" = "403" ]; then echo " old token -> $code (revoked) ✓" +else echo " old token -> $code (STILL VALID) ✗"; fail=1; fi + +# --- 8) prove the rewritten .git-credentials line AUTHENTICATES at the git transport --- +# The cutover's job is that the credential the git tooling reads is ACCEPTED (not 401). +# NOTE: the driver mints the new token with fixed write:user/read:user scopes, so a git +# repo op may return 403 (authenticated, lacks repo scope) — that is a token-SCOPE matter +# (scope-cloning is a documented follow-on), NOT a cutover failure. A broken cutover would +# yield 401 (credential rejected). So we assert: NOT 401. +echo "== 8) rewritten .git-credentials authenticates (not 401) ==" +NEWURL="$(sed -n '1p' "$GITCREDS")" +gcode="$(curl -s -m 10 -o /dev/null -w '%{http_code}' "$NEWURL/$GITEA_USER/repo.git/info/refs?service=git-upload-pack" || true)" +if [ "$gcode" = "401" ]; then + echo " git transport -> 401 (rewritten credential REJECTED — cutover broke auth) ✗"; fail=1 +else + echo " git transport -> $gcode (rewritten credential accepted; 403=token-scope, not cutover) ✓" +fi + +[ -s "$BK" ] && echo " sealed backup: $BK ✓" || { echo " no backup ✗"; fail=1; } + +echo +if [ "$fail" -eq 0 ]; then + echo "GITEA_BLAST_VM_OK — one rotation rewrote all 3 references; new alive, old revoked" +else + echo "GITEA_BLAST_VM_FAIL — inspect above"; exit 1 +fi