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>
146 lines
7.1 KiB
Bash
Executable File
146 lines
7.1 KiB
Bash
Executable File
#!/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
|