From fd6a2eeb9cfb67427df94a93c367d9a044628590 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 9 Mar 2022 18:39:09 -0800 Subject: [PATCH] Add provisioner controller The provisioner controller has the implementation of the identity function as well as the renew methods with renew after expiry support. --- authority/config/config.go | 25 ++-- authority/provisioner/claims.go | 47 ++++--- authority/provisioner/controller.go | 194 +++++++++++++++++++++++++++ authority/provisioner/provisioner.go | 87 +----------- 4 files changed, 246 insertions(+), 107 deletions(-) create mode 100644 authority/provisioner/controller.go diff --git a/authority/config/config.go b/authority/config/config.go index 589b5bbf..c33a2b1d 100644 --- a/authority/config/config.go +++ b/authority/config/config.go @@ -26,23 +26,26 @@ var ( DefaultBackdate = time.Minute // DefaultDisableRenewal disables renewals per provisioner. DefaultDisableRenewal = false + // DefaultEnableRenewAfterExpiry enables renewals even when the certificate is expired. + DefaultEnableRenewAfterExpiry = false // DefaultEnableSSHCA enable SSH CA features per provisioner or globally // for all provisioners. DefaultEnableSSHCA = false // GlobalProvisionerClaims default claims for the Authority. Can be overridden // by provisioner specific claims. GlobalProvisionerClaims = provisioner.Claims{ - MinTLSDur: &provisioner.Duration{Duration: 5 * time.Minute}, // TLS certs - MaxTLSDur: &provisioner.Duration{Duration: 24 * time.Hour}, - DefaultTLSDur: &provisioner.Duration{Duration: 24 * time.Hour}, - DisableRenewal: &DefaultDisableRenewal, - MinUserSSHDur: &provisioner.Duration{Duration: 5 * time.Minute}, // User SSH certs - MaxUserSSHDur: &provisioner.Duration{Duration: 24 * time.Hour}, - DefaultUserSSHDur: &provisioner.Duration{Duration: 16 * time.Hour}, - MinHostSSHDur: &provisioner.Duration{Duration: 5 * time.Minute}, // Host SSH certs - MaxHostSSHDur: &provisioner.Duration{Duration: 30 * 24 * time.Hour}, - DefaultHostSSHDur: &provisioner.Duration{Duration: 30 * 24 * time.Hour}, - EnableSSHCA: &DefaultEnableSSHCA, + MinTLSDur: &provisioner.Duration{Duration: 5 * time.Minute}, // TLS certs + MaxTLSDur: &provisioner.Duration{Duration: 24 * time.Hour}, + DefaultTLSDur: &provisioner.Duration{Duration: 24 * time.Hour}, + MinUserSSHDur: &provisioner.Duration{Duration: 5 * time.Minute}, // User SSH certs + MaxUserSSHDur: &provisioner.Duration{Duration: 24 * time.Hour}, + DefaultUserSSHDur: &provisioner.Duration{Duration: 16 * time.Hour}, + MinHostSSHDur: &provisioner.Duration{Duration: 5 * time.Minute}, // Host SSH certs + MaxHostSSHDur: &provisioner.Duration{Duration: 30 * 24 * time.Hour}, + DefaultHostSSHDur: &provisioner.Duration{Duration: 30 * 24 * time.Hour}, + EnableSSHCA: &DefaultEnableSSHCA, + DisableRenewal: &DefaultDisableRenewal, + EnableRenewAfterExpiry: &DefaultEnableRenewAfterExpiry, } ) diff --git a/authority/provisioner/claims.go b/authority/provisioner/claims.go index 629a313c..c8bee2e5 100644 --- a/authority/provisioner/claims.go +++ b/authority/provisioner/claims.go @@ -10,10 +10,10 @@ import ( // Claims so that individual provisioners can override global claims. type Claims struct { // TLS CA properties - MinTLSDur *Duration `json:"minTLSCertDuration,omitempty"` - MaxTLSDur *Duration `json:"maxTLSCertDuration,omitempty"` - DefaultTLSDur *Duration `json:"defaultTLSCertDuration,omitempty"` - DisableRenewal *bool `json:"disableRenewal,omitempty"` + MinTLSDur *Duration `json:"minTLSCertDuration,omitempty"` + MaxTLSDur *Duration `json:"maxTLSCertDuration,omitempty"` + DefaultTLSDur *Duration `json:"defaultTLSCertDuration,omitempty"` + // SSH CA properties MinUserSSHDur *Duration `json:"minUserSSHCertDuration,omitempty"` MaxUserSSHDur *Duration `json:"maxUserSSHCertDuration,omitempty"` @@ -22,6 +22,10 @@ type Claims struct { MaxHostSSHDur *Duration `json:"maxHostSSHCertDuration,omitempty"` DefaultHostSSHDur *Duration `json:"defaultHostSSHCertDuration,omitempty"` EnableSSHCA *bool `json:"enableSSHCA,omitempty"` + + // Renewal properties + DisableRenewal *bool `json:"disableRenewal,omitempty"` + EnableRenewAfterExpiry *bool `json:"enableRenewAfterExpiry,omitempty"` } // Claimer is the type that controls claims. It provides an interface around the @@ -40,19 +44,22 @@ func NewClaimer(claims *Claims, global Claims) (*Claimer, error) { // Claims returns the merge of the inner and global claims. func (c *Claimer) Claims() Claims { disableRenewal := c.IsDisableRenewal() + enablerenewAfterExpiry := c.IsRenewAfterExpiry() enableSSHCA := c.IsSSHCAEnabled() + return Claims{ - MinTLSDur: &Duration{c.MinTLSCertDuration()}, - MaxTLSDur: &Duration{c.MaxTLSCertDuration()}, - DefaultTLSDur: &Duration{c.DefaultTLSCertDuration()}, - DisableRenewal: &disableRenewal, - MinUserSSHDur: &Duration{c.MinUserSSHCertDuration()}, - MaxUserSSHDur: &Duration{c.MaxUserSSHCertDuration()}, - DefaultUserSSHDur: &Duration{c.DefaultUserSSHCertDuration()}, - MinHostSSHDur: &Duration{c.MinHostSSHCertDuration()}, - MaxHostSSHDur: &Duration{c.MaxHostSSHCertDuration()}, - DefaultHostSSHDur: &Duration{c.DefaultHostSSHCertDuration()}, - EnableSSHCA: &enableSSHCA, + MinTLSDur: &Duration{c.MinTLSCertDuration()}, + MaxTLSDur: &Duration{c.MaxTLSCertDuration()}, + DefaultTLSDur: &Duration{c.DefaultTLSCertDuration()}, + MinUserSSHDur: &Duration{c.MinUserSSHCertDuration()}, + MaxUserSSHDur: &Duration{c.MaxUserSSHCertDuration()}, + DefaultUserSSHDur: &Duration{c.DefaultUserSSHCertDuration()}, + MinHostSSHDur: &Duration{c.MinHostSSHCertDuration()}, + MaxHostSSHDur: &Duration{c.MaxHostSSHCertDuration()}, + DefaultHostSSHDur: &Duration{c.DefaultHostSSHCertDuration()}, + EnableSSHCA: &enableSSHCA, + DisableRenewal: &disableRenewal, + EnableRenewAfterExpiry: &enablerenewAfterExpiry, } } @@ -102,6 +109,16 @@ func (c *Claimer) IsDisableRenewal() bool { return *c.claims.DisableRenewal } +// IsRenewAfterExpiry returns if the renewal flow is authorized even if the +// certificate is expired. If the property is not set within the provisioner +// then the global value from the authority configuration will be used. +func (c *Claimer) IsRenewAfterExpiry() bool { + if c.claims == nil || c.claims.EnableRenewAfterExpiry == nil { + return *c.global.EnableRenewAfterExpiry + } + return *c.claims.EnableRenewAfterExpiry +} + // DefaultSSHCertDuration returns the default SSH certificate duration for the // given certificate type. func (c *Claimer) DefaultSSHCertDuration(certType uint32) (time.Duration, error) { diff --git a/authority/provisioner/controller.go b/authority/provisioner/controller.go new file mode 100644 index 00000000..815482f9 --- /dev/null +++ b/authority/provisioner/controller.go @@ -0,0 +1,194 @@ +package provisioner + +import ( + "context" + "crypto/x509" + "regexp" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/errs" + "golang.org/x/crypto/ssh" +) + +// Controller wraps a provisioner with other attributes useful in callback +// functions. +type Controller struct { + Interface + Audiences *Audiences + Claimer *Claimer + IdentityFunc GetIdentityFunc + AuthorizeRenewFunc AuthorizeRenewFunc + AuthorizeSSHRenewFunc AuthorizeSSHRenewFunc +} + +// NewController initializes a new provisioner controller. +func NewController(p Interface, claims *Claims, config Config) (*Controller, error) { + claimer, err := NewClaimer(claims, config.Claims) + if err != nil { + return nil, err + } + return &Controller{ + Interface: p, + Audiences: &config.Audiences, + Claimer: claimer, + IdentityFunc: config.GetIdentityFunc, + AuthorizeRenewFunc: config.AuthorizeRenewFunc, + AuthorizeSSHRenewFunc: config.AuthorizeSSHRenewFunc, + }, nil +} + +// GetIdentity returns the identity for a given email. +func (c *Controller) GetIdentity(ctx context.Context, email string) (*Identity, error) { + if c.IdentityFunc != nil { + return c.IdentityFunc(ctx, c.Interface, email) + } + return DefaultIdentityFunc(ctx, c.Interface, email) +} + +// AuthorizeRenew returns nil if the given cert can be renewed, returns an error +// otherwise. +func (c *Controller) AuthorizeRenew(ctx context.Context, cert *x509.Certificate) error { + if c.AuthorizeRenewFunc != nil { + return c.AuthorizeRenewFunc(ctx, c, cert) + } + return DefaultAuthorizeRenew(ctx, c, cert) +} + +// AuthorizeSSHRenew returns nil if the given cert can be renewed, returns an +// error otherwise. +func (c *Controller) AuthorizeSSHRenew(ctx context.Context, cert *ssh.Certificate) error { + if c.AuthorizeSSHRenewFunc != nil { + return c.AuthorizeSSHRenewFunc(ctx, c, cert) + } + return DefaultAuthorizeSSHRenew(ctx, c, cert) +} + +// Identity is the type representing an externally supplied identity that is used +// by provisioners to populate certificate fields. +type Identity struct { + Usernames []string `json:"usernames"` + Permissions `json:"permissions"` +} + +// GetIdentityFunc is a function that returns an identity. +type GetIdentityFunc func(ctx context.Context, p Interface, email string) (*Identity, error) + +// AuthorizeRenewFunc is a function that returns nil if the renewal of a +// certificate is enabled. +type AuthorizeRenewFunc func(ctx context.Context, p *Controller, cert *x509.Certificate) error + +// AuthorizeSSHRenewFunc is a function that returns nil if the renewal of the +// given SSH certificate is enabled. +type AuthorizeSSHRenewFunc func(ctx context.Context, p *Controller, cert *ssh.Certificate) error + +// DefaultIdentityFunc return a default identity depending on the provisioner +// type. For OIDC email is always present and the usernames might +// contain empty strings. +func DefaultIdentityFunc(ctx context.Context, p Interface, email string) (*Identity, error) { + switch k := p.(type) { + case *OIDC: + // OIDC principals would be: + // ~~1. Preferred usernames.~~ Note: Under discussion, currently disabled + // 2. Sanitized local. + // 3. Raw local (if different). + // 4. Email address. + name := SanitizeSSHUserPrincipal(email) + if !sshUserRegex.MatchString(name) { + return nil, errors.Errorf("invalid principal '%s' from email '%s'", name, email) + } + usernames := []string{name} + if i := strings.LastIndex(email, "@"); i >= 0 { + usernames = append(usernames, email[:i]) + } + usernames = append(usernames, email) + return &Identity{ + Usernames: SanitizeStringSlices(usernames), + }, nil + default: + return nil, errors.Errorf("provisioner type '%T' not supported by identity function", k) + } +} + +// DefaultAuthorizeRenew is the default implementation of AuthorizeRenew. It +// will return an error if the provisioner has the renewal disabled, if the +// certificate is not yet valid or if the certificate is expired and renew after +// expiry is disabled. +func DefaultAuthorizeRenew(ctx context.Context, p *Controller, cert *x509.Certificate) error { + if p.Claimer.IsDisableRenewal() { + return errs.Unauthorized("renew is disabled for provisioner '%s'", p.GetName()) + } + + now := time.Now().Truncate(time.Second) + if now.Before(cert.NotBefore) { + return errs.Unauthorized("certificate is not yet valid") + } + if now.After(cert.NotAfter) && !p.Claimer.IsRenewAfterExpiry() { + return errs.Unauthorized("certificate has expired") + } + + return nil +} + +// DefaultAuthorizeSSHRenew is the default implementation of AuthorizeSSHRenew. It +// will return an error if the provisioner has the renewal disabled, if the +// certificate is not yet valid or if the certificate is expired and renew after +// expiry is disabled. +func DefaultAuthorizeSSHRenew(ctx context.Context, p *Controller, cert *ssh.Certificate) error { + if p.Claimer.IsDisableRenewal() { + return errs.Unauthorized("renew is disabled for provisioner '%s'", p.GetName()) + } + + unixNow := time.Now().Unix() + if after := int64(cert.ValidAfter); after < 0 || unixNow < int64(cert.ValidAfter) { + return errs.Unauthorized("certificate is not yet valid") + } + if before := int64(cert.ValidBefore); cert.ValidBefore != uint64(ssh.CertTimeInfinity) && (unixNow >= before || before < 0) && !p.Claimer.IsRenewAfterExpiry() { + return errs.Unauthorized("certificate has expired") + } + + return nil +} + +var sshUserRegex = regexp.MustCompile("^[a-z][-a-z0-9_]*$") + +// SanitizeStringSlices removes duplicated an empty strings. +func SanitizeStringSlices(original []string) []string { + output := []string{} + seen := make(map[string]struct{}) + for _, entry := range original { + if entry == "" { + continue + } + if _, value := seen[entry]; !value { + seen[entry] = struct{}{} + output = append(output, entry) + } + } + return output +} + +// SanitizeSSHUserPrincipal grabs an email or a string with the format +// local@domain and returns a sanitized version of the local, valid to be used +// as a user name. If the email starts with a letter between a and z, the +// resulting string will match the regular expression `^[a-z][-a-z0-9_]*$`. +func SanitizeSSHUserPrincipal(email string) string { + if i := strings.LastIndex(email, "@"); i >= 0 { + email = email[:i] + } + return strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return r + case r >= '0' && r <= '9': + return r + case r == '-': + return '-' + case r == '.': // drop dots + return -1 + default: + return '_' + } + }, strings.ToLower(email)) +} diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 55ebe092..0b79bf4f 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -6,7 +6,6 @@ import ( "encoding/json" stderrors "errors" "net/url" - "regexp" "strings" "github.com/pkg/errors" @@ -210,6 +209,12 @@ type Config struct { // GetIdentityFunc is a function that returns an identity that will be // used by the provisioner to populate certificate attributes. GetIdentityFunc GetIdentityFunc + // AuthorizeRenewFunc is a function that returns nil if a given X.509 + // certificate can be renewed. + AuthorizeRenewFunc AuthorizeRenewFunc + // AuthorizeSSHRenewFunc is a function that returns nil if a given SSH + // certificate can be renewed. + AuthorizeSSHRenewFunc AuthorizeSSHRenewFunc } type provisioner struct { @@ -278,32 +283,6 @@ func (l *List) UnmarshalJSON(data []byte) error { return nil } -var sshUserRegex = regexp.MustCompile("^[a-z][-a-z0-9_]*$") - -// SanitizeSSHUserPrincipal grabs an email or a string with the format -// local@domain and returns a sanitized version of the local, valid to be used -// as a user name. If the email starts with a letter between a and z, the -// resulting string will match the regular expression `^[a-z][-a-z0-9_]*$`. -func SanitizeSSHUserPrincipal(email string) string { - if i := strings.LastIndex(email, "@"); i >= 0 { - email = email[:i] - } - return strings.Map(func(r rune) rune { - switch { - case r >= 'a' && r <= 'z': - return r - case r >= '0' && r <= '9': - return r - case r == '-': - return '-' - case r == '.': // drop dots - return -1 - default: - return '_' - } - }, strings.ToLower(email)) -} - type base struct{} // AuthorizeSign returns an unimplemented error. Provisioners should overwrite @@ -348,66 +327,12 @@ func (b *base) AuthorizeSSHRekey(ctx context.Context, token string) (*ssh.Certif return nil, nil, errs.Unauthorized("provisioner.AuthorizeSSHRekey not implemented") } -// Identity is the type representing an externally supplied identity that is used -// by provisioners to populate certificate fields. -type Identity struct { - Usernames []string `json:"usernames"` - Permissions `json:"permissions"` -} - // Permissions defines extra extensions and critical options to grant to an SSH certificate. type Permissions struct { Extensions map[string]string `json:"extensions"` CriticalOptions map[string]string `json:"criticalOptions"` } -// GetIdentityFunc is a function that returns an identity. -type GetIdentityFunc func(ctx context.Context, p Interface, email string) (*Identity, error) - -// DefaultIdentityFunc return a default identity depending on the provisioner -// type. For OIDC email is always present and the usernames might -// contain empty strings. -func DefaultIdentityFunc(ctx context.Context, p Interface, email string) (*Identity, error) { - switch k := p.(type) { - case *OIDC: - // OIDC principals would be: - // ~~1. Preferred usernames.~~ Note: Under discussion, currently disabled - // 2. Sanitized local. - // 3. Raw local (if different). - // 4. Email address. - name := SanitizeSSHUserPrincipal(email) - if !sshUserRegex.MatchString(name) { - return nil, errors.Errorf("invalid principal '%s' from email '%s'", name, email) - } - usernames := []string{name} - if i := strings.LastIndex(email, "@"); i >= 0 { - usernames = append(usernames, email[:i]) - } - usernames = append(usernames, email) - return &Identity{ - Usernames: SanitizeStringSlices(usernames), - }, nil - default: - return nil, errors.Errorf("provisioner type '%T' not supported by identity function", k) - } -} - -// SanitizeStringSlices removes duplicated an empty strings. -func SanitizeStringSlices(original []string) []string { - output := []string{} - seen := make(map[string]struct{}) - for _, entry := range original { - if entry == "" { - continue - } - if _, value := seen[entry]; !value { - seen[entry] = struct{}{} - output = append(output, entry) - } - } - return output -} - // MockProvisioner for testing type MockProvisioner struct { Mret1, Mret2, Mret3 interface{}