Files
incredigo/internal/rotate/gitea_refs.go
T
leetcrypt 8dbb2f21fd rotate/gitea: multi-reference blast-and-cutover (LIVE-VM)
Rotate one Gitea PAT and rewrite it in EVERY on-disk reference in lockstep,
so a real in-use token can rotate without breaking git auth. The blob carries
one ref=<file> per reference; Rotate mints the new token, proves it
authenticates, then rewrites the old->new token literal in each ref file
(atomic temp+rename, minGiteaTokenLen-guarded), Verify asserts the new token
is present in each ref, and the spine revokes the old token last. A dead token
is never written into a git config; any failure leaves every ref on a live token.

Adds internal/rotate/gitea_refs.go (read-only reference enumerator) + driver and
enumerator unit tests. Proven end-to-end against real Gitea 1.25.0 in the sandbox
VM via lab/lab-gitea-blast-vm.sh (GITEA_BLAST_VM_OK): one rotation rewrote a fake
.git-credentials, an embedded git-remote URL, and a tea config; new->200, old->401.

Two documented follow-ons before any in-use host PAT: gopass-entry-sync (PAT
duplicated across other gopass entries) and token-scope-cloning (preserve the old
token's repo scopes on mint, like sendgrid).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-19 13:25:52 -07:00

89 lines
3.2 KiB
Go

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=<file>`
// 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
}