From f01286bb48d1629e975d0a70c37cc63ca244458c Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Mon, 29 Jul 2019 15:54:07 -0700 Subject: [PATCH] Add support for SSH certificates to OIDC. Update the interface for all the provisioners. --- authority/provisioner/aws.go | 15 +++++- authority/provisioner/azure.go | 13 ++++- authority/provisioner/gcp.go | 15 +++++- authority/provisioner/jwk.go | 4 +- authority/provisioner/method.go | 34 +++++++++++++ authority/provisioner/noop.go | 7 ++- authority/provisioner/oidc.go | 58 ++++++++++++++++++++++- authority/provisioner/provisioner.go | 6 ++- authority/provisioner/sign_ssh_options.go | 8 ++-- 9 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 authority/provisioner/method.go diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go index 8b288e3d..738a2d33 100644 --- a/authority/provisioner/aws.go +++ b/authority/provisioner/aws.go @@ -1,6 +1,7 @@ package provisioner import ( + "context" "crypto/sha256" "crypto/x509" "encoding/base64" @@ -266,13 +267,18 @@ func (p *AWS) Init(config Config) (err error) { // AuthorizeSign validates the given token and returns the sign options that // will be used on certificate creation. -func (p *AWS) AuthorizeSign(token string) ([]SignOption, error) { +func (p *AWS) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) { payload, err := p.authorizeToken(token) if err != nil { return nil, err } - doc := payload.document + // Check for the sign ssh method, default to sign X.509 + if m := MethodFromContext(ctx); m == SignSSHMethod { + return p.authorizeSSHSign(payload) + } + + doc := payload.document // Enforce known CN and default DNS and IP if configured. // By default we'll accept the CN and SANs in the CSR. // There's no way to trust them other than TOFU. @@ -432,3 +438,8 @@ func (p *AWS) authorizeToken(token string) (*awsPayload, error) { payload.document = doc return &payload, nil } + +// authorizeSSHSign returns the list of SignOption for a SignSSH request. +func (p *AWS) authorizeSSHSign(claims *awsPayload) ([]SignOption, error) { + return nil, nil +} diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go index ee7fb744..9e6a41a7 100644 --- a/authority/provisioner/azure.go +++ b/authority/provisioner/azure.go @@ -1,6 +1,7 @@ package provisioner import ( + "context" "crypto/sha256" "crypto/x509" "encoding/hex" @@ -209,7 +210,7 @@ func (p *Azure) Init(config Config) (err error) { // AuthorizeSign validates the given token and returns the sign options that // will be used on certificate creation. -func (p *Azure) AuthorizeSign(token string) ([]SignOption, error) { +func (p *Azure) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) { jwt, err := jose.ParseSigned(token) if err != nil { return nil, errors.Wrapf(err, "error parsing token") @@ -264,6 +265,11 @@ func (p *Azure) AuthorizeSign(token string) ([]SignOption, error) { } } + // Check for the sign ssh method, default to sign X.509 + if m := MethodFromContext(ctx); m == SignSSHMethod { + return p.authorizeSSHSign(claims) + } + // Enforce known common name and default DNS if configured. // By default we'll accept the CN and SANs in the CSR. // There's no way to trust them other than TOFU. @@ -295,6 +301,11 @@ func (p *Azure) AuthorizeRevoke(token string) error { return errors.New("revoke is not supported on a Azure provisioner") } +// authorizeSSHSign returns the list of SignOption for a SignSSH request. +func (p *Azure) authorizeSSHSign(claims azurePayload) ([]SignOption, error) { + return nil, nil +} + // assertConfig initializes the config if it has not been initialized func (p *Azure) assertConfig() { if p.config == nil { diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index 234b00fe..b50078c1 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -2,6 +2,7 @@ package provisioner import ( "bytes" + "context" "crypto/sha256" "crypto/x509" "encoding/hex" @@ -205,13 +206,18 @@ func (p *GCP) Init(config Config) error { // AuthorizeSign validates the given token and returns the sign options that // will be used on certificate creation. -func (p *GCP) AuthorizeSign(token string) ([]SignOption, error) { +func (p *GCP) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) { claims, err := p.authorizeToken(token) if err != nil { return nil, err } - ce := claims.Google.ComputeEngine + // Check for the sign ssh method, default to sign X.509 + if m := MethodFromContext(ctx); m == SignSSHMethod { + return p.authorizeSSHSign(claims) + } + + ce := claims.Google.ComputeEngine // Enforce known common name and default DNS if configured. // By default we we'll accept the CN and SANs in the CSR. // There's no way to trust them other than TOFU. @@ -344,3 +350,8 @@ func (p *GCP) authorizeToken(token string) (*gcpPayload, error) { return &claims, nil } + +// authorizeSSHSign returns the list of SignOption for a SignSSH request. +func (p *GCP) authorizeSSHSign(claims *gcpPayload) ([]SignOption, error) { + return nil, nil +} diff --git a/authority/provisioner/jwk.go b/authority/provisioner/jwk.go index a21815b9..7c983b74 100644 --- a/authority/provisioner/jwk.go +++ b/authority/provisioner/jwk.go @@ -1,6 +1,7 @@ package provisioner import ( + "context" "crypto/x509" "time" @@ -134,7 +135,7 @@ func (p *JWK) AuthorizeRevoke(token string) error { } // AuthorizeSign validates the given token. -func (p *JWK) AuthorizeSign(token string) ([]SignOption, error) { +func (p *JWK) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) { claims, err := p.authorizeToken(token, p.audiences.Sign) if err != nil { return nil, err @@ -171,6 +172,7 @@ func (p *JWK) AuthorizeRenewal(cert *x509.Certificate) error { return nil } +// authorizeSSHSign returns the list of SignOption for a SignSSH request. func (p *JWK) authorizeSSHSign(claims *jwtPayload) ([]SignOption, error) { t := now() opts := claims.Step.SSH diff --git a/authority/provisioner/method.go b/authority/provisioner/method.go new file mode 100644 index 00000000..c8f96885 --- /dev/null +++ b/authority/provisioner/method.go @@ -0,0 +1,34 @@ +package provisioner + +import ( + "context" +) + +// Method indicates the action to action that we will perform, it's used as part +// of the context in the call to authorize. It defaults to Sing. +type Method int + +// The key to save the Method in the context. +type methodKey struct{} + +const ( + // SignMethod is the method used to sign X.509 certificates. + SignMethod Method = iota + // SignSSHMethod is the method used to sign SSH certificate. + SignSSHMethod + // RevokeMethod is the method used to revoke X.509 certificates. + RevokeMethod +) + +// NewContextWithMethod creates a new context from ctx and attaches method to +// it. +func NewContextWithMethod(ctx context.Context, method Method) context.Context { + return context.WithValue(ctx, methodKey{}, method) +} + +// MethodFromContext returns the Method saved in ctx. Returns Sign if the given +// context has no Method associated with it. +func MethodFromContext(ctx context.Context) Method { + m, _ := ctx.Value(methodKey{}).(Method) + return m +} diff --git a/authority/provisioner/noop.go b/authority/provisioner/noop.go index 44fd4600..5bdc0677 100644 --- a/authority/provisioner/noop.go +++ b/authority/provisioner/noop.go @@ -1,6 +1,9 @@ package provisioner -import "crypto/x509" +import ( + "context" + "crypto/x509" +) // noop provisioners is a provisioner that accepts anything. type noop struct{} @@ -28,7 +31,7 @@ func (p *noop) Init(config Config) error { return nil } -func (p *noop) AuthorizeSign(token string) ([]SignOption, error) { +func (p *noop) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) { return []SignOption{}, nil } diff --git a/authority/provisioner/oidc.go b/authority/provisioner/oidc.go index 0b2e2700..dcddc7ff 100644 --- a/authority/provisioner/oidc.go +++ b/authority/provisioner/oidc.go @@ -1,6 +1,7 @@ package provisioner import ( + "context" "crypto/x509" "encoding/json" "net/http" @@ -259,11 +260,17 @@ func (o *OIDC) AuthorizeRevoke(token string) error { } // AuthorizeSign validates the given token. -func (o *OIDC) AuthorizeSign(token string) ([]SignOption, error) { +func (o *OIDC) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) { claims, err := o.authorizeToken(token) if err != nil { return nil, err } + + // Check for the sign ssh method, default to sign X.509 + if m := MethodFromContext(ctx); m == SignSSHMethod { + return o.authorizeSSHSign(claims) + } + // Admins should be able to authorize any SAN if o.IsAdmin(claims.Email) { return []SignOption{ @@ -289,6 +296,37 @@ func (o *OIDC) AuthorizeRenewal(cert *x509.Certificate) error { return nil } +// authorizeSSHSign returns the list of SignOption for a SignSSH request. +func (o *OIDC) authorizeSSHSign(claims *openIDPayload) ([]SignOption, error) { + signOptions := []SignOption{ + // set the default extensions + &sshDefaultExtensionModifier{}, + // set the key id to the token subject + sshCertificateKeyIDModifier(claims.Email), + } + + // Non-admins are only able to sign user certificates + if o.IsAdmin(claims.Email) { + signOptions = append(signOptions, &sshCertificateOptionsValidator{}) + } else { + name := principalFromEmail(claims.Email) + if !sshUserRegex.MatchString(name) { + return nil, errors.Errorf("invalid principal '%s' from email address '%s'", name, claims.Email) + } + signOptions = append(signOptions, &sshCertificateOptionsValidator{&SSHOptions{ + CertType: SSHUserCert, + Principals: []string{name}, + }}) + } + + return append(signOptions, + // checks the validity bounds, and set the validity if has not been set + &sshCertificateValidityModifier{o.claimer}, + // require all the fields in the SSH certificate + &sshCertificateDefaultValidator{}, + ), nil +} + func getAndDecode(uri string, v interface{}) error { resp, err := http.Get(uri) if err != nil { @@ -300,3 +338,21 @@ func getAndDecode(uri string, v interface{}) error { } return nil } + +func principalFromEmail(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 == '.': // drop dots + return -1 + default: + return '_' + } + }, strings.ToLower(email)) +} diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 711b0439..0fd3f440 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -1,14 +1,18 @@ package provisioner import ( + "context" "crypto/x509" "encoding/json" "net/url" + "regexp" "strings" "github.com/pkg/errors" ) +var sshUserRegex = regexp.MustCompile("^[a-z][-a-z0-9_]*$") + // Interface is the interface that all provisioner types must implement. type Interface interface { GetID() string @@ -17,7 +21,7 @@ type Interface interface { GetType() Type GetEncryptedKey() (kid string, key string, ok bool) Init(config Config) error - AuthorizeSign(token string) ([]SignOption, error) + AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) AuthorizeRenewal(cert *x509.Certificate) error AuthorizeRevoke(token string) error } diff --git a/authority/provisioner/sign_ssh_options.go b/authority/provisioner/sign_ssh_options.go index 1b981504..6825131d 100644 --- a/authority/provisioner/sign_ssh_options.go +++ b/authority/provisioner/sign_ssh_options.go @@ -205,11 +205,13 @@ func (m *sshCertificateValidityModifier) Modify(cert *ssh.Certificate) error { // sshCertificateOptionsValidator validates the user SSHOptions with the ones // usually present in the token. type sshCertificateOptionsValidator struct { - *SSHOptions + Want *SSHOptions } -func (want *sshCertificateOptionsValidator) Valid(got SSHOptions) error { - return want.match(got) +// Valid implements SSHCertificateOptionsValidator and returns nil if both +// SSHOptions match. +func (v *sshCertificateOptionsValidator) Valid(got SSHOptions) error { + return v.Want.match(got) } // sshCertificateDefaultValidator implements a simple validator for all the