Files
homelab/k8s/apps/key-rotation-controller/controller/internal/zitadel/jwt.go
T
rock c2071da9d8 feat(iam): define a KeyRotation for every consumer except poimen-memory
Eight objects cover grafana, argocd, comfyui, minio, paperless, immich, forgejo and
api-gw. Each names its ZITADEL app, the Secret it writes, the consumer to roll and
whether the consumer reloads hot or needs a restart.

All are suspended: the controller image does not exist yet, and an unsuspended
object would claim an intent that no process acts on.

The README records the retirement target for the 34 .enc.yaml files: six
root-of-trust secrets that cannot rotate themselves survive, everything else
becomes a KeyRotation and leaves git.
2026-10-27 14:28:28 -07:00

90 lines
2.6 KiB
Go

// Package zitadel talks to the ZITADEL management API with the controller's own
// machine key.
//
// The controller needs a credential to rotate credentials. It signs a short
// assertion with its private key, exchanges it for an access token, and reuses
// that token until shortly before it expires.
package zitadel
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"time"
)
// assertionLifetime is deliberately short. The assertion proves one request.
const assertionLifetime = 60 * time.Second
// newAssertionClaims builds the claim set for a machine-key assertion.
//
// ZITADEL expects `iss` to name the caller, `sub` to name the user it acts as,
// and `aud` to equal the issuer of the instance.
func newAssertionClaims(clientID, userID, issuer string, now time.Time) map[string]any {
if userID == "" {
userID = clientID
}
return map[string]any{
"iss": clientID,
"sub": userID,
"aud": issuer,
"iat": now.Unix(),
"exp": now.Add(assertionLifetime).Unix(),
}
}
func base64URL(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// SignAssertion produces a signed RS256 JWT for the jwt-bearer grant.
//
// The header carries `kid`, so ZITADEL selects the matching registered key. That
// is what makes overlapping keys possible: two keys can be valid at once and the
// assertion says which one it used.
func SignAssertion(privateKeyPEM []byte, keyID, clientID, userID, issuer string, now time.Time) (string, error) {
block, _ := pem.Decode(privateKeyPEM)
if block == nil {
return "", fmt.Errorf("private key is not PEM encoded")
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
// ZITADEL exports PKCS#1 keys as well.
if rsaKey, rsaErr := x509.ParsePKCS1PrivateKey(block.Bytes); rsaErr == nil {
key = rsaKey
} else {
return "", fmt.Errorf("private key does not parse: %w", err)
}
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return "", fmt.Errorf("private key is not RSA")
}
header := map[string]any{"alg": "RS256", "typ": "JWT", "kid": keyID}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
claimsJSON, err := json.Marshal(newAssertionClaims(clientID, userID, issuer, now))
if err != nil {
return "", err
}
signingInput := base64URL(headerJSON) + "." + base64URL(claimsJSON)
digest := sha256.Sum256([]byte(signingInput))
signature, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("cannot sign the assertion: %w", err)
}
return signingInput + "." + base64URL(signature), nil
}