diff --git a/api/api.go b/api/api.go index 9ea430c2..68334dcb 100644 --- a/api/api.go +++ b/api/api.go @@ -38,7 +38,7 @@ type Authority interface { LoadProvisionerByCertificate(*x509.Certificate) (provisioner.Interface, error) LoadProvisionerByID(string) (provisioner.Interface, error) GetProvisioners(cursor string, limit int) (provisioner.List, string, error) - Revoke(*authority.RevokeOptions) error + Revoke(context.Context, *authority.RevokeOptions) error GetEncryptedKey(kid string) (string, error) GetRoots() (federation []*x509.Certificate, err error) GetFederation() ([]*x509.Certificate, error) @@ -252,6 +252,9 @@ func (h *caHandler) Route(r Router) { r.MethodFunc("GET", "/federation", h.Federation) // SSH CA r.MethodFunc("POST", "/ssh/sign", h.SSHSign) + r.MethodFunc("POST", "/ssh/renew", h.SSHRenew) + r.MethodFunc("POST", "/ssh/revoke", h.SSHRevoke) + r.MethodFunc("POST", "/ssh/rekey", h.SSHRekey) r.MethodFunc("GET", "/ssh/roots", h.SSHRoots) r.MethodFunc("GET", "/ssh/federation", h.SSHFederation) r.MethodFunc("POST", "/ssh/config", h.SSHConfig) diff --git a/api/revoke.go b/api/revoke.go index 15c42e90..aceb8305 100644 --- a/api/revoke.go +++ b/api/revoke.go @@ -1,10 +1,12 @@ package api import ( + "context" "net/http" "github.com/pkg/errors" "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/logging" "golang.org/x/crypto/ocsp" ) @@ -63,10 +65,15 @@ func (h *caHandler) Revoke(w http.ResponseWriter, r *http.Request) { PassiveOnly: body.Passive, } + ctx := provisioner.NewContextWithMethod(context.Background(), provisioner.RevokeMethod) // A token indicates that we are using the api via a provisioner token, // otherwise it is assumed that the certificate is revoking itself over mTLS. if len(body.OTT) > 0 { logOtt(w, body.OTT) + if _, err := h.Authority.Authorize(ctx, body.OTT); err != nil { + WriteError(w, Unauthorized(err)) + return + } opts.OTT = body.OTT } else { // If no token is present, then the request must be made over mTLS and @@ -77,11 +84,18 @@ func (h *caHandler) Revoke(w http.ResponseWriter, r *http.Request) { return } opts.Crt = r.TLS.PeerCertificates[0] + if opts.Crt.SerialNumber.String() != opts.Serial { + WriteError(w, BadRequest(errors.New("revoke: serial number in mtls certificate different than body"))) + return + } + // TODO: should probably be checking if the certificate was revoked here. + // Will need to thread that request down to the authority, so will need + // to add API for that. logCertificate(w, opts.Crt) opts.MTLS = true } - if err := h.Authority.Revoke(opts); err != nil { + if err := h.Authority.Revoke(ctx, opts); err != nil { WriteError(w, Forbidden(err)) return } diff --git a/api/ssh.go b/api/ssh.go index 11d59712..b2305fc6 100644 --- a/api/ssh.go +++ b/api/ssh.go @@ -16,6 +16,8 @@ import ( // SSHAuthority is the interface implemented by a SSH CA authority. type SSHAuthority interface { SignSSH(key ssh.PublicKey, opts provisioner.SSHOptions, signOpts ...provisioner.SignOption) (*ssh.Certificate, error) + RenewSSH(cert *ssh.Certificate) (*ssh.Certificate, error) + RekeySSH(cert *ssh.Certificate, key ssh.PublicKey, signOpts ...provisioner.SignOption) (*ssh.Certificate, error) SignSSHAddUser(key ssh.PublicKey, cert *ssh.Certificate) (*ssh.Certificate, error) GetSSHRoots() (*authority.SSHKeys, error) GetSSHFederation() (*authority.SSHKeys, error) @@ -67,7 +69,8 @@ type SSHCertificate struct { *ssh.Certificate `json:"omitempty"` } -// SSHGetHostsResponse +// SSHGetHostsResponse is the response object that returns the list of valid +// hosts for SSH. type SSHGetHostsResponse struct { Hosts []string `json:"hosts"` } diff --git a/api/sshRekey.go b/api/sshRekey.go new file mode 100644 index 00000000..530b9df3 --- /dev/null +++ b/api/sshRekey.go @@ -0,0 +1,78 @@ +package api + +import ( + "context" + "net/http" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/authority/provisioner" + "golang.org/x/crypto/ssh" +) + +// SSHRekeyRequest is the request body of an SSH certificate request. +type SSHRekeyRequest struct { + OTT string `json:"ott"` + PublicKey []byte `json:"publicKey"` //base64 encoded +} + +// Validate validates the SSHSignRekey. +func (s *SSHRekeyRequest) Validate() error { + switch { + case len(s.OTT) == 0: + return errors.New("missing or empty ott") + case len(s.PublicKey) == 0: + return errors.New("missing or empty public key") + default: + return nil + } +} + +// SSHRekeyResponse is the response object that returns the SSH certificate. +type SSHRekeyResponse struct { + Certificate SSHCertificate `json:"crt"` +} + +// SSHRekey is an HTTP handler that reads an RekeySSHRequest with a one-time-token +// (ott) from the body and creates a new SSH certificate with the information in +// the request. +func (h *caHandler) SSHRekey(w http.ResponseWriter, r *http.Request) { + var body SSHRekeyRequest + if err := ReadJSON(r.Body, &body); err != nil { + WriteError(w, BadRequest(errors.Wrap(err, "error reading request body"))) + return + } + + logOtt(w, body.OTT) + if err := body.Validate(); err != nil { + WriteError(w, BadRequest(err)) + return + } + + publicKey, err := ssh.ParsePublicKey(body.PublicKey) + if err != nil { + WriteError(w, BadRequest(errors.Wrap(err, "error parsing publicKey"))) + return + } + + ctx := provisioner.NewContextWithMethod(context.Background(), provisioner.RekeySSHMethod) + signOpts, err := h.Authority.Authorize(ctx, body.OTT) + if err != nil { + WriteError(w, Unauthorized(err)) + return + } + oldCert, err := provisioner.ExtractSSHPOPCert(body.OTT) + if err != nil { + WriteError(w, InternalServerError(err)) + } + + newCert, err := h.Authority.RekeySSH(oldCert, publicKey, signOpts...) + if err != nil { + WriteError(w, Forbidden(err)) + return + } + + w.WriteHeader(http.StatusCreated) + JSON(w, &SSHSignResponse{ + Certificate: SSHCertificate{newCert}, + }) +} diff --git a/api/sshRenew.go b/api/sshRenew.go new file mode 100644 index 00000000..3aea01bb --- /dev/null +++ b/api/sshRenew.go @@ -0,0 +1,68 @@ +package api + +import ( + "context" + "net/http" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/authority/provisioner" +) + +// SSHRenewRequest is the request body of an SSH certificate request. +type SSHRenewRequest struct { + OTT string `json:"ott"` +} + +// Validate validates the SSHSignRequest. +func (s *SSHRenewRequest) Validate() error { + switch { + case len(s.OTT) == 0: + return errors.New("missing or empty ott") + default: + return nil + } +} + +// SSHRenewResponse is the response object that returns the SSH certificate. +type SSHRenewResponse struct { + Certificate SSHCertificate `json:"crt"` +} + +// SSHRenew is an HTTP handler that reads an RenewSSHRequest with a one-time-token +// (ott) from the body and creates a new SSH certificate with the information in +// the request. +func (h *caHandler) SSHRenew(w http.ResponseWriter, r *http.Request) { + var body SSHRenewRequest + if err := ReadJSON(r.Body, &body); err != nil { + WriteError(w, BadRequest(errors.Wrap(err, "error reading request body"))) + return + } + + logOtt(w, body.OTT) + if err := body.Validate(); err != nil { + WriteError(w, BadRequest(err)) + return + } + + ctx := provisioner.NewContextWithMethod(context.Background(), provisioner.RenewSSHMethod) + _, err := h.Authority.Authorize(ctx, body.OTT) + if err != nil { + WriteError(w, Unauthorized(err)) + return + } + oldCert, err := provisioner.ExtractSSHPOPCert(body.OTT) + if err != nil { + WriteError(w, InternalServerError(err)) + } + + newCert, err := h.Authority.RenewSSH(oldCert) + if err != nil { + WriteError(w, Forbidden(err)) + return + } + + w.WriteHeader(http.StatusCreated) + JSON(w, &SSHSignResponse{ + Certificate: SSHCertificate{newCert}, + }) +} diff --git a/api/sshRevoke.go b/api/sshRevoke.go new file mode 100644 index 00000000..9355e5a4 --- /dev/null +++ b/api/sshRevoke.go @@ -0,0 +1,98 @@ +package api + +import ( + "context" + "net/http" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/logging" + "golang.org/x/crypto/ocsp" +) + +// SSHRevokeResponse is the response object that returns the health of the server. +type SSHRevokeResponse struct { + Status string `json:"status"` +} + +// SSHRevokeRequest is the request body for a revocation request. +type SSHRevokeRequest struct { + Serial string `json:"serial"` + OTT string `json:"ott"` + ReasonCode int `json:"reasonCode"` + Reason string `json:"reason"` + Passive bool `json:"passive"` +} + +// Validate checks the fields of the RevokeRequest and returns nil if they are ok +// or an error if something is wrong. +func (r *SSHRevokeRequest) Validate() (err error) { + if r.Serial == "" { + return BadRequest(errors.New("missing serial")) + } + if r.ReasonCode < ocsp.Unspecified || r.ReasonCode > ocsp.AACompromise { + return BadRequest(errors.New("reasonCode out of bounds")) + } + if !r.Passive { + return NotImplemented(errors.New("non-passive revocation not implemented")) + } + if len(r.OTT) == 0 { + return BadRequest(errors.New("missing ott")) + } + return +} + +// Revoke supports handful of different methods that revoke a Certificate. +// +// NOTE: currently only Passive revocation is supported. +func (h *caHandler) SSHRevoke(w http.ResponseWriter, r *http.Request) { + var body SSHRevokeRequest + if err := ReadJSON(r.Body, &body); err != nil { + WriteError(w, BadRequest(errors.Wrap(err, "error reading request body"))) + return + } + + if err := body.Validate(); err != nil { + WriteError(w, err) + return + } + + opts := &authority.RevokeOptions{ + Serial: body.Serial, + Reason: body.Reason, + ReasonCode: body.ReasonCode, + PassiveOnly: body.Passive, + } + + ctx := provisioner.NewContextWithMethod(context.Background(), provisioner.RevokeSSHMethod) + // A token indicates that we are using the api via a provisioner token, + // otherwise it is assumed that the certificate is revoking itself over mTLS. + logOtt(w, body.OTT) + if _, err := h.Authority.Authorize(ctx, body.OTT); err != nil { + WriteError(w, Unauthorized(err)) + return + } + opts.OTT = body.OTT + + if err := h.Authority.Revoke(ctx, opts); err != nil { + WriteError(w, Forbidden(err)) + return + } + + logSSHRevoke(w, opts) + JSON(w, &SSHRevokeResponse{Status: "ok"}) +} + +func logSSHRevoke(w http.ResponseWriter, ri *authority.RevokeOptions) { + if rl, ok := w.(logging.ResponseLogger); ok { + rl.WithFields(map[string]interface{}{ + "serial": ri.Serial, + "reasonCode": ri.ReasonCode, + "reason": ri.Reason, + "passiveOnly": ri.PassiveOnly, + "mTLS": ri.MTLS, + "ssh": true, + }) + } +} diff --git a/authority/authority.go b/authority/authority.go index d53d8d1b..a62e5034 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -179,8 +179,33 @@ func (a *Authority) init() error { } } + // Merge global and configuration claims + claimer, err := provisioner.NewClaimer(a.config.AuthorityConfig.Claims, globalProvisionerClaims) + if err != nil { + return err + } + // TODO: should we also be combining the ssh federated roots here? + // If we rotate ssh roots keys, sshpop provisioner will lose ability to + // validate old SSH certificates, unless they are added as federated certs. + sshKeys, err := a.GetSSHRoots() + if err != nil { + return err + } + // Initialize provisioners + config := provisioner.Config{ + Claims: claimer.Claims(), + Audiences: a.config.getAudiences(), + DB: a.db, + SSHKeys: &provisioner.SSHKeys{ + UserKeys: sshKeys.UserKeys, + HostKeys: sshKeys.HostKeys, + }, + } // Store all the provisioners for _, p := range a.config.AuthorityConfig.Provisioners { + if err := p.Init(config); err != nil { + return err + } if err := a.provisioners.Store(p); err != nil { return err } diff --git a/authority/authorize.go b/authority/authorize.go index b8f7cb6f..18eba6b9 100644 --- a/authority/authorize.go +++ b/authority/authorize.go @@ -80,13 +80,32 @@ func (a *Authority) Authorize(ctx context.Context, ott string) ([]provisioner.Si switch m := provisioner.MethodFromContext(ctx); m { case provisioner.SignMethod: return a.authorizeSign(ctx, ott) + case provisioner.RevokeMethod: + return nil, a.authorizeRevoke(ctx, ott) case provisioner.SignSSHMethod: if a.sshCAHostCertSignKey == nil && a.sshCAUserCertSignKey == nil { return nil, &apiError{errors.New("authorize: ssh signing is not enabled"), http.StatusNotImplemented, errContext} } - return a.authorizeSign(ctx, ott) - case provisioner.RevokeMethod: - return nil, &apiError{errors.New("authorize: revoke method is not supported"), http.StatusInternalServerError, errContext} + return a.authorizeSSHSign(ctx, ott) + case provisioner.RenewSSHMethod: + if a.sshCAHostCertSignKey == nil && a.sshCAUserCertSignKey == nil { + return nil, &apiError{errors.New("authorize: ssh signing is not enabled"), http.StatusNotImplemented, errContext} + } + if _, err := a.authorizeSSHRenew(ctx, ott); err != nil { + return nil, err + } + return nil, nil + case provisioner.RevokeSSHMethod: + return nil, a.authorizeSSHRevoke(ctx, ott) + case provisioner.RekeySSHMethod: + if a.sshCAHostCertSignKey == nil && a.sshCAUserCertSignKey == nil { + return nil, &apiError{errors.New("authorize: ssh signing is not enabled"), http.StatusNotImplemented, errContext} + } + _, opts, err := a.authorizeSSHRekey(ctx, ott) + if err != nil { + return nil, err + } + return opts, nil default: return nil, &apiError{errors.Errorf("authorize: method %d is not supported", m), http.StatusInternalServerError, errContext} } @@ -121,38 +140,25 @@ func (a *Authority) AuthorizeSign(ott string) ([]provisioner.SignOption, error) // authorizeRevoke authorizes a revocation request by validating and authenticating // the RevokeOptions POSTed with the request. // Returns a tuple of the provisioner ID and error, if one occurred. -func (a *Authority) authorizeRevoke(opts *RevokeOptions) (p provisioner.Interface, err error) { - if opts.MTLS { - if opts.Crt.SerialNumber.String() != opts.Serial { - return nil, errors.New("authorizeRevoke: serial number in certificate different than body") - } - // Load the Certificate provisioner if one exists. - p, err = a.LoadProvisionerByCertificate(opts.Crt) - if err != nil { - return nil, errors.Wrap(err, "authorizeRevoke") - } - } else { - // Gets the token provisioner and validates common token fields. - p, err = a.authorizeToken(opts.OTT) - if err != nil { - return nil, errors.Wrap(err, "authorizeRevoke") - } +func (a *Authority) authorizeRevoke(ctx context.Context, token string) error { + errContext := map[string]interface{}{"ott": token} - // Call the provisioner AuthorizeRevoke to apply provisioner specific auth claims. - err = p.AuthorizeRevoke(opts.OTT) - if err != nil { - return nil, errors.Wrap(err, "authorizeRevoke") - } + p, err := a.authorizeToken(token) + if err != nil { + return &apiError{errors.Wrap(err, "authorizeRevoke"), http.StatusUnauthorized, errContext} } - return + if err = p.AuthorizeSSHRevoke(ctx, token); err != nil { + return &apiError{errors.Wrap(err, "authorizeRevoke"), http.StatusUnauthorized, errContext} + } + return nil } -// authorizeRenewal tries to locate the step provisioner extension, and checks +// authorizeRenewl tries to locate the step provisioner extension, and checks // if for the configured provisioner, the renewal is enabled or not. If the // extra extension cannot be found, authorize the renewal by default. // // TODO(mariano): should we authorize by default? -func (a *Authority) authorizeRenewal(crt *x509.Certificate) error { +func (a *Authority) authorizeRenew(crt *x509.Certificate) error { errContext := map[string]interface{}{"serialNumber": crt.SerialNumber.String()} // Check the passive revocation table. @@ -180,7 +186,7 @@ func (a *Authority) authorizeRenewal(crt *x509.Certificate) error { context: errContext, } } - if err := p.AuthorizeRenewal(crt); err != nil { + if err := p.AuthorizeRenew(context.Background(), crt); err != nil { return &apiError{ err: errors.Wrap(err, "renew"), code: http.StatusUnauthorized, diff --git a/authority/config.go b/authority/config.go index 5ec83477..e70eba48 100644 --- a/authority/config.go +++ b/authority/config.go @@ -81,23 +81,6 @@ func (c *AuthConfig) Validate(audiences provisioner.Audiences) error { return errors.New("authority.provisioners cannot be empty") } - // Merge global and configuration claims - claimer, err := provisioner.NewClaimer(c.Claims, globalProvisionerClaims) - if err != nil { - return err - } - - // Initialize provisioners - config := provisioner.Config{ - Claims: claimer.Claims(), - Audiences: audiences, - } - for _, p := range c.Provisioners { - if err := p.Init(config); err != nil { - return err - } - } - if c.Template == nil { c.Template = &x509util.ASN1DN{} } @@ -194,8 +177,11 @@ func (c *Config) Validate() error { // front so we cannot rely on the port. func (c *Config) getAudiences() provisioner.Audiences { audiences := provisioner.Audiences{ - Sign: []string{legacyAuthority}, - Revoke: []string{legacyAuthority}, + Sign: []string{legacyAuthority}, + Revoke: []string{legacyAuthority}, + SSHSign: []string{}, + SSHRevoke: []string{}, + SSHRenew: []string{}, } for _, name := range c.DNSNames { @@ -203,6 +189,14 @@ func (c *Config) getAudiences() provisioner.Audiences { fmt.Sprintf("https://%s/sign", name), fmt.Sprintf("https://%s/1.0/sign", name)) audiences.Revoke = append(audiences.Revoke, fmt.Sprintf("https://%s/revoke", name), fmt.Sprintf("https://%s/1.0/revoke", name)) + audiences.SSHSign = append(audiences.SSHSign, + fmt.Sprintf("https://%s/ssh/sign", name), fmt.Sprintf("https://%s/1.0/ssh/sign", name)) + audiences.SSHRevoke = append(audiences.SSHRevoke, + fmt.Sprintf("https://%s/ssh/revoke", name), fmt.Sprintf("https://%s/1.0/ssh/revoke", name)) + audiences.SSHRenew = append(audiences.SSHRenew, + fmt.Sprintf("https://%s/ssh/renew", name), fmt.Sprintf("https://%s/1.0/ssh/renew", name)) + audiences.SSHRekey = append(audiences.SSHRekey, + fmt.Sprintf("https://%s/ssh/rekey", name), fmt.Sprintf("https://%s/1.0/ssh/rekey", name)) } return audiences diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index d1933d47..adba8fd3 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -10,6 +10,7 @@ import ( // ACME is the acme provisioner type, an entity that can authorize the ACME // provisioning flow. type ACME struct { + *base Type string `json:"type"` Name string `json:"name"` Claims *Claims `json:"claims,omitempty"` @@ -58,16 +59,10 @@ func (p *ACME) Init(config Config) (err error) { return err } -// AuthorizeRevoke is not implemented yet for the ACME provisioner. -func (p *ACME) AuthorizeRevoke(token string) error { - return nil -} - -// AuthorizeSign validates the given token. -func (p *ACME) AuthorizeSign(ctx context.Context, _ string) ([]SignOption, error) { - if m := MethodFromContext(ctx); m != SignMethod { - return nil, errors.Errorf("unexpected method type %d in context", m) - } +// AuthorizeSign does not do any validation, because all validation is handled +// in the ACME protocol. This method returns a list of modifiers / constraints +// on the resulting certificate. +func (p *ACME) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) { return []SignOption{ // modifiers / withOptions newProvisionerExtensionOption(TypeACME, p.Name, ""), @@ -78,8 +73,11 @@ func (p *ACME) AuthorizeSign(ctx context.Context, _ string) ([]SignOption, error }, nil } -// AuthorizeRenewal is not implemented for the ACME provisioner. -func (p *ACME) AuthorizeRenewal(cert *x509.Certificate) error { +// AuthorizeRenew returns an error if the renewal is disabled. +// NOTE: This method does not actually validate the certificate or check it's +// revocation status. Just confirms that the provisioner that created the +// certificate was configured to allow renewals. +func (p *ACME) AuthorizeRenew(ctx context.Context, cert *x509.Certificate) error { if p.claimer.IsDisableRenewal() { return errors.Errorf("renew is disabled for provisioner %s", p.GetID()) } diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go index e1b2ef9d..a58ffb7e 100644 --- a/authority/provisioner/aws.go +++ b/authority/provisioner/aws.go @@ -123,6 +123,7 @@ type awsInstanceIdentityDocument struct { // Amazon Identity docs are available at // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html type AWS struct { + *base Type string `json:"type"` Name string `json:"name"` Accounts []string `json:"accounts"` @@ -273,14 +274,6 @@ func (p *AWS) AuthorizeSign(ctx context.Context, token string) ([]SignOption, er return nil, err } - // Check for the sign ssh method, default to sign X.509 - if MethodFromContext(ctx) == SignSSHMethod { - if !p.claimer.IsSSHCAEnabled() { - return nil, errors.Errorf("ssh ca is disabled for provisioner %s", p.GetID()) - } - 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. @@ -306,20 +299,17 @@ func (p *AWS) AuthorizeSign(ctx context.Context, token string) ([]SignOption, er ), nil } -// AuthorizeRenewal returns an error if the renewal is disabled. -func (p *AWS) AuthorizeRenewal(cert *x509.Certificate) error { +// AuthorizeRenew returns an error if the renewal is disabled. +// NOTE: This method does not actually validate the certificate or check it's +// revocation status. Just confirms that the provisioner that created the +// certificate was configured to allow renewals. +func (p *AWS) AuthorizeRenew(ctx context.Context, cert *x509.Certificate) error { if p.claimer.IsDisableRenewal() { return errors.Errorf("renew is disabled for provisioner %s", p.GetID()) } return nil } -// AuthorizeRevoke returns an error because revoke is not supported on AWS -// provisioners. -func (p *AWS) AuthorizeRevoke(token string) error { - return errors.New("revoke is not supported on a AWS provisioner") -} - // assertConfig initializes the config if it has not been initialized func (p *AWS) assertConfig() (err error) { if p.config != nil { @@ -445,8 +435,16 @@ func (p *AWS) authorizeToken(token string) (*awsPayload, error) { return &payload, nil } -// authorizeSSHSign returns the list of SignOption for a SignSSH request. -func (p *AWS) authorizeSSHSign(claims *awsPayload) ([]SignOption, error) { +// AuthorizeSSHSign returns the list of SignOption for a SignSSH request. +func (p *AWS) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, error) { + if !p.claimer.IsSSHCAEnabled() { + return nil, errors.Errorf("ssh ca is disabled for provisioner %s", p.GetID()) + } + claims, err := p.authorizeToken(token) + if err != nil { + return nil, err + } + doc := claims.document signOptions := []SignOption{ diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go index d8252799..5e338e18 100644 --- a/authority/provisioner/azure.go +++ b/authority/provisioner/azure.go @@ -80,6 +80,7 @@ type azurePayload struct { // https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token // and https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service type Azure struct { + *base Type string `json:"type"` Name string `json:"name"` TenantID string `json:"tenantId"` @@ -208,15 +209,14 @@ func (p *Azure) Init(config Config) (err error) { return nil } -// AuthorizeSign validates the given token and returns the sign options that -// will be used on certificate creation. -func (p *Azure) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) { +// parseToken returuns the claims, name, group, error. +func (p *Azure) parseToken(token string) (*azurePayload, string, string, error) { jwt, err := jose.ParseSigned(token) if err != nil { - return nil, errors.Wrapf(err, "error parsing token") + return nil, "", "", errors.Wrapf(err, "error parsing token") } if len(jwt.Headers) == 0 { - return nil, errors.New("error parsing token: header is missing") + return nil, "", "", errors.New("error parsing token: header is missing") } var found bool @@ -229,7 +229,7 @@ func (p *Azure) AuthorizeSign(ctx context.Context, token string) ([]SignOption, } } if !found { - return nil, errors.New("cannot validate token") + return nil, "", "", errors.New("cannot validate token") } if err := claims.ValidateWithLeeway(jose.Expected{ @@ -237,19 +237,29 @@ func (p *Azure) AuthorizeSign(ctx context.Context, token string) ([]SignOption, Issuer: p.oidcConfig.Issuer, Time: time.Now(), }, 1*time.Minute); err != nil { - return nil, errors.Wrap(err, "failed to validate payload") + return nil, "", "", errors.Wrap(err, "failed to validate payload") } // Validate TenantID if claims.TenantID != p.TenantID { - return nil, errors.New("validation failed: invalid tenant id claim (tid)") + return nil, "", "", errors.New("validation failed: invalid tenant id claim (tid)") } re := azureXMSMirIDRegExp.FindStringSubmatch(claims.XMSMirID) if len(re) != 4 { - return nil, errors.Errorf("error parsing xms_mirid claim: %s", claims.XMSMirID) + return nil, "", "", errors.Errorf("error parsing xms_mirid claim: %s", claims.XMSMirID) } group, name := re[2], re[3] + return &claims, name, group, nil +} + +// AuthorizeSign validates the given token and returns the sign options that +// will be used on certificate creation. +func (p *Azure) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) { + _, name, group, err := p.parseToken(token) + if err != nil { + return nil, err + } // Filter by resource group if len(p.ResourceGroups) > 0 { @@ -265,14 +275,6 @@ func (p *Azure) AuthorizeSign(ctx context.Context, token string) ([]SignOption, } } - // Check for the sign ssh method, default to sign X.509 - if MethodFromContext(ctx) == SignSSHMethod { - if !p.claimer.IsSSHCAEnabled() { - return nil, errors.Errorf("ssh ca is disabled for provisioner %s", p.GetID()) - } - return p.authorizeSSHSign(claims, name) - } - // 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. @@ -293,22 +295,27 @@ func (p *Azure) AuthorizeSign(ctx context.Context, token string) ([]SignOption, ), nil } -// AuthorizeRenewal returns an error if the renewal is disabled. -func (p *Azure) AuthorizeRenewal(cert *x509.Certificate) error { +// AuthorizeRenew returns an error if the renewal is disabled. +// NOTE: This method does not actually validate the certificate or check it's +// revocation status. Just confirms that the provisioner that created the +// certificate was configured to allow renewals. +func (p *Azure) AuthorizeRenew(ctx context.Context, cert *x509.Certificate) error { if p.claimer.IsDisableRenewal() { return errors.Errorf("renew is disabled for provisioner %s", p.GetID()) } return nil } -// AuthorizeRevoke returns an error because revoke is not supported on Azure -// provisioners. -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(ctx context.Context, token string) ([]SignOption, error) { + if !p.claimer.IsSSHCAEnabled() { + return nil, errors.Errorf("ssh ca is disabled for provisioner %s", p.GetID()) + } -// authorizeSSHSign returns the list of SignOption for a SignSSH request. -func (p *Azure) authorizeSSHSign(claims azurePayload, name string) ([]SignOption, error) { + _, name, _, err := p.parseToken(token) + if err != nil { + return nil, err + } signOptions := []SignOption{ // set the key id to the token subject sshCertificateKeyIDModifier(name), diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index b2ec509a..30a65909 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -74,6 +74,7 @@ func newGCPConfig() *gcpConfig { // Google Identity docs are available at // https://cloud.google.com/compute/docs/instances/verifying-instance-identity type GCP struct { + *base Type string `json:"type"` Name string `json:"name"` ServiceAccounts []string `json:"serviceAccounts"` @@ -212,14 +213,6 @@ func (p *GCP) AuthorizeSign(ctx context.Context, token string) ([]SignOption, er return nil, err } - // Check for the sign ssh method, default to sign X.509 - if MethodFromContext(ctx) == SignSSHMethod { - if !p.claimer.IsSSHCAEnabled() { - return nil, errors.Errorf("ssh ca is disabled for provisioner %s", p.GetID()) - } - 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. @@ -247,19 +240,13 @@ func (p *GCP) AuthorizeSign(ctx context.Context, token string) ([]SignOption, er } // AuthorizeRenewal returns an error if the renewal is disabled. -func (p *GCP) AuthorizeRenewal(cert *x509.Certificate) error { +func (p *GCP) AuthorizeRenewal(ctx context.Context, cert *x509.Certificate) error { if p.claimer.IsDisableRenewal() { return errors.Errorf("renew is disabled for provisioner %s", p.GetID()) } return nil } -// AuthorizeRevoke returns an error because revoke is not supported on GCP -// provisioners. -func (p *GCP) AuthorizeRevoke(token string) error { - return errors.New("revoke is not supported on a GCP provisioner") -} - // assertConfig initializes the config if it has not been initialized. func (p *GCP) assertConfig() { if p.config == nil { @@ -357,8 +344,16 @@ 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) { +// AuthorizeSSHSign returns the list of SignOption for a SignSSH request. +func (p *GCP) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, error) { + if !p.claimer.IsSSHCAEnabled() { + return nil, errors.Errorf("ssh ca is disabled for provisioner %s", p.GetID()) + } + claims, err := p.authorizeToken(token) + if err != nil { + return nil, err + } + ce := claims.Google.ComputeEngine signOptions := []SignOption{ diff --git a/authority/provisioner/jwk.go b/authority/provisioner/jwk.go index f9178bb7..a3a7d1d9 100644 --- a/authority/provisioner/jwk.go +++ b/authority/provisioner/jwk.go @@ -24,6 +24,7 @@ type stepPayload struct { // JWK is the default provisioner, an entity that can sign tokens necessary for // signature requests. type JWK struct { + *base Type string `json:"type"` Name string `json:"name"` Key *jose.JSONWebKey `json:"key"` @@ -129,7 +130,7 @@ func (p *JWK) authorizeToken(token string, audiences []string) (*jwtPayload, err // AuthorizeRevoke returns an error if the provisioner does not have rights to // revoke the certificate with serial number in the `sub` property. -func (p *JWK) AuthorizeRevoke(token string) error { +func (p *JWK) AuthorizeRevoke(ctx context.Context, token string) error { _, err := p.authorizeToken(token, p.audiences.Revoke) return err } @@ -141,14 +142,6 @@ func (p *JWK) AuthorizeSign(ctx context.Context, token string) ([]SignOption, er return nil, err } - // Check for SSH sign-ing request. - if MethodFromContext(ctx) == SignSSHMethod { - if !p.claimer.IsSSHCAEnabled() { - return nil, errors.Errorf("ssh ca is disabled for provisioner %s", p.GetID()) - } - return p.authorizeSSHSign(claims) - } - // NOTE: This is for backwards compatibility with older versions of cli // and certificates. Older versions added the token subject as the only SAN // in a CSR by default. @@ -171,17 +164,27 @@ func (p *JWK) AuthorizeSign(ctx context.Context, token string) ([]SignOption, er }, nil } -// AuthorizeRenewal returns an error if the renewal is disabled. -func (p *JWK) AuthorizeRenewal(cert *x509.Certificate) error { +// AuthorizeRenew returns an error if the renewal is disabled. +// NOTE: This method does not actually validate the certificate or check it's +// revocation status. Just confirms that the provisioner that created the +// certificate was configured to allow renewals. +func (p *JWK) AuthorizeRenew(ctx context.Context, cert *x509.Certificate) error { if p.claimer.IsDisableRenewal() { return errors.Errorf("renew is disabled for provisioner %s", p.GetID()) } return nil } -// authorizeSSHSign returns the list of SignOption for a SignSSH request. -func (p *JWK) authorizeSSHSign(claims *jwtPayload) ([]SignOption, error) { - t := now() +// AuthorizeSSHSign returns the list of SignOption for a SignSSH request. +func (p *JWK) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, error) { + if !p.claimer.IsSSHCAEnabled() { + return nil, errors.Errorf("ssh ca is disabled for provisioner %s", p.GetID()) + } + // TODO: fix audiences + claims, err := p.authorizeToken(token, p.audiences.Sign) + if err != nil { + return nil, err + } if claims.Step == nil || claims.Step.SSH == nil { return nil, errors.New("authorization token must be an SSH provisioning token") } @@ -193,6 +196,7 @@ func (p *JWK) authorizeSSHSign(claims *jwtPayload) ([]SignOption, error) { sshCertificateKeyIDModifier(claims.Subject), } + t := now() // Add modifiers from custom claims if opts.CertType != "" { signOptions = append(signOptions, sshCertificateCertTypeModifier(opts.CertType)) @@ -223,3 +227,10 @@ func (p *JWK) authorizeSSHSign(claims *jwtPayload) ([]SignOption, error) { &sshCertificateDefaultValidator{}, ), nil } + +// AuthorizeSSHRevoke returns nil if the token is valid, false otherwise. +func (p *JWK) AuthorizeSSHRevoke(ctx context.Context, token string) error { + // TODO fix audience. + _, err := p.authorizeToken(token, p.audiences.SSHRevoke) + return err +} diff --git a/authority/provisioner/k8sSA.go b/authority/provisioner/k8sSA.go index 3e08a7e2..0abed1f3 100644 --- a/authority/provisioner/k8sSA.go +++ b/authority/provisioner/k8sSA.go @@ -40,6 +40,7 @@ type k8sSAPayload struct { // K8sSA represents a Kubernetes ServiceAccount provisioner; an // entity trusted to make signature requests. type K8sSA struct { + *base Type string `json:"type"` Name string `json:"name"` Claims *Claims `json:"claims,omitempty"` @@ -199,7 +200,7 @@ func (p *K8sSA) authorizeToken(token string, audiences []string) (*k8sSAPayload, // AuthorizeRevoke returns an error if the provisioner does not have rights to // revoke the certificate with serial number in the `sub` property. -func (p *K8sSA) AuthorizeRevoke(token string) error { +func (p *K8sSA) AuthorizeRevoke(ctx context.Context, token string) error { _, err := p.authorizeToken(token, p.audiences.Revoke) return err } diff --git a/authority/provisioner/method.go b/authority/provisioner/method.go index c8f96885..4e5f32a7 100644 --- a/authority/provisioner/method.go +++ b/authority/provisioner/method.go @@ -14,12 +14,38 @@ 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 + // SignSSHMethod is the method used to sign SSH certificates. + SignSSHMethod + // RenewSSHMethod is the method used to renew SSH certificates. + RenewSSHMethod + // RevokeSSHMethod is the method used to revoke SSH certificates. + RevokeSSHMethod + // RekeySSHMethod is the method used to rekey SSH certificates. + RekeySSHMethod ) +// String returns a string representation of the context method. +func (m Method) String() string { + switch m { + case SignMethod: + return "sign-method" + case RevokeMethod: + return "revoke-method" + case SignSSHMethod: + return "sign-ssh-method" + case RenewSSHMethod: + return "renew-ssh-method" + case RevokeSSHMethod: + return "revoke-ssh-method" + case RekeySSHMethod: + return "rekey-ssh-method" + default: + return "unknown" + } +} + // NewContextWithMethod creates a new context from ctx and attaches method to // it. func NewContextWithMethod(ctx context.Context, method Method) context.Context { diff --git a/authority/provisioner/noop.go b/authority/provisioner/noop.go index 5bdc0677..ccdeccf4 100644 --- a/authority/provisioner/noop.go +++ b/authority/provisioner/noop.go @@ -3,6 +3,8 @@ package provisioner import ( "context" "crypto/x509" + + "golang.org/x/crypto/ssh" ) // noop provisioners is a provisioner that accepts anything. @@ -35,10 +37,26 @@ func (p *noop) AuthorizeSign(ctx context.Context, token string) ([]SignOption, e return []SignOption{}, nil } -func (p *noop) AuthorizeRenewal(cert *x509.Certificate) error { +func (p *noop) AuthorizeRenew(ctx context.Context, cert *x509.Certificate) error { return nil } -func (p *noop) AuthorizeRevoke(token string) error { +func (p *noop) AuthorizeRevoke(ctx context.Context, token string) error { return nil } + +func (p *noop) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, error) { + return []SignOption{}, nil +} + +func (p *noop) AuthorizeSSHRenew(ctx context.Context, token string) (*ssh.Certificate, error) { + return nil, nil +} + +func (p *noop) AuthorizeSSHRevoke(ctx context.Context, token string) error { + return nil +} + +func (p *noop) AuthorizeSSHRekey(ctx context.Context, token string) (*ssh.Certificate, []SignOption, error) { + return nil, []SignOption{}, nil +} diff --git a/authority/provisioner/oidc.go b/authority/provisioner/oidc.go index b65d9b6f..90e46701 100644 --- a/authority/provisioner/oidc.go +++ b/authority/provisioner/oidc.go @@ -50,6 +50,7 @@ type openIDPayload struct { // // ClientSecret is mandatory, but it can be an empty string. type OIDC struct { + *base Type string `json:"type"` Name string `json:"name"` ClientID string `json:"clientID"` @@ -264,7 +265,7 @@ func (o *OIDC) authorizeToken(token string) (*openIDPayload, error) { // AuthorizeRevoke returns an error if the provisioner does not have rights to // revoke the certificate with serial number in the `sub` property. // Only tokens generated by an admin have the right to revoke a certificate. -func (o *OIDC) AuthorizeRevoke(token string) error { +func (o *OIDC) AuthorizeRevoke(ctx context.Context, token string) error { claims, err := o.authorizeToken(token) if err != nil { return err @@ -284,14 +285,6 @@ func (o *OIDC) AuthorizeSign(ctx context.Context, token string) ([]SignOption, e return nil, err } - // Check for the sign ssh method, default to sign X.509 - if MethodFromContext(ctx) == SignSSHMethod { - if !o.claimer.IsSSHCAEnabled() { - return nil, errors.Errorf("ssh ca is disabled for provisioner %s", o.GetID()) - } - return o.authorizeSSHSign(claims) - } - so := []SignOption{ // modifiers / withOptions newProvisionerExtensionOption(TypeOIDC, o.Name, o.ClientID), @@ -308,16 +301,26 @@ func (o *OIDC) AuthorizeSign(ctx context.Context, token string) ([]SignOption, e return append(so, emailOnlyIdentity(claims.Email)), nil } -// AuthorizeRenewal returns an error if the renewal is disabled. -func (o *OIDC) AuthorizeRenewal(cert *x509.Certificate) error { +// AuthorizeRenew returns an error if the renewal is disabled. +// NOTE: This method does not actually validate the certificate or check it's +// revocation status. Just confirms that the provisioner that created the +// certificate was configured to allow renewals. +func (o *OIDC) AuthorizeRenew(ctx context.Context, cert *x509.Certificate) error { if o.claimer.IsDisableRenewal() { return errors.Errorf("renew is disabled for provisioner %s", o.GetID()) } return nil } -// authorizeSSHSign returns the list of SignOption for a SignSSH request. -func (o *OIDC) authorizeSSHSign(claims *openIDPayload) ([]SignOption, error) { +// AuthorizeSSHSign returns the list of SignOption for a SignSSH request. +func (o *OIDC) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, error) { + if !o.claimer.IsSSHCAEnabled() { + return nil, errors.Errorf("ssh ca is disabled for provisioner %s", o.GetID()) + } + claims, err := o.authorizeToken(token) + if err != nil { + return nil, err + } signOptions := []SignOption{ // set the key id to the token subject sshCertificateKeyIDModifier(claims.Email), @@ -356,6 +359,20 @@ func (o *OIDC) authorizeSSHSign(claims *openIDPayload) ([]SignOption, error) { ), nil } +// AuthorizeSSHRevoke returns nil if the token is valid, false otherwise. +func (o *OIDC) AuthorizeSSHRevoke(ctx context.Context, token string) error { + claims, err := o.authorizeToken(token) + if err != nil { + return err + } + + // Only admins can revoke certificates. + if o.IsAdmin(claims.Email) { + return nil + } + return errors.New("cannot revoke with non-admin token") +} + func getAndDecode(uri string, v interface{}) error { resp, err := http.Get(uri) if err != nil { diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 0ed56832..4a17626c 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -9,6 +9,8 @@ import ( "strings" "github.com/pkg/errors" + "github.com/smallstep/certificates/db" + "golang.org/x/crypto/ssh" ) // Interface is the interface that all provisioner types must implement. @@ -20,27 +22,45 @@ type Interface interface { GetEncryptedKey() (kid string, key string, ok bool) Init(config Config) error AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) - AuthorizeRenewal(cert *x509.Certificate) error - AuthorizeRevoke(token string) error + AuthorizeRevoke(ctx context.Context, token string) error + AuthorizeRenew(ctx context.Context, cert *x509.Certificate) error + AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, error) + AuthorizeSSHRevoke(ctx context.Context, token string) error + AuthorizeSSHRenew(ctx context.Context, token string) (*ssh.Certificate, error) + AuthorizeSSHRekey(ctx context.Context, token string) (*ssh.Certificate, []SignOption, error) } // Audiences stores all supported audiences by request type. type Audiences struct { - Sign []string - Revoke []string + Sign []string + Revoke []string + SSHSign []string + SSHRevoke []string + SSHRenew []string + SSHRekey []string } // All returns all supported audiences across all request types in one list. -func (a Audiences) All() []string { - return append(a.Sign, a.Revoke...) +func (a Audiences) All() (auds []string) { + auds = a.Sign + auds = append(auds, a.Revoke...) + auds = append(auds, a.SSHSign...) + auds = append(auds, a.SSHRevoke...) + auds = append(auds, a.SSHRenew...) + auds = append(auds, a.SSHRekey...) + return } // WithFragment returns a copy of audiences where the url audiences contains the // given fragment. func (a Audiences) WithFragment(fragment string) Audiences { ret := Audiences{ - Sign: make([]string, len(a.Sign)), - Revoke: make([]string, len(a.Revoke)), + Sign: make([]string, len(a.Sign)), + Revoke: make([]string, len(a.Revoke)), + SSHSign: make([]string, len(a.SSHSign)), + SSHRevoke: make([]string, len(a.SSHRevoke)), + SSHRenew: make([]string, len(a.SSHRenew)), + SSHRekey: make([]string, len(a.SSHRekey)), } for i, s := range a.Sign { if u, err := url.Parse(s); err == nil { @@ -56,6 +76,34 @@ func (a Audiences) WithFragment(fragment string) Audiences { ret.Revoke[i] = s } } + for i, s := range a.SSHSign { + if u, err := url.Parse(s); err == nil { + ret.SSHSign[i] = u.ResolveReference(&url.URL{Fragment: fragment}).String() + } else { + ret.SSHSign[i] = s + } + } + for i, s := range a.SSHRevoke { + if u, err := url.Parse(s); err == nil { + ret.SSHRevoke[i] = u.ResolveReference(&url.URL{Fragment: fragment}).String() + } else { + ret.SSHRevoke[i] = s + } + } + for i, s := range a.SSHRenew { + if u, err := url.Parse(s); err == nil { + ret.SSHRenew[i] = u.ResolveReference(&url.URL{Fragment: fragment}).String() + } else { + ret.SSHRenew[i] = s + } + } + for i, s := range a.SSHRekey { + if u, err := url.Parse(s); err == nil { + ret.SSHRekey[i] = u.ResolveReference(&url.URL{Fragment: fragment}).String() + } else { + ret.SSHRekey[i] = s + } + } return ret } @@ -92,11 +140,6 @@ const ( TypeK8sSA Type = 8 // TypeSSHPOP is used to indicate the SSHPOP provisioners. TypeSSHPOP Type = 9 - - // RevokeAudienceKey is the key for the 'revoke' audiences in the audiences map. - RevokeAudienceKey = "revoke" - // SignAudienceKey is the key for the 'sign' audiences in the audiences map. - SignAudienceKey = "sign" ) // String returns the string representation of the type. @@ -125,6 +168,12 @@ func (t Type) String() string { } } +// SSHKeys represents the SSH User and Host public keys. +type SSHKeys struct { + UserKeys []ssh.PublicKey + HostKeys []ssh.PublicKey +} + // Config defines the default parameters used in the initialization of // provisioners. type Config struct { @@ -132,6 +181,10 @@ type Config struct { Claims Claims // Audiences are the audiences used in the default provisioner, (JWK). Audiences Audiences + // DB is the interface to the authority DB client. + DB db.AuthDB + // SSHKeys are the root SSH public keys + SSHKeys *SSHKeys } type provisioner struct { @@ -222,6 +275,50 @@ func SanitizeSSHUserPrincipal(email string) string { }, strings.ToLower(email)) } +type base struct{} + +// AuthorizeSign returns an unimplmented error. Provisioners should overwrite +// this method if they will support authorizing tokens for signing x509 Certificates. +func (b *base) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) { + return nil, errors.New("not implemented; provisioner does not implement AuthorizeSign") +} + +// AuthorizeRevoke returns an unimplmented error. Provisioners should overwrite +// this method if they will support authorizing tokens for revoking x509 Certificates. +func (b *base) AuthorizeRevoke(ctx context.Context, token string) error { + return errors.New("not implemented; provisioner does not implement AuthorizeRevoke") +} + +// AuthorizeRenew returns an unimplmented error. Provisioners should overwrite +// this method if they will support authorizing tokens for renewing x509 Certificates. +func (b *base) AuthorizeRenew(ctx context.Context, cert *x509.Certificate) error { + return errors.New("not implemented; provisioner does not implement AuthorizeRenew") +} + +// AuthorizeSSHSign returns an unimplmented error. Provisioners should overwrite +// this method if they will support authorizing tokens for signing SSH Certificates. +func (b *base) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, error) { + return nil, errors.New("not implemented; provisioner does not implement AuthorizeSSHSign") +} + +// AuthorizeRevoke returns an unimplmented error. Provisioners should overwrite +// this method if they will support authorizing tokens for revoking SSH Certificates. +func (b *base) AuthorizeSSHRevoke(ctx context.Context, token string) error { + return errors.New("not implemented; provisioner does not implement AuthorizeSSHRevoke") +} + +// AuthorizeSSHRenew returns an unimplmented error. Provisioners should overwrite +// this method if they will support authorizing tokens for renewing SSH Certificates. +func (b *base) AuthorizeSSHRenew(ctx context.Context, token string) (*ssh.Certificate, error) { + return nil, errors.New("not implemented; provisioner does not implement AuthorizeSSHRenew") +} + +// AuthorizeSSHRekey returns an unimplmented error. Provisioners should overwrite +// this method if they will support authorizing tokens for renewing SSH Certificates. +func (b *base) AuthorizeSSHRekey(ctx context.Context, token string) (*ssh.Certificate, []SignOption, error) { + return nil, nil, errors.New("not implemented; provisioner does not implement AuthorizeSSHRekey") +} + // MockProvisioner for testing type MockProvisioner struct { Mret1, Mret2, Mret3 interface{} diff --git a/authority/provisioner/sshpop.go b/authority/provisioner/sshpop.go index a49d32e8..e0c4a2f7 100644 --- a/authority/provisioner/sshpop.go +++ b/authority/provisioner/sshpop.go @@ -2,18 +2,14 @@ package provisioner import ( "context" - "crypto/ecdsa" - "crypto/rsa" - "crypto/x509" "encoding/base64" - "encoding/pem" + "fmt" + "strconv" "time" "github.com/pkg/errors" - "github.com/smallstep/cli/crypto/pemutil" - "github.com/smallstep/cli/crypto/x509util" + "github.com/smallstep/certificates/db" "github.com/smallstep/cli/jose" - "golang.org/x/crypto/ed25519" "golang.org/x/crypto/ssh" ) @@ -28,13 +24,14 @@ type sshPOPPayload struct { // SSHPOP is the default provisioner, an entity that can sign tokens necessary for // signature requests. type SSHPOP struct { + *base Type string `json:"type"` Name string `json:"name"` - PubKeys []byte `json:"pubKeys"` Claims *Claims `json:"claims,omitempty"` + db db.AuthDB claimer *Claimer audiences Audiences - sshPubKeys []ssh.PublicKey + sshPubKeys *SSHKeys } // GetID returns the provisioner unique identifier. The name and credential id @@ -83,38 +80,8 @@ func (p *SSHPOP) Init(config Config) error { return errors.New("provisioner type cannot be empty") case p.Name == "": return errors.New("provisioner name cannot be empty") - case len(p.PubKeys) == 0: - return errors.New("provisioner root(s) cannot be empty") - } - - var ( - block *pem.Block - rest = p.PubKeys - ) - for rest != nil { - block, rest = pem.Decode(rest) - if block == nil { - break - } - key, err := pemutil.ParseKey(pem.EncodeToMemory(block)) - if err != nil { - return errors.Wrapf(err, "error parsing public key in provisioner %s", p.GetID()) - } - switch q := key.(type) { - case *rsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey: - sshKey, err := ssh.NewPublicKey(key) - if err != nil { - return errors.Wrap(err, "error converting pub key to SSH pub key") - } - p.sshPubKeys = append(p.sshPubKeys, sshKey) - default: - return errors.Errorf("Unexpected public key type %T in provisioner %s", q, p.GetID()) - } - } - - // Verify that at least one root was found. - if len(p.sshPubKeys) == 0 { - return errors.Errorf("no root public keys found in pub keys attribute for provisioner %s", p.GetName()) + case config.SSHKeys == nil: + return errors.New("provisioner public SSH validation keys cannot be empty") } // Update claims with global ones @@ -124,6 +91,8 @@ func (p *SSHPOP) Init(config Config) error { } p.audiences = config.Audiences.WithFragment(p.GetID()) + p.db = config.DB + p.sshPubKeys = config.SSHKeys return nil } @@ -131,50 +100,63 @@ func (p *SSHPOP) Init(config Config) error { // claims for case specific downstream parsing. // e.g. a Sign request will auth/validate different fields than a Revoke request. func (p *SSHPOP) authorizeToken(token string, audiences []string) (*sshPOPPayload, error) { - jwt, err := jose.ParseSigned(token) + sshCert, err := ExtractSSHPOPCert(token) if err != nil { - return nil, errors.Wrapf(err, "error parsing token") + return nil, errors.Wrap(err, "authorizeToken ssh-pop") } - encodedSSHCert, ok := jwt.Headers[0].ExtraHeaders["sshpop"] - if !ok { - return nil, errors.New("token missing sshpop header") - } - encodedSSHCertStr, ok := encodedSSHCert.(string) - if !ok { - return nil, errors.New("error unexpected type for sshpop header") + // Check for revocation. + if isRevoked, err := p.db.IsSSHRevoked(strconv.FormatUint(sshCert.Serial, 10)); err != nil { + return nil, errors.Wrap(err, "authorizeToken ssh-pop") + } else if isRevoked { + return nil, errors.New("authorizeToken ssh-pop: ssh certificate has been revoked") } - sshCertBytes, err := base64.RawURLEncoding.DecodeString(encodedSSHCertStr) + + jwt, err := jose.ParseSigned(token) if err != nil { - return nil, errors.Wrap(err, "error decoding sshpop header") + return nil, errors.Wrapf(err, "error parsing token") } - sshPub, err := ssh.ParsePublicKey(sshCertBytes) - if err != nil { - return nil, errors.Wrap(err, "error parsing ssh public key") + // Check validity period of the certificate. + n := time.Now() + if sshCert.ValidAfter != 0 && time.Unix(int64(sshCert.ValidAfter), 0).After(n) { + return nil, errors.New("sshpop certificate validAfter is in the future") } - sshCert, ok := sshPub.(*ssh.Certificate) + if sshCert.ValidBefore != 0 && time.Unix(int64(sshCert.ValidBefore), 0).Before(n) { + return nil, errors.New("sshpop certificate validBefore is in the past") + } + sshCryptoPubKey, ok := sshCert.Key.(ssh.CryptoPublicKey) if !ok { - return nil, errors.New("error converting ssh public key to ssh certificate") + return nil, errors.New("ssh public key could not be cast to ssh CryptoPublicKey") } + pubKey := sshCryptoPubKey.CryptoPublicKey() - data := bytesForSigning(sshCert) - var found bool - for _, k := range p.sshPubKeys { + var ( + found bool + data = bytesForSigning(sshCert) + keys []ssh.PublicKey + ) + if sshCert.CertType == ssh.UserCert { + keys = p.sshPubKeys.UserKeys + } else { + keys = p.sshPubKeys.HostKeys + } + for _, k := range keys { if err = (&ssh.Certificate{Key: k}).Verify(data, sshCert.Signature); err == nil { found = true + break } } if !found { return nil, errors.New("error: provisioner could could not verify the sshpop header certificate") } - // Using the leaf certificates key to validate the claims accomplishes two + // Using the ssh certificates key to validate the claims accomplishes two // things: // 1. Asserts that the private key used to sign the token corresponds // to the public certificate in the `sshpop` header of the token. // 2. Asserts that the claims are valid - have not been tampered with. var claims sshPOPPayload - if err = jwt.Claims(sshCert.Key, &claims); err != nil { + if err = jwt.Claims(pubKey, &claims); err != nil { return nil, errors.Wrap(err, "error parsing claims") } @@ -189,6 +171,8 @@ func (p *SSHPOP) authorizeToken(token string, audiences []string) (*sshPOPPayloa // validate audiences with the defaults if !matchesAudience(claims.Audience, audiences) { + fmt.Printf("claims.Audience = %+v\n", claims.Audience) + fmt.Printf("audiences = %+v\n", audiences) return nil, errors.New("invalid token: invalid audience claim (aud)") } @@ -200,102 +184,77 @@ func (p *SSHPOP) authorizeToken(token string, audiences []string) (*sshPOPPayloa return &claims, nil } -// AuthorizeRevoke returns an error if the provisioner does not have rights to -// revoke the certificate with serial number in the `sub` property. -func (p *SSHPOP) AuthorizeRevoke(token string) error { - _, err := p.authorizeToken(token, p.audiences.Revoke) +// AuthorizeSSHRevoke validates the authorization token and extracts/validates +// the SSH certificate from the ssh-pop header. +func (p *SSHPOP) AuthorizeSSHRevoke(ctx context.Context, token string) error { + claims, err := p.authorizeToken(token, p.audiences.SSHRevoke) + if err != nil { + return err + } + if claims.Subject != strconv.FormatUint(claims.sshCert.Serial, 10) { + return errors.New("token subject must be equivalent to certificate serial number") + } return err } -// AuthorizeSign validates the given token. -func (p *SSHPOP) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) { - claims, err := p.authorizeToken(token, p.audiences.Sign) +// AuthorizeSSHRenew validates the authorization token and extracts/validates +// the SSH certificate from the ssh-pop header. +func (p *SSHPOP) AuthorizeSSHRenew(ctx context.Context, token string) (*ssh.Certificate, error) { + claims, err := p.authorizeToken(token, p.audiences.SSHRenew) if err != nil { return nil, err } + return claims.sshCert, nil - // Check for SSH sign-ing request. - if MethodFromContext(ctx) == SignSSHMethod { - if !p.claimer.IsSSHCAEnabled() { - return nil, errors.Errorf("ssh ca is disabled for provisioner %s", p.GetID()) - } - return p.authorizeSSHSign(claims) - } +} - // NOTE: This is for backwards compatibility with older versions of cli - // and certificates. Older versions added the token subject as the only SAN - // in a CSR by default. - if len(claims.SANs) == 0 { - claims.SANs = []string{claims.Subject} +// AuthorizeSSHRekey validates the authorization token and extracts/validates +// the SSH certificate from the ssh-pop header. +func (p *SSHPOP) AuthorizeSSHRekey(ctx context.Context, token string) (*ssh.Certificate, []SignOption, error) { + claims, err := p.authorizeToken(token, p.audiences.SSHRekey) + if err != nil { + return nil, nil, err } - - dnsNames, ips, emails := x509util.SplitSANs(claims.SANs) - - return []SignOption{ - // modifiers / withOptions - newProvisionerExtensionOption(TypeSSHPOP, p.Name, ""), - profileLimitDuration{p.claimer.DefaultTLSCertDuration(), time.Unix(int64(claims.sshCert.ValidBefore), 0)}, - // validators - commonNameValidator(claims.Subject), - defaultPublicKeyValidator{}, - dnsNamesValidator(dnsNames), - emailAddressesValidator(emails), - ipAddressesValidator(ips), - newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()), + return claims.sshCert, []SignOption{ + // Validate public key + &sshDefaultPublicKeyValidator{}, + // Validate the validity period. + &sshCertificateValidityValidator{p.claimer}, + // Require and validate all the default fields in the SSH certificate. + &sshCertificateDefaultValidator{}, }, nil -} -// AuthorizeRenewal returns an error if the renewal is disabled. -func (p *SSHPOP) AuthorizeRenewal(cert *x509.Certificate) error { - if p.claimer.IsDisableRenewal() { - return errors.Errorf("renew is disabled for provisioner %s", p.GetID()) - } - return nil } -// authorizeSSHSign returns the list of SignOption for a SignSSH request. -func (p *SSHPOP) authorizeSSHSign(claims *sshPOPPayload) ([]SignOption, error) { - if claims.Step == nil || claims.Step.SSH == nil { - return nil, errors.New("authorization token must be an SSH provisioning token") - } - opts := claims.Step.SSH - signOptions := []SignOption{ - // validates user's SSHOptions with the ones in the token - sshCertificateOptionsValidator(*opts), - // set the key id to the token subject - sshCertificateKeyIDModifier(claims.Subject), +// ExtractSSHPOPCert parses a JWT and extracts and loads the SSH Certificate +// in the sshpop header. If the header is missing, an error is returned. +func ExtractSSHPOPCert(token string) (*ssh.Certificate, error) { + jwt, err := jose.ParseSigned(token) + if err != nil { + return nil, errors.Wrapf(err, "error parsing token") } - // Add modifiers from custom claims - if opts.CertType != "" { - signOptions = append(signOptions, sshCertificateCertTypeModifier(opts.CertType)) + encodedSSHCert, ok := jwt.Headers[0].ExtraHeaders["sshpop"] + if !ok { + return nil, errors.New("token missing sshpop header") } - if len(opts.Principals) > 0 { - signOptions = append(signOptions, sshCertificatePrincipalsModifier(opts.Principals)) + encodedSSHCertStr, ok := encodedSSHCert.(string) + if !ok { + return nil, errors.New("error unexpected type for sshpop header") } - t := now() - if !opts.ValidAfter.IsZero() { - signOptions = append(signOptions, sshCertificateValidAfterModifier(opts.ValidAfter.RelativeTime(t).Unix())) + sshCertBytes, err := base64.StdEncoding.DecodeString(encodedSSHCertStr) + if err != nil { + return nil, errors.Wrap(err, "error decoding sshpop header") } - if !opts.ValidBefore.IsZero() { - signOptions = append(signOptions, sshCertificateValidBeforeModifier(opts.ValidBefore.RelativeTime(t).Unix())) + sshPub, err := ssh.ParsePublicKey(sshCertBytes) + if err != nil { + return nil, errors.Wrap(err, "error parsing ssh public key") } - - // Default to a user certificate with no principals if not set - signOptions = append(signOptions, sshCertificateDefaultsModifier{CertType: SSHUserCert}) - - return append(signOptions, - // Set the default extensions. - &sshDefaultExtensionModifier{}, - // Checks the validity bounds, and set the validity if has not been set. - sshLimitValidityModifier(p.claimer, time.Unix(int64(claims.sshCert.ValidBefore), 0)), - // Validate public key. - &sshDefaultPublicKeyValidator{}, - // Validate the validity period. - &sshCertificateValidityValidator{p.claimer}, - // Require all the fields in the SSH certificate - &sshCertificateDefaultValidator{}, - ), nil + sshCert, ok := sshPub.(*ssh.Certificate) + if !ok { + return nil, errors.New("error converting ssh public key to ssh certificate") + } + return sshCert, nil } func bytesForSigning(cert *ssh.Certificate) []byte { diff --git a/authority/provisioner/x5c.go b/authority/provisioner/x5c.go index 55725982..84236b2c 100644 --- a/authority/provisioner/x5c.go +++ b/authority/provisioner/x5c.go @@ -22,6 +22,7 @@ type x5cPayload struct { // X5C is the default provisioner, an entity that can sign tokens necessary for // signature requests. type X5C struct { + *base Type string `json:"type"` Name string `json:"name"` Roots []byte `json:"roots"` @@ -170,7 +171,7 @@ func (p *X5C) authorizeToken(token string, audiences []string) (*x5cPayload, err // AuthorizeRevoke returns an error if the provisioner does not have rights to // revoke the certificate with serial number in the `sub` property. -func (p *X5C) AuthorizeRevoke(token string) error { +func (p *X5C) AuthorizeRevoke(ctx context.Context, token string) error { _, err := p.authorizeToken(token, p.audiences.Revoke) return err } @@ -213,8 +214,8 @@ func (p *X5C) AuthorizeSign(ctx context.Context, token string) ([]SignOption, er }, nil } -// AuthorizeRenewal returns an error if the renewal is disabled. -func (p *X5C) AuthorizeRenewal(cert *x509.Certificate) error { +// AuthorizeRenew returns an error if the renewal is disabled. +func (p *X5C) AuthorizeRenew(ctx context.Context, cert *x509.Certificate) error { if p.claimer.IsDisableRenewal() { return errors.Errorf("renew is disabled for provisioner %s", p.GetID()) } diff --git a/authority/ssh.go b/authority/ssh.go index 74833256..9181b7bc 100644 --- a/authority/ssh.go +++ b/authority/ssh.go @@ -1,10 +1,12 @@ package authority import ( + "context" "crypto/rand" "encoding/binary" "net/http" "strings" + "time" "github.com/pkg/errors" "github.com/smallstep/certificates/authority/provisioner" @@ -155,6 +157,22 @@ func (a *Authority) GetSSHConfig(typ string, data map[string]string) ([]template return output, nil } +// authorizeSSHSign loads the provisioner from the token, checks that it has not +// been used again and calls the provisioner AuthorizeSSHSign method. Returns a +// list of methods to apply to the signing flow. +func (a *Authority) authorizeSSHSign(ctx context.Context, ott string) ([]provisioner.SignOption, error) { + var errContext = apiCtx{"ott": ott} + p, err := a.authorizeToken(ott) + if err != nil { + return nil, &apiError{errors.Wrap(err, "authorizeSSHSign"), http.StatusUnauthorized, errContext} + } + opts, err := p.AuthorizeSSHSign(ctx, ott) + if err != nil { + return nil, &apiError{errors.Wrap(err, "authorizeSSHSign"), http.StatusUnauthorized, errContext} + } + return opts, nil +} + // SignSSH creates a signed SSH certificate with the given public key and options. func (a *Authority) SignSSH(key ssh.PublicKey, opts provisioner.SSHOptions, signOpts ...provisioner.SignOption) (*ssh.Certificate, error) { var mods []provisioner.SSHCertificateModifier @@ -274,6 +292,263 @@ func (a *Authority) SignSSH(key ssh.PublicKey, opts provisioner.SSHOptions, sign return cert, nil } +// authorizeSSHRenew authorizes an SSH certificate renewal request, by +// validating the contents of an SSHPOP token. +func (a *Authority) authorizeSSHRenew(ctx context.Context, token string) (*ssh.Certificate, error) { + errContext := map[string]interface{}{"ott": token} + + p, err := a.authorizeToken(token) + if err != nil { + return nil, &apiError{ + err: errors.Wrap(err, "authorizeSSHRenew"), + code: http.StatusUnauthorized, + context: errContext, + } + } + cert, err := p.AuthorizeSSHRenew(ctx, token) + if err != nil { + return nil, &apiError{ + err: errors.Wrap(err, "authorizeSSHRenew"), + code: http.StatusUnauthorized, + context: errContext, + } + } + return cert, nil +} + +// RenewSSH creates a signed SSH certificate using the old SSH certificate as a template. +func (a *Authority) RenewSSH(oldCert *ssh.Certificate) (*ssh.Certificate, error) { + nonce, err := randutil.ASCII(32) + if err != nil { + return nil, &apiError{err: err, code: http.StatusInternalServerError} + } + + var serial uint64 + if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil { + return nil, &apiError{ + err: errors.Wrap(err, "renewSSH: error reading random number"), + code: http.StatusInternalServerError, + } + } + + if oldCert.ValidAfter == 0 || oldCert.ValidBefore == 0 { + return nil, errors.New("rewnewSSh: cannot renew certificate without validity period") + } + dur := time.Duration(oldCert.ValidBefore-oldCert.ValidAfter) * time.Second + va := time.Now() + vb := va.Add(dur) + + // Build base certificate with the key and some random values + cert := &ssh.Certificate{ + Nonce: []byte(nonce), + Key: oldCert.Key, + Serial: serial, + CertType: oldCert.CertType, + KeyId: oldCert.KeyId, + ValidPrincipals: oldCert.ValidPrincipals, + Permissions: oldCert.Permissions, + ValidAfter: uint64(va.Unix()), + ValidBefore: uint64(vb.Unix()), + } + + // Get signer from authority keys + var signer ssh.Signer + switch cert.CertType { + case ssh.UserCert: + if a.sshCAUserCertSignKey == nil { + return nil, &apiError{ + err: errors.New("renewSSH: user certificate signing is not enabled"), + code: http.StatusNotImplemented, + } + } + signer = a.sshCAUserCertSignKey + case ssh.HostCert: + if a.sshCAHostCertSignKey == nil { + return nil, &apiError{ + err: errors.New("renewSSH: host certificate signing is not enabled"), + code: http.StatusNotImplemented, + } + } + signer = a.sshCAHostCertSignKey + default: + return nil, &apiError{ + err: errors.Errorf("renewSSH: unexpected ssh certificate type: %d", cert.CertType), + code: http.StatusInternalServerError, + } + } + cert.SignatureKey = signer.PublicKey() + + // Get bytes for signing trailing the signature length. + data := cert.Marshal() + data = data[:len(data)-4] + + // Sign the certificate + sig, err := signer.Sign(rand.Reader, data) + if err != nil { + return nil, &apiError{ + err: errors.Wrap(err, "renewSSH: error signing certificate"), + code: http.StatusInternalServerError, + } + } + cert.Signature = sig + + if err = a.db.StoreSSHCertificate(cert); err != nil && err != db.ErrNotImplemented { + return nil, &apiError{ + err: errors.Wrap(err, "renewSSH: error storing certificate in db"), + code: http.StatusInternalServerError, + } + } + + return cert, nil +} + +// authorizeSSHRekey authorizes an SSH certificate rekey request, by +// validating the contents of an SSHPOP token. +func (a *Authority) authorizeSSHRekey(ctx context.Context, token string) (*ssh.Certificate, []provisioner.SignOption, error) { + errContext := map[string]interface{}{"ott": token} + + p, err := a.authorizeToken(token) + if err != nil { + return nil, nil, &apiError{ + err: errors.Wrap(err, "authorizeSSHRenew"), + code: http.StatusUnauthorized, + context: errContext, + } + } + cert, opts, err := p.AuthorizeSSHRekey(ctx, token) + if err != nil { + return nil, nil, &apiError{ + err: errors.Wrap(err, "authorizeSSHRekey"), + code: http.StatusUnauthorized, + context: errContext, + } + } + return cert, opts, nil +} + +// RekeySSH creates a signed SSH certificate using the old SSH certificate as a template. +func (a *Authority) RekeySSH(oldCert *ssh.Certificate, pub ssh.PublicKey, signOpts ...provisioner.SignOption) (*ssh.Certificate, error) { + var validators []provisioner.SSHCertificateValidator + + for _, op := range signOpts { + switch o := op.(type) { + // validate the ssh.Certificate + case provisioner.SSHCertificateValidator: + validators = append(validators, o) + default: + return nil, &apiError{ + err: errors.Errorf("rekeySSH: invalid extra option type %T", o), + code: http.StatusInternalServerError, + } + } + } + + nonce, err := randutil.ASCII(32) + if err != nil { + return nil, &apiError{err: err, code: http.StatusInternalServerError} + } + + var serial uint64 + if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil { + return nil, &apiError{ + err: errors.Wrap(err, "rekeySSH: error reading random number"), + code: http.StatusInternalServerError, + } + } + + if oldCert.ValidAfter == 0 || oldCert.ValidBefore == 0 { + return nil, errors.New("rekeySSh: cannot rekey certificate without validity period") + } + dur := time.Duration(oldCert.ValidBefore-oldCert.ValidAfter) * time.Second + va := time.Now() + vb := va.Add(dur) + + // Build base certificate with the key and some random values + cert := &ssh.Certificate{ + Nonce: []byte(nonce), + Key: pub, + Serial: serial, + CertType: oldCert.CertType, + KeyId: oldCert.KeyId, + ValidPrincipals: oldCert.ValidPrincipals, + Permissions: oldCert.Permissions, + ValidAfter: uint64(va.Unix()), + ValidBefore: uint64(vb.Unix()), + } + + // Get signer from authority keys + var signer ssh.Signer + switch cert.CertType { + case ssh.UserCert: + if a.sshCAUserCertSignKey == nil { + return nil, &apiError{ + err: errors.New("rekeySSH: user certificate signing is not enabled"), + code: http.StatusNotImplemented, + } + } + signer = a.sshCAUserCertSignKey + case ssh.HostCert: + if a.sshCAHostCertSignKey == nil { + return nil, &apiError{ + err: errors.New("rekeySSH: host certificate signing is not enabled"), + code: http.StatusNotImplemented, + } + } + signer = a.sshCAHostCertSignKey + default: + return nil, &apiError{ + err: errors.Errorf("rekeySSH: unexpected ssh certificate type: %d", cert.CertType), + code: http.StatusInternalServerError, + } + } + cert.SignatureKey = signer.PublicKey() + + // Get bytes for signing trailing the signature length. + data := cert.Marshal() + data = data[:len(data)-4] + + // Sign the certificate + sig, err := signer.Sign(rand.Reader, data) + if err != nil { + return nil, &apiError{ + err: errors.Wrap(err, "rekeySSH: error signing certificate"), + code: http.StatusInternalServerError, + } + } + cert.Signature = sig + + // User provisioners validators + for _, v := range validators { + if err := v.Valid(cert); err != nil { + return nil, &apiError{err: err, code: http.StatusForbidden} + } + } + + if err = a.db.StoreSSHCertificate(cert); err != nil && err != db.ErrNotImplemented { + return nil, &apiError{ + err: errors.Wrap(err, "rekeySSH: error storing certificate in db"), + code: http.StatusInternalServerError, + } + } + + return cert, nil +} + +// authorizeSSHRevoke authorizes an SSH certificate revoke request, by +// validating the contents of an SSHPOP token. +func (a *Authority) authorizeSSHRevoke(ctx context.Context, token string) error { + errContext := map[string]interface{}{"ott": token} + + p, err := a.authorizeToken(token) + if err != nil { + return &apiError{errors.Wrap(err, "authorizeSSHRevoke"), http.StatusUnauthorized, errContext} + } + if err = p.AuthorizeSSHRevoke(ctx, token); err != nil { + return &apiError{errors.Wrap(err, "authorizeSSHRevoke"), http.StatusUnauthorized, errContext} + } + return nil +} + // SignSSHAddUser signs a certificate that provisions a new user in a server. func (a *Authority) SignSSHAddUser(key ssh.PublicKey, subject *ssh.Certificate) (*ssh.Certificate, error) { if a.sshCAUserCertSignKey == nil { diff --git a/authority/tls.go b/authority/tls.go index eb20639e..e7c8eb3d 100644 --- a/authority/tls.go +++ b/authority/tls.go @@ -1,6 +1,7 @@ package authority import ( + "context" "crypto/tls" "crypto/x509" "encoding/asn1" @@ -16,6 +17,7 @@ import ( "github.com/smallstep/cli/crypto/pemutil" "github.com/smallstep/cli/crypto/tlsutil" "github.com/smallstep/cli/crypto/x509util" + "github.com/smallstep/cli/jose" ) // GetTLSOptions returns the tls options configured. @@ -127,7 +129,7 @@ func (a *Authority) Sign(csr *x509.CertificateRequest, signOpts provisioner.Opti // with a validity window that begins 'now'. func (a *Authority) Renew(oldCert *x509.Certificate) ([]*x509.Certificate, error) { // Check step provisioner extensions - if err := a.authorizeRenewal(oldCert); err != nil { + if err := a.authorizeRenew(oldCert); err != nil { return nil, err } @@ -147,15 +149,15 @@ func (a *Authority) Renew(oldCert *x509.Certificate) ([]*x509.Certificate, error ExtKeyUsage: oldCert.ExtKeyUsage, UnknownExtKeyUsage: oldCert.UnknownExtKeyUsage, BasicConstraintsValid: oldCert.BasicConstraintsValid, - IsCA: oldCert.IsCA, - MaxPathLen: oldCert.MaxPathLen, - MaxPathLenZero: oldCert.MaxPathLenZero, - OCSPServer: oldCert.OCSPServer, - IssuingCertificateURL: oldCert.IssuingCertificateURL, - DNSNames: oldCert.DNSNames, - EmailAddresses: oldCert.EmailAddresses, - IPAddresses: oldCert.IPAddresses, - URIs: oldCert.URIs, + IsCA: oldCert.IsCA, + MaxPathLen: oldCert.MaxPathLen, + MaxPathLenZero: oldCert.MaxPathLenZero, + OCSPServer: oldCert.OCSPServer, + IssuingCertificateURL: oldCert.IssuingCertificateURL, + DNSNames: oldCert.DNSNames, + EmailAddresses: oldCert.EmailAddresses, + IPAddresses: oldCert.IPAddresses, + URIs: oldCert.URIs, PermittedDNSDomainsCritical: oldCert.PermittedDNSDomainsCritical, PermittedDNSDomains: oldCert.PermittedDNSDomains, ExcludedDNSDomains: oldCert.ExcludedDNSDomains, @@ -220,13 +222,14 @@ type RevokeOptions struct { // being renewed. // // TODO: Add OCSP and CRL support. -func (a *Authority) Revoke(opts *RevokeOptions) error { +func (a *Authority) Revoke(ctx context.Context, opts *RevokeOptions) error { errContext := apiCtx{ "serialNumber": opts.Serial, "reasonCode": opts.ReasonCode, "reason": opts.Reason, "passiveOnly": opts.PassiveOnly, "mTLS": opts.MTLS, + "context": string(provisioner.MethodFromContext(ctx)), } if opts.MTLS { errContext["certificate"] = base64.StdEncoding.EncodeToString(opts.Crt.Raw) @@ -242,26 +245,57 @@ func (a *Authority) Revoke(opts *RevokeOptions) error { RevokedAt: time.Now().UTC(), } - // Authorize mTLS or token request and get back a provisioner interface. - p, err := a.authorizeRevoke(opts) - if err != nil { - return &apiError{errors.Wrap(err, "revoke"), - http.StatusUnauthorized, errContext} - } - + var ( + p provisioner.Interface + err error + ) // If not mTLS then get the TokenID of the token. if !opts.MTLS { + // Validate payload + token, err := jose.ParseSigned(opts.OTT) + if err != nil { + return &apiError{errors.Wrapf(err, "revoke: error parsing token"), + http.StatusUnauthorized, errContext} + } + + // Get claims w/out verification. We should have already verified this token + // earlier with a call to authorizeSSHRevoke. + var claims Claims + if err = token.UnsafeClaimsWithoutVerification(&claims); err != nil { + return &apiError{errors.Wrap(err, "revoke"), http.StatusUnauthorized, errContext} + } + + // This method will also validate the audiences for JWK provisioners. + var ok bool + p, ok = a.provisioners.LoadByToken(token, &claims.Claims) + if !ok { + return &apiError{ + errors.Errorf("revoke: provisioner not found"), + http.StatusInternalServerError, errContext} + } rci.TokenID, err = p.GetTokenID(opts.OTT) if err != nil { return &apiError{errors.Wrap(err, "revoke: could not get ID for token"), http.StatusInternalServerError, errContext} } errContext["tokenID"] = rci.TokenID + } else { + // Load the Certificate provisioner if one exists. + p, err = a.LoadProvisionerByCertificate(opts.Crt) + if err != nil { + return &apiError{ + errors.Wrap(err, "revoke: unable to load certificate provisioner"), + http.StatusUnauthorized, errContext} + } } rci.ProvisionerID = p.GetID() errContext["provisionerID"] = rci.ProvisionerID - err = a.db.Revoke(rci) + if provisioner.MethodFromContext(ctx) == provisioner.RevokeSSHMethod { + err = a.db.RevokeSSH(rci) + } else { // default to revoke x509 + err = a.db.Revoke(rci) + } switch err { case nil: return nil diff --git a/ca/client.go b/ca/client.go index 35e07758..68c6be22 100644 --- a/ca/client.go +++ b/ca/client.go @@ -528,6 +528,72 @@ func (c *Client) SSHSign(req *api.SSHSignRequest) (*api.SSHSignResponse, error) return &sign, nil } +// SSHRenew performs the POST /ssh/renew request to the CA and returns the +// api.SSHRenewResponse struct. +func (c *Client) SSHRenew(req *api.SSHRenewRequest) (*api.SSHRenewResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, errors.Wrap(err, "error marshaling request") + } + u := c.endpoint.ResolveReference(&url.URL{Path: "/ssh/renew"}) + resp, err := c.client.Post(u.String(), "application/json", bytes.NewReader(body)) + if err != nil { + return nil, errors.Wrapf(err, "client POST %s failed", u) + } + if resp.StatusCode >= 400 { + return nil, readError(resp.Body) + } + var renew api.SSHRenewResponse + if err := readJSON(resp.Body, &renew); err != nil { + return nil, errors.Wrapf(err, "error reading %s", u) + } + return &renew, nil +} + +// SSHRekey performs the POST /ssh/rekey request to the CA and returns the +// api.SSHRekeyResponse struct. +func (c *Client) SSHRekey(req *api.SSHRekeyRequest) (*api.SSHRekeyResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, errors.Wrap(err, "error marshaling request") + } + u := c.endpoint.ResolveReference(&url.URL{Path: "/ssh/rekey"}) + resp, err := c.client.Post(u.String(), "application/json", bytes.NewReader(body)) + if err != nil { + return nil, errors.Wrapf(err, "client POST %s failed", u) + } + if resp.StatusCode >= 400 { + return nil, readError(resp.Body) + } + var rekey api.SSHRekeyResponse + if err := readJSON(resp.Body, &rekey); err != nil { + return nil, errors.Wrapf(err, "error reading %s", u) + } + return &rekey, nil +} + +// SSHRevoke performs the POST /ssh/revoke request to the CA and returns the +// api.SSHRevokeResponse struct. +func (c *Client) SSHRevoke(req *api.SSHRevokeRequest) (*api.SSHRevokeResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, errors.Wrap(err, "error marshaling request") + } + u := c.endpoint.ResolveReference(&url.URL{Path: "/ssh/revoke"}) + resp, err := c.client.Post(u.String(), "application/json", bytes.NewReader(body)) + if err != nil { + return nil, errors.Wrapf(err, "client POST %s failed", u) + } + if resp.StatusCode >= 400 { + return nil, readError(resp.Body) + } + var revoke api.SSHRevokeResponse + if err := readJSON(resp.Body, &revoke); err != nil { + return nil, errors.Wrapf(err, "error reading %s", u) + } + return &revoke, nil +} + // SSHRoots performs the GET /ssh/roots request to the CA and returns the // api.SSHRootsResponse struct. func (c *Client) SSHRoots() (*api.SSHRootsResponse, error) { diff --git a/db/db.go b/db/db.go index 5195e1e3..7535185b 100644 --- a/db/db.go +++ b/db/db.go @@ -16,6 +16,7 @@ import ( var ( certsTable = []byte("x509_certs") revokedCertsTable = []byte("revoked_x509_certs") + revokedSSHCertsTable = []byte("revoked_ssh_certs") usedOTTTable = []byte("used_ott") sshCertsTable = []byte("ssh_certs") sshHostsTable = []byte("ssh_hosts") @@ -38,7 +39,9 @@ type Config struct { // AuthDB is an interface over an Authority DB client that implements a nosql.DB interface. type AuthDB interface { IsRevoked(sn string) (bool, error) + IsSSHRevoked(sn string) (bool, error) Revoke(rci *RevokedCertificateInfo) error + RevokeSSH(rci *RevokedCertificateInfo) error StoreCertificate(crt *x509.Certificate) error UseToken(id, tok string) (bool, error) IsSSHHost(name string) (bool, error) @@ -68,6 +71,7 @@ func New(c *Config) (AuthDB, error) { tables := [][]byte{ revokedCertsTable, certsTable, usedOTTTable, sshCertsTable, sshHostsTable, sshHostPrincipalsTable, sshUsersTable, + revokedSSHCertsTable, } for _, b := range tables { if err := db.CreateTable(b); err != nil { @@ -114,6 +118,29 @@ func (db *DB) IsRevoked(sn string) (bool, error) { return true, nil } +// IsSSHRevoked returns whether or not a certificate with the given identifier +// has been revoked. +// In the case of an X509 Certificate the `id` should be the Serial Number of +// the Certificate. +func (db *DB) IsSSHRevoked(sn string) (bool, error) { + // If the DB is nil then act as pass through. + if db == nil { + return false, nil + } + + // If the error is `Not Found` then the certificate has not been revoked. + // Any other error should be propagated to the caller. + if _, err := db.Get(revokedSSHCertsTable, []byte(sn)); err != nil { + if nosql.IsErrNotFound(err) { + return false, nil + } + return false, errors.Wrap(err, "error checking revocation bucket") + } + + // This certificate has been revoked. + return true, nil +} + // Revoke adds a certificate to the revocation table. func (db *DB) Revoke(rci *RevokedCertificateInfo) error { rcib, err := json.Marshal(rci) @@ -132,6 +159,24 @@ func (db *DB) Revoke(rci *RevokedCertificateInfo) error { } } +// RevokeSSH adds a SSH certificate to the revocation table. +func (db *DB) RevokeSSH(rci *RevokedCertificateInfo) error { + rcib, err := json.Marshal(rci) + if err != nil { + return errors.Wrap(err, "error marshaling revoked certificate info") + } + + _, swapped, err := db.CmpAndSwap(revokedSSHCertsTable, []byte(rci.Serial), nil, rcib) + switch { + case err != nil: + return errors.Wrap(err, "error AuthDB CmpAndSwap") + case !swapped: + return ErrAlreadyExists + default: + return nil + } +} + // StoreCertificate stores a certificate PEM. func (db *DB) StoreCertificate(crt *x509.Certificate) error { if err := db.Set(certsTable, []byte(crt.SerialNumber.String()), crt.Raw); err != nil { diff --git a/db/simple.go b/db/simple.go index b0733d8d..05626497 100644 --- a/db/simple.go +++ b/db/simple.go @@ -31,11 +31,21 @@ func (s *SimpleDB) IsRevoked(sn string) (bool, error) { return false, nil } +// IsSSHRevoked noop +func (s *SimpleDB) IsSSHRevoked(sn string) (bool, error) { + return false, nil +} + // Revoke returns a "NotImplemented" error. func (s *SimpleDB) Revoke(rci *RevokedCertificateInfo) error { return ErrNotImplemented } +// RevokeSSH returns a "NotImplemented" error. +func (s *SimpleDB) RevokeSSH(rci *RevokedCertificateInfo) error { + return ErrNotImplemented +} + // StoreCertificate returns a "NotImplemented" error. func (s *SimpleDB) StoreCertificate(crt *x509.Certificate) error { return ErrNotImplemented