169 lines
5.7 KiB
Go
169 lines
5.7 KiB
Go
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
|