35f19c007a
Each driver is real-service code only (no test awareness; tests inject just an HTTPClient/Bin), following the established patterns: - mongo: in-place changeUserPassword over the old connection; no secret on argv (host/db on the URI, both passwords on stdin, output scrubbed). - npm: create token -> /-/whoami -> delete-by-key; blob records the key. - gcp: service-account KEY rotation; mints an RS256 JWT signed with the SA's own private key (crypto/rsa, stdlib) to self-auth the IAM create/verify/delete. - twilio: API Key create -> get -> delete, Basic auth (KeySid/secret). - flyio: GraphQL — a managing token mints/deletes the rotated deploy token. - k8s: create a service-account-token Secret, await the populated token, verify against the SA, delete the old Secret (invalidates the old token). All 6 register their honest proof level (MOCK-ONLY) in proofs.go + ROTATION-PROOFS.md; mongo/npm/k8s are LIVE-VM candidates pending real-target VM POCs. Emulators enforce credential validity (gcp verifies the JWT signature), tests assert Verify(old) fails after revoke + a secret-leak canary. Full suite: 103 green, -race clean on rotate/sink/vault. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
221 lines
6.1 KiB
Go
221 lines
6.1 KiB
Go
package rotate
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"incredigo/internal/discover"
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// flyioEmu emulates the Fly.io GraphQL token API and ENFORCES token validity: the
|
|
// managing token is always valid and may create/delete; a deploy token authenticates
|
|
// the app query only while it exists, and deleteLimitedAccessToken makes it stop
|
|
// working — so a test can prove a real cutover.
|
|
type flyioEmu struct {
|
|
mu sync.Mutex
|
|
managing string
|
|
app string
|
|
tokens map[string]string // deploy token id -> token value
|
|
valid map[string]bool // deploy token value -> alive
|
|
srv *httptest.Server
|
|
}
|
|
|
|
func newFlyioEmu(t *testing.T, managing, app, seedID, seedToken string) *flyioEmu {
|
|
e := &flyioEmu{
|
|
managing: managing,
|
|
app: app,
|
|
tokens: map[string]string{seedID: seedToken},
|
|
valid: map[string]bool{seedToken: true},
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/graphql", e.handle)
|
|
e.srv = httptest.NewTLSServer(mux)
|
|
t.Cleanup(e.srv.Close)
|
|
return e
|
|
}
|
|
|
|
func (e *flyioEmu) handle(w http.ResponseWriter, r *http.Request) {
|
|
bear := bearer(r)
|
|
var body struct {
|
|
Query string `json:"query"`
|
|
Variables map[string]any `json:"variables"`
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
|
|
switch {
|
|
case strings.Contains(body.Query, "createLimitedAccessToken"):
|
|
if bear != e.managing {
|
|
gqlErr(w, "unauthorized: create requires managing token")
|
|
return
|
|
}
|
|
idRaw := make([]byte, 8)
|
|
rand.Read(idRaw)
|
|
id := "lat_" + hex.EncodeToString(idRaw)
|
|
tRaw := make([]byte, 16)
|
|
rand.Read(tRaw)
|
|
tok := "FlyV1_" + hex.EncodeToString(tRaw)
|
|
e.mu.Lock()
|
|
e.tokens[id] = tok
|
|
e.valid[tok] = true
|
|
e.mu.Unlock()
|
|
gqlData(w, map[string]any{
|
|
"createLimitedAccessToken": map[string]any{
|
|
"limitedAccessToken": map[string]any{"id": id, "token": tok},
|
|
},
|
|
})
|
|
case strings.Contains(body.Query, "deleteLimitedAccessToken"):
|
|
if bear != e.managing {
|
|
gqlErr(w, "unauthorized: delete requires managing token")
|
|
return
|
|
}
|
|
id, _ := body.Variables["id"].(string)
|
|
e.mu.Lock()
|
|
if v, ok := e.tokens[id]; ok {
|
|
delete(e.valid, v)
|
|
delete(e.tokens, id)
|
|
}
|
|
e.mu.Unlock()
|
|
gqlData(w, map[string]any{"deleteLimitedAccessToken": map[string]any{"token": "deleted"}})
|
|
case strings.Contains(body.Query, "app(name"):
|
|
e.mu.Lock()
|
|
ok := e.valid[bear]
|
|
e.mu.Unlock()
|
|
if !ok {
|
|
gqlErr(w, "unauthorized: unknown or revoked token")
|
|
return
|
|
}
|
|
gqlData(w, map[string]any{"app": map[string]any{"id": "app_123", "name": e.app}})
|
|
default:
|
|
gqlErr(w, "unknown query")
|
|
}
|
|
}
|
|
|
|
func gqlData(w http.ResponseWriter, data map[string]any) {
|
|
json.NewEncoder(w).Encode(map[string]any{"data": data})
|
|
}
|
|
|
|
func gqlErr(w http.ResponseWriter, msg string) {
|
|
json.NewEncoder(w).Encode(map[string]any{"errors": []map[string]any{{"message": msg}}})
|
|
}
|
|
|
|
func (e *flyioEmu) tokenValid(tok string) bool {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return e.valid[tok]
|
|
}
|
|
|
|
func TestFlyioDetect(t *testing.T) {
|
|
f := &Flyio{}
|
|
if !f.Detect(discover.Credential{Source: "flyio"}) {
|
|
t.Error("should detect Source=flyio")
|
|
}
|
|
if f.Detect(discover.Credential{Source: "gcp"}) {
|
|
t.Error("should not detect Source=gcp")
|
|
}
|
|
}
|
|
|
|
func TestFlyioRotateRealCutover(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
ctx := context.Background()
|
|
|
|
const managing, app, seedID, oldTok = "FlyV1_managing0123", "labapp", "lat_seed01", "FlyV1_old0123456789"
|
|
emu := newFlyioEmu(t, managing, app, seedID, oldTok)
|
|
|
|
host := strings.TrimPrefix(emu.srv.URL, "https://")
|
|
oldBlob := "flyio://" + host + "/?org=orglab&app=" + app + "&auth=" + managing + "&id=" + seedID + "&token=" + oldTok
|
|
cred := discover.Credential{
|
|
Source: "flyio",
|
|
Kind: discover.KindToken,
|
|
Identity: app + " @ flyio",
|
|
Location: "imported/flyio/labapp",
|
|
Secret: v.Store([]byte(oldBlob)),
|
|
}
|
|
f := &Flyio{HTTPClient: emu.srv.Client()}
|
|
|
|
if !emu.tokenValid(oldTok) {
|
|
t.Fatal("seed deploy token should be valid at start")
|
|
}
|
|
|
|
// 1. Rotate.
|
|
newH, err := f.Rotate(ctx, cred, v)
|
|
if err != nil {
|
|
t.Fatalf("Rotate: %v", err)
|
|
}
|
|
ns, err := parseFlyioSecret(v, newH)
|
|
if err != nil {
|
|
t.Fatalf("parse new secret: %v", err)
|
|
}
|
|
if ns.token == oldTok {
|
|
t.Fatal("new deploy token equals old — rotation minted nothing")
|
|
}
|
|
if ns.id == "" || ns.id == seedID {
|
|
t.Fatalf("new secret must carry a fresh token id, got %q", ns.id)
|
|
}
|
|
if ns.auth != managing {
|
|
t.Fatal("managing token must be preserved across rotation")
|
|
}
|
|
if !emu.tokenValid(oldTok) {
|
|
t.Fatal("old deploy token must remain valid before revoke")
|
|
}
|
|
if !emu.tokenValid(ns.token) {
|
|
t.Fatal("new deploy token must be valid after create")
|
|
}
|
|
|
|
// 2. Verify(new).
|
|
if err := f.Verify(ctx, newH, v); err != nil {
|
|
t.Fatalf("Verify(new): %v", err)
|
|
}
|
|
|
|
// 3. RevokeOld.
|
|
if err := f.RevokeOld(ctx, cred, v); err != nil {
|
|
t.Fatalf("RevokeOld: %v", err)
|
|
}
|
|
if emu.tokenValid(oldTok) {
|
|
t.Fatal("old deploy token must be invalid after revoke")
|
|
}
|
|
if err := f.Verify(ctx, newH, v); err != nil {
|
|
t.Fatalf("new deploy token must still authenticate after revoke: %v", err)
|
|
}
|
|
|
|
// Leak check.
|
|
for _, fld := range []string{cred.Identity, cred.Source, cred.Location, string(cred.Kind)} {
|
|
if strings.Contains(fld, oldTok) || strings.Contains(fld, ns.token) || strings.Contains(fld, managing) {
|
|
t.Errorf("token leaked into non-secret field %q", fld)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFlyioRevokeRequiresID(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
f := &Flyio{}
|
|
cred := discover.Credential{
|
|
Source: "flyio",
|
|
Secret: v.Store([]byte("flyio://api.fly.io/?app=a&auth=mgmt&token=deploytok")),
|
|
}
|
|
err := f.RevokeOld(context.Background(), cred, v)
|
|
if err == nil || !strings.Contains(err.Error(), "no recorded id") {
|
|
t.Fatalf("expected revoke-without-id error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFlyioRejectsBadSecret(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
f := &Flyio{}
|
|
// no managing token
|
|
cred := discover.Credential{Source: "flyio", Secret: v.Store([]byte("flyio://api.fly.io/?app=a&token=deploytok"))}
|
|
if _, err := f.Rotate(context.Background(), cred, v); err == nil {
|
|
t.Error("Rotate should reject a secret with no managing token")
|
|
}
|
|
}
|