rotate: nine rotation drivers + verify-before-revoke execute spine
Implements the Rotator interface across all four rotation patterns, each a self-contained one-file driver self-registering via init(): in-place DB password postgres, mysql (3-stmt unprivileged fallback), redis local keypair + propagate sshkey (ed25519), wireguard (clamped curve25519) provider-API token gitea PAT, mullvad device key cloud-key self-identifying aws IAM access key (hand-rolled SigV4, no SDK dep) Spine (execute.go) enforces Hard Rule #2: backup -> rotate -> verify(new) -> store -> re-read+verify -> revoke-old; dryrun.go keeps --execute gated. Each driver ships a table-driven test proving real cutover (old secret stops authenticating) and asserting no secret substring leaks to argv/Meta/errors. Promotes golang.org/x/crypto to a direct dependency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// giteaEmu is a minimal Gitea API emulator that ENFORCES token validity: a token
|
||||
// authenticates only while it exists, and DELETE makes it stop working — so the
|
||||
// test can prove a real cutover (old token dead, new token alive).
|
||||
type giteaEmu struct {
|
||||
mu sync.Mutex
|
||||
byName map[string]string // name -> token value
|
||||
byValue map[string]string // token value -> name
|
||||
user string
|
||||
srv *httptest.Server
|
||||
createOK func(r *http.Request) bool // auth gate for create/delete
|
||||
}
|
||||
|
||||
func newGiteaEmu(t *testing.T, user, seedName, seedValue string) *giteaEmu {
|
||||
e := &giteaEmu{
|
||||
byName: map[string]string{seedName: seedValue},
|
||||
byValue: map[string]string{seedValue: seedName},
|
||||
user: user,
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/user", e.handleUser)
|
||||
mux.HandleFunc("/api/v1/users/", e.handleTokens)
|
||||
e.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(e.srv.Close)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *giteaEmu) valid(token string) bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
_, ok := e.byValue[token]
|
||||
return ok
|
||||
}
|
||||
|
||||
// presentedToken returns the bearer token from an "Authorization: token <v>" header.
|
||||
func presentedToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(h, "token ") {
|
||||
return strings.TrimPrefix(h, "token ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *giteaEmu) handleUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.valid(presentedToken(r)) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"login": e.user, "id": 1})
|
||||
}
|
||||
|
||||
func (e *giteaEmu) handleTokens(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorize management calls: a valid token (token auth) or any basic auth.
|
||||
authed := e.valid(presentedToken(r))
|
||||
if u, _, ok := r.BasicAuth(); ok && u == e.user {
|
||||
authed = true
|
||||
}
|
||||
if e.createOK != nil {
|
||||
authed = e.createOK(r)
|
||||
}
|
||||
if !authed {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
raw := make([]byte, 20)
|
||||
rand.Read(raw)
|
||||
val := hex.EncodeToString(raw)
|
||||
e.mu.Lock()
|
||||
e.byName[body.Name] = val
|
||||
e.byValue[val] = body.Name
|
||||
e.mu.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]any{"id": 2, "name": body.Name, "sha1": val})
|
||||
case http.MethodDelete:
|
||||
// path: /api/v1/users/{user}/tokens/{name}
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
name := parts[len(parts)-1]
|
||||
e.mu.Lock()
|
||||
if v, ok := e.byName[name]; ok {
|
||||
delete(e.byValue, v)
|
||||
delete(e.byName, name)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaRotateRealCutover(t *testing.T) {
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
ctx := context.Background()
|
||||
|
||||
const user, seedName, oldTok = "alice", "seed", "0123456789abcdef0123456789abcdef01234567"
|
||||
emu := newGiteaEmu(t, user, seedName, oldTok)
|
||||
|
||||
oldBlob := emu.srv.URL + "/?name=" + seedName // becomes http://host/?name=seed with userinfo below
|
||||
// Build the blob with userinfo: http://alice:oldtok@host/?name=seed
|
||||
oldBlob = strings.Replace(emu.srv.URL, "http://", "http://"+user+":"+oldTok+"@", 1) + "/?name=" + seedName
|
||||
|
||||
cred := discover.Credential{
|
||||
Source: "gitea",
|
||||
Kind: discover.KindToken,
|
||||
Identity: user + " @ gitea",
|
||||
Location: "imported/gitea/alice",
|
||||
Secret: v.Store([]byte(oldBlob)),
|
||||
}
|
||||
|
||||
g := &Gitea{}
|
||||
|
||||
// Baseline: the seeded token authenticates.
|
||||
if !emu.valid(oldTok) {
|
||||
t.Fatal("seed token should be valid at start")
|
||||
}
|
||||
|
||||
// 1. Rotate — create a new token (old still valid).
|
||||
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 {
|
||||
t.Fatal("new token equals old — rotation minted nothing")
|
||||
}
|
||||
if !emu.valid(oldTok) {
|
||||
t.Fatal("old token must remain valid before revoke")
|
||||
}
|
||||
if !emu.valid(ns.token) {
|
||||
t.Fatal("new token must be valid after create")
|
||||
}
|
||||
|
||||
// 2. Verify(new) via the driver.
|
||||
if err := g.Verify(ctx, newH, v); err != nil {
|
||||
t.Fatalf("Verify(new): %v", err)
|
||||
}
|
||||
|
||||
// 3. RevokeOld — delete the old token by name.
|
||||
if err := g.RevokeOld(ctx, cred, v); err != nil {
|
||||
t.Fatalf("RevokeOld: %v", err)
|
||||
}
|
||||
|
||||
// Cutover assertions: old dead, new alive.
|
||||
if emu.valid(oldTok) {
|
||||
t.Fatal("old token must be invalid after revoke")
|
||||
}
|
||||
if err := g.Verify(ctx, newH, v); err != nil {
|
||||
t.Fatalf("new token must still authenticate after revoke: %v", err)
|
||||
}
|
||||
|
||||
// Leak check: token values must not appear in non-secret credential fields.
|
||||
for _, f := range []string{cred.Identity, cred.Source, cred.Location, string(cred.Kind)} {
|
||||
if strings.Contains(f, oldTok) || strings.Contains(f, ns.token) {
|
||||
t.Errorf("token leaked into non-secret field %q", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaRevokeRequiresName(t *testing.T) {
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
g := &Gitea{}
|
||||
// Blob with no name= query → RevokeOld must refuse (guided), not silently pass.
|
||||
cred := discover.Credential{
|
||||
Source: "gitea",
|
||||
Secret: v.Store([]byte("https://bob:tok123@gitea.example.com/")),
|
||||
}
|
||||
err := g.RevokeOld(context.Background(), cred, v)
|
||||
if err == nil || !strings.Contains(err.Error(), "no recorded name") {
|
||||
t.Fatalf("expected revoke-without-name error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user