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>
This commit is contained in:
+74
-17
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user