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:
+25
-1
@@ -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=<file>` 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
|
||||
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -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=<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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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=<file>` 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
|
||||
|
||||
Executable
+145
@@ -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" <<CFG
|
||||
[remote "origin"]
|
||||
url = http://$GITEA_USER:$SEED_TOK@127.0.0.1:3000/$GITEA_USER/repo.git
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
CFG
|
||||
cat > "$TEACONF" <<YML
|
||||
logins:
|
||||
- name: lab
|
||||
url: http://127.0.0.1:3000
|
||||
user: $GITEA_USER
|
||||
token: $SEED_TOK
|
||||
YML
|
||||
for f in "$GITCREDS" "$GITCONFIG" "$TEACONF"; do
|
||||
grep -q "$SEED_TOK" "$f" || { echo "FAIL: seed token not in $f"; exit 1; }
|
||||
done
|
||||
echo " 3 refs seeded, each holds the token verbatim"
|
||||
|
||||
# --- 3) stage the driver-ready blob with one ref= per file (secrets via var, not argv) ---
|
||||
echo "== 3) stage blob with ref= per file =="
|
||||
BLOB="http://$GITEA_USER:$SEED_TOK@127.0.0.1:3000/?name=$SEED_NAME&pw=$GITEA_PW"
|
||||
BLOB="$BLOB&ref=$GITCREDS&ref=$GITCONFIG&ref=$TEACONF"
|
||||
printf '%s' "$BLOB" | gopass insert --multiline=false -f "$ENTRY" >/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
|
||||
Reference in New Issue
Block a user