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:
leetcrypt
2026-07-19 13:25:52 -07:00
parent 9f227bb05f
commit 8dbb2f21fd
7 changed files with 622 additions and 18 deletions
+159
View File
@@ -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()