commit
578beec25d
@ -0,0 +1,427 @@
|
||||
package provisioner
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/smallstep/cli/jose"
|
||||
)
|
||||
|
||||
// awsIssuer is the string used as issuer in the generated tokens.
|
||||
const awsIssuer = "ec2.amazonaws.com"
|
||||
|
||||
// awsIdentityURL is the url used to retrieve the instance identity document.
|
||||
const awsIdentityURL = "http://169.254.169.254/latest/dynamic/instance-identity/document"
|
||||
|
||||
// awsSignatureURL is the url used to retrieve the instance identity signature.
|
||||
const awsSignatureURL = "http://169.254.169.254/latest/dynamic/instance-identity/signature"
|
||||
|
||||
// awsCertificate is the certificate used to validate the instance identity
|
||||
// signature.
|
||||
const awsCertificate = `-----BEGIN CERTIFICATE-----
|
||||
MIIDIjCCAougAwIBAgIJAKnL4UEDMN/FMA0GCSqGSIb3DQEBBQUAMGoxCzAJBgNV
|
||||
BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdTZWF0dGxlMRgw
|
||||
FgYDVQQKEw9BbWF6b24uY29tIEluYy4xGjAYBgNVBAMTEWVjMi5hbWF6b25hd3Mu
|
||||
Y29tMB4XDTE0MDYwNTE0MjgwMloXDTI0MDYwNTE0MjgwMlowajELMAkGA1UEBhMC
|
||||
VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1NlYXR0bGUxGDAWBgNV
|
||||
BAoTD0FtYXpvbi5jb20gSW5jLjEaMBgGA1UEAxMRZWMyLmFtYXpvbmF3cy5jb20w
|
||||
gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAIe9GN//SRK2knbjySG0ho3yqQM3
|
||||
e2TDhWO8D2e8+XZqck754gFSo99AbT2RmXClambI7xsYHZFapbELC4H91ycihvrD
|
||||
jbST1ZjkLQgga0NE1q43eS68ZeTDccScXQSNivSlzJZS8HJZjgqzBlXjZftjtdJL
|
||||
XeE4hwvo0sD4f3j9AgMBAAGjgc8wgcwwHQYDVR0OBBYEFCXWzAgVyrbwnFncFFIs
|
||||
77VBdlE4MIGcBgNVHSMEgZQwgZGAFCXWzAgVyrbwnFncFFIs77VBdlE4oW6kbDBq
|
||||
MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHU2Vh
|
||||
dHRsZTEYMBYGA1UEChMPQW1hem9uLmNvbSBJbmMuMRowGAYDVQQDExFlYzIuYW1h
|
||||
em9uYXdzLmNvbYIJAKnL4UEDMN/FMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEF
|
||||
BQADgYEAFYcz1OgEhQBXIwIdsgCOS8vEtiJYF+j9uO6jz7VOmJqO+pRlAbRlvY8T
|
||||
C1haGgSI/A1uZUKs/Zfnph0oEI0/hu1IIJ/SKBDtN5lvmZ/IzbOPIJWirlsllQIQ
|
||||
7zvWbGd9c9+Rm3p04oTvhup99la7kZqevJK0QRdD/6NpCKsqP/0=
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
// awsSignatureAlgorithm is the signature algorithm used to verify the identity
|
||||
// document signature.
|
||||
const awsSignatureAlgorithm = x509.SHA256WithRSA
|
||||
|
||||
type awsConfig struct {
|
||||
identityURL string
|
||||
signatureURL string
|
||||
certificate *x509.Certificate
|
||||
signatureAlgorithm x509.SignatureAlgorithm
|
||||
}
|
||||
|
||||
func newAWSConfig() (*awsConfig, error) {
|
||||
block, _ := pem.Decode([]byte(awsCertificate))
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return nil, errors.New("error decoding AWS certificate")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error parsing AWS certificate")
|
||||
}
|
||||
return &awsConfig{
|
||||
identityURL: awsIdentityURL,
|
||||
signatureURL: awsSignatureURL,
|
||||
certificate: cert,
|
||||
signatureAlgorithm: awsSignatureAlgorithm,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type awsPayload struct {
|
||||
jose.Claims
|
||||
Amazon awsAmazonPayload `json:"amazon"`
|
||||
SANs []string `json:"sans"`
|
||||
document awsInstanceIdentityDocument
|
||||
}
|
||||
|
||||
type awsAmazonPayload struct {
|
||||
Document []byte `json:"document"`
|
||||
Signature []byte `json:"signature"`
|
||||
}
|
||||
|
||||
type awsInstanceIdentityDocument struct {
|
||||
AccountID string `json:"accountId"`
|
||||
Architecture string `json:"architecture"`
|
||||
AvailabilityZone string `json:"availabilityZone"`
|
||||
BillingProducts []string `json:"billingProducts"`
|
||||
DevpayProductCodes []string `json:"devpayProductCodes"`
|
||||
ImageID string `json:"imageId"`
|
||||
InstanceID string `json:"instanceId"`
|
||||
InstanceType string `json:"instanceType"`
|
||||
KernelID string `json:"kernelId"`
|
||||
PendingTime time.Time `json:"pendingTime"`
|
||||
PrivateIP string `json:"privateIp"`
|
||||
RamdiskID string `json:"ramdiskId"`
|
||||
Region string `json:"region"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// AWS is the provisioner that supports identity tokens created from the Amazon
|
||||
// Web Services Instance Identity Documents.
|
||||
//
|
||||
// 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 a pendingTime within the given
|
||||
// period will be accepted.
|
||||
//
|
||||
// Amazon Identity docs are available at
|
||||
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html
|
||||
type AWS struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Accounts []string `json:"accounts"`
|
||||
DisableCustomSANs bool `json:"disableCustomSANs"`
|
||||
DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"`
|
||||
InstanceAge Duration `json:"instanceAge,omitempty"`
|
||||
Claims *Claims `json:"claims,omitempty"`
|
||||
claimer *Claimer
|
||||
config *awsConfig
|
||||
audiences Audiences
|
||||
}
|
||||
|
||||
// GetID returns the provisioner unique identifier.
|
||||
func (p *AWS) GetID() string {
|
||||
return "aws/" + p.Name
|
||||
}
|
||||
|
||||
// GetTokenID returns the identifier of the token.
|
||||
func (p *AWS) GetTokenID(token string) (string, error) {
|
||||
payload, err := p.authorizeToken(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// If TOFU is disabled create an ID for the token, so it cannot be reused.
|
||||
// The timestamps, document and signatures should be mostly unique.
|
||||
if p.DisableTrustOnFirstUse {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return strings.ToLower(hex.EncodeToString(sum[:])), nil
|
||||
}
|
||||
return payload.ID, nil
|
||||
}
|
||||
|
||||
// GetName returns the name of the provisioner.
|
||||
func (p *AWS) GetName() string {
|
||||
return p.Name
|
||||
}
|
||||
|
||||
// GetType returns the type of provisioner.
|
||||
func (p *AWS) GetType() Type {
|
||||
return TypeAWS
|
||||
}
|
||||
|
||||
// GetEncryptedKey is not available in an AWS provisioner.
|
||||
func (p *AWS) GetEncryptedKey() (kid string, key string, ok bool) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// GetIdentityToken retrieves the identity document and it's signature and
|
||||
// generates a token with them.
|
||||
func (p *AWS) GetIdentityToken(caURL string) (string, error) {
|
||||
// Initialize the config if this method is used from the cli.
|
||||
if err := p.assertConfig(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var idoc awsInstanceIdentityDocument
|
||||
doc, err := p.readURL(p.config.identityURL)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "error retrieving identity document, are you in an AWS VM?")
|
||||
}
|
||||
if err := json.Unmarshal(doc, &idoc); err != nil {
|
||||
return "", errors.Wrap(err, "error unmarshaling identity document")
|
||||
}
|
||||
sig, err := p.readURL(p.config.signatureURL)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "error retrieving identity document signature, are you in an AWS VM?")
|
||||
}
|
||||
signature, err := base64.StdEncoding.DecodeString(string(sig))
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "error decoding identity document signature")
|
||||
}
|
||||
if err := p.checkSignature(doc, signature); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
audience, err := generateSignAudience(caURL, p.GetID())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 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(), idoc.InstanceID)
|
||||
sum := sha256.Sum256([]byte(unique))
|
||||
|
||||
// Create a JWT from the identity document
|
||||
signer, err := jose.NewSigner(
|
||||
jose.SigningKey{Algorithm: jose.HS256, Key: signature},
|
||||
new(jose.SignerOptions).WithType("JWT"),
|
||||
)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "error creating signer")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
payload := awsPayload{
|
||||
Claims: jose.Claims{
|
||||
Issuer: awsIssuer,
|
||||
Subject: idoc.InstanceID,
|
||||
Audience: []string{audience},
|
||||
Expiry: jose.NewNumericDate(now.Add(5 * time.Minute)),
|
||||
NotBefore: jose.NewNumericDate(now),
|
||||
IssuedAt: jose.NewNumericDate(now),
|
||||
ID: strings.ToLower(hex.EncodeToString(sum[:])),
|
||||
},
|
||||
Amazon: awsAmazonPayload{
|
||||
Document: doc,
|
||||
Signature: signature,
|
||||
},
|
||||
}
|
||||
|
||||
tok, err := jose.Signed(signer).Claims(payload).CompactSerialize()
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "error serialiazing token")
|
||||
}
|
||||
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// Init validates and initializes the AWS provisioner.
|
||||
func (p *AWS) Init(config Config) (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")
|
||||
}
|
||||
// Update claims with global ones
|
||||
if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil {
|
||||
return err
|
||||
}
|
||||
// Add default config
|
||||
if p.config, err = newAWSConfig(); 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 *AWS) AuthorizeSign(token string) ([]SignOption, error) {
|
||||
payload, err := p.authorizeToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc := payload.document
|
||||
|
||||
// Enforce default DNS and IP if configured.
|
||||
// By default we'll accept the SANs in the CSR.
|
||||
// There's no way to trust them other than TOFU.
|
||||
var so []SignOption
|
||||
if p.DisableCustomSANs {
|
||||
so = append(so, dnsNamesValidator([]string{
|
||||
fmt.Sprintf("ip-%s.%s.compute.internal", strings.Replace(doc.PrivateIP, ".", "-", -1), doc.Region),
|
||||
}))
|
||||
so = append(so, ipAddressesValidator([]net.IP{
|
||||
net.ParseIP(doc.PrivateIP),
|
||||
}))
|
||||
}
|
||||
|
||||
return append(so,
|
||||
commonNameValidator(doc.InstanceID),
|
||||
profileDefaultDuration(p.claimer.DefaultTLSCertDuration()),
|
||||
newProvisionerExtensionOption(TypeAWS, p.Name, doc.AccountID),
|
||||
newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()),
|
||||
), nil
|
||||
}
|
||||
|
||||
// AuthorizeRenewal returns an error if the renewal is disabled.
|
||||
func (p *AWS) 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 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 {
|
||||
return
|
||||
}
|
||||
p.config, err = newAWSConfig()
|
||||
return err
|
||||
}
|
||||
|
||||
// checkSignature returns an error if the signature is not valid.
|
||||
func (p *AWS) checkSignature(signed, signature []byte) error {
|
||||
if err := p.config.certificate.CheckSignature(p.config.signatureAlgorithm, signed, signature); err != nil {
|
||||
return errors.Wrap(err, "error validating identity document signature")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readURL does a GET request to the given url and returns the body. It's not
|
||||
// using pkg/errors to avoid verbose errors, the caller should use it and write
|
||||
// the appropriate error.
|
||||
func (p *AWS) readURL(url string) ([]byte, error) {
|
||||
r, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Body.Close()
|
||||
b, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, 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 *AWS) authorizeToken(token string) (*awsPayload, 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 unsafeClaims awsPayload
|
||||
if err := jwt.UnsafeClaimsWithoutVerification(&unsafeClaims); err != nil {
|
||||
return nil, errors.Wrap(err, "error unmarshaling claims")
|
||||
}
|
||||
|
||||
var payload awsPayload
|
||||
if err := jwt.Claims(unsafeClaims.Amazon.Signature, &payload); err != nil {
|
||||
return nil, errors.Wrap(err, "error verifying claims")
|
||||
}
|
||||
|
||||
// Validate identity document signature
|
||||
if err := p.checkSignature(payload.Amazon.Document, payload.Amazon.Signature); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var doc awsInstanceIdentityDocument
|
||||
if err := json.Unmarshal(payload.Amazon.Document, &doc); err != nil {
|
||||
return nil, errors.Wrap(err, "error unmarshaling identity document")
|
||||
}
|
||||
|
||||
switch {
|
||||
case doc.AccountID == "":
|
||||
return nil, errors.New("identity document accountId cannot be empty")
|
||||
case doc.InstanceID == "":
|
||||
return nil, errors.New("identity document instanceId cannot be empty")
|
||||
case doc.PrivateIP == "":
|
||||
return nil, errors.New("identity document privateIp cannot be empty")
|
||||
case doc.Region == "":
|
||||
return nil, errors.New("identity document region cannot be empty")
|
||||
}
|
||||
|
||||
// According to "rfc7519 JSON Web Token" acceptable skew should be no
|
||||
// more than a few minutes.
|
||||
now := time.Now().UTC()
|
||||
if err = payload.ValidateWithLeeway(jose.Expected{
|
||||
Issuer: awsIssuer,
|
||||
Subject: doc.InstanceID,
|
||||
Time: now,
|
||||
}, time.Minute); err != nil {
|
||||
return nil, errors.Wrapf(err, "invalid token")
|
||||
}
|
||||
|
||||
// validate audiences with the defaults
|
||||
if !matchesAudience(payload.Audience, p.audiences.Sign) {
|
||||
fmt.Println(payload.Audience, "vs", p.audiences.Sign)
|
||||
return nil, errors.New("invalid token: invalid audience claim (aud)")
|
||||
}
|
||||
|
||||
// validate accounts
|
||||
if len(p.Accounts) > 0 {
|
||||
var found bool
|
||||
for _, sa := range p.Accounts {
|
||||
if sa == doc.AccountID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, errors.New("invalid identity document: accountId is not valid")
|
||||
}
|
||||
}
|
||||
|
||||
// validate instance age
|
||||
if d := p.InstanceAge.Value(); d > 0 {
|
||||
if now.Sub(doc.PendingTime) > d {
|
||||
return nil, errors.New("identity document pendingTime is too old")
|
||||
}
|
||||
}
|
||||
|
||||
payload.document = doc
|
||||
return &payload, nil
|
||||
}
|
@ -0,0 +1,389 @@
|
||||
package provisioner
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/smallstep/assert"
|
||||
"github.com/smallstep/cli/jose"
|
||||
)
|
||||
|
||||
func TestAWS_Getters(t *testing.T) {
|
||||
p, err := generateAWS()
|
||||
assert.FatalError(t, err)
|
||||
aud := "aws/" + p.Name
|
||||
if got := p.GetID(); got != aud {
|
||||
t.Errorf("AWS.GetID() = %v, want %v", got, aud)
|
||||
}
|
||||
if got := p.GetName(); got != p.Name {
|
||||
t.Errorf("AWS.GetName() = %v, want %v", got, p.Name)
|
||||
}
|
||||
if got := p.GetType(); got != TypeAWS {
|
||||
t.Errorf("AWS.GetType() = %v, want %v", got, TypeAWS)
|
||||
}
|
||||
kid, key, ok := p.GetEncryptedKey()
|
||||
if kid != "" || key != "" || ok == true {
|
||||
t.Errorf("AWS.GetEncryptedKey() = (%v, %v, %v), want (%v, %v, %v)",
|
||||
kid, key, ok, "", "", false)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWS_GetTokenID(t *testing.T) {
|
||||
p1, srv, err := generateAWSWithServer()
|
||||
assert.FatalError(t, err)
|
||||
defer srv.Close()
|
||||
|
||||
p2, err := generateAWS()
|
||||
assert.FatalError(t, err)
|
||||
p2.Accounts = p1.Accounts
|
||||
p2.config = p1.config
|
||||
p2.DisableTrustOnFirstUse = true
|
||||
|
||||
t1, err := p1.GetIdentityToken("https://ca.smallstep.com")
|
||||
assert.FatalError(t, err)
|
||||
_, claims, err := parseAWSToken(t1)
|
||||
assert.FatalError(t, err)
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s.%s", p1.GetID(), claims.document.InstanceID)))
|
||||
w1 := strings.ToLower(hex.EncodeToString(sum[:]))
|
||||
|
||||
t2, err := p2.GetIdentityToken("https://ca.smallstep.com")
|
||||
assert.FatalError(t, err)
|
||||
sum = sha256.Sum256([]byte(t2))
|
||||
w2 := strings.ToLower(hex.EncodeToString(sum[:]))
|
||||
|
||||
type args struct {
|
||||
token string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
aws *AWS
|
||||
args args
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{t1}, w1, false},
|
||||
{"ok no TOFU", p2, args{t2}, w2, false},
|
||||
{"fail", p1, args{"bad-token"}, "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.aws.GetTokenID(tt.args.token)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("AWS.GetTokenID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("AWS.GetTokenID() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWS_GetIdentityToken(t *testing.T) {
|
||||
p1, srv, err := generateAWSWithServer()
|
||||
assert.FatalError(t, err)
|
||||
defer srv.Close()
|
||||
|
||||
p2, err := generateAWS()
|
||||
assert.FatalError(t, err)
|
||||
p2.Accounts = p1.Accounts
|
||||
p2.config.identityURL = srv.URL + "/bad-document"
|
||||
p2.config.signatureURL = p1.config.signatureURL
|
||||
|
||||
p3, err := generateAWS()
|
||||
assert.FatalError(t, err)
|
||||
p3.Accounts = p1.Accounts
|
||||
p3.config.signatureURL = srv.URL
|
||||
p3.config.identityURL = p1.config.identityURL
|
||||
|
||||
p4, err := generateAWS()
|
||||
assert.FatalError(t, err)
|
||||
p4.Accounts = p1.Accounts
|
||||
p4.config.signatureURL = srv.URL + "/bad-signature"
|
||||
p4.config.identityURL = p1.config.identityURL
|
||||
|
||||
caURL := "https://ca.smallstep.com"
|
||||
u, err := url.Parse(caURL)
|
||||
assert.FatalError(t, err)
|
||||
|
||||
type args struct {
|
||||
caURL string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
aws *AWS
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{caURL}, false},
|
||||
{"fail ca url", p1, args{"://ca.smallstep.com"}, true},
|
||||
{"fail identityURL", p2, args{caURL}, true},
|
||||
{"fail signatureURL", p3, args{caURL}, true},
|
||||
{"fail signature", p4, args{caURL}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.aws.GetIdentityToken(tt.args.caURL)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("AWS.GetIdentityToken() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.wantErr == false {
|
||||
_, c, err := parseAWSToken(got)
|
||||
if assert.NoError(t, err) {
|
||||
assert.Equals(t, awsIssuer, c.Issuer)
|
||||
assert.Equals(t, c.document.InstanceID, c.Subject)
|
||||
assert.Equals(t, jose.Audience{u.ResolveReference(&url.URL{Path: "/1.0/sign", Fragment: tt.aws.GetID()}).String()}, c.Audience)
|
||||
assert.Equals(t, tt.aws.Accounts[0], c.document.AccountID)
|
||||
err = tt.aws.config.certificate.CheckSignature(
|
||||
tt.aws.config.signatureAlgorithm, c.Amazon.Document, c.Amazon.Signature)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWS_Init(t *testing.T) {
|
||||
config := Config{
|
||||
Claims: globalProvisionerClaims,
|
||||
}
|
||||
badClaims := &Claims{
|
||||
DefaultTLSDur: &Duration{0},
|
||||
}
|
||||
zero := Duration{Duration: 0}
|
||||
|
||||
type fields struct {
|
||||
Type string
|
||||
Name string
|
||||
Accounts []string
|
||||
DisableCustomSANs bool
|
||||
DisableTrustOnFirstUse bool
|
||||
InstanceAge Duration
|
||||
Claims *Claims
|
||||
}
|
||||
type args struct {
|
||||
config Config
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", fields{"AWS", "name", []string{"account"}, false, false, zero, nil}, args{config}, false},
|
||||
{"ok", fields{"AWS", "name", []string{"account"}, true, true, Duration{Duration: 1 * time.Minute}, nil}, args{config}, false},
|
||||
{"fail type ", fields{"", "name", []string{"account"}, false, false, zero, nil}, args{config}, true},
|
||||
{"fail name", fields{"AWS", "", []string{"account"}, false, false, zero, nil}, args{config}, true},
|
||||
{"bad instance age", fields{"AWS", "name", []string{"account"}, false, false, Duration{Duration: -1 * time.Minute}, nil}, args{config}, true},
|
||||
{"fail claims", fields{"AWS", "name", []string{"account"}, false, false, zero, badClaims}, args{config}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := &AWS{
|
||||
Type: tt.fields.Type,
|
||||
Name: tt.fields.Name,
|
||||
Accounts: tt.fields.Accounts,
|
||||
DisableCustomSANs: tt.fields.DisableCustomSANs,
|
||||
DisableTrustOnFirstUse: tt.fields.DisableTrustOnFirstUse,
|
||||
InstanceAge: tt.fields.InstanceAge,
|
||||
Claims: tt.fields.Claims,
|
||||
}
|
||||
if err := p.Init(tt.args.config); (err != nil) != tt.wantErr {
|
||||
t.Errorf("AWS.Init() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWS_AuthorizeSign(t *testing.T) {
|
||||
p1, srv, err := generateAWSWithServer()
|
||||
assert.FatalError(t, err)
|
||||
defer srv.Close()
|
||||
|
||||
p2, err := generateAWS()
|
||||
assert.FatalError(t, err)
|
||||
p2.Accounts = p1.Accounts
|
||||
p2.config = p1.config
|
||||
p2.DisableCustomSANs = true
|
||||
p2.InstanceAge = Duration{1 * time.Minute}
|
||||
|
||||
p3, err := generateAWS()
|
||||
assert.FatalError(t, err)
|
||||
p3.config = p1.config
|
||||
|
||||
t1, err := p1.GetIdentityToken("https://ca.smallstep.com")
|
||||
assert.FatalError(t, err)
|
||||
t2, err := p2.GetIdentityToken("https://ca.smallstep.com")
|
||||
assert.FatalError(t, err)
|
||||
t3, err := p3.GetIdentityToken("https://ca.smallstep.com")
|
||||
assert.FatalError(t, err)
|
||||
|
||||
block, _ := pem.Decode([]byte(awsTestKey))
|
||||
if block == nil || block.Type != "RSA PRIVATE KEY" {
|
||||
t.Fatal("error decoding AWS key")
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
assert.FatalError(t, err)
|
||||
|
||||
badKey, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
assert.FatalError(t, err)
|
||||
|
||||
t4, err := generateAWSToken(
|
||||
"instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id",
|
||||
"127.0.0.1", "us-west-1", time.Now(), key)
|
||||
assert.FatalError(t, err)
|
||||
failSubject, err := generateAWSToken(
|
||||
"bad-subject", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id",
|
||||
"127.0.0.1", "us-west-1", time.Now(), key)
|
||||
assert.FatalError(t, err)
|
||||
failIssuer, err := generateAWSToken(
|
||||
"instance-id", "bad-issuer", p1.GetID(), p1.Accounts[0], "instance-id",
|
||||
"127.0.0.1", "us-west-1", time.Now(), key)
|
||||
assert.FatalError(t, err)
|
||||
failAudience, err := generateAWSToken(
|
||||
"instance-id", awsIssuer, "bad-audience", p1.Accounts[0], "instance-id",
|
||||
"127.0.0.1", "us-west-1", time.Now(), key)
|
||||
assert.FatalError(t, err)
|
||||
failAccount, err := generateAWSToken(
|
||||
"instance-id", awsIssuer, p1.GetID(), "", "instance-id",
|
||||
"127.0.0.1", "us-west-1", time.Now(), key)
|
||||
assert.FatalError(t, err)
|
||||
failInstanceID, err := generateAWSToken(
|
||||
"instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "",
|
||||
"127.0.0.1", "us-west-1", time.Now(), key)
|
||||
assert.FatalError(t, err)
|
||||
failPrivateIP, err := generateAWSToken(
|
||||
"instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id",
|
||||
"", "us-west-1", time.Now(), key)
|
||||
assert.FatalError(t, err)
|
||||
failRegion, err := generateAWSToken(
|
||||
"instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id",
|
||||
"127.0.0.1", "", time.Now(), key)
|
||||
assert.FatalError(t, err)
|
||||
failExp, err := generateAWSToken(
|
||||
"instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id",
|
||||
"127.0.0.1", "us-west-1", time.Now().Add(-360*time.Second), key)
|
||||
assert.FatalError(t, err)
|
||||
failNbf, err := generateAWSToken(
|
||||
"instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id",
|
||||
"127.0.0.1", "us-west-1", time.Now().Add(360*time.Second), key)
|
||||
assert.FatalError(t, err)
|
||||
failKey, err := generateAWSToken(
|
||||
"instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id",
|
||||
"127.0.0.1", "us-west-1", time.Now(), badKey)
|
||||
assert.FatalError(t, err)
|
||||
failInstanceAge, err := generateAWSToken(
|
||||
"instance-id", awsIssuer, p2.GetID(), p2.Accounts[0], "instance-id",
|
||||
"127.0.0.1", "us-west-1", time.Now().Add(-1*time.Minute), key)
|
||||
assert.FatalError(t, err)
|
||||
|
||||
type args struct {
|
||||
token string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
aws *AWS
|
||||
args args
|
||||
wantLen int
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{t1}, 4, false},
|
||||
{"ok", p2, args{t2}, 6, false},
|
||||
{"ok", p1, args{t4}, 4, false},
|
||||
{"fail account", p3, args{t3}, 0, true},
|
||||
{"fail token", p1, args{"token"}, 0, true},
|
||||
{"fail subject", p1, args{failSubject}, 0, true},
|
||||
{"fail issuer", p1, args{failIssuer}, 0, true},
|
||||
{"fail audience", p1, args{failAudience}, 0, true},
|
||||
{"fail account", p1, args{failAccount}, 0, true},
|
||||
{"fail instanceID", p1, args{failInstanceID}, 0, true},
|
||||
{"fail privateIP", p1, args{failPrivateIP}, 0, true},
|
||||
{"fail region", p1, args{failRegion}, 0, true},
|
||||
{"fail exp", p1, args{failExp}, 0, true},
|
||||
{"fail nbf", p1, args{failNbf}, 0, true},
|
||||
{"fail key", p1, args{failKey}, 0, true},
|
||||
{"fail instance age", p2, args{failInstanceAge}, 0, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.aws.AuthorizeSign(tt.args.token)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("AWS.AuthorizeSign() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
assert.Len(t, tt.wantLen, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWS_AuthorizeRenewal(t *testing.T) {
|
||||
p1, err := generateAWS()
|
||||
assert.FatalError(t, err)
|
||||
p2, err := generateAWS()
|
||||
assert.FatalError(t, err)
|
||||
|
||||
// disable renewal
|
||||
disable := true
|
||||
p2.Claims = &Claims{DisableRenewal: &disable}
|
||||
p2.claimer, err = NewClaimer(p2.Claims, globalProvisionerClaims)
|
||||
assert.FatalError(t, err)
|
||||
|
||||
type args struct {
|
||||
cert *x509.Certificate
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
aws *AWS
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{nil}, false},
|
||||
{"fail", p2, args{nil}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.aws.AuthorizeRenewal(tt.args.cert); (err != nil) != tt.wantErr {
|
||||
t.Errorf("AWS.AuthorizeRenewal() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWS_AuthorizeRevoke(t *testing.T) {
|
||||
p1, srv, err := generateAWSWithServer()
|
||||
assert.FatalError(t, err)
|
||||
defer srv.Close()
|
||||
|
||||
t1, err := p1.GetIdentityToken("https://ca.smallstep.com")
|
||||
assert.FatalError(t, err)
|
||||
|
||||
type args struct {
|
||||
token string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
aws *AWS
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{t1}, true}, // revoke is disabled
|
||||
{"fail", p1, args{"token"}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.aws.AuthorizeRevoke(tt.args.token); (err != nil) != tt.wantErr {
|
||||
t.Errorf("AWS.AuthorizeRevoke() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,303 @@
|
||||
package provisioner
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/smallstep/cli/jose"
|
||||
)
|
||||
|
||||
// azureOIDCBaseURL is the base discovery url for Microsoft Azure tokens.
|
||||
const azureOIDCBaseURL = "https://login.microsoftonline.com"
|
||||
|
||||
// azureIdentityTokenURL is the URL to get the identity token for an instance.
|
||||
const azureIdentityTokenURL = "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F"
|
||||
|
||||
// azureDefaultAudience is the default audience used.
|
||||
const azureDefaultAudience = "https://management.azure.com/"
|
||||
|
||||
// azureXMSMirIDRegExp is the regular expression used to parse the xms_mirid claim.
|
||||
// Using case insensitive as resourceGroups appears as resourcegroups.
|
||||
var azureXMSMirIDRegExp = regexp.MustCompile(`(?i)^/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft.Compute/virtualMachines/([^/]+)$`)
|
||||
|
||||
type azureConfig struct {
|
||||
oidcDiscoveryURL string
|
||||
identityTokenURL string
|
||||
}
|
||||
|
||||
func newAzureConfig(tenantID string) *azureConfig {
|
||||
return &azureConfig{
|
||||
oidcDiscoveryURL: azureOIDCBaseURL + "/" + tenantID + "/.well-known/openid-configuration",
|
||||
identityTokenURL: azureIdentityTokenURL,
|
||||
}
|
||||
}
|
||||
|
||||
type azureIdentityToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ClientID string `json:"client_id"`
|
||||
ExpiresIn int64 `json:"expires_in,string"`
|
||||
ExpiresOn int64 `json:"expires_on,string"`
|
||||
ExtExpiresIn int64 `json:"ext_expires_in,string"`
|
||||
NotBefore int64 `json:"not_before,string"`
|
||||
Resource string `json:"resource"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
type azurePayload struct {
|
||||
jose.Claims
|
||||
AppID string `json:"appid"`
|
||||
AppIDAcr string `json:"appidacr"`
|
||||
IdentityProvider string `json:"idp"`
|
||||
ObjectID string `json:"oid"`
|
||||
TenantID string `json:"tid"`
|
||||
Version string `json:"ver"`
|
||||
XMSMirID string `json:"xms_mirid"`
|
||||
}
|
||||
|
||||
// Azure is the provisioner that supports identity tokens created from the
|
||||
// Microsoft Azure Instance Metadata service.
|
||||
//
|
||||
// The default audience is "https://management.azure.com/".
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Microsoft Azure identity docs are available at
|
||||
// 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 {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
TenantID string `json:"tenantId"`
|
||||
ResourceGroups []string `json:"resourceGroups"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
DisableCustomSANs bool `json:"disableCustomSANs"`
|
||||
DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"`
|
||||
Claims *Claims `json:"claims,omitempty"`
|
||||
claimer *Claimer
|
||||
config *azureConfig
|
||||
oidcConfig openIDConfiguration
|
||||
keyStore *keyStore
|
||||
}
|
||||
|
||||
// GetID returns the provisioner unique identifier.
|
||||
func (p *Azure) GetID() string {
|
||||
return p.TenantID
|
||||
}
|
||||
|
||||
// GetTokenID returns the identifier of the token. The default value for Azure
|
||||
// the SHA256 of "xms_mirid", but if DisableTrustOnFirstUse is set to true, then
|
||||
// it will be the token kid.
|
||||
func (p *Azure) GetTokenID(token string) (string, error) {
|
||||
jwt, 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 azurePayload
|
||||
if err = jwt.UnsafeClaimsWithoutVerification(&claims); err != nil {
|
||||
return "", errors.Wrap(err, "error verifying claims")
|
||||
}
|
||||
|
||||
// If TOFU is disabled create return the token kid
|
||||
if p.DisableTrustOnFirstUse {
|
||||
return claims.ID, nil
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(claims.XMSMirID))
|
||||
return strings.ToLower(hex.EncodeToString(sum[:])), nil
|
||||
}
|
||||
|
||||
// GetName returns the name of the provisioner.
|
||||
func (p *Azure) GetName() string {
|
||||
return p.Name
|
||||
}
|
||||
|
||||
// GetType returns the type of provisioner.
|
||||
func (p *Azure) GetType() Type {
|
||||
return TypeAzure
|
||||
}
|
||||
|
||||
// GetEncryptedKey is not available in an Azure provisioner.
|
||||
func (p *Azure) GetEncryptedKey() (kid string, key string, ok bool) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// GetIdentityToken retrieves from the metadata service the identity token and
|
||||
// returns it.
|
||||
func (p *Azure) GetIdentityToken() (string, error) {
|
||||
// Initialize the config if this method is used from the cli.
|
||||
p.assertConfig()
|
||||
|
||||
req, err := http.NewRequest("GET", p.config.identityTokenURL, http.NoBody)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "error creating request")
|
||||
}
|
||||
req.Header.Set("Metadata", "true")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "error getting identity token, are you in a Azure VM?")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "error reading identity token response")
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", errors.Errorf("error getting identity token: status=%d, response=%s", resp.StatusCode, b)
|
||||
}
|
||||
|
||||
var identityToken azureIdentityToken
|
||||
if err := json.Unmarshal(b, &identityToken); err != nil {
|
||||
return "", errors.Wrap(err, "error unmarshaling identity token response")
|
||||
}
|
||||
|
||||
return identityToken.AccessToken, nil
|
||||
}
|
||||
|
||||
// Init validates and initializes the Azure provisioner.
|
||||
func (p *Azure) Init(config Config) (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.TenantID == "":
|
||||
return errors.New("provisioner tenantId cannot be empty")
|
||||
case p.Audience == "": // use default audience
|
||||
p.Audience = azureDefaultAudience
|
||||
}
|
||||
// Initialize config
|
||||
p.assertConfig()
|
||||
|
||||
// Update claims with global ones
|
||||
if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Decode and validate openid-configuration endpoint
|
||||
if err := getAndDecode(p.config.oidcDiscoveryURL, &p.oidcConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.oidcConfig.Validate(); err != nil {
|
||||
return errors.Wrapf(err, "error parsing %s", p.config.oidcDiscoveryURL)
|
||||
}
|
||||
// Get JWK key set
|
||||
if p.keyStore, err = newKeyStore(p.oidcConfig.JWKSetURI); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorizeSign validates the given token and returns the sign options that
|
||||
// will be used on certificate creation.
|
||||
func (p *Azure) AuthorizeSign(token string) ([]SignOption, error) {
|
||||
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 azurePayload
|
||||
keys := p.keyStore.Get(jwt.Headers[0].KeyID)
|
||||
for _, key := range keys {
|
||||
if err := jwt.Claims(key.Public(), &claims); err == nil {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, errors.New("cannot validate token")
|
||||
}
|
||||
|
||||
if err := claims.ValidateWithLeeway(jose.Expected{
|
||||
Audience: []string{p.Audience},
|
||||
Issuer: p.oidcConfig.Issuer,
|
||||
Time: time.Now(),
|
||||
}, 1*time.Minute); err != nil {
|
||||
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)")
|
||||
}
|
||||
|
||||
re := azureXMSMirIDRegExp.FindStringSubmatch(claims.XMSMirID)
|
||||
if len(re) != 4 {
|
||||
return nil, errors.Errorf("error parsing xms_mirid claim: %s", claims.XMSMirID)
|
||||
}
|
||||
group, name := re[2], re[3]
|
||||
|
||||
// Filter by resource group
|
||||
if len(p.ResourceGroups) > 0 {
|
||||
var found bool
|
||||
for _, g := range p.ResourceGroups {
|
||||
if g == group {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, errors.New("validation failed: invalid resource group")
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce default DNS if configured.
|
||||
// By default we'll accept the SANs in the CSR.
|
||||
// There's no way to trust them other than TOFU.
|
||||
var so []SignOption
|
||||
if p.DisableCustomSANs {
|
||||
// name will work only inside the virtual network
|
||||
so = append(so, dnsNamesValidator([]string{name}))
|
||||
}
|
||||
|
||||
return append(so,
|
||||
commonNameValidator(name),
|
||||
profileDefaultDuration(p.claimer.DefaultTLSCertDuration()),
|
||||
newProvisionerExtensionOption(TypeAzure, p.Name, p.TenantID),
|
||||
newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()),
|
||||
), nil
|
||||
}
|
||||
|
||||
// AuthorizeRenewal returns an error if the renewal is disabled.
|
||||
func (p *Azure) 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 Azure
|
||||
// provisioners.
|
||||
func (p *Azure) AuthorizeRevoke(token string) error {
|
||||
return errors.New("revoke is not supported on a Azure provisioner")
|
||||
}
|
||||
|
||||
// assertConfig initializes the config if it has not been initialized
|
||||
func (p *Azure) assertConfig() {
|
||||
if p.config == nil {
|
||||
p.config = newAzureConfig(p.TenantID)
|
||||
}
|
||||
}
|
@ -0,0 +1,384 @@
|
||||
package provisioner
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/smallstep/assert"
|
||||
)
|
||||
|
||||
func TestAzure_Getters(t *testing.T) {
|
||||
p, err := generateAzure()
|
||||
assert.FatalError(t, err)
|
||||
if got := p.GetID(); got != p.TenantID {
|
||||
t.Errorf("Azure.GetID() = %v, want %v", got, p.TenantID)
|
||||
}
|
||||
if got := p.GetName(); got != p.Name {
|
||||
t.Errorf("Azure.GetName() = %v, want %v", got, p.Name)
|
||||
}
|
||||
if got := p.GetType(); got != TypeAzure {
|
||||
t.Errorf("Azure.GetType() = %v, want %v", got, TypeAzure)
|
||||
}
|
||||
kid, key, ok := p.GetEncryptedKey()
|
||||
if kid != "" || key != "" || ok == true {
|
||||
t.Errorf("Azure.GetEncryptedKey() = (%v, %v, %v), want (%v, %v, %v)",
|
||||
kid, key, ok, "", "", false)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzure_GetTokenID(t *testing.T) {
|
||||
p1, srv, err := generateAzureWithServer()
|
||||
assert.FatalError(t, err)
|
||||
defer srv.Close()
|
||||
|
||||
p2, err := generateAzure()
|
||||
assert.FatalError(t, err)
|
||||
p2.TenantID = p1.TenantID
|
||||
p2.config = p1.config
|
||||
p2.oidcConfig = p1.oidcConfig
|
||||
p2.keyStore = p1.keyStore
|
||||
p2.DisableTrustOnFirstUse = true
|
||||
|
||||
t1, err := p1.GetIdentityToken()
|
||||
assert.FatalError(t, err)
|
||||
t2, err := p2.GetIdentityToken()
|
||||
assert.FatalError(t, err)
|
||||
|
||||
sum := sha256.Sum256([]byte("/subscriptions/subscriptionID/resourceGroups/resourceGroup/providers/Microsoft.Compute/virtualMachines/virtualMachine"))
|
||||
w1 := strings.ToLower(hex.EncodeToString(sum[:]))
|
||||
|
||||
type args struct {
|
||||
token string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
azure *Azure
|
||||
args args
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{t1}, w1, false},
|
||||
{"ok no TOFU", p2, args{t2}, "the-jti", false},
|
||||
{"fail token", p1, args{"bad-token"}, "", true},
|
||||
{"fail claims", p1, args{"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey.fooo"}, "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.azure.GetTokenID(tt.args.token)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Azure.GetTokenID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("Azure.GetTokenID() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzure_GetIdentityToken(t *testing.T) {
|
||||
p1, err := generateAzure()
|
||||
assert.FatalError(t, err)
|
||||
|
||||
t1, err := generateAzureToken("subject", p1.oidcConfig.Issuer, azureDefaultAudience,
|
||||
p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/bad-request":
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
case "/bad-json":
|
||||
w.Write([]byte(t1))
|
||||
default:
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write([]byte(fmt.Sprintf(`{"access_token":"%s"}`, t1)))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
azure *Azure
|
||||
identityTokenURL string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, srv.URL, t1, false},
|
||||
{"fail request", p1, srv.URL + "/bad-request", "", true},
|
||||
{"fail unmarshal", p1, srv.URL + "/bad-json", "", true},
|
||||
{"fail url", p1, "://ca.smallstep.com", "", true},
|
||||
{"fail connect", p1, "foobarzar", "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.azure.config.identityTokenURL = tt.identityTokenURL
|
||||
got, err := tt.azure.GetIdentityToken()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Azure.GetIdentityToken() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("Azure.GetIdentityToken() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzure_Init(t *testing.T) {
|
||||
p1, srv, err := generateAzureWithServer()
|
||||
assert.FatalError(t, err)
|
||||
defer srv.Close()
|
||||
|
||||
config := Config{
|
||||
Claims: globalProvisionerClaims,
|
||||
}
|
||||
badClaims := &Claims{
|
||||
DefaultTLSDur: &Duration{0},
|
||||
}
|
||||
|
||||
badDiscoveryURL := &azureConfig{
|
||||
oidcDiscoveryURL: srv.URL + "/error",
|
||||
identityTokenURL: p1.config.identityTokenURL,
|
||||
}
|
||||
badJWKURL := &azureConfig{
|
||||
oidcDiscoveryURL: srv.URL + "/openid-configuration-fail-jwk",
|
||||
identityTokenURL: p1.config.identityTokenURL,
|
||||
}
|
||||
badAzureConfig := &azureConfig{
|
||||
oidcDiscoveryURL: srv.URL + "/openid-configuration-no-issuer",
|
||||
identityTokenURL: p1.config.identityTokenURL,
|
||||
}
|
||||
|
||||
type fields struct {
|
||||
Type string
|
||||
Name string
|
||||
TenantID string
|
||||
Claims *Claims
|
||||
config *azureConfig
|
||||
}
|
||||
type args struct {
|
||||
config Config
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", fields{p1.Type, p1.Name, p1.TenantID, nil, p1.config}, args{config}, false},
|
||||
{"ok with config", fields{p1.Type, p1.Name, p1.TenantID, nil, p1.config}, args{config}, false},
|
||||
{"fail type", fields{"", p1.Name, p1.TenantID, nil, p1.config}, args{config}, true},
|
||||
{"fail name", fields{p1.Type, "", p1.TenantID, nil, p1.config}, args{config}, true},
|
||||
{"fail tenant id", fields{p1.Type, p1.Name, "", nil, p1.config}, args{config}, true},
|
||||
{"fail claims", fields{p1.Type, p1.Name, p1.TenantID, badClaims, p1.config}, args{config}, true},
|
||||
{"fail discovery URL", fields{p1.Type, p1.Name, p1.TenantID, nil, badDiscoveryURL}, args{config}, true},
|
||||
{"fail JWK URL", fields{p1.Type, p1.Name, p1.TenantID, nil, badJWKURL}, args{config}, true},
|
||||
{"fail config Validate", fields{p1.Type, p1.Name, p1.TenantID, nil, badAzureConfig}, args{config}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := &Azure{
|
||||
Type: tt.fields.Type,
|
||||
Name: tt.fields.Name,
|
||||
TenantID: tt.fields.TenantID,
|
||||
Claims: tt.fields.Claims,
|
||||
config: tt.fields.config,
|
||||
}
|
||||
if err := p.Init(tt.args.config); (err != nil) != tt.wantErr {
|
||||
t.Errorf("Azure.Init() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzure_AuthorizeSign(t *testing.T) {
|
||||
p1, srv, err := generateAzureWithServer()
|
||||
assert.FatalError(t, err)
|
||||
defer srv.Close()
|
||||
|
||||
p2, err := generateAzure()
|
||||
assert.FatalError(t, err)
|
||||
p2.TenantID = p1.TenantID
|
||||
p2.ResourceGroups = []string{"resourceGroup"}
|
||||
p2.config = p1.config
|
||||
p2.oidcConfig = p1.oidcConfig
|
||||
p2.keyStore = p1.keyStore
|
||||
p2.DisableCustomSANs = true
|
||||
|
||||
p3, err := generateAzure()
|
||||
assert.FatalError(t, err)
|
||||
p3.config = p1.config
|
||||
p3.oidcConfig = p1.oidcConfig
|
||||
p3.keyStore = p1.keyStore
|
||||
|
||||
p4, err := generateAzure()
|
||||
assert.FatalError(t, err)
|
||||
p4.TenantID = p1.TenantID
|
||||
p4.ResourceGroups = []string{"foobarzar"}
|
||||
p4.config = p1.config
|
||||
p4.oidcConfig = p1.oidcConfig
|
||||
p4.keyStore = p1.keyStore
|
||||
|
||||
badKey, err := generateJSONWebKey()
|
||||
assert.FatalError(t, err)
|
||||
|
||||
t1, err := p1.GetIdentityToken()
|
||||
assert.FatalError(t, err)
|
||||
t2, err := p2.GetIdentityToken()
|
||||
assert.FatalError(t, err)
|
||||
t3, err := p3.GetIdentityToken()
|
||||
assert.FatalError(t, err)
|
||||
t4, err := p4.GetIdentityToken()
|
||||
assert.FatalError(t, err)
|
||||
|
||||
t11, err := generateAzureToken("subject", p1.oidcConfig.Issuer, azureDefaultAudience,
|
||||
p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
|
||||
failIssuer, err := generateAzureToken("subject", "bad-issuer", azureDefaultAudience,
|
||||
p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failAudience, err := generateAzureToken("subject", p1.oidcConfig.Issuer, "bad-audience",
|
||||
p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failExp, err := generateAzureToken("subject", p1.oidcConfig.Issuer, azureDefaultAudience,
|
||||
p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine",
|
||||
time.Now().Add(-360*time.Second), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failNbf, err := generateAzureToken("subject", p1.oidcConfig.Issuer, azureDefaultAudience,
|
||||
p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine",
|
||||
time.Now().Add(360*time.Second), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failKey, err := generateAzureToken("subject", p1.oidcConfig.Issuer, azureDefaultAudience,
|
||||
p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine",
|
||||
time.Now(), badKey)
|
||||
assert.FatalError(t, err)
|
||||
|
||||
type args struct {
|
||||
token string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
azure *Azure
|
||||
args args
|
||||
wantLen int
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{t1}, 4, false},
|
||||
{"ok", p2, args{t2}, 5, false},
|
||||
{"ok", p1, args{t11}, 4, false},
|
||||
{"fail tenant", p3, args{t3}, 0, true},
|
||||
{"fail resource group", p4, args{t4}, 0, true},
|
||||
{"fail token", p1, args{"token"}, 0, true},
|
||||
{"fail issuer", p1, args{failIssuer}, 0, true},
|
||||
{"fail audience", p1, args{failAudience}, 0, true},
|
||||
{"fail exp", p1, args{failExp}, 0, true},
|
||||
{"fail nbf", p1, args{failNbf}, 0, true},
|
||||
{"fail key", p1, args{failKey}, 0, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.azure.AuthorizeSign(tt.args.token)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Azure.AuthorizeSign() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
assert.Len(t, tt.wantLen, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzure_AuthorizeRenewal(t *testing.T) {
|
||||
p1, err := generateAzure()
|
||||
assert.FatalError(t, err)
|
||||
p2, err := generateAzure()
|
||||
assert.FatalError(t, err)
|
||||
|
||||
// disable renewal
|
||||
disable := true
|
||||
p2.Claims = &Claims{DisableRenewal: &disable}
|
||||
p2.claimer, err = NewClaimer(p2.Claims, globalProvisionerClaims)
|
||||
assert.FatalError(t, err)
|
||||
|
||||
type args struct {
|
||||
cert *x509.Certificate
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
azure *Azure
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{nil}, false},
|
||||
{"fail", p2, args{nil}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.azure.AuthorizeRenewal(tt.args.cert); (err != nil) != tt.wantErr {
|
||||
t.Errorf("Azure.AuthorizeRenewal() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzure_AuthorizeRevoke(t *testing.T) {
|
||||
az, srv, err := generateAzureWithServer()
|
||||
assert.FatalError(t, err)
|
||||
defer srv.Close()
|
||||
|
||||
token, err := az.GetIdentityToken()
|
||||
assert.FatalError(t, err)
|
||||
|
||||
type args struct {
|
||||
token string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
azure *Azure
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok token", az, args{token}, true}, // revoke is disabled
|
||||
{"bad token", az, args{"bad token"}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.azure.AuthorizeRevoke(tt.args.token); (err != nil) != tt.wantErr {
|
||||
t.Errorf("Azure.AuthorizeRevoke() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzure_assertConfig(t *testing.T) {
|
||||
p1, err := generateAzure()
|
||||
assert.FatalError(t, err)
|
||||
p2, err := generateAzure()
|
||||
assert.FatalError(t, err)
|
||||
p2.config = nil
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
azure *Azure
|
||||
}{
|
||||
{"ok with config", p1},
|
||||
{"ok no config", p2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.azure.assertConfig()
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,343 @@
|
||||
package provisioner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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(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(token string) ([]SignOption, error) {
|
||||
claims, err := p.authorizeToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ce := claims.Google.ComputeEngine
|
||||
|
||||
// Enforce default DNS if configured.
|
||||
// By default we we'll accept the SANs in the CSR.
|
||||
// There's no way to trust them other than TOFU.
|
||||
var so []SignOption
|
||||
if p.DisableCustomSANs {
|
||||
so = append(so, dnsNamesValidator([]string{
|
||||
fmt.Sprintf("%s.c.%s.internal", ce.InstanceName, ce.ProjectID),
|
||||
fmt.Sprintf("%s.%s.c.%s.internal", ce.InstanceName, ce.Zone, ce.ProjectID),
|
||||
}))
|
||||
}
|
||||
|
||||
return append(so,
|
||||
commonNameValidator(ce.InstanceName),
|
||||
profileDefaultDuration(p.claimer.DefaultTLSCertDuration()),
|
||||
newProvisionerExtensionOption(TypeGCP, p.Name, claims.Subject),
|
||||
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
|
||||
}
|
@ -0,0 +1,404 @@
|
||||
package provisioner
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/smallstep/assert"
|
||||
)
|
||||
|
||||
func TestGCP_Getters(t *testing.T) {
|
||||
p, err := generateGCP()
|
||||
assert.FatalError(t, err)
|
||||
id := "gcp/" + p.Name
|
||||
if got := p.GetID(); got != id {
|
||||
t.Errorf("GCP.GetID() = %v, want %v", got, id)
|
||||
}
|
||||
if got := p.GetName(); got != p.Name {
|
||||
t.Errorf("GCP.GetName() = %v, want %v", got, p.Name)
|
||||
}
|
||||
if got := p.GetType(); got != TypeGCP {
|
||||
t.Errorf("GCP.GetType() = %v, want %v", got, TypeGCP)
|
||||
}
|
||||
kid, key, ok := p.GetEncryptedKey()
|
||||
if kid != "" || key != "" || ok == true {
|
||||
t.Errorf("GCP.GetEncryptedKey() = (%v, %v, %v), want (%v, %v, %v)",
|
||||
kid, key, ok, "", "", false)
|
||||
}
|
||||
|
||||
aud := "https://ca.smallstep.com/1.0/sign#" + url.QueryEscape(id)
|
||||
expected := fmt.Sprintf("http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=%s&format=full&licenses=FALSE", url.QueryEscape(aud))
|
||||
if got := p.GetIdentityURL(aud); got != expected {
|
||||
t.Errorf("GCP.GetIdentityURL() = %v, want %v", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCP_GetTokenID(t *testing.T) {
|
||||
p1, err := generateGCP()
|
||||
assert.FatalError(t, err)
|
||||
p1.Name = "name"
|
||||
|
||||
p2, err := generateGCP()
|
||||
assert.FatalError(t, err)
|
||||
p2.DisableTrustOnFirstUse = true
|
||||
|
||||
now := time.Now()
|
||||
t1, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://accounts.google.com", "gcp/name",
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
now, &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
t2, err := generateGCPToken(p2.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p2.GetID(),
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
now, &p2.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
|
||||
sum := sha256.Sum256([]byte("gcp/name.instance-id"))
|
||||
want1 := strings.ToLower(hex.EncodeToString(sum[:]))
|
||||
sum = sha256.Sum256([]byte(t2))
|
||||
want2 := strings.ToLower(hex.EncodeToString(sum[:]))
|
||||
|
||||
type args struct {
|
||||
token string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
gcp *GCP
|
||||
args args
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{t1}, want1, false},
|
||||
{"ok", p2, args{t2}, want2, false},
|
||||
{"fail token", p1, args{"token"}, "", true},
|
||||
{"fail claims", p1, args{"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey.fooo"}, "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.gcp.GetTokenID(tt.args.token)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GCP.GetTokenID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("GCP.GetTokenID() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCP_GetIdentityToken(t *testing.T) {
|
||||
p1, err := generateGCP()
|
||||
assert.FatalError(t, err)
|
||||
|
||||
t1, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p1.GetID(),
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/bad-request":
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
default:
|
||||
w.Write([]byte(t1))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
type args struct {
|
||||
caURL string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
gcp *GCP
|
||||
args args
|
||||
identityURL string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{"https://ca"}, srv.URL, t1, false},
|
||||
{"fail ca url", p1, args{"://ca"}, srv.URL, "", true},
|
||||
{"fail request", p1, args{"https://ca"}, srv.URL + "/bad-request", "", true},
|
||||
{"fail url", p1, args{"https://ca"}, "://ca.smallstep.com", "", true},
|
||||
{"fail connect", p1, args{"https://ca"}, "foobarzar", "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.gcp.config.IdentityURL = tt.identityURL
|
||||
got, err := tt.gcp.GetIdentityToken(tt.args.caURL)
|
||||
t.Log(err)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GCP.GetIdentityToken() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("GCP.GetIdentityToken() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCP_Init(t *testing.T) {
|
||||
srv := generateJWKServer(2)
|
||||
defer srv.Close()
|
||||
|
||||
config := Config{
|
||||
Claims: globalProvisionerClaims,
|
||||
}
|
||||
badClaims := &Claims{
|
||||
DefaultTLSDur: &Duration{0},
|
||||
}
|
||||
zero := Duration{Duration: 0}
|
||||
type fields struct {
|
||||
Type string
|
||||
Name string
|
||||
ServiceAccounts []string
|
||||
InstanceAge Duration
|
||||
Claims *Claims
|
||||
}
|
||||
type args struct {
|
||||
config Config
|
||||
certsURL string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", fields{"GCP", "name", nil, zero, nil}, args{config, srv.URL}, false},
|
||||
{"ok", fields{"GCP", "name", []string{"service-account"}, zero, nil}, args{config, srv.URL}, false},
|
||||
{"ok", fields{"GCP", "name", []string{"service-account"}, Duration{Duration: 1 * time.Minute}, nil}, args{config, srv.URL}, false},
|
||||
{"bad type", fields{"", "name", nil, zero, nil}, args{config, srv.URL}, true},
|
||||
{"bad name", fields{"GCP", "", nil, zero, nil}, args{config, srv.URL}, true},
|
||||
{"bad duration", fields{"GCP", "name", nil, Duration{Duration: -1 * time.Minute}, nil}, args{config, srv.URL}, true},
|
||||
{"bad claims", fields{"GCP", "name", nil, zero, badClaims}, args{config, srv.URL}, true},
|
||||
{"bad certs", fields{"GCP", "name", nil, zero, nil}, args{config, srv.URL + "/error"}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := &GCP{
|
||||
Type: tt.fields.Type,
|
||||
Name: tt.fields.Name,
|
||||
ServiceAccounts: tt.fields.ServiceAccounts,
|
||||
InstanceAge: tt.fields.InstanceAge,
|
||||
Claims: tt.fields.Claims,
|
||||
config: &gcpConfig{
|
||||
CertsURL: tt.args.certsURL,
|
||||
IdentityURL: gcpIdentityURL,
|
||||
},
|
||||
}
|
||||
if err := p.Init(tt.args.config); (err != nil) != tt.wantErr {
|
||||
t.Errorf("GCP.Init() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCP_AuthorizeSign(t *testing.T) {
|
||||
p1, err := generateGCP()
|
||||
assert.FatalError(t, err)
|
||||
|
||||
p2, err := generateGCP()
|
||||
assert.FatalError(t, err)
|
||||
p2.DisableCustomSANs = true
|
||||
|
||||
p3, err := generateGCP()
|
||||
assert.FatalError(t, err)
|
||||
p3.ProjectIDs = []string{"other-project-id"}
|
||||
p3.ServiceAccounts = []string{"foo@developer.gserviceaccount.com"}
|
||||
p3.InstanceAge = Duration{1 * time.Minute}
|
||||
|
||||
aKey, err := generateJSONWebKey()
|
||||
assert.FatalError(t, err)
|
||||
|
||||
t1, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p1.GetID(),
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
t2, err := generateGCPToken(p2.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p2.GetID(),
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
time.Now(), &p2.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
t3, err := generateGCPToken(p3.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p3.GetID(),
|
||||
"instance-id", "instance-name", "other-project-id", "zone",
|
||||
time.Now(), &p3.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
|
||||
failKey, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p1.GetID(),
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
time.Now(), aKey)
|
||||
assert.FatalError(t, err)
|
||||
failIss, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://foo.bar.zar", p1.GetID(),
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failAud, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://accounts.google.com", "gcp:foo",
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failExp, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p1.GetID(),
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
time.Now().Add(-360*time.Second), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failNbf, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p1.GetID(),
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
time.Now().Add(360*time.Second), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failServiceAccount, err := generateGCPToken("foo",
|
||||
"https://accounts.google.com", p1.GetID(),
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failInvalidProjectID, err := generateGCPToken(p3.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p3.GetID(),
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
time.Now(), &p3.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failInvalidInstanceAge, err := generateGCPToken(p3.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p3.GetID(),
|
||||
"instance-id", "instance-name", "other-project-id", "zone",
|
||||
time.Now().Add(-1*time.Minute), &p3.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failInstanceID, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p1.GetID(),
|
||||
"", "instance-name", "project-id", "zone",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failInstanceName, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p1.GetID(),
|
||||
"instance-id", "", "project-id", "zone",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failProjectID, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p1.GetID(),
|
||||
"instance-id", "instance-name", "", "zone",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
failZone, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p1.GetID(),
|
||||
"instance-id", "instance-name", "project-id", "",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
|
||||
type args struct {
|
||||
token string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
gcp *GCP
|
||||
args args
|
||||
wantLen int
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{t1}, 4, false},
|
||||
{"ok", p2, args{t2}, 5, false},
|
||||
{"ok", p3, args{t3}, 4, false},
|
||||
{"fail token", p1, args{"token"}, 0, true},
|
||||
{"fail key", p1, args{failKey}, 0, true},
|
||||
{"fail iss", p1, args{failIss}, 0, true},
|
||||
{"fail aud", p1, args{failAud}, 0, true},
|
||||
{"fail exp", p1, args{failExp}, 0, true},
|
||||
{"fail nbf", p1, args{failNbf}, 0, true},
|
||||
{"fail service account", p1, args{failServiceAccount}, 0, true},
|
||||
{"fail invalid project id", p3, args{failInvalidProjectID}, 0, true},
|
||||
{"fail invalid instance age", p3, args{failInvalidInstanceAge}, 0, true},
|
||||
{"fail instance id", p1, args{failInstanceID}, 0, true},
|
||||
{"fail instance name", p1, args{failInstanceName}, 0, true},
|
||||
{"fail project id", p1, args{failProjectID}, 0, true},
|
||||
{"fail zone", p1, args{failZone}, 0, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.gcp.AuthorizeSign(tt.args.token)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GCP.AuthorizeSign() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
assert.Len(t, tt.wantLen, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCP_AuthorizeRenewal(t *testing.T) {
|
||||
p1, err := generateGCP()
|
||||
assert.FatalError(t, err)
|
||||
p2, err := generateGCP()
|
||||
assert.FatalError(t, err)
|
||||
|
||||
// disable renewal
|
||||
disable := true
|
||||
p2.Claims = &Claims{DisableRenewal: &disable}
|
||||
p2.claimer, err = NewClaimer(p2.Claims, globalProvisionerClaims)
|
||||
assert.FatalError(t, err)
|
||||
|
||||
type args struct {
|
||||
cert *x509.Certificate
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
prov *GCP
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{nil}, false},
|
||||
{"fail", p2, args{nil}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.prov.AuthorizeRenewal(tt.args.cert); (err != nil) != tt.wantErr {
|
||||
t.Errorf("GCP.AuthorizeRenewal() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCP_AuthorizeRevoke(t *testing.T) {
|
||||
p1, err := generateGCP()
|
||||
assert.FatalError(t, err)
|
||||
|
||||
t1, err := generateGCPToken(p1.ServiceAccounts[0],
|
||||
"https://accounts.google.com", p1.GetID(),
|
||||
"instance-id", "instance-name", "project-id", "zone",
|
||||
time.Now(), &p1.keyStore.keySet.Keys[0])
|
||||
assert.FatalError(t, err)
|
||||
|
||||
type args struct {
|
||||
token string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
gcp *GCP
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", p1, args{t1}, true}, // revoke is disabled
|
||||
{"fail", p1, args{"token"}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.gcp.AuthorizeRevoke(tt.args.token); (err != nil) != tt.wantErr {
|
||||
t.Errorf("GCP.AuthorizeRevoke() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
package provisioner
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestType_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
t Type
|
||||
want string
|
||||
}{
|
||||
{"JWK", TypeJWK, "JWK"},
|
||||
{"OIDC", TypeOIDC, "OIDC"},
|
||||
{"AWS", TypeAWS, "AWS"},
|
||||
{"Azure", TypeAzure, "Azure"},
|
||||
{"GCP", TypeGCP, "GCP"},
|
||||
{"noop", noopType, ""},
|
||||
{"notFound", 1000, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.t.String(); got != tt.want {
|
||||
t.Errorf("Type.String() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,311 @@
|
||||
# Provisioners
|
||||
|
||||
Provisioners are people or code that are registered with the CA and authorized
|
||||
to issue "provisioning tokens". Provisioning tokens are single-use tokens that
|
||||
can be used to authenticate with the CA and get a certificate.
|
||||
|
||||
## JWK
|
||||
|
||||
JWK is the default provisioner type. It uses public-key cryptography sign and
|
||||
validate a JSON Web Token (JWT).
|
||||
|
||||
The [step](https://github.com/smallstep/cli) CLI tool will create a JWK
|
||||
provisioner when `step ca init` is used, and it also contains commands to add
|
||||
(`step ca provisioner add`) and remove (`step ca provisioner remove`) JWK
|
||||
provisioners.
|
||||
|
||||
In the ca.json configuration file, a complete JWK provisioner example looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "JWK",
|
||||
"name": "you@smallstep.com",
|
||||
"key": {
|
||||
"use": "sig",
|
||||
"kty": "EC",
|
||||
"kid": "NPM_9Gz_omTqchS6Xx9Yfvs-EuxkYo6VAk4sL7gyyM4",
|
||||
"crv": "P-256",
|
||||
"alg": "ES256",
|
||||
"x": "bBI5AkO9lwvDuWGfOr0F6ttXC-ZRzJo8kKn5wTzRJXI",
|
||||
"y": "rcfaqE-EEZgs34Q9SSH3f9Ua5a8dKopXNfEzDD8KRlU"
|
||||
},
|
||||
"encryptedKey": "eyJhbGciOiJQQkVTMi1IUzI1NitBMTI4S1ciLCJjdHkiOiJqd2sranNvbiIsImVuYyI6IkEyNTZHQ00iLCJwMmMiOjEwMDAwMCwicDJzIjoiTlV6MjlEb3hKMVdOaFI3dUNjaGdYZyJ9.YN7xhz6RAbz_9bcuXoymBOj8bOg23ETAdmSCRyHpxGekkV0q3STYYg.vo1oBnZsZjgRu5Ln.Xop8AvZ74h_im2jxeaq-hYYWnaK_eF7MGr4xcZGodMUxp-hGPqS85oWkyprkQLYt1-jXTURfpejtmPeB4-sxgj7OFxMYYus84BdkG9BZgSBmMN9SqZItOv4pqg_NwQA0bv9g9A_e-N6QUFanxuYQsEPX_-IwWBDbNKyN9bXbpEQa0FKNVsTvFahGzOxQngXipi265VADkh8MJLjYerplKIbNeOJJbLd9CbS9fceLvQUNr3ACGgAejSaWmeNUVqbho1lY4882iS8QVx1VzjluTXlAMdSUUDHArHEihz008kCyF0YfvNdGebyEDLvTmF6KkhqMpsWn3zASYBidc9k._ch9BtvRRhcLD838itIQlw",
|
||||
"claims": {
|
||||
"minTLSCertDuration": "5m",
|
||||
"maxTLSCertDuration": "24h",
|
||||
"defaultTLSCertDuration": "24h",
|
||||
"disableRenewal": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* `type` (mandatory): for a JWK provisioner it must be `JWK`, this field is case
|
||||
insensitive.
|
||||
|
||||
* `name` (mandatory): identifies the provisioner, a good practice is to
|
||||
use an email address or a descriptive string that allows the identification of
|
||||
the owner, but it can be any non-empty string.
|
||||
|
||||
* `key` (mandatory): is the JWK (JSON Web Key) representation of a public key
|
||||
used to validate a signed token.
|
||||
|
||||
* `encryptedKey` (recommended): is the encrypted private key used to sign a
|
||||
token. It's a JWE compact string containing the JWK representation of the
|
||||
private key.
|
||||
|
||||
We can use [step](https://github.com/smallstep/cli) to see the private key
|
||||
encrypted with the password `asdf`:
|
||||
|
||||
```sh
|
||||
$ echo ey...lw | step crypto jwe decrypt | jq
|
||||
Please enter the password to decrypt the content encryption key:
|
||||
{
|
||||
"use": "sig",
|
||||
"kty": "EC",
|
||||
"kid": "NPM_9Gz_omTqchS6Xx9Yfvs-EuxkYo6VAk4sL7gyyM4",
|
||||
"crv": "P-256",
|
||||
"alg": "ES256",
|
||||
"x": "bBI5AkO9lwvDuWGfOr0F6ttXC-ZRzJo8kKn5wTzRJXI",
|
||||
"y": "rcfaqE-EEZgs34Q9SSH3f9Ua5a8dKopXNfEzDD8KRlU",
|
||||
"d": "rsjCCM_2FQ-uk7nywBEQHl84oaPo4mTpYDgXAu63igE"
|
||||
}
|
||||
```
|
||||
|
||||
If the ca.json does not contain the encryptedKey, the private key must be
|
||||
provided using the `--key` flag of the `step ca token` to be able to sign the
|
||||
token.
|
||||
|
||||
* `claims` (optional): overwrites the default claims set in the authority.
|
||||
You can set one or more of the following claims:
|
||||
|
||||
* `minTLSCertDuration`: do not allow certificates with a duration less than
|
||||
this value.
|
||||
|
||||
* `maxTLSCertDuration`: do not allow certificates with a duration greater than
|
||||
this value.
|
||||
|
||||
* `defaultTLSCertDuration`: if no certificate validity period is specified,
|
||||
use this value.
|
||||
|
||||
* `disableIssuedAtCheck`: disable a check verifying that provisioning tokens
|
||||
must be issued after the CA has booted. This claim is one prevention against
|
||||
token reuse. The default value is `false`. Do not change this unless you
|
||||
know what you are doing.
|
||||
|
||||
## OIDC
|
||||
|
||||
An OIDC provisioner allows a user to get a certificate after authenticating
|
||||
himself with an OAuth OpenID Connect identity provider. The ID token provided
|
||||
will be used on the CA authentication, and by default, the certificate will only
|
||||
have the user's email as a Subject Alternative Name (SAN) Extension.
|
||||
|
||||
One of the most common providers and the one we'll use in the following example
|
||||
is G-Suite.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "OIDC",
|
||||
"name": "Google",
|
||||
"clientID": "1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com",
|
||||
"clientSecret": "udTrOT3gzrO7W9fDPgZQLfYJ",
|
||||
"configurationEndpoint": "https://accounts.google.com/.well-known/openid-configuration",
|
||||
"admins": ["you@smallstep.com"],
|
||||
"domains": ["smallstep.com"],
|
||||
"claims": {
|
||||
"maxTLSCertDuration": "8h",
|
||||
"defaultTLSCertDuration": "2h",
|
||||
"disableRenewal": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* `type` (mandatory): indicates the provisioner type and must be `OIDC`.
|
||||
|
||||
* `name` (mandatory): a string used to identify the provider when the CLI is
|
||||
used.
|
||||
|
||||
* `clientID` (mandatory): the client id provided by the identity provider used
|
||||
to initialize the authentication flow.
|
||||
|
||||
* `clientSecret` (mandatory): the client secret provided by the identity
|
||||
provider used to get the id token. Some identity providers might use an empty
|
||||
string as a secret.
|
||||
|
||||
* `configurationEndpoing` (mandatory): is the HTTP address used by the CA to get
|
||||
the OpenID Connect configuration and public keys used to validate the tokens.
|
||||
|
||||
* `admins` (optional): is the list of emails that will be able to get
|
||||
certificates with custom SANs. If a user is not an admin, it will only be able
|
||||
to get a certificate with its email in it.
|
||||
|
||||
* `domains` (optional): is the list of domains valid. If provided only the
|
||||
emails with the provided domains will be able to authenticate.
|
||||
|
||||
* `claims` (optional): overwrites the default claims set in the authority, see
|
||||
the [JWK](#jwk) section for all the options.
|
||||
|
||||
## Provisioners for Cloud Identities
|
||||
|
||||
[Step certificates](https://github.com/smallstep/certificates) can grant
|
||||
certificates to code running in a machine without any other authentication than
|
||||
the one provided by the cloud. Usually, this is implemented with some kind of
|
||||
signed document, but the information contained on them might not be enough to
|
||||
generate a certificate. Due to this limitation, the cloud identities use by
|
||||
default a trust model called Trust On First Use (TOFU).
|
||||
|
||||
The Trust On First Use model allows the use of more permissive CSRs that can
|
||||
have custom SANs that cannot be validated. But it comes with the limitation that
|
||||
you can only grant a certificate once. After this first grant, the same machine
|
||||
will need to renew the certificate using mTLS, and the CA will block any other
|
||||
attempt to grant a certificate to that instance.
|
||||
|
||||
### AWS
|
||||
|
||||
The AWS provisioner allows granting a certificate to an Amazon EC2 instance
|
||||
using the [Instance Identity Documents](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html)
|
||||
|
||||
The [step](https://github.com/smallstep/cli) CLI will generate a custom JWT
|
||||
token containing the instance identity document and its signature and the CA
|
||||
will grant a certificate after validating it.
|
||||
|
||||
In the ca.json, an AWS provisioner looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "AWS",
|
||||
"name": "Amazon Web Services",
|
||||
"accounts": ["1234567890"],
|
||||
"disableCustomSANs": false,
|
||||
"disableTrustOnFirstUse": false,
|
||||
"instanceAge": "1h",
|
||||
"claims": {
|
||||
"maxTLSCertDuration": "2160h",
|
||||
"defaultTLSCertDuration": "2160h"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* `type` (mandatory): indicates the provisioner type and must be `AWS`.
|
||||
|
||||
* `name` (mandatory): a string used to identify the provider when the CLI is
|
||||
used.
|
||||
|
||||
* `accounts` (optional): the list of AWS account numbers that are allowed to use
|
||||
this provisioner. If none is specified, all AWS accounts will be valid.
|
||||
|
||||
* `disableCustomSANs` (optional): by default custom SANs are valid, but if this
|
||||
option is set to true only the SANs available in the instance identity
|
||||
document will be valid, these are the private IP and the DNS
|
||||
`ip-<private-ip>.<region>.compute.internal`.
|
||||
|
||||
* `disableTrustOnFirstUse` (optional): by default only one certificate will be
|
||||
granted per instance, but if the option is set to true this limit is not set
|
||||
and different tokens can be used to get different certificates.
|
||||
|
||||
* `instanceAge` (optional): the maximum age of an instance to grant a
|
||||
certificate. The instance age is a string using the duration format.
|
||||
|
||||
* `claims` (optional): overwrites the default claims set in the authority, see
|
||||
the [JWK](#jwk) section for all the options.
|
||||
|
||||
### GCP
|
||||
|
||||
The GCP provisioner grants certificates to Google Compute Engine instance using
|
||||
its [identity](https://cloud.google.com/compute/docs/instances/verifying-instance-identity)
|
||||
token. The CA will validate the JWT and grant a certificate.
|
||||
|
||||
In the ca.json, a GCP provisioner looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "GCP",
|
||||
"name": "Google Cloud",
|
||||
"serviceAccounts": ["1234567890"],
|
||||
"projectIDs": ["project-id"],
|
||||
"disableCustomSANs": false,
|
||||
"disableTrustOnFirstUse": false,
|
||||
"instanceAge": "1h",
|
||||
"claims": {
|
||||
"maxTLSCertDuration": "2160h",
|
||||
"defaultTLSCertDuration": "2160h"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* `type` (mandatory): indicates the provisioner type and must be `GCP`.
|
||||
|
||||
* `name` (mandatory): a string used to identify the provider when the CLI is
|
||||
used.
|
||||
|
||||
* `serviceAccounts` (optional): the list of service account numbers that are
|
||||
allowed to use this provisioner. If none is specified, all service accounts
|
||||
will be valid.
|
||||
|
||||
* `projectIDs` (optional): the list of project identifiers that are allowed to
|
||||
use this provisioner. If non is specified all project will be valid.
|
||||
|
||||
* `disableCustomSANs` (optional): by default custom SANs are valid, but if this
|
||||
option is set to true only the SANs available in the instance identity
|
||||
document will be valid, these are the DNS
|
||||
`<instance-name>.c.<project-id>.internal` and
|
||||
`<instance-name>.<zone>.c.<project-id>.internal`
|
||||
|
||||
* `disableTrustOnFirstUse` (optional): by default only one certificate will be
|
||||
granted per instance, but if the option is set to true this limit is not set
|
||||
and different tokens can be used to get different certificates.
|
||||
|
||||
* `instanceAge` (optional): the maximum age of an instance to grant a
|
||||
certificate. The instance age is a string using the duration format.
|
||||
|
||||
* `claims` (optional): overwrites the default claims set in the authority, see
|
||||
the [JWK](#jwk) section for all the options.
|
||||
|
||||
### Azure
|
||||
|
||||
The Azure provisioner grants certificates to Microsoft Azure instances using
|
||||
the [managed identities tokens](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token).
|
||||
The CA will validate the JWT and grant a certificate.
|
||||
|
||||
In the ca.json, an Azure provisioner looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Azure",
|
||||
"name": "Microsoft Azure",
|
||||
"tenantId": "b17c217c-84db-43f0-babd-e06a71083cda",
|
||||
"resourceGroups": ["backend", "accounting"],
|
||||
"audience": "https://management.azure.com/",
|
||||
"disableCustomSANs": false,
|
||||
"disableTrustOnFirstUse": false,
|
||||
"claims": {
|
||||
"maxTLSCertDuration": "2160h",
|
||||
"defaultTLSCertDuration": "2160h"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* `type` (mandatory): indicates the provisioner type and must be `Azure`.
|
||||
|
||||
* `name` (mandatory): a string used to identify the provider when the CLI is
|
||||
used.
|
||||
|
||||
* `tenantId` (mandatory): the Azure account tenant id for this provisioner. This
|
||||
id is the Directory ID available in the Azure Active Directory properties.
|
||||
|
||||
* `audience` (optional): defaults to `https://management.azure.com/` but it can
|
||||
be changed if necessary.
|
||||
|
||||
* `resourceGroups` (optional): the list of resource group names that are allowed
|
||||
to use this provisioner. If none is specified, all resource groups will be
|
||||
valid.
|
||||
|
||||
* `disableCustomSANs` (optional): by default custom SANs are valid, but if this
|
||||
option is set to true only the SANs available in the token will be valid, in
|
||||
Azure only the virtual machine name is available.
|
||||
|
||||
* `disableTrustOnFirstUse` (optional): by default only one certificate will be
|
||||
granted per instance, but if the option is set to true this limit is not set
|
||||
and different tokens can be used to get different certificates.
|
||||
|
||||
* `claims` (optional): overwrites the default claims set in the authority, see
|
||||
the [JWK](#jwk) section for all the options.
|
Loading…
Reference in New Issue