package reconcile import ( "context" "encoding/base64" "encoding/json" "fmt" iamv1 "github.com/riotpiao/key-rotation-controller/api" "github.com/riotpiao/key-rotation-controller/internal/zitadel" ) // MachineKeyProvider rotates a service user's private key. // // ZITADEL keeps many keys per service user, and each assertion names its key with // `kid`. A new key can be created while the deployed key stays valid, so this // provider retires the old key only after the new one proves itself. Nothing in // the window is invalid. type MachineKeyProvider struct { // Client talks to the instance that owns the service user. Client *zitadel.Client // AuthClientID is the API app the controller authenticates as. AuthClientID string // KeyIDFile and KeyFile name the Secret keys, defaulting to key-id and key. KeyIDFile string KeyFile string } // Name implements Provider. func (p *MachineKeyProvider) Name() string { return iamv1.ProviderZitadelMachineKey } // Strategy implements Provider. Machine keys overlap. func (p *MachineKeyProvider) Strategy() string { return iamv1.StrategyOverlap } // Prepare creates a new key. The currently deployed key remains valid. func (p *MachineKeyProvider) Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error) { userID := kr.Spec.Target.MachineUserID if userID == "" { return nil, fmt.Errorf("spec.target.machineUserID is required") } key, err := p.Client.CreateMachineKey(userID) if err != nil { return nil, fmt.Errorf("creating a machine key: %w", err) } fields := map[string][]byte{ p.keyField(kr): []byte(key.Key), p.keyIDField(kr): []byte(key.KeyID), } return &Credential{ID: key.KeyID, Fields: fields}, nil } // Verify exchanges a token with the new key. A key that cannot obtain a token is // not deployed as a working credential, whatever the API said. func (p *MachineKeyProvider) Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error { keyPEM := cred.Fields[p.keyField(kr)] keyID := string(cred.Fields[p.keyIDField(kr)]) return p.Client.ExchangeMachineKey( p.AuthClientID, kr.Spec.Target.MachineUserID, keyID, keyPEM, kr.Spec.Verify.AudienceProject, ) } // Retire deletes the superseded key. func (p *MachineKeyProvider) Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error { return p.Client.DeleteMachineKey(kr.Spec.Target.MachineUserID, credentialID) } func (p *MachineKeyProvider) keyField(kr *iamv1.KeyRotation) string { if key, ok := kr.Spec.Target.SecretKeys["key"]; ok && key != "" { return key } return "key" } func (p *MachineKeyProvider) keyIDField(kr *iamv1.KeyRotation) string { if key, ok := kr.Spec.Target.SecretKeys["key-id"]; ok && key != "" { return key } return "key-id" } // OIDCClientProvider rotates an app's client secret. // // ZITADEL stores exactly one secret per app, so creating the new secret kills the // old one. The strategy is atomic: publish immediately, roll the consumer, verify, // and if verification fails, put the previous value back. type OIDCClientProvider struct { Client *zitadel.Client // ClientIDFunc maps a KeyRotation to the app's client id, which the // introspection endpoint needs. ClientIDFunc func(kr *iamv1.KeyRotation) string // SecretKey names the Secret key that holds the secret. SecretKey string } // Name implements Provider. func (p *OIDCClientProvider) Name() string { return iamv1.ProviderZitadelOIDCClient } // Strategy implements Provider. Client secrets cannot overlap. func (p *OIDCClientProvider) Strategy() string { return iamv1.StrategyAtomic } // Prepare regenerates the client secret. func (p *OIDCClientProvider) Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error) { projectID := kr.Spec.Target.ZitadelProjectID appID := kr.Spec.Target.ZitadelAppID if projectID == "" || appID == "" { return nil, fmt.Errorf("spec.target.zitadelProjectID and zitadelAppID are required") } secret, err := p.Client.GenerateClientSecret(projectID, appID) if err != nil { return nil, fmt.Errorf("generating a client secret: %w", err) } field := p.secretField(kr) // The credential id is the generation marker, not a ZITADEL identifier: the // API returns only the value. return &Credential{ ID: "secret-" + shortHash(secret), Fields: map[string][]byte{field: []byte(secret)}, }, nil } // Verify checks the credential against the introspection endpoint. func (p *OIDCClientProvider) Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error { if !kr.Spec.Verify.Introspect { return nil } clientID := "" if p.ClientIDFunc != nil { clientID = p.ClientIDFunc(kr) } if clientID == "" { return fmt.Errorf("no client id configured for introspection") } return p.Client.Introspect(clientID, string(cred.Fields[p.secretField(kr)])) } // Retire is a no-op: an atomic provider has nothing to retire, because the // provider already invalidated the previous value. func (p *OIDCClientProvider) Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error { return nil } func (p *OIDCClientProvider) secretField(kr *iamv1.KeyRotation) string { if p.SecretKey != "" { return p.SecretKey } if key, ok := kr.Spec.Target.SecretKeys["secret"]; ok && key != "" { return key } return "secret" } // shortHash gives a stable, non-reversible marker for a secret so status can // record which generation is live without storing the value. func shortHash(value string) string { sum := 0 for _, b := range []byte(value) { sum = (sum*31 + int(b)) % 100000007 } encoded := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d", sum))) return encoded } // unusedJSON keeps the import set honest if a provider needs raw JSON later. var _ = json.Marshal