forked from TrueCloudLab/certificates
452 lines
13 KiB
Go
452 lines
13 KiB
Go
package provisioner
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"net"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
"github.com/slackhq/nebula/cert"
|
|
"github.com/smallstep/certificates/errs"
|
|
"go.step.sm/crypto/jose"
|
|
"go.step.sm/crypto/sshutil"
|
|
"go.step.sm/crypto/x25519"
|
|
"go.step.sm/crypto/x509util"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
const (
|
|
// NebulaCertHeader is the token header that contains a nebula certificate.
|
|
NebulaCertHeader jose.HeaderKey = "nebula"
|
|
)
|
|
|
|
// Nebula is a provisioner that verifies tokens signed using nebula private
|
|
// keys. The tokens embed a header parameter with the certificate that can be
|
|
// used to verify the signature. Those certificates are verified using the
|
|
// Nebula CAs encoded in Roots. The process is similar to X5C or SSHPOP tokens.
|
|
//
|
|
// Because of Nebula "leaf" certificates use X25519 keys, the tokens are signed
|
|
// using XEd25519 defined at
|
|
// https://signal.org/docs/specifications/xeddsa/#xeddsa and implemented by
|
|
// go.step.sm/crypto/x25519.
|
|
type Nebula struct {
|
|
ID string `json:"-"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Roots []byte `json:"roots"`
|
|
Claims *Claims `json:"claims,omitempty"`
|
|
Options *Options `json:"options,omitempty"`
|
|
claimer *Claimer
|
|
caPool *cert.NebulaCAPool
|
|
audiences Audiences
|
|
}
|
|
|
|
// Init verifies and initializes the nebula provisioner.
|
|
func (p *Nebula) 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.Roots) == 0:
|
|
return errors.New("provisioner root(s) cannot be empty")
|
|
}
|
|
|
|
var err error
|
|
if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil {
|
|
return err
|
|
}
|
|
|
|
p.caPool, err = cert.NewCAPoolFromBytes(p.Roots)
|
|
if err != nil {
|
|
return errs.InternalServer("failed to create ca pool: %v", err)
|
|
}
|
|
|
|
p.audiences = config.Audiences.WithFragment(p.GetIDForToken())
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetID returns the provisioner id.
|
|
func (p *Nebula) GetID() string {
|
|
if p.ID != "" {
|
|
return p.ID
|
|
}
|
|
return p.GetIDForToken()
|
|
}
|
|
|
|
// GetIDForToken returns an identifier that will be used to load the provisioner
|
|
// from a token.
|
|
func (p *Nebula) GetIDForToken() string {
|
|
return "nebula/" + p.Name
|
|
}
|
|
|
|
// GetTokenID returns the identifier of the token.
|
|
func (p *Nebula) GetTokenID(token string) (string, error) {
|
|
// Validate payload
|
|
t, err := jose.ParseSigned(token)
|
|
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 = t.UnsafeClaimsWithoutVerification(&claims); err != nil {
|
|
return "", errors.Wrap(err, "error verifying claims")
|
|
}
|
|
return claims.ID, nil
|
|
}
|
|
|
|
// GetName returns the name of the provisioner.
|
|
func (p *Nebula) GetName() string {
|
|
return p.Name
|
|
}
|
|
|
|
// GetType returns the type of provisioner.
|
|
func (p *Nebula) GetType() Type {
|
|
return TypeNebula
|
|
}
|
|
|
|
// GetEncryptedKey returns the base provisioner encrypted key if it's defined.
|
|
func (p *Nebula) GetEncryptedKey() (kid, key string, ok bool) {
|
|
return "", "", false
|
|
}
|
|
|
|
// AuthorizeSign returns the list of SignOption for a Sign request.
|
|
func (p *Nebula) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) {
|
|
crt, claims, err := p.authorizeToken(token, p.audiences.Sign)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sans := claims.SANs
|
|
if len(sans) == 0 {
|
|
sans = make([]string, len(crt.Details.Ips)+1)
|
|
sans[0] = crt.Details.Name
|
|
for i, ipnet := range crt.Details.Ips {
|
|
sans[i+1] = ipnet.IP.String()
|
|
}
|
|
}
|
|
|
|
data := x509util.CreateTemplateData(claims.Subject, sans)
|
|
if v, err := unsafeParseSigned(token); err == nil {
|
|
data.SetToken(v)
|
|
}
|
|
|
|
// The nebula certificate will be available using the template variable Crt.
|
|
// For example {{ .Crt.Details.Groups }} can be used to get all the groups.
|
|
data.SetAuthorizationCertificate(crt)
|
|
|
|
templateOptions, err := TemplateOptions(p.Options, data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return []SignOption{
|
|
templateOptions,
|
|
// modifiers / withOptions
|
|
newProvisionerExtensionOption(TypeNebula, p.Name, ""),
|
|
profileLimitDuration{
|
|
def: p.claimer.DefaultTLSCertDuration(),
|
|
notBefore: crt.Details.NotBefore,
|
|
notAfter: crt.Details.NotAfter,
|
|
},
|
|
// validators
|
|
commonNameValidator(claims.Subject),
|
|
nebulaSANsValidator{
|
|
Name: crt.Details.Name,
|
|
IPs: crt.Details.Ips,
|
|
},
|
|
defaultPublicKeyValidator{},
|
|
newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()),
|
|
}, nil
|
|
}
|
|
|
|
// AuthorizeSSHSign returns the list of SignOption for a SignSSH request.
|
|
// Currently the nebula provisioner only grant host ssh certificates
|
|
func (p *Nebula) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, error) {
|
|
if !p.claimer.IsSSHCAEnabled() {
|
|
return nil, errs.Unauthorized("ssh is disabled for nebula provisioner '%s'", p.Name)
|
|
}
|
|
|
|
crt, claims, err := p.authorizeToken(token, p.audiences.SSHSign)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Default template attributes.
|
|
keyID := claims.Subject
|
|
principals := make([]string, len(crt.Details.Ips)+1)
|
|
principals[0] = crt.Details.Name
|
|
for i, ipnet := range crt.Details.Ips {
|
|
principals[i+1] = ipnet.IP.String()
|
|
}
|
|
|
|
var signOptions []SignOption
|
|
// If step ssh options are given, validate them and set key id, principals
|
|
// and validity.
|
|
if claims.Step != nil && claims.Step.SSH != nil {
|
|
opts := claims.Step.SSH
|
|
|
|
// Check that the token only contains valid principals.
|
|
v := nebulaPrincipalsValidator{
|
|
Name: crt.Details.Name,
|
|
IPs: crt.Details.Ips,
|
|
}
|
|
if err := v.Valid(*opts); err != nil {
|
|
return nil, err
|
|
}
|
|
// Check that the cert type is a valid one.
|
|
if opts.CertType != "" && opts.CertType != SSHHostCert {
|
|
return nil, errs.Forbidden("ssh certificate type does not match - got %v, want %v", opts.CertType, SSHHostCert)
|
|
}
|
|
|
|
signOptions = []SignOption{
|
|
// validate is a host certificate and users's KeyID is the subject.
|
|
sshCertOptionsValidator(SignSSHOptions{
|
|
CertType: SSHHostCert,
|
|
KeyID: claims.Subject,
|
|
}),
|
|
// validates user's SSHOptions with the ones in the token
|
|
sshCertOptionsValidator(*opts),
|
|
}
|
|
|
|
// Use options in the token.
|
|
if opts.KeyID != "" {
|
|
keyID = opts.KeyID
|
|
}
|
|
if len(opts.Principals) > 0 {
|
|
principals = opts.Principals
|
|
}
|
|
|
|
// Add modifiers from custom claims
|
|
t := now()
|
|
if !opts.ValidAfter.IsZero() {
|
|
signOptions = append(signOptions, sshCertValidAfterModifier(opts.ValidAfter.RelativeTime(t).Unix()))
|
|
}
|
|
if !opts.ValidBefore.IsZero() {
|
|
signOptions = append(signOptions, sshCertValidBeforeModifier(opts.ValidBefore.RelativeTime(t).Unix()))
|
|
}
|
|
}
|
|
|
|
// Certificate templates.
|
|
data := sshutil.CreateTemplateData(sshutil.HostCert, keyID, principals)
|
|
if v, err := unsafeParseSigned(token); err == nil {
|
|
data.SetToken(v)
|
|
}
|
|
|
|
// The nebula certificate will be available using the template variable Crt.
|
|
// For example {{ .AuthorizationCrt.Details.Groups }} can be used to get all the groups.
|
|
data.SetAuthorizationCertificate(crt)
|
|
|
|
templateOptions, err := TemplateSSHOptions(p.Options, data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return append(signOptions,
|
|
templateOptions,
|
|
// Checks the validity bounds, and set the validity if has not been set.
|
|
&sshLimitDuration{p.claimer, crt.Details.NotAfter},
|
|
// Validate public key.
|
|
&sshDefaultPublicKeyValidator{},
|
|
// Validate the validity period.
|
|
&sshCertValidityValidator{p.claimer},
|
|
// Require all the fields in the SSH certificate
|
|
&sshCertDefaultValidator{},
|
|
), nil
|
|
}
|
|
|
|
// AuthorizeRenew returns an error if the renewal is disabled.
|
|
func (p *Nebula) AuthorizeRenew(ctx context.Context, crt *x509.Certificate) error {
|
|
if p.claimer.IsDisableRenewal() {
|
|
return errs.Unauthorized("renew is disabled for nebula provisioner '%s'", p.GetName())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AuthorizeRevoke returns an error if the token is not valid.
|
|
func (p *Nebula) AuthorizeRevoke(ctx context.Context, token string) error {
|
|
return p.validateToken(token, p.audiences.Revoke)
|
|
}
|
|
|
|
// AuthorizeSSHRevoke returns an error if SSH is disabled or the token is invalid.
|
|
func (p *Nebula) AuthorizeSSHRevoke(ctx context.Context, token string) error {
|
|
if !p.claimer.IsSSHCAEnabled() {
|
|
return errs.Unauthorized("ssh is disabled for nebula provisioner '%s'", p.Name)
|
|
}
|
|
if _, _, err := p.authorizeToken(token, p.audiences.SSHRevoke); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AuthorizeSSHRenew returns an unauthorized error.
|
|
func (p *Nebula) AuthorizeSSHRenew(ctx context.Context, token string) (*ssh.Certificate, error) {
|
|
return nil, errs.Unauthorized("nebula provisioner does not support SSH renew")
|
|
}
|
|
|
|
// AuthorizeSSHRekey returns an unauthorized error.
|
|
func (p *Nebula) AuthorizeSSHRekey(ctx context.Context, token string) (*ssh.Certificate, []SignOption, error) {
|
|
return nil, nil, errs.Unauthorized("nebula provisioner does not support SSH rekey")
|
|
}
|
|
|
|
func (p *Nebula) validateToken(token string, audiences []string) error {
|
|
_, _, err := p.authorizeToken(token, audiences)
|
|
return err
|
|
}
|
|
|
|
func (p *Nebula) authorizeToken(token string, audiences []string) (*cert.NebulaCertificate, *jwtPayload, error) {
|
|
jwt, err := jose.ParseSigned(token)
|
|
if err != nil {
|
|
return nil, nil, errs.UnauthorizedErr(err, errs.WithMessage("failed to parse token"))
|
|
}
|
|
|
|
// Extract nebula certificate
|
|
h, ok := jwt.Headers[0].ExtraHeaders[NebulaCertHeader]
|
|
if !ok {
|
|
return nil, nil, errs.Unauthorized("failed to parse token: nebula header is missing")
|
|
}
|
|
s, ok := h.(string)
|
|
if !ok {
|
|
return nil, nil, errs.Unauthorized("failed to parse token: nebula header is not valid")
|
|
}
|
|
b, err := base64.StdEncoding.DecodeString(s)
|
|
if err != nil {
|
|
return nil, nil, errs.UnauthorizedErr(err, errs.WithMessage("failed to parse token: nebula header is not valid"))
|
|
}
|
|
c, err := cert.UnmarshalNebulaCertificate(b)
|
|
if err != nil {
|
|
return nil, nil, errs.UnauthorizedErr(err, errs.WithMessage("failed to parse nebula certificate: nebula header is not valid"))
|
|
}
|
|
|
|
// Validate nebula certificate against CA
|
|
if valid, err := c.Verify(now(), p.caPool); !valid {
|
|
if err != nil {
|
|
return nil, nil, errs.UnauthorizedErr(err, errs.WithMessage("token is not valid: failed to unmarshal certificate"))
|
|
}
|
|
return nil, nil, errs.Unauthorized("token is not valid: failed to unmarshal certificate")
|
|
}
|
|
|
|
var pub interface{}
|
|
if c.Details.IsCA {
|
|
pub = ed25519.PublicKey(c.Details.PublicKey)
|
|
} else {
|
|
pub = x25519.PublicKey(c.Details.PublicKey)
|
|
}
|
|
|
|
// Validate token with public key
|
|
var claims jwtPayload
|
|
if err := jose.Verify(jwt, pub, &claims); err != nil {
|
|
return nil, nil, errs.UnauthorizedErr(err, errs.WithMessage("token is not valid: signature does not match"))
|
|
}
|
|
|
|
// 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: now(),
|
|
}, time.Minute); err != nil {
|
|
return nil, nil, errs.UnauthorizedErr(err, errs.WithMessage("token is not valid: invalid claims"))
|
|
}
|
|
// Validate token and subject too.
|
|
if !matchesAudience(claims.Audience, audiences) {
|
|
return nil, nil, errs.Unauthorized("token is not valid: invalid claims")
|
|
}
|
|
if claims.Subject == "" {
|
|
return nil, nil, errs.Unauthorized("token is not valid: subject cannot be empty")
|
|
}
|
|
|
|
return c, &claims, nil
|
|
}
|
|
|
|
type nebulaSANsValidator struct {
|
|
Name string
|
|
IPs []*net.IPNet
|
|
}
|
|
|
|
// Valid verifies that the SANs stored in the validator are contained with those
|
|
// requested in the x509 certificate request.
|
|
func (v nebulaSANsValidator) Valid(req *x509.CertificateRequest) error {
|
|
dnsNames, ips, emails, uris := x509util.SplitSANs([]string{v.Name})
|
|
if len(req.DNSNames) > 0 {
|
|
if err := dnsNamesValidator(dnsNames).Valid(req); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(req.EmailAddresses) > 0 {
|
|
if err := emailAddressesValidator(emails).Valid(req); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(req.URIs) > 0 {
|
|
if err := urisValidator(uris).Valid(req); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(req.IPAddresses) > 0 {
|
|
for _, ip := range req.IPAddresses {
|
|
var valid bool
|
|
// Check ip in name
|
|
for _, ipInName := range ips {
|
|
if ip.Equal(ipInName) {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
// Check ip network
|
|
if !valid {
|
|
for _, ipnet := range v.IPs {
|
|
if ip.Equal(ipnet.IP) {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !valid {
|
|
return errs.Forbidden("certificate request does not contain a valid IP addresses - got %v, want %v", req.IPAddresses, v.IPs)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type nebulaPrincipalsValidator struct {
|
|
Name string
|
|
IPs []*net.IPNet
|
|
}
|
|
|
|
// Valid checks that the SignSSHOptions principals contains only names in the
|
|
// nebula certificate.
|
|
func (v nebulaPrincipalsValidator) Valid(got SignSSHOptions) error {
|
|
for _, p := range got.Principals {
|
|
var valid bool
|
|
if p == v.Name {
|
|
valid = true
|
|
}
|
|
if !valid {
|
|
if ip := net.ParseIP(p); ip != nil {
|
|
for _, ipnet := range v.IPs {
|
|
if ip.Equal(ipnet.IP) {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !valid {
|
|
return errs.Forbidden(
|
|
"ssh certificate principals does not contain a valid name or IP address - got %v, want %s or %v",
|
|
got.Principals, v.Name, v.IPs,
|
|
)
|
|
}
|
|
}
|
|
return nil
|
|
}
|