rotate: 6 service-API drivers (mongo, npm, gcp, twilio, flyio, k8s)
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>
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// gcpEmu emulates the GCP token endpoint + IAM service-account keys API and ENFORCES
|
||||
// key validity: it verifies the RS256 JWT signature against the public key recorded
|
||||
// for the JWT's kid, so a token only mints while its key exists — and DELETE makes the
|
||||
// key (and therefore its self-auth) stop working. This proves a real cutover and that
|
||||
// the driver's JWT signing is genuine, not stubbed.
|
||||
type gcpEmu struct {
|
||||
mu sync.Mutex
|
||||
pubByKid map[string]*rsa.PublicKey
|
||||
tokens map[string]bool // issued access tokens
|
||||
email string
|
||||
project string
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func newGCPEmu(t *testing.T, project, email, seedKid string, seedPub *rsa.PublicKey) *gcpEmu {
|
||||
e := &gcpEmu{
|
||||
pubByKid: map[string]*rsa.PublicKey{seedKid: seedPub},
|
||||
tokens: map[string]bool{},
|
||||
email: email,
|
||||
project: project,
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/token", e.handleToken)
|
||||
mux.HandleFunc("/v1/", e.handleIAM)
|
||||
e.srv = httptest.NewTLSServer(mux)
|
||||
t.Cleanup(e.srv.Close)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *gcpEmu) handleToken(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
assertion := r.Form.Get("assertion")
|
||||
parts := strings.Split(assertion, ".")
|
||||
if len(parts) != 3 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hdrBytes, _ := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
var hdr struct {
|
||||
Kid string `json:"kid"`
|
||||
}
|
||||
json.Unmarshal(hdrBytes, &hdr)
|
||||
|
||||
e.mu.Lock()
|
||||
pub, ok := e.pubByKid[hdr.Kid]
|
||||
e.mu.Unlock()
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig) != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
raw := make([]byte, 16)
|
||||
rand.Read(raw)
|
||||
tok := "ya29." + hex.EncodeToString(raw)
|
||||
e.mu.Lock()
|
||||
e.tokens[tok] = true
|
||||
e.mu.Unlock()
|
||||
json.NewEncoder(w).Encode(map[string]any{"access_token": tok, "token_type": "Bearer", "expires_in": 3600})
|
||||
}
|
||||
|
||||
func (e *gcpEmu) bearerOK(r *http.Request) bool {
|
||||
tok := bearer(r) // reuses helper from npm_test.go
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.tokens[tok]
|
||||
}
|
||||
|
||||
func (e *gcpEmu) handleIAM(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.bearerOK(r) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
// Find the "keys" segment, if any.
|
||||
keysIdx := -1
|
||||
for i, p := range parts {
|
||||
if p == "keys" {
|
||||
keysIdx = i
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodPost && keysIdx == len(parts)-1:
|
||||
e.createKey(w)
|
||||
case r.Method == http.MethodDelete && keysIdx == len(parts)-2:
|
||||
kid := parts[len(parts)-1]
|
||||
e.mu.Lock()
|
||||
delete(e.pubByKid, kid)
|
||||
e.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{}"))
|
||||
case r.Method == http.MethodGet && keysIdx == -1:
|
||||
// GET the serviceAccount resource.
|
||||
json.NewEncoder(w).Encode(map[string]any{"email": e.email, "projectId": e.project})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *gcpEmu) createKey(w http.ResponseWriter) {
|
||||
priv, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
kidRaw := make([]byte, 12)
|
||||
rand.Read(kidRaw)
|
||||
kid := hex.EncodeToString(kidRaw)
|
||||
e.mu.Lock()
|
||||
e.pubByKid[kid] = &priv.PublicKey
|
||||
e.mu.Unlock()
|
||||
|
||||
saJSON, _ := json.Marshal(map[string]string{
|
||||
"type": "service_account",
|
||||
"project_id": e.project,
|
||||
"private_key_id": kid,
|
||||
"private_key": pkcs8PEM(priv),
|
||||
"client_email": e.email,
|
||||
"token_uri": e.srv.URL + "/token",
|
||||
})
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"name": "projects/" + e.project + "/serviceAccounts/" + e.email + "/keys/" + kid,
|
||||
"privateKeyData": base64.StdEncoding.EncodeToString(saJSON),
|
||||
})
|
||||
}
|
||||
|
||||
func pkcs8PEM(k *rsa.PrivateKey) string {
|
||||
der, _ := x509.MarshalPKCS8PrivateKey(k)
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
|
||||
}
|
||||
|
||||
// seedBlob builds the initial gcp:// blob carrying the seed SA JSON.
|
||||
func gcpSeedBlob(t *testing.T, emu *gcpEmu, kid string, priv *rsa.PrivateKey) string {
|
||||
t.Helper()
|
||||
saJSON, _ := json.Marshal(map[string]string{
|
||||
"type": "service_account",
|
||||
"project_id": emu.project,
|
||||
"private_key_id": kid,
|
||||
"private_key": pkcs8PEM(priv),
|
||||
"client_email": emu.email,
|
||||
"token_uri": emu.srv.URL + "/token",
|
||||
})
|
||||
return (gcpSecret{raw: saJSON, endpoint: emu.srv.URL}).build()
|
||||
}
|
||||
|
||||
func TestGCPDetect(t *testing.T) {
|
||||
g := &GCP{}
|
||||
if !g.Detect(discover.Credential{Source: "gcp"}) {
|
||||
t.Error("should detect Source=gcp")
|
||||
}
|
||||
if g.Detect(discover.Credential{Source: "aws"}) {
|
||||
t.Error("should not detect Source=aws")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCPRotateRealCutover(t *testing.T) {
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
ctx := context.Background()
|
||||
|
||||
const project, email = "lab-proj", "labsa@lab-proj.iam.gserviceaccount.com"
|
||||
seedPriv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedKid := "seedkid000111222"
|
||||
emu := newGCPEmu(t, project, email, seedKid, &seedPriv.PublicKey)
|
||||
|
||||
oldBlob := gcpSeedBlob(t, emu, seedKid, seedPriv)
|
||||
cred := discover.Credential{
|
||||
Source: "gcp",
|
||||
Kind: discover.KindToken,
|
||||
Identity: email + " @ gcp",
|
||||
Location: "imported/gcp/labsa",
|
||||
Secret: v.Store([]byte(oldBlob)),
|
||||
}
|
||||
g := &GCP{HTTPClient: emu.srv.Client()}
|
||||
|
||||
// 1. Rotate — create a new key (old still valid).
|
||||
newH, err := g.Rotate(ctx, cred, v)
|
||||
if err != nil {
|
||||
t.Fatalf("Rotate: %v", err)
|
||||
}
|
||||
ns, err := parseGCPSecret(v, newH)
|
||||
if err != nil {
|
||||
t.Fatalf("parse new secret: %v", err)
|
||||
}
|
||||
if ns.sa.PrivateKeyID == seedKid {
|
||||
t.Fatal("new key id equals old — rotation minted nothing")
|
||||
}
|
||||
if ns.sa.PrivateKey == "" || strings.Contains(ns.sa.PrivateKey, pkcs8PEM(seedPriv)) {
|
||||
t.Fatal("new secret must carry a fresh private key")
|
||||
}
|
||||
|
||||
// 2. Verify(new) authenticates against the IAM API.
|
||||
if err := g.Verify(ctx, newH, v); err != nil {
|
||||
t.Fatalf("Verify(new): %v", err)
|
||||
}
|
||||
// old key still authenticates before revoke.
|
||||
if err := g.Verify(ctx, cred.Secret, v); err != nil {
|
||||
t.Fatalf("old key must authenticate before revoke: %v", err)
|
||||
}
|
||||
|
||||
// 3. RevokeOld — delete the old key.
|
||||
if err := g.RevokeOld(ctx, cred, v); err != nil {
|
||||
t.Fatalf("RevokeOld: %v", err)
|
||||
}
|
||||
|
||||
// Cutover: old key can no longer mint a token, new key still works.
|
||||
if err := g.Verify(ctx, cred.Secret, v); err == nil {
|
||||
t.Fatal("old key must fail to authenticate after revoke")
|
||||
}
|
||||
if err := g.Verify(ctx, newH, v); err != nil {
|
||||
t.Fatalf("new key must still authenticate after revoke: %v", err)
|
||||
}
|
||||
|
||||
// Leak check: the private key PEM must not appear in non-secret fields.
|
||||
for _, f := range []string{cred.Identity, cred.Source, cred.Location, string(cred.Kind)} {
|
||||
if strings.Contains(f, "PRIVATE KEY") || strings.Contains(f, ns.sa.PrivateKey) {
|
||||
t.Errorf("key material leaked into non-secret field %q", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCPRejectsBadSecret(t *testing.T) {
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
g := &GCP{}
|
||||
// sa json missing private_key
|
||||
bad, _ := json.Marshal(map[string]string{"client_email": "x@y.iam"})
|
||||
blob := (gcpSecret{raw: bad}).build()
|
||||
cred := discover.Credential{Source: "gcp", Secret: v.Store([]byte(blob))}
|
||||
if _, err := g.Rotate(context.Background(), cred, v); err == nil {
|
||||
t.Error("Rotate should reject a sa json with no private_key")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user