308 lines
9.5 KiB
Go
308 lines
9.5 KiB
Go
|
package provisioner
|
||
|
|
||
|
import (
|
||
|
"context"
|
||
|
"crypto/ecdsa"
|
||
|
"crypto/rsa"
|
||
|
"crypto/x509"
|
||
|
"encoding/base64"
|
||
|
"encoding/pem"
|
||
|
"time"
|
||
|
|
||
|
"github.com/pkg/errors"
|
||
|
"github.com/smallstep/cli/crypto/pemutil"
|
||
|
"github.com/smallstep/cli/crypto/x509util"
|
||
|
"github.com/smallstep/cli/jose"
|
||
|
"golang.org/x/crypto/ed25519"
|
||
|
"golang.org/x/crypto/ssh"
|
||
|
)
|
||
|
|
||
|
// sshPOPPayload extends jwt.Claims with step attributes.
|
||
|
type sshPOPPayload struct {
|
||
|
jose.Claims
|
||
|
SANs []string `json:"sans,omitempty"`
|
||
|
Step *stepPayload `json:"step,omitempty"`
|
||
|
sshCert *ssh.Certificate
|
||
|
}
|
||
|
|
||
|
// SSHPOP is the default provisioner, an entity that can sign tokens necessary for
|
||
|
// signature requests.
|
||
|
type SSHPOP struct {
|
||
|
Type string `json:"type"`
|
||
|
Name string `json:"name"`
|
||
|
PubKeys []byte `json:"pubKeys"`
|
||
|
Claims *Claims `json:"claims,omitempty"`
|
||
|
claimer *Claimer
|
||
|
audiences Audiences
|
||
|
sshPubKeys []ssh.PublicKey
|
||
|
}
|
||
|
|
||
|
// GetID returns the provisioner unique identifier. The name and credential id
|
||
|
// should uniquely identify any SSH-POP provisioner.
|
||
|
func (p *SSHPOP) GetID() string {
|
||
|
return "sshpop/" + p.Name
|
||
|
}
|
||
|
|
||
|
// GetTokenID returns the identifier of the token.
|
||
|
func (p *SSHPOP) GetTokenID(ott string) (string, error) {
|
||
|
// Validate payload
|
||
|
token, err := jose.ParseSigned(ott)
|
||
|
if err != nil {
|
||
|
return "", errors.Wrap(err, "error parsing token")
|
||
|
}
|
||
|
|
||
|
// Get claims w/out verification. We need to look up the provisioner
|
||
|
// key in order to verify the claims and we need the issuer from the claims
|
||
|
// before we can look up the provisioner.
|
||
|
var claims jose.Claims
|
||
|
if err = token.UnsafeClaimsWithoutVerification(&claims); err != nil {
|
||
|
return "", errors.Wrap(err, "error verifying claims")
|
||
|
}
|
||
|
return claims.ID, nil
|
||
|
}
|
||
|
|
||
|
// GetName returns the name of the provisioner.
|
||
|
func (p *SSHPOP) GetName() string {
|
||
|
return p.Name
|
||
|
}
|
||
|
|
||
|
// GetType returns the type of provisioner.
|
||
|
func (p *SSHPOP) GetType() Type {
|
||
|
return TypeSSHPOP
|
||
|
}
|
||
|
|
||
|
// GetEncryptedKey returns the base provisioner encrypted key if it's defined.
|
||
|
func (p *SSHPOP) GetEncryptedKey() (string, string, bool) {
|
||
|
return "", "", false
|
||
|
}
|
||
|
|
||
|
// Init initializes and validates the fields of a SSHPOP type.
|
||
|
func (p *SSHPOP) Init(config Config) error {
|
||
|
switch {
|
||
|
case p.Type == "":
|
||
|
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())
|
||
|
}
|
||
|
|
||
|
// Update claims with global ones
|
||
|
var err error
|
||
|
if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
p.audiences = config.Audiences.WithFragment(p.GetID())
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// authorizeToken performs common jwt authorization actions and returns the
|
||
|
// 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)
|
||
|
if err != nil {
|
||
|
return nil, errors.Wrapf(err, "error parsing token")
|
||
|
}
|
||
|
|
||
|
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")
|
||
|
}
|
||
|
sshCertBytes, err := base64.RawURLEncoding.DecodeString(encodedSSHCertStr)
|
||
|
if err != nil {
|
||
|
return nil, errors.Wrap(err, "error decoding sshpop header")
|
||
|
}
|
||
|
sshPub, err := ssh.ParsePublicKey(sshCertBytes)
|
||
|
if err != nil {
|
||
|
return nil, errors.Wrap(err, "error parsing ssh public key")
|
||
|
}
|
||
|
sshCert, ok := sshPub.(*ssh.Certificate)
|
||
|
if !ok {
|
||
|
return nil, errors.New("error converting ssh public key to ssh certificate")
|
||
|
}
|
||
|
|
||
|
data := bytesForSigning(sshCert)
|
||
|
var found bool
|
||
|
for _, k := range p.sshPubKeys {
|
||
|
if err = (&ssh.Certificate{Key: k}).Verify(data, sshCert.Signature); err == nil {
|
||
|
found = true
|
||
|
}
|
||
|
}
|
||
|
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
|
||
|
// 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 {
|
||
|
return nil, errors.Wrap(err, "error parsing claims")
|
||
|
}
|
||
|
|
||
|
// According to "rfc7519 JSON Web Token" acceptable skew should be no
|
||
|
// more than a few minutes.
|
||
|
if err = claims.ValidateWithLeeway(jose.Expected{
|
||
|
Issuer: p.Name,
|
||
|
Time: time.Now().UTC(),
|
||
|
}, time.Minute); err != nil {
|
||
|
return nil, errors.Wrapf(err, "invalid token")
|
||
|
}
|
||
|
|
||
|
// validate audiences with the defaults
|
||
|
if !matchesAudience(claims.Audience, audiences) {
|
||
|
return nil, errors.New("invalid token: invalid audience claim (aud)")
|
||
|
}
|
||
|
|
||
|
if claims.Subject == "" {
|
||
|
return nil, errors.New("token subject cannot be empty")
|
||
|
}
|
||
|
|
||
|
claims.sshCert = sshCert
|
||
|
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)
|
||
|
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)
|
||
|
if err != nil {
|
||
|
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.
|
||
|
if len(claims.SANs) == 0 {
|
||
|
claims.SANs = []string{claims.Subject}
|
||
|
}
|
||
|
|
||
|
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()),
|
||
|
}, 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),
|
||
|
}
|
||
|
|
||
|
// Add modifiers from custom claims
|
||
|
if opts.CertType != "" {
|
||
|
signOptions = append(signOptions, sshCertificateCertTypeModifier(opts.CertType))
|
||
|
}
|
||
|
if len(opts.Principals) > 0 {
|
||
|
signOptions = append(signOptions, sshCertificatePrincipalsModifier(opts.Principals))
|
||
|
}
|
||
|
t := now()
|
||
|
if !opts.ValidAfter.IsZero() {
|
||
|
signOptions = append(signOptions, sshCertificateValidAfterModifier(opts.ValidAfter.RelativeTime(t).Unix()))
|
||
|
}
|
||
|
if !opts.ValidBefore.IsZero() {
|
||
|
signOptions = append(signOptions, sshCertificateValidBeforeModifier(opts.ValidBefore.RelativeTime(t).Unix()))
|
||
|
}
|
||
|
|
||
|
// 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
|
||
|
}
|
||
|
|
||
|
func bytesForSigning(cert *ssh.Certificate) []byte {
|
||
|
c2 := *cert
|
||
|
c2.Signature = nil
|
||
|
out := c2.Marshal()
|
||
|
// Drop trailing signature length.
|
||
|
return out[:len(out)-4]
|
||
|
}
|