forked from TrueCloudLab/certificates
388 lines
12 KiB
Go
388 lines
12 KiB
Go
package provisioner
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
"github.com/smallstep/cli/jose"
|
|
)
|
|
|
|
// gcpCertsURL is the url that serves Google OAuth2 public keys.
|
|
const gcpCertsURL = "https://www.googleapis.com/oauth2/v3/certs"
|
|
|
|
// gcpIdentityURL is the base url for the identity document in GCP.
|
|
const gcpIdentityURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity"
|
|
|
|
// gcpPayload extends jwt.Claims with custom GCP attributes.
|
|
type gcpPayload struct {
|
|
jose.Claims
|
|
AuthorizedParty string `json:"azp"`
|
|
Email string `json:"email"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
Google gcpGooglePayload `json:"google"`
|
|
}
|
|
|
|
type gcpGooglePayload struct {
|
|
ComputeEngine gcpComputeEnginePayload `json:"compute_engine"`
|
|
}
|
|
|
|
type gcpComputeEnginePayload struct {
|
|
InstanceID string `json:"instance_id"`
|
|
InstanceName string `json:"instance_name"`
|
|
InstanceCreationTimestamp *jose.NumericDate `json:"instance_creation_timestamp"`
|
|
ProjectID string `json:"project_id"`
|
|
ProjectNumber int64 `json:"project_number"`
|
|
Zone string `json:"zone"`
|
|
LicenseID []string `json:"license_id"`
|
|
}
|
|
|
|
type gcpConfig struct {
|
|
CertsURL string
|
|
IdentityURL string
|
|
}
|
|
|
|
func newGCPConfig() *gcpConfig {
|
|
return &gcpConfig{
|
|
CertsURL: gcpCertsURL,
|
|
IdentityURL: gcpIdentityURL,
|
|
}
|
|
}
|
|
|
|
// GCP is the provisioner that supports identity tokens created by the Google
|
|
// Cloud Platform metadata API.
|
|
//
|
|
// If DisableCustomSANs is true, only the internal DNS and IP will be added as a
|
|
// SAN. By default it will accept any SAN in the CSR.
|
|
//
|
|
// If DisableTrustOnFirstUse is true, multiple sign request for this provisioner
|
|
// with the same instance will be accepted. By default only the first request
|
|
// will be accepted.
|
|
//
|
|
// If InstanceAge is set, only the instances with an instance_creation_timestamp
|
|
// within the given period will be accepted.
|
|
//
|
|
// Google Identity docs are available at
|
|
// https://cloud.google.com/compute/docs/instances/verifying-instance-identity
|
|
type GCP struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
ServiceAccounts []string `json:"serviceAccounts"`
|
|
ProjectIDs []string `json:"projectIDs"`
|
|
DisableCustomSANs bool `json:"disableCustomSANs"`
|
|
DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"`
|
|
InstanceAge Duration `json:"instanceAge,omitempty"`
|
|
Claims *Claims `json:"claims,omitempty"`
|
|
claimer *Claimer
|
|
config *gcpConfig
|
|
keyStore *keyStore
|
|
audiences Audiences
|
|
}
|
|
|
|
// GetID returns the provisioner unique identifier. The name should uniquely
|
|
// identify any GCP provisioner.
|
|
func (p *GCP) GetID() string {
|
|
return "gcp/" + p.Name
|
|
}
|
|
|
|
// GetTokenID returns the identifier of the token. The default value for GCP the
|
|
// SHA256 of "provisioner_id.instance_id", but if DisableTrustOnFirstUse is set
|
|
// to true, then it will be the SHA256 of the token.
|
|
func (p *GCP) GetTokenID(token string) (string, error) {
|
|
jwt, err := jose.ParseSigned(token)
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "error parsing token")
|
|
}
|
|
|
|
// If TOFU is disabled create an ID for the token, so it cannot be reused.
|
|
if p.DisableTrustOnFirstUse {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return strings.ToLower(hex.EncodeToString(sum[:])), nil
|
|
}
|
|
|
|
// Get claims w/out verification.
|
|
var claims gcpPayload
|
|
if err = jwt.UnsafeClaimsWithoutVerification(&claims); err != nil {
|
|
return "", errors.Wrap(err, "error verifying claims")
|
|
}
|
|
|
|
// Create unique ID for Trust On First Use (TOFU). Only the first instance
|
|
// per provisioner is allowed as we don't have a way to trust the given
|
|
// sans.
|
|
unique := fmt.Sprintf("%s.%s", p.GetID(), claims.Google.ComputeEngine.InstanceID)
|
|
sum := sha256.Sum256([]byte(unique))
|
|
return strings.ToLower(hex.EncodeToString(sum[:])), nil
|
|
}
|
|
|
|
// GetName returns the name of the provisioner.
|
|
func (p *GCP) GetName() string {
|
|
return p.Name
|
|
}
|
|
|
|
// GetType returns the type of provisioner.
|
|
func (p *GCP) GetType() Type {
|
|
return TypeGCP
|
|
}
|
|
|
|
// GetEncryptedKey is not available in a GCP provisioner.
|
|
func (p *GCP) GetEncryptedKey() (kid string, key string, ok bool) {
|
|
return "", "", false
|
|
}
|
|
|
|
// GetIdentityURL returns the url that generates the GCP token.
|
|
func (p *GCP) GetIdentityURL(audience string) string {
|
|
// Initialize config if required
|
|
p.assertConfig()
|
|
|
|
q := url.Values{}
|
|
q.Add("audience", audience)
|
|
q.Add("format", "full")
|
|
q.Add("licenses", "FALSE")
|
|
return fmt.Sprintf("%s?%s", p.config.IdentityURL, q.Encode())
|
|
}
|
|
|
|
// GetIdentityToken does an HTTP request to the identity url.
|
|
func (p *GCP) GetIdentityToken(subject, caURL string) (string, error) {
|
|
audience, err := generateSignAudience(caURL, p.GetID())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", p.GetIdentityURL(audience), http.NoBody)
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "error creating identity request")
|
|
}
|
|
req.Header.Set("Metadata-Flavor", "Google")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "error doing identity request, are you in a GCP VM?")
|
|
}
|
|
defer resp.Body.Close()
|
|
b, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "error on identity request")
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return "", errors.Errorf("error on identity request: status=%d, response=%s", resp.StatusCode, b)
|
|
}
|
|
return string(bytes.TrimSpace(b)), nil
|
|
}
|
|
|
|
// Init validates and initializes the GCP provisioner.
|
|
func (p *GCP) Init(config Config) error {
|
|
var err 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 p.InstanceAge.Value() < 0:
|
|
return errors.New("provisioner instanceAge cannot be negative")
|
|
}
|
|
// Initialize config
|
|
p.assertConfig()
|
|
// Update claims with global ones
|
|
if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil {
|
|
return err
|
|
}
|
|
// Initialize key store
|
|
p.keyStore, err = newKeyStore(p.config.CertsURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
p.audiences = config.Audiences.WithFragment(p.GetID())
|
|
return nil
|
|
}
|
|
|
|
// AuthorizeSign validates the given token and returns the sign options that
|
|
// will be used on certificate creation.
|
|
func (p *GCP) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) {
|
|
claims, err := p.authorizeToken(token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check for the sign ssh method, default to sign X.509
|
|
if m := MethodFromContext(ctx); m == SignSSHMethod {
|
|
if p.claimer.IsSSHCAEnabled() == false {
|
|
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.
|
|
// There's no way to trust them other than TOFU.
|
|
var so []SignOption
|
|
if p.DisableCustomSANs {
|
|
dnsName1 := fmt.Sprintf("%s.c.%s.internal", ce.InstanceName, ce.ProjectID)
|
|
dnsName2 := fmt.Sprintf("%s.%s.c.%s.internal", ce.InstanceName, ce.Zone, ce.ProjectID)
|
|
so = append(so, commonNameSliceValidator([]string{
|
|
ce.InstanceName, ce.InstanceID, dnsName1, dnsName2,
|
|
}))
|
|
so = append(so, dnsNamesValidator([]string{
|
|
dnsName1, dnsName2,
|
|
}))
|
|
}
|
|
|
|
return append(so,
|
|
defaultPublicKeyValidator{},
|
|
profileDefaultDuration(p.claimer.DefaultTLSCertDuration()),
|
|
newProvisionerExtensionOption(TypeGCP, p.Name, claims.Subject, "InstanceID", ce.InstanceID, "InstanceName", ce.InstanceName),
|
|
newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()),
|
|
), nil
|
|
}
|
|
|
|
// AuthorizeRenewal returns an error if the renewal is disabled.
|
|
func (p *GCP) AuthorizeRenewal(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 {
|
|
p.config = newGCPConfig()
|
|
}
|
|
}
|
|
|
|
// 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 *GCP) authorizeToken(token string) (*gcpPayload, error) {
|
|
jwt, err := jose.ParseSigned(token)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "error parsing token")
|
|
}
|
|
if len(jwt.Headers) == 0 {
|
|
return nil, errors.New("error parsing token: header is missing")
|
|
}
|
|
|
|
var found bool
|
|
var claims gcpPayload
|
|
kid := jwt.Headers[0].KeyID
|
|
keys := p.keyStore.Get(kid)
|
|
for _, key := range keys {
|
|
if err := jwt.Claims(key.Public(), &claims); err == nil {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, errors.Errorf("failed to validate payload: cannot find key for kid %s", kid)
|
|
}
|
|
|
|
// According to "rfc7519 JSON Web Token" acceptable skew should be no
|
|
// more than a few minutes.
|
|
now := time.Now().UTC()
|
|
if err = claims.ValidateWithLeeway(jose.Expected{
|
|
Issuer: "https://accounts.google.com",
|
|
Time: now,
|
|
}, time.Minute); err != nil {
|
|
return nil, errors.Wrapf(err, "invalid token")
|
|
}
|
|
|
|
// validate audiences with the defaults
|
|
if !matchesAudience(claims.Audience, p.audiences.Sign) {
|
|
return nil, errors.New("invalid token: invalid audience claim (aud)")
|
|
}
|
|
|
|
// validate subject (service account)
|
|
if len(p.ServiceAccounts) > 0 {
|
|
var found bool
|
|
for _, sa := range p.ServiceAccounts {
|
|
if sa == claims.Subject || sa == claims.Email {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, errors.New("invalid token: invalid subject claim")
|
|
}
|
|
}
|
|
|
|
// validate projects
|
|
if len(p.ProjectIDs) > 0 {
|
|
var found bool
|
|
for _, pi := range p.ProjectIDs {
|
|
if pi == claims.Google.ComputeEngine.ProjectID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, errors.New("invalid token: invalid project id")
|
|
}
|
|
}
|
|
|
|
// validate instance age
|
|
if d := p.InstanceAge.Value(); d > 0 {
|
|
if now.Sub(claims.Google.ComputeEngine.InstanceCreationTimestamp.Time()) > d {
|
|
return nil, errors.New("token google.compute_engine.instance_creation_timestamp is too old")
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case claims.Google.ComputeEngine.InstanceID == "":
|
|
return nil, errors.New("token google.compute_engine.instance_id cannot be empty")
|
|
case claims.Google.ComputeEngine.InstanceName == "":
|
|
return nil, errors.New("token google.compute_engine.instance_name cannot be empty")
|
|
case claims.Google.ComputeEngine.ProjectID == "":
|
|
return nil, errors.New("token google.compute_engine.project_id cannot be empty")
|
|
case claims.Google.ComputeEngine.Zone == "":
|
|
return nil, errors.New("token google.compute_engine.zone cannot be empty")
|
|
}
|
|
|
|
return &claims, nil
|
|
}
|
|
|
|
// authorizeSSHSign returns the list of SignOption for a SignSSH request.
|
|
func (p *GCP) authorizeSSHSign(claims *gcpPayload) ([]SignOption, error) {
|
|
ce := claims.Google.ComputeEngine
|
|
|
|
signOptions := []SignOption{
|
|
// set the key id to the token subject
|
|
sshCertificateKeyIDModifier(ce.InstanceName),
|
|
}
|
|
|
|
// Default to host + known hostnames
|
|
defaults := SSHOptions{
|
|
CertType: SSHHostCert,
|
|
Principals: []string{
|
|
fmt.Sprintf("%s.c.%s.internal", ce.InstanceName, ce.ProjectID),
|
|
fmt.Sprintf("%s.%s.c.%s.internal", ce.InstanceName, ce.Zone, ce.ProjectID),
|
|
},
|
|
}
|
|
// Validate user options
|
|
signOptions = append(signOptions, sshCertificateOptionsValidator(defaults))
|
|
// Set defaults if not given as user options
|
|
signOptions = append(signOptions, sshCertificateDefaultsModifier(defaults))
|
|
|
|
return append(signOptions,
|
|
// set the default extensions
|
|
&sshDefaultExtensionModifier{},
|
|
// checks the validity bounds, and set the validity if has not been set
|
|
&sshCertificateValidityModifier{p.claimer},
|
|
// require all the fields in the SSH certificate
|
|
&sshCertificateDefaultValidator{},
|
|
), nil
|
|
}
|