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>
231 lines
6.3 KiB
Go
231 lines
6.3 KiB
Go
package rotate
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"incredigo/internal/discover"
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// k8sEmu emulates the relevant slice of the kube-apiserver and ENFORCES token
|
|
// validity: a token authenticates only while its backing Secret exists. Creating a
|
|
// token Secret immediately populates a fresh token (acting as the token-controller);
|
|
// deleting a token Secret invalidates its token — so a test can prove a real cutover.
|
|
type k8sEmu struct {
|
|
mu sync.Mutex
|
|
ns string
|
|
sa string
|
|
secrets map[string]string // secret name -> token value
|
|
valid map[string]bool // token value -> alive
|
|
srv *httptest.Server
|
|
}
|
|
|
|
func newK8sEmu(t *testing.T, ns, sa, seedSecret, seedToken string) *k8sEmu {
|
|
e := &k8sEmu{
|
|
ns: ns,
|
|
sa: sa,
|
|
secrets: map[string]string{seedSecret: seedToken},
|
|
valid: map[string]bool{seedToken: true},
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/namespaces/"+ns+"/secrets", e.handleSecrets)
|
|
mux.HandleFunc("/api/v1/namespaces/"+ns+"/secrets/", e.handleSecret)
|
|
mux.HandleFunc("/api/v1/namespaces/"+ns+"/serviceaccounts/", e.handleSA)
|
|
e.srv = httptest.NewTLSServer(mux)
|
|
t.Cleanup(e.srv.Close)
|
|
return e
|
|
}
|
|
|
|
func (e *k8sEmu) tokenOK(r *http.Request) bool {
|
|
tok := bearer(r)
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return e.valid[tok]
|
|
}
|
|
|
|
// handleSecrets serves POST .../secrets (create token Secret + populate token).
|
|
func (e *k8sEmu) handleSecrets(w http.ResponseWriter, r *http.Request) {
|
|
if !e.tokenOK(r) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var obj map[string]any
|
|
json.NewDecoder(r.Body).Decode(&obj)
|
|
meta, _ := obj["metadata"].(map[string]any)
|
|
name, _ := meta["name"].(string)
|
|
raw := make([]byte, 24)
|
|
rand.Read(raw)
|
|
tok := "eyJ" + hex.EncodeToString(raw) // pseudo-JWT-looking token
|
|
e.mu.Lock()
|
|
e.secrets[name] = tok
|
|
e.valid[tok] = true
|
|
e.mu.Unlock()
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]any{"metadata": map[string]any{"name": name}})
|
|
}
|
|
|
|
// handleSecret serves GET/DELETE .../secrets/<name>.
|
|
func (e *k8sEmu) handleSecret(w http.ResponseWriter, r *http.Request) {
|
|
if !e.tokenOK(r) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
name := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
e.mu.Lock()
|
|
tok, ok := e.secrets[name]
|
|
e.mu.Unlock()
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"metadata": map[string]any{"name": name},
|
|
"data": map[string]string{"token": base64.StdEncoding.EncodeToString([]byte(tok))},
|
|
})
|
|
case http.MethodDelete:
|
|
e.mu.Lock()
|
|
if tok, ok := e.secrets[name]; ok {
|
|
delete(e.valid, tok)
|
|
delete(e.secrets, name)
|
|
}
|
|
e.mu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("{}"))
|
|
default:
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (e *k8sEmu) handleSA(w http.ResponseWriter, r *http.Request) {
|
|
if !e.tokenOK(r) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"metadata": map[string]any{"name": e.sa, "namespace": e.ns}})
|
|
}
|
|
|
|
func (e *k8sEmu) tokenValid(tok string) bool {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return e.valid[tok]
|
|
}
|
|
|
|
func TestK8sDetect(t *testing.T) {
|
|
k := &K8s{}
|
|
if !k.Detect(discover.Credential{Source: "k8s"}) {
|
|
t.Error("should detect Source=k8s")
|
|
}
|
|
if k.Detect(discover.Credential{Source: "gcp"}) {
|
|
t.Error("should not detect Source=gcp")
|
|
}
|
|
}
|
|
|
|
func TestK8sRotateRealCutover(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
ctx := context.Background()
|
|
|
|
const ns, sa, seedSecret, oldTok = "labns", "labsa", "labsa-token-seed", "eyJold0123456789"
|
|
emu := newK8sEmu(t, ns, sa, seedSecret, oldTok)
|
|
|
|
host := strings.TrimPrefix(emu.srv.URL, "https://")
|
|
oldBlob := "k8s://" + host + "/?ns=" + ns + "&sa=" + sa + "&secret=" + seedSecret + "&token=" + oldTok
|
|
cred := discover.Credential{
|
|
Source: "k8s",
|
|
Kind: discover.KindToken,
|
|
Identity: sa + " @ k8s",
|
|
Location: "imported/k8s/labsa",
|
|
Secret: v.Store([]byte(oldBlob)),
|
|
}
|
|
k := &K8s{HTTPClient: emu.srv.Client()}
|
|
|
|
if !emu.tokenValid(oldTok) {
|
|
t.Fatal("seed token should be valid at start")
|
|
}
|
|
|
|
// 1. Rotate.
|
|
newH, err := k.Rotate(ctx, cred, v)
|
|
if err != nil {
|
|
t.Fatalf("Rotate: %v", err)
|
|
}
|
|
nc, err := parseK8sCred(v, newH)
|
|
if err != nil {
|
|
t.Fatalf("parse new secret: %v", err)
|
|
}
|
|
if nc.token == oldTok {
|
|
t.Fatal("new token equals old — rotation minted nothing")
|
|
}
|
|
if nc.secret == "" || nc.secret == seedSecret {
|
|
t.Fatalf("new secret must carry a fresh token-secret name, got %q", nc.secret)
|
|
}
|
|
if !emu.tokenValid(oldTok) {
|
|
t.Fatal("old token must remain valid before revoke")
|
|
}
|
|
if !emu.tokenValid(nc.token) {
|
|
t.Fatal("new token must be valid after create")
|
|
}
|
|
|
|
// 2. Verify(new).
|
|
if err := k.Verify(ctx, newH, v); err != nil {
|
|
t.Fatalf("Verify(new): %v", err)
|
|
}
|
|
|
|
// 3. RevokeOld.
|
|
if err := k.RevokeOld(ctx, cred, v); err != nil {
|
|
t.Fatalf("RevokeOld: %v", err)
|
|
}
|
|
if emu.tokenValid(oldTok) {
|
|
t.Fatal("old token must be invalid after revoke")
|
|
}
|
|
if err := k.Verify(ctx, newH, v); err != nil {
|
|
t.Fatalf("new token must still authenticate after revoke: %v", err)
|
|
}
|
|
|
|
// Leak check.
|
|
for _, f := range []string{cred.Identity, cred.Source, cred.Location, string(cred.Kind)} {
|
|
if strings.Contains(f, oldTok) || strings.Contains(f, nc.token) {
|
|
t.Errorf("token leaked into non-secret field %q", f)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestK8sRevokeRequiresSecretName(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
k := &K8s{}
|
|
cred := discover.Credential{
|
|
Source: "k8s",
|
|
Secret: v.Store([]byte("k8s://10.0.0.1:6443/?ns=n&sa=s&token=t")),
|
|
}
|
|
err := k.RevokeOld(context.Background(), cred, v)
|
|
if err == nil || !strings.Contains(err.Error(), "no recorded secret name") {
|
|
t.Fatalf("expected revoke-without-secret error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestK8sRejectsBadSecret(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
k := &K8s{}
|
|
// missing namespace/sa
|
|
cred := discover.Credential{Source: "k8s", Secret: v.Store([]byte("k8s://10.0.0.1:6443/?token=t"))}
|
|
if _, err := k.Rotate(context.Background(), cred, v); err == nil {
|
|
t.Error("Rotate should reject a secret with no namespace/sa")
|
|
}
|
|
}
|