8dbb2f21fd
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>
89 lines
3.2 KiB
Go
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
|
|
}
|