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>
269 lines
8.7 KiB
Go
269 lines
8.7 KiB
Go
package rotate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"incredigo/internal/discover"
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// K8s rotates a Kubernetes ServiceAccount token (the legacy, individually-revocable
|
|
// kind backed by a Secret of type kubernetes.io/service-account-token) by CREATING a
|
|
// new token Secret, verifying the minted token authenticates, then DELETING the old
|
|
// Secret — the provider-API rotation pattern (create-new → verify → revoke-old).
|
|
//
|
|
// Bound TokenRequest tokens cannot be revoked individually, so rotation targets the
|
|
// Secret-backed token whose deletion immediately invalidates it. The credential blob:
|
|
//
|
|
// k8s://<host:port>/?ns=<namespace>&sa=<serviceaccount>&secret=<secret-name>&token=<token>
|
|
//
|
|
// - scheme "k8s" maps to https; "http"/"https" are accepted for a loopback emulator.
|
|
// - <host> the apiserver host:port.
|
|
// - ns= the namespace holding the SA + token Secret.
|
|
// - sa= the ServiceAccount name (the new Secret is annotated to it).
|
|
// - secret= the token Secret's name — required to DELETE the old token.
|
|
// - token= the bearer token value (rotated).
|
|
//
|
|
// AUTH: every call authenticates with a token Bearer header against the apiserver. The
|
|
// rotating SA needs RBAC to create/delete Secrets in its namespace. The new token is
|
|
// read (base64) from the controller-populated Secret straight into the vault; it never
|
|
// reaches Identity/Meta/logs/argv.
|
|
type K8s struct {
|
|
// HTTPClient is injectable for tests / custom CA. A real apiserver needs a client
|
|
// configured with the cluster CA; defaults to a 15s-timeout client otherwise.
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// init self-registers the driver. Availability alone changes nothing; the
|
|
// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies.
|
|
func init() { Register(&K8s{}) }
|
|
|
|
// Name identifies the driver in plans and audit records.
|
|
func (k *K8s) Name() string { return "k8s" }
|
|
|
|
// Detect claims credentials tagged Source == "k8s".
|
|
func (k *K8s) Detect(c discover.Credential) bool { return c.Source == "k8s" }
|
|
|
|
func (k *K8s) client() *http.Client {
|
|
if k.HTTPClient != nil {
|
|
return k.HTTPClient
|
|
}
|
|
return &http.Client{Timeout: 15 * time.Second}
|
|
}
|
|
|
|
// k8sCred is the parsed credential blob. None of its fields are ever logged.
|
|
type k8sCred struct {
|
|
base string // scheme://host:port
|
|
ns string
|
|
sa string
|
|
secret string // token Secret name
|
|
token string
|
|
}
|
|
|
|
func parseK8sCred(v *vault.Vault, h *vault.Handle) (k8sCred, error) {
|
|
buf, err := v.Open(h)
|
|
if err != nil {
|
|
return k8sCred{}, err
|
|
}
|
|
u, err := url.Parse(strings.TrimSpace(string(buf.Bytes())))
|
|
if err != nil {
|
|
return k8sCred{}, fmt.Errorf("k8s: parse secret url: %w", err)
|
|
}
|
|
if u.Scheme != "k8s" && u.Scheme != "http" && u.Scheme != "https" {
|
|
return k8sCred{}, fmt.Errorf("k8s: unexpected scheme %q", u.Scheme)
|
|
}
|
|
scheme := u.Scheme
|
|
if scheme == "k8s" {
|
|
scheme = "https"
|
|
}
|
|
if u.Host == "" {
|
|
return k8sCred{}, fmt.Errorf("k8s: secret has no apiserver host")
|
|
}
|
|
q := u.Query()
|
|
c := k8sCred{base: scheme + "://" + u.Host, ns: q.Get("ns"), sa: q.Get("sa"), secret: q.Get("secret"), token: q.Get("token")}
|
|
if c.ns == "" || c.sa == "" {
|
|
return k8sCred{}, fmt.Errorf("k8s: secret missing namespace or serviceaccount")
|
|
}
|
|
if c.token == "" {
|
|
return k8sCred{}, fmt.Errorf("k8s: secret has no token")
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// build re-encodes a k8sCred into the single-line blob form (scheme normalised back
|
|
// to "k8s").
|
|
func (c k8sCred) build() string {
|
|
u := &url.URL{Scheme: "k8s", Host: hostPortOf(c.base), Path: "/"}
|
|
q := url.Values{}
|
|
q.Set("ns", c.ns)
|
|
q.Set("sa", c.sa)
|
|
if c.secret != "" {
|
|
q.Set("secret", c.secret)
|
|
}
|
|
q.Set("token", c.token)
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
// hostPortOf strips the scheme from a scheme://host:port base.
|
|
func hostPortOf(base string) string {
|
|
if i := strings.Index(base, "://"); i >= 0 {
|
|
return base[i+3:]
|
|
}
|
|
return base
|
|
}
|
|
|
|
// k8sSecretObj is the subset of a core/v1 Secret the driver reads or writes.
|
|
type k8sSecretObj struct {
|
|
Metadata struct {
|
|
Name string `json:"name"`
|
|
Annotations map[string]string `json:"annotations,omitempty"`
|
|
} `json:"metadata"`
|
|
Type string `json:"type,omitempty"`
|
|
Data map[string]string `json:"data,omitempty"`
|
|
}
|
|
|
|
// do issues an authenticated apiserver request and returns the body for 2xx responses.
|
|
func (k *K8s) do(ctx context.Context, method, urlStr, token string, body []byte) ([]byte, int, error) {
|
|
var r io.Reader
|
|
if body != nil {
|
|
r = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, urlStr, r)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := k.client().Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
return rb, resp.StatusCode, nil
|
|
}
|
|
|
|
func (c k8sCred) secretsURL(name string) string {
|
|
base := c.base + "/api/v1/namespaces/" + url.PathEscape(c.ns) + "/secrets"
|
|
if name != "" {
|
|
return base + "/" + url.PathEscape(name)
|
|
}
|
|
return base
|
|
}
|
|
|
|
// Rotate creates a NEW token Secret for the SA (authenticated by the OLD token), waits
|
|
// for the controller to populate its token, and returns a vault handle to the rebuilt
|
|
// blob carrying the new Secret name + token. It does not delete the old Secret.
|
|
func (k *K8s) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
|
|
cr, err := parseK8sCred(v, c.Secret)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
suffix, err := genHex(4)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
newName := cr.sa + "-token-" + suffix
|
|
obj := k8sSecretObj{Type: "kubernetes.io/service-account-token"}
|
|
obj.Metadata.Name = newName
|
|
obj.Metadata.Annotations = map[string]string{"kubernetes.io/service-account.name": cr.sa}
|
|
body, _ := json.Marshal(obj)
|
|
|
|
_, code, err := k.do(ctx, http.MethodPost, cr.secretsURL(""), cr.token, body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("k8s: create token secret: %w", err)
|
|
}
|
|
if code != http.StatusCreated && code != http.StatusOK {
|
|
return nil, fmt.Errorf("k8s: create token secret: status %d", code)
|
|
}
|
|
|
|
// Wait for the token-controller to populate .data.token.
|
|
token, err := k.awaitToken(ctx, cr, newName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nc := cr
|
|
nc.secret = newName
|
|
nc.token = token
|
|
return v.Store([]byte(nc.build())), nil
|
|
}
|
|
|
|
// awaitToken polls the new Secret until its .data.token is present, decoding it.
|
|
func (k *K8s) awaitToken(ctx context.Context, cr k8sCred, name string) (string, error) {
|
|
for attempt := 0; attempt < 10; attempt++ {
|
|
rb, code, err := k.do(ctx, http.MethodGet, cr.secretsURL(name), cr.token, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("k8s: read token secret: %w", err)
|
|
}
|
|
if code == http.StatusOK {
|
|
var got k8sSecretObj
|
|
if err := json.Unmarshal(rb, &got); err != nil {
|
|
return "", fmt.Errorf("k8s: decode token secret: %w", err)
|
|
}
|
|
if enc := got.Data["token"]; enc != "" {
|
|
tok, err := base64.StdEncoding.DecodeString(enc)
|
|
if err != nil {
|
|
return "", fmt.Errorf("k8s: decode token data: %w", err)
|
|
}
|
|
return string(tok), nil
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
}
|
|
return "", fmt.Errorf("k8s: token secret never populated a token")
|
|
}
|
|
|
|
// Verify proves the new token authenticates by GETting the ServiceAccount with it.
|
|
func (k *K8s) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
|
|
cr, err := parseK8sCred(v, newSecret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
saURL := cr.base + "/api/v1/namespaces/" + url.PathEscape(cr.ns) + "/serviceaccounts/" + url.PathEscape(cr.sa)
|
|
_, code, err := k.do(ctx, http.MethodGet, saURL, cr.token, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("k8s verify: %w", err)
|
|
}
|
|
if code != http.StatusOK {
|
|
return fmt.Errorf("k8s verify: get serviceaccount status %d", code)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RevokeOld deletes the OLD token Secret, which invalidates the old token. The old
|
|
// token is still valid at this point (revoke runs only after the new token is
|
|
// stored+verified), so it authenticates its own Secret's deletion.
|
|
func (k *K8s) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
|
|
cr, err := parseK8sCred(v, c.Secret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cr.secret == "" {
|
|
return fmt.Errorf("k8s revoke: old token has no recorded secret name — delete it manually (guided)")
|
|
}
|
|
_, code, err := k.do(ctx, http.MethodDelete, cr.secretsURL(cr.secret), cr.token, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("k8s revoke: %w", err)
|
|
}
|
|
if code != http.StatusOK && code != http.StatusNoContent {
|
|
return fmt.Errorf("k8s revoke: delete token secret status %d", code)
|
|
}
|
|
return nil
|
|
}
|