mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 17:52:32 +00:00
- The PKI plugin needs to use the customized pkcs7 fork so move it out from the aws credential plugin's package into a shared location
2003 lines
77 KiB
Go
2003 lines
77 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package awsauth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
awsClient "github.com/aws/aws-sdk-go/aws/client"
|
|
"github.com/aws/aws-sdk-go/aws/endpoints"
|
|
"github.com/aws/aws-sdk-go/service/ec2"
|
|
"github.com/aws/aws-sdk-go/service/iam"
|
|
"github.com/aws/aws-sdk-go/service/sts"
|
|
"github.com/hashicorp/errwrap"
|
|
"github.com/hashicorp/go-cleanhttp"
|
|
"github.com/hashicorp/go-retryablehttp"
|
|
"github.com/hashicorp/go-secure-stdlib/awsutil"
|
|
"github.com/hashicorp/go-secure-stdlib/parseutil"
|
|
"github.com/hashicorp/go-secure-stdlib/strutil"
|
|
"github.com/hashicorp/go-uuid"
|
|
|
|
"github.com/hashicorp/vault/helper/pkcs7"
|
|
"github.com/hashicorp/vault/sdk/framework"
|
|
"github.com/hashicorp/vault/sdk/helper/cidrutil"
|
|
"github.com/hashicorp/vault/sdk/helper/jsonutil"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
)
|
|
|
|
const (
|
|
reauthenticationDisabledNonce = "reauthentication-disabled-nonce"
|
|
iamAuthType = "iam"
|
|
ec2AuthType = "ec2"
|
|
ec2EntityType = "ec2_instance"
|
|
|
|
// Retry configuration
|
|
retryWaitMin = 500 * time.Millisecond
|
|
retryWaitMax = 30 * time.Second
|
|
)
|
|
|
|
var (
|
|
errRequestBodyNotValid = errors.New("iam request body is invalid")
|
|
errInvalidGetCallerIdentityResponse = errors.New("body of GetCallerIdentity is invalid")
|
|
)
|
|
|
|
func (b *backend) pathLogin() *framework.Path {
|
|
return &framework.Path{
|
|
Pattern: "login$",
|
|
DisplayAttrs: &framework.DisplayAttributes{
|
|
OperationPrefix: operationPrefixAWS,
|
|
OperationVerb: "login",
|
|
},
|
|
Fields: map[string]*framework.FieldSchema{
|
|
"role": {
|
|
Type: framework.TypeString,
|
|
Description: `Name of the role against which the login is being attempted.
|
|
If 'role' is not specified, then the login endpoint looks for a role
|
|
bearing the name of the AMI ID of the EC2 instance that is trying to login.
|
|
If a matching role is not found, login fails.`,
|
|
},
|
|
|
|
"pkcs7": {
|
|
Type: framework.TypeString,
|
|
Description: `PKCS7 signature of the identity document when using an auth_type
|
|
of ec2.`,
|
|
},
|
|
|
|
"nonce": {
|
|
Type: framework.TypeString,
|
|
Description: `The nonce to be used for subsequent login requests when
|
|
auth_type is ec2. If this parameter is not specified at
|
|
all and if reauthentication is allowed, then the backend will generate a random
|
|
nonce, attaches it to the instance's identity access list entry and returns the
|
|
nonce back as part of auth metadata. This value should be used with further
|
|
login requests, to establish client authenticity. Clients can choose to set a
|
|
custom nonce if preferred, in which case, it is recommended that clients provide
|
|
a strong nonce. If a nonce is provided but with an empty value, it indicates
|
|
intent to disable reauthentication. Note that, when 'disallow_reauthentication'
|
|
option is enabled on either the role or the role tag, the 'nonce' holds no
|
|
significance.`,
|
|
},
|
|
|
|
"iam_http_request_method": {
|
|
Type: framework.TypeString,
|
|
Description: `HTTP method to use for the AWS request when auth_type is
|
|
iam. This must match what has been signed in the
|
|
presigned request.`,
|
|
},
|
|
|
|
"iam_request_url": {
|
|
Type: framework.TypeString,
|
|
Description: `Base64-encoded full URL against which to make the AWS request
|
|
when using iam auth_type.`,
|
|
},
|
|
|
|
"iam_request_body": {
|
|
Type: framework.TypeString,
|
|
Description: `Base64-encoded request body when auth_type is iam.
|
|
This must match the request body included in the signature.`,
|
|
},
|
|
"iam_request_headers": {
|
|
Type: framework.TypeHeader,
|
|
Description: `Key/value pairs of headers for use in the
|
|
sts:GetCallerIdentity HTTP requests headers when auth_type is iam. Can be either
|
|
a Base64-encoded, JSON-serialized string, or a JSON object of key/value pairs.
|
|
This must at a minimum include the headers over which AWS has included a signature.`,
|
|
},
|
|
"identity": {
|
|
Type: framework.TypeString,
|
|
Description: `Base64 encoded EC2 instance identity document. This needs to be supplied along
|
|
with the 'signature' parameter. If using 'curl' for fetching the identity
|
|
document, consider using the option '-w 0' while piping the output to 'base64'
|
|
binary.`,
|
|
},
|
|
"signature": {
|
|
Type: framework.TypeString,
|
|
Description: `Base64 encoded SHA256 RSA signature of the instance identity document. This
|
|
needs to be supplied along with 'identity' parameter.`,
|
|
},
|
|
},
|
|
|
|
Operations: map[logical.Operation]framework.OperationHandler{
|
|
logical.UpdateOperation: &framework.PathOperation{
|
|
Callback: b.pathLoginUpdate,
|
|
},
|
|
logical.AliasLookaheadOperation: &framework.PathOperation{
|
|
Callback: b.pathLoginUpdate,
|
|
},
|
|
logical.ResolveRoleOperation: &framework.PathOperation{
|
|
Callback: b.pathLoginResolveRole,
|
|
},
|
|
},
|
|
|
|
HelpSynopsis: pathLoginSyn,
|
|
HelpDescription: pathLoginDesc,
|
|
}
|
|
}
|
|
|
|
func (b *backend) pathLoginResolveRole(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
|
anyEc2, allEc2 := hasValuesForEc2Auth(data)
|
|
anyIam, allIam := hasValuesForIamAuth(data)
|
|
switch {
|
|
case anyEc2 && anyIam:
|
|
return logical.ErrorResponse("supplied auth values for both ec2 and iam auth types"), nil
|
|
case anyEc2 && !allEc2:
|
|
return logical.ErrorResponse("supplied some of the auth values for the ec2 auth type but not all"), nil
|
|
case anyEc2:
|
|
return b.pathLoginResolveRoleEc2(ctx, req, data)
|
|
case anyIam && !allIam:
|
|
return logical.ErrorResponse("supplied some of the auth values for the iam auth type but not all"), nil
|
|
case anyIam:
|
|
return b.pathLoginResolveRoleIam(ctx, req, data)
|
|
default:
|
|
return logical.ErrorResponse("didn't supply required authentication values"), nil
|
|
}
|
|
}
|
|
|
|
func (b *backend) pathLoginEc2GetRoleNameAndIdentityDoc(ctx context.Context, req *logical.Request, data *framework.FieldData) (string, *identityDocument, *logical.Response, error) {
|
|
identityDocB64 := data.Get("identity").(string)
|
|
var identityDocBytes []byte
|
|
var err error
|
|
if identityDocB64 != "" {
|
|
identityDocBytes, err = base64.StdEncoding.DecodeString(identityDocB64)
|
|
if err != nil || len(identityDocBytes) == 0 {
|
|
return "", nil, logical.ErrorResponse("failed to base64 decode the instance identity document"), nil
|
|
}
|
|
}
|
|
|
|
signatureB64 := data.Get("signature").(string)
|
|
var signatureBytes []byte
|
|
if signatureB64 != "" {
|
|
signatureBytes, err = base64.StdEncoding.DecodeString(signatureB64)
|
|
if err != nil {
|
|
return "", nil, logical.ErrorResponse("failed to base64 decode the SHA256 RSA signature of the instance identity document"), nil
|
|
}
|
|
}
|
|
|
|
pkcs7B64 := data.Get("pkcs7").(string)
|
|
|
|
// Either the pkcs7 signature of the instance identity document, or
|
|
// the identity document itself along with its SHA256 RSA signature
|
|
// needs to be provided.
|
|
if pkcs7B64 == "" && (len(identityDocBytes) == 0 && len(signatureBytes) == 0) {
|
|
return "", nil, logical.ErrorResponse("either pkcs7 or a tuple containing the instance identity document and its SHA256 RSA signature needs to be provided"), nil
|
|
} else if pkcs7B64 != "" && (len(identityDocBytes) != 0 && len(signatureBytes) != 0) {
|
|
return "", nil, logical.ErrorResponse("both pkcs7 and a tuple containing the instance identity document and its SHA256 RSA signature is supplied; provide only one"), nil
|
|
}
|
|
|
|
// Verify the signature of the identity document and unmarshal it
|
|
var identityDocParsed *identityDocument
|
|
if pkcs7B64 != "" {
|
|
identityDocParsed, err = b.parseIdentityDocument(ctx, req.Storage, pkcs7B64)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
if identityDocParsed == nil {
|
|
return "", nil, logical.ErrorResponse("failed to verify the instance identity document using pkcs7"), nil
|
|
}
|
|
} else {
|
|
identityDocParsed, err = b.verifyInstanceIdentitySignature(ctx, req.Storage, identityDocBytes, signatureBytes)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
if identityDocParsed == nil {
|
|
return "", nil, logical.ErrorResponse("failed to verify the instance identity document using the SHA256 RSA digest"), nil
|
|
}
|
|
}
|
|
|
|
roleName := data.Get("role").(string)
|
|
|
|
// If roleName is not supplied, a role in the name of the instance's AMI ID will be looked for
|
|
if roleName == "" {
|
|
roleName = identityDocParsed.AmiID
|
|
}
|
|
|
|
// Get the entry for the role used by the instance
|
|
// Note that we don't return the roleEntry, but use it to determine if the role exists
|
|
// roleEntry does not contain the role name, so it is not appropriate to return
|
|
roleEntry, err := b.role(ctx, req.Storage, roleName)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
if roleEntry == nil {
|
|
return "", nil, logical.ErrorResponse(fmt.Sprintf("entry for role %q not found", roleName)), nil
|
|
}
|
|
return roleName, identityDocParsed, nil, nil
|
|
}
|
|
|
|
func (b *backend) pathLoginResolveRoleEc2(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
|
role, _, resp, err := b.pathLoginEc2GetRoleNameAndIdentityDoc(ctx, req, data)
|
|
if resp != nil || err != nil {
|
|
return resp, err
|
|
}
|
|
return logical.ResolveRoleResponse(role)
|
|
}
|
|
|
|
func (b *backend) pathLoginIamGetRoleNameCallerIdAndEntity(ctx context.Context, req *logical.Request, data *framework.FieldData) (string, *GetCallerIdentityResult, *iamEntity, *logical.Response, error) {
|
|
method := data.Get("iam_http_request_method").(string)
|
|
if method == "" {
|
|
return "", nil, nil, logical.ErrorResponse("missing iam_http_request_method"), nil
|
|
}
|
|
|
|
if method != http.MethodGet && method != http.MethodPost {
|
|
return "", nil, nil, logical.ErrorResponse("invalid iam_http_request_method; currently only 'GET' and 'POST' are supported"), nil
|
|
}
|
|
|
|
rawUrlB64 := data.Get("iam_request_url").(string)
|
|
if rawUrlB64 == "" {
|
|
return "", nil, nil, logical.ErrorResponse("missing iam_request_url"), nil
|
|
}
|
|
rawUrl, err := base64.StdEncoding.DecodeString(rawUrlB64)
|
|
if err != nil {
|
|
return "", nil, nil, logical.ErrorResponse("failed to base64 decode iam_request_url"), nil
|
|
}
|
|
parsedUrl, err := url.Parse(string(rawUrl))
|
|
if err != nil {
|
|
return "", nil, nil, logical.ErrorResponse("error parsing iam_request_url"), nil
|
|
}
|
|
if err = validateLoginIamRequestUrl(method, parsedUrl); err != nil {
|
|
return "", nil, nil, logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
bodyB64 := data.Get("iam_request_body").(string)
|
|
if bodyB64 == "" && method != http.MethodGet {
|
|
return "", nil, nil, logical.ErrorResponse("missing iam_request_body which is required for POST requests"), nil
|
|
}
|
|
bodyRaw, err := base64.StdEncoding.DecodeString(bodyB64)
|
|
if err != nil {
|
|
return "", nil, nil, logical.ErrorResponse("failed to base64 decode iam_request_body"), nil
|
|
}
|
|
body := string(bodyRaw)
|
|
if err = validateLoginIamRequestBody(body); err != nil {
|
|
return "", nil, nil, logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
|
|
headers := data.Get("iam_request_headers").(http.Header)
|
|
if len(headers) == 0 {
|
|
return "", nil, nil, logical.ErrorResponse("missing iam_request_headers"), nil
|
|
}
|
|
|
|
config, err := b.lockedClientConfigEntry(ctx, req.Storage)
|
|
if err != nil {
|
|
return "", nil, nil, nil, fmt.Errorf("error getting configuration: %w", err)
|
|
}
|
|
|
|
endpoint := "https://sts.amazonaws.com"
|
|
|
|
maxRetries := awsClient.DefaultRetryerMaxNumRetries
|
|
if config != nil {
|
|
if config.IAMServerIdHeaderValue != "" {
|
|
err = validateVaultHeaderValue(method, headers, parsedUrl, config.IAMServerIdHeaderValue)
|
|
if err != nil {
|
|
return "", nil, nil, logical.ErrorResponse(fmt.Sprintf("error validating %s header: %v", iamServerIdHeader, err)), nil
|
|
}
|
|
}
|
|
if err = config.validateAllowedSTSHeaderValues(headers); err != nil {
|
|
return "", nil, nil, logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
if method == http.MethodGet {
|
|
if err = config.validateAllowedSTSQueryValues(parsedUrl.Query()); err != nil {
|
|
return "", nil, nil, logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
}
|
|
if config.STSEndpoint != "" {
|
|
endpoint = config.STSEndpoint
|
|
}
|
|
if config.MaxRetries >= 0 {
|
|
maxRetries = config.MaxRetries
|
|
}
|
|
|
|
// Extract and use a regional STS endpoint
|
|
// based on the region set in the Authorization header.
|
|
if config.UseSTSRegionFromClient {
|
|
clientSpecifiedRegion, err := awsRegionFromHeader(headers.Get("Authorization"))
|
|
if err != nil {
|
|
return "", nil, nil, logical.ErrorResponse("region missing from Authorization header"), nil
|
|
}
|
|
|
|
url, err := stsRegionalEndpoint(clientSpecifiedRegion)
|
|
if err != nil {
|
|
return "", nil, nil, logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
|
|
b.Logger().Debug("use_sts_region_from_client set; using region specified from header", "region", clientSpecifiedRegion)
|
|
endpoint = url
|
|
}
|
|
}
|
|
|
|
b.Logger().Debug("submitting caller identity request", "endpoint", endpoint)
|
|
callerID, err := submitCallerIdentityRequest(ctx, maxRetries, method, endpoint, parsedUrl, body, headers)
|
|
if err != nil {
|
|
return "", nil, nil, logical.ErrorResponse(fmt.Sprintf("error making upstream request: %v", err)), nil
|
|
}
|
|
|
|
entity, err := parseIamArn(callerID.Arn)
|
|
if err != nil {
|
|
return "", nil, nil, logical.ErrorResponse(fmt.Sprintf("error parsing arn %q: %v", callerID.Arn, err)), nil
|
|
}
|
|
|
|
roleName := data.Get("role").(string)
|
|
if roleName == "" {
|
|
roleName = entity.FriendlyName
|
|
}
|
|
return roleName, callerID, entity, nil, nil
|
|
}
|
|
|
|
func (b *backend) pathLoginResolveRoleIam(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
|
role, _, _, resp, err := b.pathLoginIamGetRoleNameCallerIdAndEntity(ctx, req, data)
|
|
if resp != nil || err != nil {
|
|
return resp, err
|
|
}
|
|
return logical.ResolveRoleResponse(role)
|
|
}
|
|
|
|
// instanceIamRoleARN fetches the IAM role ARN associated with the given
|
|
// instance profile name
|
|
func (b *backend) instanceIamRoleARN(ctx context.Context, iamClient *iam.IAM, instanceProfileName string) (string, error) {
|
|
if iamClient == nil {
|
|
return "", fmt.Errorf("nil iamClient")
|
|
}
|
|
if instanceProfileName == "" {
|
|
return "", fmt.Errorf("missing instance profile name")
|
|
}
|
|
|
|
profile, err := iamClient.GetInstanceProfileWithContext(ctx, &iam.GetInstanceProfileInput{
|
|
InstanceProfileName: aws.String(instanceProfileName),
|
|
})
|
|
if err != nil {
|
|
return "", awsutil.AppendAWSError(err)
|
|
}
|
|
if profile == nil {
|
|
return "", fmt.Errorf("nil output while getting instance profile details")
|
|
}
|
|
|
|
if profile.InstanceProfile == nil {
|
|
return "", fmt.Errorf("nil instance profile in the output of instance profile details")
|
|
}
|
|
|
|
if profile.InstanceProfile.Roles == nil || len(profile.InstanceProfile.Roles) != 1 {
|
|
return "", fmt.Errorf("invalid roles in the output of instance profile details")
|
|
}
|
|
|
|
if profile.InstanceProfile.Roles[0].Arn == nil {
|
|
return "", fmt.Errorf("nil role ARN in the output of instance profile details")
|
|
}
|
|
|
|
return *profile.InstanceProfile.Roles[0].Arn, nil
|
|
}
|
|
|
|
// validateInstance queries the status of the EC2 instance using AWS EC2 API
|
|
// and checks if the instance is running and is healthy
|
|
func (b *backend) validateInstance(ctx context.Context, s logical.Storage, instanceID, region, accountID string) (*ec2.Instance, error) {
|
|
// Create an EC2 client to pull the instance information
|
|
ec2Client, err := b.clientEC2(ctx, s, region, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
status, err := ec2Client.DescribeInstancesWithContext(ctx, &ec2.DescribeInstancesInput{
|
|
InstanceIds: []*string{
|
|
aws.String(instanceID),
|
|
},
|
|
})
|
|
if err != nil {
|
|
errW := fmt.Errorf("error fetching description for instance ID %q: %w", instanceID, err)
|
|
return nil, errwrap.Wrap(errW, awsutil.CheckAWSError(err))
|
|
}
|
|
if status == nil {
|
|
return nil, fmt.Errorf("nil output from describe instances")
|
|
}
|
|
if len(status.Reservations) == 0 {
|
|
return nil, fmt.Errorf("no reservations found in instance description")
|
|
}
|
|
if len(status.Reservations[0].Instances) == 0 {
|
|
return nil, fmt.Errorf("no instance details found in reservations")
|
|
}
|
|
if *status.Reservations[0].Instances[0].InstanceId != instanceID {
|
|
return nil, fmt.Errorf("expected instance ID not matching the instance ID in the instance description")
|
|
}
|
|
if status.Reservations[0].Instances[0].State == nil {
|
|
return nil, fmt.Errorf("instance state in instance description is nil")
|
|
}
|
|
if *status.Reservations[0].Instances[0].State.Name != "running" {
|
|
return nil, fmt.Errorf("instance is not in 'running' state")
|
|
}
|
|
return status.Reservations[0].Instances[0], nil
|
|
}
|
|
|
|
// validateMetadata matches the given client nonce and pending time with the
|
|
// one cached in the identity access list during the previous login. But, if
|
|
// reauthentication is disabled, login attempt is failed immediately.
|
|
func validateMetadata(clientNonce, pendingTime string, storedIdentity *accessListIdentity, roleEntry *awsRoleEntry) error {
|
|
// For sanity
|
|
if !storedIdentity.DisallowReauthentication && storedIdentity.ClientNonce == "" {
|
|
return fmt.Errorf("client nonce missing in stored identity")
|
|
}
|
|
|
|
// If reauthentication is disabled or if the nonce supplied matches a
|
|
// predefined nonce which indicates reauthentication to be disabled,
|
|
// authentication will not succeed.
|
|
if storedIdentity.DisallowReauthentication ||
|
|
subtle.ConstantTimeCompare([]byte(reauthenticationDisabledNonce), []byte(clientNonce)) == 1 {
|
|
return fmt.Errorf("reauthentication is disabled")
|
|
}
|
|
|
|
givenPendingTime, err := time.Parse(time.RFC3339, pendingTime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
storedPendingTime, err := time.Parse(time.RFC3339, storedIdentity.PendingTime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// When the presented client nonce does not match the cached entry, it
|
|
// is either that a rogue client is trying to login or that a valid
|
|
// client suffered a migration. The migration is detected via
|
|
// pendingTime in the instance metadata, which sadly is only updated
|
|
// when an instance is stopped and started but *not* when the instance
|
|
// is rebooted. If reboot survivability is needed, either
|
|
// instrumentation to delete the instance ID from the access list is
|
|
// necessary, or the client must durably store the nonce.
|
|
//
|
|
// If the `allow_instance_migration` property of the registered role is
|
|
// enabled, then the client nonce mismatch is ignored, as long as the
|
|
// pending time in the presented instance identity document is newer
|
|
// than the cached pending time. The new pendingTime is stored and used
|
|
// for future checks.
|
|
//
|
|
// This is a weak criterion and hence the `allow_instance_migration`
|
|
// option should be used with caution.
|
|
if subtle.ConstantTimeCompare([]byte(clientNonce), []byte(storedIdentity.ClientNonce)) != 1 {
|
|
if !roleEntry.AllowInstanceMigration {
|
|
return fmt.Errorf("client nonce mismatch")
|
|
}
|
|
if roleEntry.AllowInstanceMigration && !givenPendingTime.After(storedPendingTime) {
|
|
return fmt.Errorf("client nonce mismatch and instance meta-data incorrect")
|
|
}
|
|
}
|
|
|
|
// Ensure that the 'pendingTime' on the given identity document is not
|
|
// before the 'pendingTime' that was used for previous login. This
|
|
// disallows old metadata documents from being used to perform login.
|
|
if givenPendingTime.Before(storedPendingTime) {
|
|
return fmt.Errorf("instance meta-data is older than the one used for previous login")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Verifies the integrity of the instance identity document using its SHA256
|
|
// RSA signature. After verification, returns the unmarshaled instance identity
|
|
// document.
|
|
func (b *backend) verifyInstanceIdentitySignature(ctx context.Context, s logical.Storage, identityBytes, signatureBytes []byte) (*identityDocument, error) {
|
|
if len(identityBytes) == 0 {
|
|
return nil, fmt.Errorf("missing instance identity document")
|
|
}
|
|
|
|
if len(signatureBytes) == 0 {
|
|
return nil, fmt.Errorf("missing SHA256 RSA signature of the instance identity document")
|
|
}
|
|
|
|
// Get the public certificates that are used to verify the signature.
|
|
// This returns a slice of certificates containing the default
|
|
// certificate and all the registered certificates via
|
|
// 'config/certificate/<cert_name>' endpoint, for verifying the RSA
|
|
// digest.
|
|
publicCerts, err := b.awsPublicCertificates(ctx, s, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if publicCerts == nil || len(publicCerts) == 0 {
|
|
return nil, fmt.Errorf("certificates to verify the signature are not found")
|
|
}
|
|
|
|
// Check if any of the certs registered at the backend can verify the
|
|
// signature
|
|
for _, cert := range publicCerts {
|
|
err := cert.CheckSignature(x509.SHA256WithRSA, identityBytes, signatureBytes)
|
|
if err == nil {
|
|
var identityDoc identityDocument
|
|
if decErr := jsonutil.DecodeJSON(identityBytes, &identityDoc); decErr != nil {
|
|
return nil, decErr
|
|
}
|
|
return &identityDoc, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("instance identity verification using SHA256 RSA signature is unsuccessful")
|
|
}
|
|
|
|
// Verifies the correctness of the authenticated attributes present in the PKCS#7
|
|
// signature. After verification, extracts the instance identity document from the
|
|
// signature, parses it and returns it.
|
|
func (b *backend) parseIdentityDocument(ctx context.Context, s logical.Storage, pkcs7B64 string) (*identityDocument, error) {
|
|
// Insert the header and footer for the signature to be able to pem decode it
|
|
pkcs7B64 = fmt.Sprintf("-----BEGIN PKCS7-----\n%s\n-----END PKCS7-----", pkcs7B64)
|
|
|
|
// Decode the PEM encoded signature
|
|
pkcs7BER, pkcs7Rest := pem.Decode([]byte(pkcs7B64))
|
|
if len(pkcs7Rest) != 0 {
|
|
return nil, fmt.Errorf("failed to decode the PEM encoded PKCS#7 signature")
|
|
}
|
|
|
|
// Parse the signature from asn1 format into a struct
|
|
pkcs7Data, err := pkcs7.Parse(pkcs7BER.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse the BER encoded PKCS#7 signature: %w", err)
|
|
}
|
|
|
|
// Get the public certificates that are used to verify the signature.
|
|
// This returns a slice of certificates containing the default certificate
|
|
// and all the registered certificates via 'config/certificate/<cert_name>' endpoint
|
|
publicCerts, err := b.awsPublicCertificates(ctx, s, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if publicCerts == nil || len(publicCerts) == 0 {
|
|
return nil, fmt.Errorf("certificates to verify the signature are not found")
|
|
}
|
|
|
|
// Before calling Verify() on the PKCS#7 struct, set the certificates to be used
|
|
// to verify the contents in the signer information.
|
|
pkcs7Data.Certificates = publicCerts
|
|
|
|
// Verify extracts the authenticated attributes in the PKCS#7 signature, and verifies
|
|
// the authenticity of the content using 'dsa.PublicKey' embedded in the public certificate.
|
|
if err := pkcs7Data.Verify(); err != nil {
|
|
return nil, fmt.Errorf("failed to verify the signature: %w", err)
|
|
}
|
|
|
|
// Check if the signature has content inside of it
|
|
if len(pkcs7Data.Content) == 0 {
|
|
return nil, fmt.Errorf("instance identity document could not be found in the signature")
|
|
}
|
|
|
|
var identityDoc identityDocument
|
|
if err := jsonutil.DecodeJSON(pkcs7Data.Content, &identityDoc); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &identityDoc, nil
|
|
}
|
|
|
|
func (b *backend) pathLoginUpdate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
|
anyEc2, allEc2 := hasValuesForEc2Auth(data)
|
|
anyIam, allIam := hasValuesForIamAuth(data)
|
|
switch {
|
|
case anyEc2 && anyIam:
|
|
return logical.ErrorResponse("supplied auth values for both ec2 and iam auth types"), nil
|
|
case anyEc2 && !allEc2:
|
|
return logical.ErrorResponse("supplied some of the auth values for the ec2 auth type but not all"), nil
|
|
case anyEc2:
|
|
return b.pathLoginUpdateEc2(ctx, req, data)
|
|
case anyIam && !allIam:
|
|
return logical.ErrorResponse("supplied some of the auth values for the iam auth type but not all"), nil
|
|
case anyIam:
|
|
return b.pathLoginUpdateIam(ctx, req, data)
|
|
default:
|
|
return logical.ErrorResponse("didn't supply required authentication values"), nil
|
|
}
|
|
}
|
|
|
|
// Returns whether the EC2 instance meets the requirements of the particular
|
|
// AWS role entry.
|
|
// The first error return value is whether there's some sort of validation
|
|
// error that means the instance doesn't meet the role requirements
|
|
// The second error return value indicates whether there's an error in even
|
|
// trying to validate those requirements
|
|
func (b *backend) verifyInstanceMeetsRoleRequirements(ctx context.Context,
|
|
s logical.Storage, instance *ec2.Instance, roleEntry *awsRoleEntry, roleName string, identityDoc *identityDocument) (error, error,
|
|
) {
|
|
switch {
|
|
case instance == nil:
|
|
return nil, fmt.Errorf("nil instance")
|
|
case roleEntry == nil:
|
|
return nil, fmt.Errorf("nil roleEntry")
|
|
case identityDoc == nil:
|
|
return nil, fmt.Errorf("nil identityDoc")
|
|
}
|
|
|
|
// Verify that the instance ID matches one of the ones set by the role
|
|
if len(roleEntry.BoundEc2InstanceIDs) > 0 && !strutil.StrListContains(roleEntry.BoundEc2InstanceIDs, *instance.InstanceId) {
|
|
return fmt.Errorf("instance ID %q does not belong to the role %q", *instance.InstanceId, roleName), nil
|
|
}
|
|
|
|
// Verify that the AccountID of the instance trying to login matches the
|
|
// AccountID specified as a constraint on role
|
|
if len(roleEntry.BoundAccountIDs) > 0 && !strutil.StrListContains(roleEntry.BoundAccountIDs, identityDoc.AccountID) {
|
|
return fmt.Errorf("account ID %q does not belong to role %q", identityDoc.AccountID, roleName), nil
|
|
}
|
|
|
|
// Verify that the AMI ID of the instance trying to login matches the
|
|
// AMI ID specified as a constraint on the role.
|
|
//
|
|
// Here, we're making a tradeoff and pulling the AMI ID out of the EC2
|
|
// API rather than the signed instance identity doc. They *should* match.
|
|
// This means we require an EC2 API call to retrieve the AMI ID, but we're
|
|
// already calling the API to validate the Instance ID anyway, so it shouldn't
|
|
// matter. The benefit is that we have the exact same code whether auth_type
|
|
// is ec2 or iam.
|
|
if len(roleEntry.BoundAmiIDs) > 0 {
|
|
if instance.ImageId == nil {
|
|
return nil, fmt.Errorf("AMI ID in the instance description is nil")
|
|
}
|
|
if !strutil.StrListContains(roleEntry.BoundAmiIDs, *instance.ImageId) {
|
|
return fmt.Errorf("AMI ID %q does not belong to role %q", *instance.ImageId, roleName), nil
|
|
}
|
|
}
|
|
|
|
// Validate the SubnetID if corresponding bound was set on the role
|
|
if len(roleEntry.BoundSubnetIDs) > 0 {
|
|
if instance.SubnetId == nil {
|
|
return nil, fmt.Errorf("subnet ID in the instance description is nil")
|
|
}
|
|
if !strutil.StrListContains(roleEntry.BoundSubnetIDs, *instance.SubnetId) {
|
|
return fmt.Errorf("subnet ID %q does not satisfy the constraint on role %q", *instance.SubnetId, roleName), nil
|
|
}
|
|
}
|
|
|
|
// Validate the VpcID if corresponding bound was set on the role
|
|
if len(roleEntry.BoundVpcIDs) > 0 {
|
|
if instance.VpcId == nil {
|
|
return nil, fmt.Errorf("VPC ID in the instance description is nil")
|
|
}
|
|
if !strutil.StrListContains(roleEntry.BoundVpcIDs, *instance.VpcId) {
|
|
return fmt.Errorf("VPC ID %q does not satisfy the constraint on role %q", *instance.VpcId, roleName), nil
|
|
}
|
|
}
|
|
|
|
// Check if the IAM instance profile ARN of the instance trying to
|
|
// login, matches the IAM instance profile ARN specified as a constraint
|
|
// on the role
|
|
if len(roleEntry.BoundIamInstanceProfileARNs) > 0 {
|
|
if instance.IamInstanceProfile == nil {
|
|
return nil, fmt.Errorf("IAM instance profile in the instance description is nil")
|
|
}
|
|
if instance.IamInstanceProfile.Arn == nil {
|
|
return nil, fmt.Errorf("IAM instance profile ARN in the instance description is nil")
|
|
}
|
|
iamInstanceProfileARN := *instance.IamInstanceProfile.Arn
|
|
matchesInstanceProfile := false
|
|
// NOTE: Can't use strutil.StrListContainsGlob. A * is a perfectly valid character in the "path" component
|
|
// of an ARN. See, e.g., https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateInstanceProfile.html :
|
|
// The path allows strings "containing any ASCII character from the ! (\u0021) thru the DEL character
|
|
// (\u007F), including most punctuation characters, digits, and upper and lowercased letters."
|
|
// So, e.g., arn:aws:iam::123456789012:instance-profile/Some*Path/MyProfileName is a perfectly valid instance
|
|
// profile ARN, and it wouldn't be correct to expand the * in the middle as a wildcard.
|
|
// If a user wants to match an IAM instance profile arn beginning with arn:aws:iam::123456789012:instance-profile/foo*
|
|
// then bound_iam_instance_profile_arn would need to be arn:aws:iam::123456789012:instance-profile/foo**
|
|
// Wanting to exactly match an ARN that has a * at the end is not a valid use case. The * is only valid in the
|
|
// path; it's not valid in the name. That means no valid ARN can ever end with a *. For example,
|
|
// arn:aws:iam::123456789012:instance-profile/Foo* is NOT valid as an instance profile ARN, so no valid instance
|
|
// profile ARN could ever equal that value.
|
|
for _, boundInstanceProfileARN := range roleEntry.BoundIamInstanceProfileARNs {
|
|
switch {
|
|
case strings.HasSuffix(boundInstanceProfileARN, "*") && strings.HasPrefix(iamInstanceProfileARN, boundInstanceProfileARN[:len(boundInstanceProfileARN)-1]):
|
|
matchesInstanceProfile = true
|
|
break
|
|
case iamInstanceProfileARN == boundInstanceProfileARN:
|
|
matchesInstanceProfile = true
|
|
break
|
|
}
|
|
}
|
|
if !matchesInstanceProfile {
|
|
return fmt.Errorf("IAM instance profile ARN %q does not satisfy the constraint role %q", iamInstanceProfileARN, roleName), nil
|
|
}
|
|
}
|
|
|
|
// Check if the IAM role ARN of the instance trying to login, matches
|
|
// the IAM role ARN specified as a constraint on the role.
|
|
if len(roleEntry.BoundIamRoleARNs) > 0 {
|
|
if instance.IamInstanceProfile == nil {
|
|
return nil, fmt.Errorf("IAM instance profile in the instance description is nil")
|
|
}
|
|
if instance.IamInstanceProfile.Arn == nil {
|
|
return nil, fmt.Errorf("IAM instance profile ARN in the instance description is nil")
|
|
}
|
|
|
|
// Fetch the instance profile ARN from the instance description
|
|
iamInstanceProfileARN := *instance.IamInstanceProfile.Arn
|
|
|
|
if iamInstanceProfileARN == "" {
|
|
return nil, fmt.Errorf("IAM instance profile ARN in the instance description is empty")
|
|
}
|
|
|
|
// Extract out the instance profile name from the instance
|
|
// profile ARN
|
|
iamInstanceProfileEntity, err := parseIamArn(iamInstanceProfileARN)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse IAM instance profile ARN %q: %w", iamInstanceProfileARN, err)
|
|
}
|
|
|
|
// Use instance profile ARN to fetch the associated role ARN
|
|
iamClient, err := b.clientIAM(ctx, s, identityDoc.Region, identityDoc.AccountID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not fetch IAM client: %w", err)
|
|
} else if iamClient == nil {
|
|
return nil, fmt.Errorf("received a nil iamClient")
|
|
}
|
|
iamRoleARN, err := b.instanceIamRoleARN(ctx, iamClient, iamInstanceProfileEntity.FriendlyName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("IAM role ARN could not be fetched: %w", err)
|
|
}
|
|
if iamRoleARN == "" {
|
|
return nil, fmt.Errorf("IAM role ARN could not be fetched")
|
|
}
|
|
|
|
matchesInstanceRoleARN := false
|
|
for _, boundIamRoleARN := range roleEntry.BoundIamRoleARNs {
|
|
switch {
|
|
// as with boundInstanceProfileARN, can't use strutil.StrListContainsGlob because * can validly exist in the middle of an ARN
|
|
case strings.HasSuffix(boundIamRoleARN, "*") && strings.HasPrefix(iamRoleARN, boundIamRoleARN[:len(boundIamRoleARN)-1]):
|
|
matchesInstanceRoleARN = true
|
|
break
|
|
case iamRoleARN == boundIamRoleARN:
|
|
matchesInstanceRoleARN = true
|
|
break
|
|
}
|
|
}
|
|
if !matchesInstanceRoleARN {
|
|
return fmt.Errorf("IAM role ARN %q does not satisfy the constraint role %q", iamRoleARN, roleName), nil
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// pathLoginUpdateEc2 is used to create a Vault token by the EC2 instances
|
|
// by providing the pkcs7 signature of the instance identity document
|
|
// and a client created nonce. Client nonce is optional if 'disallow_reauthentication'
|
|
// option is enabled on the registered role.
|
|
func (b *backend) pathLoginUpdateEc2(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
|
roleName, identityDocParsed, errResp, err := b.pathLoginEc2GetRoleNameAndIdentityDoc(ctx, req, data)
|
|
if errResp != nil || err != nil {
|
|
return errResp, err
|
|
}
|
|
|
|
// Get the entry for the role used by the instance
|
|
roleEntry, err := b.role(ctx, req.Storage, roleName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if roleEntry == nil {
|
|
return logical.ErrorResponse(fmt.Sprintf("entry for role %q not found", roleName)), nil
|
|
}
|
|
|
|
// Check for a CIDR match.
|
|
if len(roleEntry.TokenBoundCIDRs) > 0 {
|
|
if req.Connection == nil {
|
|
b.Logger().Warn("token bound CIDRs found but no connection information available for validation")
|
|
return nil, logical.ErrPermissionDenied
|
|
}
|
|
if !cidrutil.RemoteAddrIsOk(req.Connection.RemoteAddr, roleEntry.TokenBoundCIDRs) {
|
|
return nil, logical.ErrPermissionDenied
|
|
}
|
|
}
|
|
|
|
if roleEntry.AuthType != ec2AuthType {
|
|
return logical.ErrorResponse(fmt.Sprintf("auth method ec2 not allowed for role %s", roleName)), nil
|
|
}
|
|
|
|
identityConfigEntry, err := identityConfigEntry(ctx, req.Storage)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
identityAlias := ""
|
|
|
|
switch identityConfigEntry.EC2Alias {
|
|
case identityAliasRoleID:
|
|
identityAlias = roleEntry.RoleID
|
|
case identityAliasEC2InstanceID:
|
|
identityAlias = identityDocParsed.InstanceID
|
|
case identityAliasEC2ImageID:
|
|
identityAlias = identityDocParsed.AmiID
|
|
}
|
|
|
|
// If we're just looking up for MFA, return the Alias info
|
|
if req.Operation == logical.AliasLookaheadOperation {
|
|
return &logical.Response{
|
|
Auth: &logical.Auth{
|
|
Alias: &logical.Alias{
|
|
Name: identityAlias,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Validate the instance ID by making a call to AWS EC2 DescribeInstances API
|
|
// and fetching the instance description. Validation succeeds only if the
|
|
// instance is in 'running' state.
|
|
instance, err := b.validateInstance(ctx, req.Storage, identityDocParsed.InstanceID, identityDocParsed.Region, identityDocParsed.AccountID)
|
|
if err != nil {
|
|
return logical.ErrorResponse(fmt.Sprintf("failed to verify instance ID: %v", err)), nil
|
|
}
|
|
|
|
// Verify that the `Region` of the instance trying to login matches the
|
|
// `Region` specified as a constraint on role
|
|
if len(roleEntry.BoundRegions) > 0 && !strutil.StrListContains(roleEntry.BoundRegions, identityDocParsed.Region) {
|
|
return logical.ErrorResponse(fmt.Sprintf("Region %q does not satisfy the constraint on role %q", identityDocParsed.Region, roleName)), nil
|
|
}
|
|
|
|
validationError, err := b.verifyInstanceMeetsRoleRequirements(ctx, req.Storage, instance, roleEntry, roleName, identityDocParsed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if validationError != nil {
|
|
return logical.ErrorResponse(fmt.Sprintf("Error validating instance: %v", validationError)), nil
|
|
}
|
|
|
|
// Get the entry from the identity access list, if there is one
|
|
storedIdentity, err := accessListIdentityEntry(ctx, req.Storage, identityDocParsed.InstanceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// disallowReauthentication value that gets cached at the stored
|
|
// identity access list entry is determined not just by the role entry.
|
|
// If client explicitly sets nonce to be empty, it implies intent to
|
|
// disable reauthentication. Also, role tag can override the 'false'
|
|
// value with 'true' (the other way around is not allowed).
|
|
|
|
// Read the value from the role entry
|
|
disallowReauthentication := roleEntry.DisallowReauthentication
|
|
|
|
clientNonce := ""
|
|
|
|
// Check if the nonce is supplied by the client
|
|
clientNonceRaw, clientNonceSupplied := data.GetOk("nonce")
|
|
if clientNonceSupplied {
|
|
clientNonce = clientNonceRaw.(string)
|
|
|
|
// Nonce explicitly set to empty implies intent to disable
|
|
// reauthentication by the client. Set a predefined nonce which
|
|
// indicates reauthentication being disabled.
|
|
if clientNonce == "" {
|
|
clientNonce = reauthenticationDisabledNonce
|
|
|
|
// Ensure that the intent lands in the access list
|
|
disallowReauthentication = true
|
|
}
|
|
}
|
|
|
|
// This is NOT a first login attempt from the client
|
|
if storedIdentity != nil {
|
|
// Check if the client nonce match the cached nonce and if the pending time
|
|
// of the identity document is not before the pending time of the document
|
|
// with which previous login was made. If 'allow_instance_migration' is
|
|
// enabled on the registered role, client nonce requirement is relaxed.
|
|
if err = validateMetadata(clientNonce, identityDocParsed.PendingTime, storedIdentity, roleEntry); err != nil {
|
|
return logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
|
|
// Don't let subsequent login attempts to bypass the initial
|
|
// intent of disabling reauthentication, despite the properties
|
|
// of role getting updated. For example: Role has the value set
|
|
// to 'false', a role-tag login sets the value to 'true', then
|
|
// role gets updated to not use a role-tag, and a login attempt
|
|
// is made with role's value set to 'false'. Removing the entry
|
|
// from the identity access list should be the only way to be
|
|
// able to login from the instance again.
|
|
disallowReauthentication = disallowReauthentication || storedIdentity.DisallowReauthentication
|
|
}
|
|
|
|
// If we reach this point without erroring and if the client nonce was
|
|
// not supplied, a first time login is implied and that the client
|
|
// intends that the nonce be generated by the backend. Create a random
|
|
// nonce to be associated for the instance ID.
|
|
if !clientNonceSupplied {
|
|
if clientNonce, err = uuid.GenerateUUID(); err != nil {
|
|
return nil, fmt.Errorf("failed to generate random nonce")
|
|
}
|
|
}
|
|
|
|
// Load the current values for max TTL and policies from the role entry,
|
|
// before checking for overriding max TTL in the role tag. The shortest
|
|
// max TTL is used to cap the token TTL; the longest max TTL is used to
|
|
// make the access list entry as long as possible as it controls for replay
|
|
// attacks.
|
|
shortestMaxTTL := b.System().MaxLeaseTTL()
|
|
longestMaxTTL := b.System().MaxLeaseTTL()
|
|
if roleEntry.TokenMaxTTL > time.Duration(0) && roleEntry.TokenMaxTTL < shortestMaxTTL {
|
|
shortestMaxTTL = roleEntry.TokenMaxTTL
|
|
}
|
|
if roleEntry.TokenMaxTTL > longestMaxTTL {
|
|
longestMaxTTL = roleEntry.TokenMaxTTL
|
|
}
|
|
|
|
policies := roleEntry.TokenPolicies
|
|
rTagMaxTTL := time.Duration(0)
|
|
var roleTagResp *roleTagLoginResponse
|
|
if roleEntry.RoleTag != "" {
|
|
roleTagResp, err = b.handleRoleTagLogin(ctx, req.Storage, roleName, roleEntry, instance)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if roleTagResp == nil {
|
|
return logical.ErrorResponse("failed to fetch and verify the role tag"), nil
|
|
}
|
|
}
|
|
|
|
if roleTagResp != nil {
|
|
// Role tag is enabled on the role.
|
|
|
|
// Overwrite the policies with the ones returned from processing the role tag
|
|
// If there are no policies on the role tag, policies on the role are inherited.
|
|
// If policies on role tag are set, by this point, it is verified that it is a subset of the
|
|
// policies on the role. So, apply only those.
|
|
if len(roleTagResp.Policies) != 0 {
|
|
policies = roleTagResp.Policies
|
|
}
|
|
|
|
// If roleEntry had disallowReauthentication set to 'true', do not reset it
|
|
// to 'false' based on role tag having it not set. But, if role tag had it set,
|
|
// be sure to override the value.
|
|
if !disallowReauthentication {
|
|
disallowReauthentication = roleTagResp.DisallowReauthentication
|
|
}
|
|
|
|
// Cache the value of role tag's max_ttl value
|
|
rTagMaxTTL = roleTagResp.MaxTTL
|
|
|
|
// Scope the shortestMaxTTL to the value set on the role tag
|
|
if roleTagResp.MaxTTL > time.Duration(0) && roleTagResp.MaxTTL < shortestMaxTTL {
|
|
shortestMaxTTL = roleTagResp.MaxTTL
|
|
}
|
|
if roleTagResp.MaxTTL > longestMaxTTL {
|
|
longestMaxTTL = roleTagResp.MaxTTL
|
|
}
|
|
}
|
|
|
|
// Save the login attempt in the identity access list
|
|
currentTime := time.Now()
|
|
if storedIdentity == nil {
|
|
// Role, ClientNonce and CreationTime of the identity entry,
|
|
// once set, should never change.
|
|
storedIdentity = &accessListIdentity{
|
|
Role: roleName,
|
|
ClientNonce: clientNonce,
|
|
CreationTime: currentTime,
|
|
}
|
|
}
|
|
|
|
// DisallowReauthentication, PendingTime, LastUpdatedTime and
|
|
// ExpirationTime may change.
|
|
storedIdentity.LastUpdatedTime = currentTime
|
|
storedIdentity.ExpirationTime = currentTime.Add(longestMaxTTL)
|
|
storedIdentity.PendingTime = identityDocParsed.PendingTime
|
|
storedIdentity.DisallowReauthentication = disallowReauthentication
|
|
|
|
// Don't cache the nonce if DisallowReauthentication is set
|
|
if storedIdentity.DisallowReauthentication {
|
|
storedIdentity.ClientNonce = ""
|
|
}
|
|
|
|
// Sanitize the nonce to a reasonable length
|
|
if len(clientNonce) > 128 && !storedIdentity.DisallowReauthentication {
|
|
return logical.ErrorResponse("client nonce exceeding the limit of 128 characters"), nil
|
|
}
|
|
|
|
if err = setAccessListIdentityEntry(ctx, req.Storage, identityDocParsed.InstanceID, storedIdentity); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
auth := &logical.Auth{
|
|
Metadata: map[string]string{
|
|
"role_tag_max_ttl": rTagMaxTTL.String(),
|
|
"role": roleName,
|
|
},
|
|
Alias: &logical.Alias{
|
|
Name: identityAlias,
|
|
},
|
|
InternalData: map[string]interface{}{
|
|
"instance_id": identityDocParsed.InstanceID,
|
|
"region": identityDocParsed.Region,
|
|
"account_id": identityDocParsed.AccountID,
|
|
},
|
|
}
|
|
roleEntry.PopulateTokenAuth(auth)
|
|
if err := identityConfigEntry.EC2AuthMetadataHandler.PopulateDesiredMetadata(auth, map[string]string{
|
|
"instance_id": identityDocParsed.InstanceID,
|
|
"region": identityDocParsed.Region,
|
|
"account_id": identityDocParsed.AccountID,
|
|
"ami_id": identityDocParsed.AmiID,
|
|
"auth_type": ec2AuthType,
|
|
}); err != nil {
|
|
b.Logger().Warn("unable to set alias metadata", "err", err)
|
|
}
|
|
|
|
resp := &logical.Response{
|
|
Auth: auth,
|
|
}
|
|
resp.Auth.Policies = policies
|
|
resp.Auth.LeaseOptions.MaxTTL = shortestMaxTTL
|
|
|
|
// Return the nonce only if reauthentication is allowed and if the nonce
|
|
// was not supplied by the user.
|
|
if !disallowReauthentication && !clientNonceSupplied {
|
|
// Echo the client nonce back. If nonce param was not supplied
|
|
// to the endpoint at all (setting it to empty string does not
|
|
// qualify here), callers should extract out the nonce from
|
|
// this field for reauthentication requests.
|
|
resp.Auth.Metadata["nonce"] = clientNonce
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// handleRoleTagLogin is used to fetch the role tag of the instance and
|
|
// verifies it to be correct. Then the policies for the login request will be
|
|
// set off of the role tag, if certain criteria satisfies.
|
|
func (b *backend) handleRoleTagLogin(ctx context.Context, s logical.Storage, roleName string, roleEntry *awsRoleEntry, instance *ec2.Instance) (*roleTagLoginResponse, error) {
|
|
if roleEntry == nil {
|
|
return nil, fmt.Errorf("nil role entry")
|
|
}
|
|
if instance == nil {
|
|
return nil, fmt.Errorf("nil instance")
|
|
}
|
|
|
|
// Input validation on instance is not performed here considering
|
|
// that it would have been done in validateInstance method.
|
|
tags := instance.Tags
|
|
if tags == nil || len(tags) == 0 {
|
|
return nil, fmt.Errorf("missing tag with key %q on the instance", roleEntry.RoleTag)
|
|
}
|
|
|
|
// Iterate through the tags attached on the instance and look for
|
|
// a tag with its 'key' matching the expected role tag value.
|
|
rTagValue := ""
|
|
for _, tagItem := range tags {
|
|
if tagItem.Key != nil && *tagItem.Key == roleEntry.RoleTag {
|
|
rTagValue = *tagItem.Value
|
|
break
|
|
}
|
|
}
|
|
|
|
// If 'role_tag' is enabled on the role, and if a corresponding tag is not found
|
|
// to be attached to the instance, fail.
|
|
if rTagValue == "" {
|
|
return nil, fmt.Errorf("missing tag with key %q on the instance", roleEntry.RoleTag)
|
|
}
|
|
|
|
// Parse the role tag into a struct, extract the plaintext part of it and verify its HMAC
|
|
rTag, err := b.parseAndVerifyRoleTagValue(ctx, s, rTagValue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check if the role name with which this login is being made is same
|
|
// as the role name embedded in the tag.
|
|
if rTag.Role != roleName {
|
|
return nil, fmt.Errorf("role on the tag is not matching the role supplied")
|
|
}
|
|
|
|
// If instance_id was set on the role tag, check if the same instance is attempting to login
|
|
if rTag.InstanceID != "" && rTag.InstanceID != *instance.InstanceId {
|
|
return nil, fmt.Errorf("role tag is being used by an unauthorized instance")
|
|
}
|
|
|
|
// Check if the role tag is deny listed
|
|
denyListEntry, err := b.lockedDenyLististRoleTagEntry(ctx, s, rTagValue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if denyListEntry != nil {
|
|
return nil, fmt.Errorf("role tag is deny listed")
|
|
}
|
|
|
|
// Ensure that the policies on the RoleTag is a subset of policies on the role
|
|
if !strutil.StrListSubset(roleEntry.TokenPolicies, rTag.Policies) {
|
|
return nil, fmt.Errorf("policies on the role tag must be subset of policies on the role")
|
|
}
|
|
|
|
return &roleTagLoginResponse{
|
|
Policies: rTag.Policies,
|
|
MaxTTL: rTag.MaxTTL,
|
|
DisallowReauthentication: rTag.DisallowReauthentication,
|
|
}, nil
|
|
}
|
|
|
|
// pathLoginRenew is used to renew an authenticated token
|
|
func (b *backend) pathLoginRenew(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
|
authType, ok := req.Auth.Metadata["auth_type"]
|
|
if !ok {
|
|
// backwards compatibility for clients that have leases from before we added auth_type
|
|
authType = ec2AuthType
|
|
}
|
|
|
|
if authType == ec2AuthType {
|
|
return b.pathLoginRenewEc2(ctx, req, data)
|
|
} else if authType == iamAuthType {
|
|
return b.pathLoginRenewIam(ctx, req, data)
|
|
} else {
|
|
return nil, fmt.Errorf("unrecognized auth_type: %q", authType)
|
|
}
|
|
}
|
|
|
|
func (b *backend) pathLoginRenewIam(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
|
canonicalArn, err := getMetadataValue(req.Auth, "canonical_arn")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
roleName := ""
|
|
roleNameIfc, ok := req.Auth.InternalData["role_name"]
|
|
if ok {
|
|
roleName = roleNameIfc.(string)
|
|
}
|
|
if roleName == "" {
|
|
return nil, fmt.Errorf("error retrieving role_name during renewal")
|
|
}
|
|
roleEntry, err := b.role(ctx, req.Storage, roleName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if roleEntry == nil {
|
|
return nil, fmt.Errorf("role entry not found")
|
|
}
|
|
|
|
// we don't really care what the inferred entity type was when the role was initially created. We
|
|
// care about what the role currently requires. However, the metadata's inferred_entity_id is only
|
|
// set when inferencing is turned on at initial login time. So, if inferencing is turned on, any
|
|
// existing roles will NOT be able to renew tokens.
|
|
// This might change later, but authenticating the actual inferred entity ID is NOT done if there
|
|
// is no inferencing requested in the role. The reason is that authenticating the inferred entity
|
|
// ID requires additional AWS IAM permissions that might not be present (e.g.,
|
|
// ec2:DescribeInstances) as well as additional inferencing configuration (the inferred region).
|
|
// So, for now, if you want to turn on inferencing, all clients must re-authenticate and cannot
|
|
// renew existing tokens.
|
|
if roleEntry.InferredEntityType != "" {
|
|
if roleEntry.InferredEntityType == ec2EntityType {
|
|
instanceID, err := getMetadataValue(req.Auth, "inferred_entity_id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
instanceRegion, err := getMetadataValue(req.Auth, "inferred_aws_region")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accountID, err := getMetadataValue(req.Auth, "account_id")
|
|
if err != nil {
|
|
b.Logger().Debug("account_id not present during iam renewal attempt, continuing to attempt validation")
|
|
}
|
|
if _, err := b.validateInstance(ctx, req.Storage, instanceID, instanceRegion, accountID); err != nil {
|
|
return nil, fmt.Errorf("failed to verify instance ID %q: %w", instanceID, err)
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("unrecognized entity_type in metadata: %q", roleEntry.InferredEntityType)
|
|
}
|
|
}
|
|
|
|
// Note that the error messages below can leak a little bit of information about the role information
|
|
// For example, if on renew, the client gets the "error parsing ARN..." error message, the client
|
|
// will know that it's a wildcard bind (but not the actual bind), even if the client can't actually
|
|
// read the role directly to know what the bind is. It's a relatively small amount of leakage, in
|
|
// some fairly corner cases, and in the most likely error case (role has been changed to a new ARN),
|
|
// the error message is identical.
|
|
if len(roleEntry.BoundIamPrincipalARNs) > 0 {
|
|
// We might not get here if all bindings were on the inferred entity, which we've already validated
|
|
// above
|
|
// As with logins, there are three ways to pass this check:
|
|
// 1: clientUserId is in roleEntry.BoundIamPrincipalIDs (entries in roleEntry.BoundIamPrincipalIDs
|
|
// implies that roleEntry.ResolveAWSUniqueIDs is true)
|
|
// 2: roleEntry.ResolveAWSUniqueIDs is false and canonical_arn is in roleEntry.BoundIamPrincipalARNs
|
|
// 3: Full ARN matches one of the wildcard globs in roleEntry.BoundIamPrincipalARNs
|
|
clientUserId, err := getMetadataValue(req.Auth, "client_user_id")
|
|
switch {
|
|
case err == nil && strutil.StrListContains(roleEntry.BoundIamPrincipalIDs, clientUserId): // check 1 passed
|
|
case !roleEntry.ResolveAWSUniqueIDs && strutil.StrListContains(roleEntry.BoundIamPrincipalARNs, canonicalArn): // check 2 passed
|
|
default:
|
|
// check 3 is a bit more complex, so we do it last
|
|
// only try to look up full ARNs if there's a wildcard ARN in BoundIamPrincipalIDs.
|
|
if !hasWildcardBind(roleEntry.BoundIamPrincipalARNs) {
|
|
return nil, fmt.Errorf("role %q no longer bound to ARN %q", roleName, canonicalArn)
|
|
}
|
|
|
|
fullArn := b.getCachedUserId(clientUserId)
|
|
if fullArn == "" {
|
|
entity, err := parseIamArn(canonicalArn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"error parsing ARN %q when updating login for role %q: %w",
|
|
canonicalArn,
|
|
roleName,
|
|
err,
|
|
)
|
|
}
|
|
fullArn, err = b.fullArn(ctx, entity, req.Storage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"error looking up full ARN of entity %v when updating login for role %q: %w",
|
|
entity,
|
|
roleName,
|
|
err,
|
|
)
|
|
}
|
|
if fullArn == "" {
|
|
return nil, fmt.Errorf("got empty string back when looking up full ARN of entity %v when updating login for role %q", entity, roleName)
|
|
}
|
|
if clientUserId != "" {
|
|
b.setCachedUserId(clientUserId, fullArn)
|
|
}
|
|
}
|
|
matchedWildcardBind := false
|
|
for _, principalARN := range roleEntry.BoundIamPrincipalARNs {
|
|
if strings.HasSuffix(principalARN, "*") && strutil.GlobbedStringsMatch(principalARN, fullArn) {
|
|
matchedWildcardBind = true
|
|
break
|
|
}
|
|
}
|
|
if !matchedWildcardBind {
|
|
return nil, fmt.Errorf("role %q no longer bound to ARN %q", roleName, canonicalArn)
|
|
}
|
|
}
|
|
}
|
|
|
|
resp := &logical.Response{Auth: req.Auth}
|
|
resp.Auth.TTL = roleEntry.TokenTTL
|
|
resp.Auth.MaxTTL = roleEntry.TokenMaxTTL
|
|
resp.Auth.Period = roleEntry.TokenPeriod
|
|
return resp, nil
|
|
}
|
|
|
|
func (b *backend) pathLoginRenewEc2(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
|
|
instanceID, err := getMetadataValue(req.Auth, "instance_id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
region, err := getMetadataValue(req.Auth, "region")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accountID, err := getMetadataValue(req.Auth, "account_id")
|
|
if err != nil {
|
|
b.Logger().Debug("account_id not present during ec2 renewal attempt, continuing to attempt validation")
|
|
}
|
|
|
|
// Cross check that the instance is still in 'running' state
|
|
if _, err := b.validateInstance(ctx, req.Storage, instanceID, region, accountID); err != nil {
|
|
return nil, fmt.Errorf("failed to verify instance ID %q: %w", instanceID, err)
|
|
}
|
|
|
|
storedIdentity, err := accessListIdentityEntry(ctx, req.Storage, instanceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if storedIdentity == nil {
|
|
return nil, fmt.Errorf("failed to verify the access list identity entry for instance ID: %q", instanceID)
|
|
}
|
|
|
|
// Ensure that role entry is not deleted
|
|
roleEntry, err := b.role(ctx, req.Storage, storedIdentity.Role)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if roleEntry == nil {
|
|
return nil, fmt.Errorf("role entry not found")
|
|
}
|
|
|
|
// If the login was made using the role tag, then max_ttl from tag
|
|
// is cached in internal data during login and used here to cap the
|
|
// max_ttl of renewal.
|
|
rTagMaxTTL, err := parseutil.ParseDurationSecond(req.Auth.Metadata["role_tag_max_ttl"])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Re-evaluate the maxTTL bounds
|
|
shortestMaxTTL := b.System().MaxLeaseTTL()
|
|
longestMaxTTL := b.System().MaxLeaseTTL()
|
|
if roleEntry.TokenMaxTTL > time.Duration(0) && roleEntry.TokenMaxTTL < shortestMaxTTL {
|
|
shortestMaxTTL = roleEntry.TokenMaxTTL
|
|
}
|
|
if roleEntry.TokenMaxTTL > longestMaxTTL {
|
|
longestMaxTTL = roleEntry.TokenMaxTTL
|
|
}
|
|
if rTagMaxTTL > time.Duration(0) && rTagMaxTTL < shortestMaxTTL {
|
|
shortestMaxTTL = rTagMaxTTL
|
|
}
|
|
if rTagMaxTTL > longestMaxTTL {
|
|
longestMaxTTL = rTagMaxTTL
|
|
}
|
|
|
|
// Only LastUpdatedTime and ExpirationTime change and all other fields remain the same
|
|
currentTime := time.Now()
|
|
storedIdentity.LastUpdatedTime = currentTime
|
|
storedIdentity.ExpirationTime = currentTime.Add(longestMaxTTL)
|
|
|
|
// Updating the expiration time is required for the tidy operation on the
|
|
// access list identity storage items
|
|
if err = setAccessListIdentityEntry(ctx, req.Storage, instanceID, storedIdentity); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := &logical.Response{Auth: req.Auth}
|
|
resp.Auth.TTL = roleEntry.TokenTTL
|
|
resp.Auth.MaxTTL = shortestMaxTTL
|
|
resp.Auth.Period = roleEntry.TokenPeriod
|
|
return resp, nil
|
|
}
|
|
|
|
func (b *backend) pathLoginUpdateIam(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
|
roleName, callerID, entity, errResp, err := b.pathLoginIamGetRoleNameCallerIdAndEntity(ctx, req, data)
|
|
if errResp != nil || err != nil {
|
|
return errResp, err
|
|
}
|
|
|
|
roleEntry, err := b.role(ctx, req.Storage, roleName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if roleEntry == nil {
|
|
return logical.ErrorResponse(fmt.Sprintf("entry for role %s not found", roleName)), nil
|
|
}
|
|
|
|
// Check for a CIDR match.
|
|
if len(roleEntry.TokenBoundCIDRs) > 0 {
|
|
if req.Connection == nil {
|
|
b.Logger().Warn("token bound CIDRs found but no connection information available for validation")
|
|
return nil, logical.ErrPermissionDenied
|
|
}
|
|
if !cidrutil.RemoteAddrIsOk(req.Connection.RemoteAddr, roleEntry.TokenBoundCIDRs) {
|
|
return nil, logical.ErrPermissionDenied
|
|
}
|
|
}
|
|
|
|
if roleEntry.AuthType != iamAuthType {
|
|
return logical.ErrorResponse(fmt.Sprintf("auth method iam not allowed for role %s", roleName)), nil
|
|
}
|
|
|
|
identityConfigEntry, err := identityConfigEntry(ctx, req.Storage)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// This could either be a "userID:SessionID" (in the case of an assumed role) or just a "userID"
|
|
// (in the case of an IAM user).
|
|
callerUniqueId := strings.Split(callerID.UserId, ":")[0]
|
|
identityAlias := ""
|
|
switch identityConfigEntry.IAMAlias {
|
|
case identityAliasRoleID:
|
|
identityAlias = roleEntry.RoleID
|
|
case identityAliasIAMUniqueID:
|
|
identityAlias = callerUniqueId
|
|
case identityAliasIAMFullArn:
|
|
identityAlias = callerID.Arn
|
|
}
|
|
|
|
// If we're just looking up for MFA, return the Alias info
|
|
if req.Operation == logical.AliasLookaheadOperation {
|
|
return &logical.Response{
|
|
Auth: &logical.Auth{
|
|
Alias: &logical.Alias{
|
|
Name: identityAlias,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// The role creation should ensure that either we're inferring this is an EC2 instance
|
|
// or that we're binding an ARN
|
|
if len(roleEntry.BoundIamPrincipalARNs) > 0 {
|
|
// As with renews, there are three ways to pass this check:
|
|
// 1: callerUniqueId is in roleEntry.BoundIamPrincipalIDs (entries in roleEntry.BoundIamPrincipalIDs
|
|
// implies that roleEntry.ResolveAWSUniqueIDs is true)
|
|
// 2: roleEntry.ResolveAWSUniqueIDs is false and entity.canonicalArn() is in roleEntry.BoundIamPrincipalARNs
|
|
// 3: Full ARN matches one of the wildcard globs in roleEntry.BoundIamPrincipalARNs
|
|
// Need to be able to handle pathological configurations such as roleEntry.BoundIamPrincipalARNs looking something like:
|
|
// arn:aw:iam::123456789012:{user/UserName,user/path/*,role/RoleName,role/path/*}
|
|
switch {
|
|
case strutil.StrListContains(roleEntry.BoundIamPrincipalIDs, callerUniqueId): // check 1 passed
|
|
case !roleEntry.ResolveAWSUniqueIDs && strutil.StrListContains(roleEntry.BoundIamPrincipalARNs, entity.canonicalArn()): // check 2 passed
|
|
default:
|
|
// evaluate check 3 -- only try to look up full ARNs if there's a wildcard ARN in BoundIamPrincipalIDs.
|
|
if !hasWildcardBind(roleEntry.BoundIamPrincipalARNs) {
|
|
return logical.ErrorResponse("IAM Principal %q does not belong to the role %q", callerID.Arn, roleName), nil
|
|
}
|
|
|
|
fullArn := b.getCachedUserId(callerUniqueId)
|
|
if fullArn == "" {
|
|
fullArn, err = b.fullArn(ctx, entity, req.Storage)
|
|
if err != nil {
|
|
return logical.ErrorResponse("error looking up full ARN of entity %v when attempting login for role %q: %v", entity, roleName, err), nil
|
|
}
|
|
if fullArn == "" {
|
|
return logical.ErrorResponse("got empty string back when looking up full ARN of entity %v when attempting login for role %q", entity, roleName), nil
|
|
}
|
|
b.setCachedUserId(callerUniqueId, fullArn)
|
|
}
|
|
matchedWildcardBind := false
|
|
for _, principalARN := range roleEntry.BoundIamPrincipalARNs {
|
|
if strings.HasSuffix(principalARN, "*") && strutil.GlobbedStringsMatch(principalARN, fullArn) {
|
|
matchedWildcardBind = true
|
|
break
|
|
}
|
|
}
|
|
if !matchedWildcardBind {
|
|
return logical.ErrorResponse("IAM Principal %q does not belong to the role %q", callerID.Arn, roleName), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
inferredEntityType := ""
|
|
inferredEntityID := ""
|
|
if roleEntry.InferredEntityType == ec2EntityType {
|
|
instance, err := b.validateInstance(ctx, req.Storage, entity.SessionInfo, roleEntry.InferredAWSRegion, callerID.Account)
|
|
if err != nil {
|
|
return logical.ErrorResponse("failed to verify %s as a valid EC2 instance in region %s: %s", entity.SessionInfo, roleEntry.InferredAWSRegion, err), nil
|
|
}
|
|
|
|
// build a fake identity doc to pass on metadata about the instance to verifyInstanceMeetsRoleRequirements
|
|
identityDoc := &identityDocument{
|
|
Tags: nil, // Don't really need the tags, so not doing the work of converting them from Instance.Tags to identityDocument.Tags
|
|
InstanceID: *instance.InstanceId,
|
|
AmiID: *instance.ImageId,
|
|
AccountID: callerID.Account,
|
|
Region: roleEntry.InferredAWSRegion,
|
|
PendingTime: instance.LaunchTime.Format(time.RFC3339),
|
|
}
|
|
|
|
validationError, err := b.verifyInstanceMeetsRoleRequirements(ctx, req.Storage, instance, roleEntry, roleName, identityDoc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if validationError != nil {
|
|
return logical.ErrorResponse(fmt.Sprintf("error validating instance: %s", validationError)), nil
|
|
}
|
|
|
|
inferredEntityType = ec2EntityType
|
|
inferredEntityID = entity.SessionInfo
|
|
}
|
|
|
|
auth := &logical.Auth{
|
|
Metadata: map[string]string{
|
|
"role_id": roleEntry.RoleID,
|
|
},
|
|
InternalData: map[string]interface{}{
|
|
"role_name": roleName,
|
|
"role_id": roleEntry.RoleID,
|
|
"canonical_arn": entity.canonicalArn(),
|
|
"client_user_id": callerUniqueId,
|
|
"inferred_entity_id": inferredEntityID,
|
|
"inferred_aws_region": roleEntry.InferredAWSRegion,
|
|
"account_id": entity.AccountNumber,
|
|
},
|
|
DisplayName: entity.FriendlyName,
|
|
Alias: &logical.Alias{
|
|
Name: identityAlias,
|
|
},
|
|
}
|
|
|
|
if entity.Type == "assumed-role" {
|
|
auth.DisplayName = strings.Join([]string{entity.FriendlyName, entity.SessionInfo}, "/")
|
|
}
|
|
|
|
roleEntry.PopulateTokenAuth(auth)
|
|
if err := identityConfigEntry.IAMAuthMetadataHandler.PopulateDesiredMetadata(auth, map[string]string{
|
|
"client_arn": callerID.Arn,
|
|
"canonical_arn": entity.canonicalArn(),
|
|
"client_user_id": callerUniqueId,
|
|
"auth_type": iamAuthType,
|
|
"inferred_entity_type": inferredEntityType,
|
|
"inferred_entity_id": inferredEntityID,
|
|
"inferred_aws_region": roleEntry.InferredAWSRegion,
|
|
"account_id": entity.AccountNumber,
|
|
}); err != nil {
|
|
b.Logger().Warn(fmt.Sprintf("unable to set alias metadata due to %s", err))
|
|
}
|
|
|
|
return &logical.Response{
|
|
Auth: auth,
|
|
}, nil
|
|
}
|
|
|
|
func hasWildcardBind(boundIamPrincipalARNs []string) bool {
|
|
for _, principalARN := range boundIamPrincipalARNs {
|
|
if strings.HasSuffix(principalARN, "*") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Validate that the iam_request_url passed is valid for the STS request
|
|
func validateLoginIamRequestUrl(method string, parsedUrl *url.URL) error {
|
|
switch method {
|
|
case http.MethodGet:
|
|
actions := map[string][]string(parsedUrl.Query())["Action"]
|
|
if len(actions) == 0 {
|
|
return fmt.Errorf("no action found in request")
|
|
}
|
|
if len(actions) != 1 {
|
|
return fmt.Errorf("found multiple actions")
|
|
}
|
|
if actions[0] != "GetCallerIdentity" {
|
|
return fmt.Errorf("unexpected action parameter, %s", actions[0])
|
|
}
|
|
return nil
|
|
case http.MethodPost:
|
|
if parsedUrl.RawQuery != "" {
|
|
return logical.ErrInvalidRequest
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unsupported method, %s", method)
|
|
}
|
|
}
|
|
|
|
// Validate that the iam_request_body passed is valid for the STS request
|
|
func validateLoginIamRequestBody(body string) error {
|
|
qs, err := url.ParseQuery(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for k, v := range qs {
|
|
switch k {
|
|
case "Action":
|
|
if len(v) != 1 || v[0] != "GetCallerIdentity" {
|
|
return errRequestBodyNotValid
|
|
}
|
|
case "Version":
|
|
// Will assume for now that future versions don't change
|
|
// the semantics
|
|
default:
|
|
// Not expecting any other values
|
|
return errRequestBodyNotValid
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// These two methods (hasValuesFor*) return two bools
|
|
// The first is a hasAll, that is, does the request have all the values
|
|
// necessary for this auth method
|
|
// The second is a hasAny, that is, does the request have any of the fields
|
|
// exclusive to this auth method
|
|
func hasValuesForEc2Auth(data *framework.FieldData) (bool, bool) {
|
|
_, hasPkcs7 := data.GetOk("pkcs7")
|
|
_, hasIdentity := data.GetOk("identity")
|
|
_, hasSignature := data.GetOk("signature")
|
|
return (hasPkcs7 || (hasIdentity && hasSignature)), (hasPkcs7 || hasIdentity || hasSignature)
|
|
}
|
|
|
|
func hasValuesForIamAuth(data *framework.FieldData) (bool, bool) {
|
|
method, hasRequestMethod := data.GetOk("iam_http_request_method")
|
|
_, hasRequestURL := data.GetOk("iam_request_url")
|
|
_, hasRequestBody := data.GetOk("iam_request_body")
|
|
_, hasRequestHeaders := data.GetOk("iam_request_headers")
|
|
return (hasRequestMethod && hasRequestURL && (method == http.MethodGet || hasRequestBody) && hasRequestHeaders),
|
|
(hasRequestMethod || hasRequestURL || hasRequestBody || hasRequestHeaders)
|
|
}
|
|
|
|
func parseIamArn(iamArn string) (*iamEntity, error) {
|
|
// iamArn should look like one of the following:
|
|
// 1. arn:aws:iam::<account_id>:<entity_type>/<UserName>
|
|
// 2. arn:aws:sts::<account_id>:assumed-role/<RoleName>/<RoleSessionName>
|
|
// if we get something like 2, then we want to transform that back to what
|
|
// most people would expect, which is arn:aws:iam::<account_id>:role/<RoleName>
|
|
var entity iamEntity
|
|
fullParts := strings.Split(iamArn, ":")
|
|
if len(fullParts) != 6 {
|
|
return nil, fmt.Errorf("unrecognized arn: contains %d colon-separated parts, expected 6", len(fullParts))
|
|
}
|
|
if fullParts[0] != "arn" {
|
|
return nil, fmt.Errorf("unrecognized arn: does not begin with \"arn:\"")
|
|
}
|
|
// normally aws, but could be aws-cn or aws-us-gov
|
|
entity.Partition = fullParts[1]
|
|
if fullParts[2] != "iam" && fullParts[2] != "sts" {
|
|
return nil, fmt.Errorf("unrecognized service: %v, not one of iam or sts", fullParts[2])
|
|
}
|
|
// fullParts[3] is the region, which doesn't matter for AWS IAM entities
|
|
entity.AccountNumber = fullParts[4]
|
|
// fullParts[5] would now be something like user/<UserName> or assumed-role/<RoleName>/<RoleSessionName>
|
|
parts := strings.Split(fullParts[5], "/")
|
|
if len(parts) < 2 {
|
|
return nil, fmt.Errorf("unrecognized arn: %q contains fewer than 2 slash-separated parts", fullParts[5])
|
|
}
|
|
entity.Type = parts[0]
|
|
entity.Path = strings.Join(parts[1:len(parts)-1], "/")
|
|
entity.FriendlyName = parts[len(parts)-1]
|
|
// now, entity.FriendlyName should either be <UserName> or <RoleName>
|
|
switch entity.Type {
|
|
case "assumed-role":
|
|
// Check for three parts for assumed role ARNs
|
|
if len(parts) < 3 {
|
|
return nil, fmt.Errorf("unrecognized arn: %q contains fewer than 3 slash-separated parts", fullParts[5])
|
|
}
|
|
// Assumed roles don't have paths and have a slightly different format
|
|
// parts[2] is <RoleSessionName>
|
|
entity.Path = ""
|
|
entity.FriendlyName = parts[1]
|
|
entity.SessionInfo = parts[2]
|
|
case "user":
|
|
case "role":
|
|
case "instance-profile":
|
|
default:
|
|
return &iamEntity{}, fmt.Errorf("unrecognized principal type: %q", entity.Type)
|
|
}
|
|
return &entity, nil
|
|
}
|
|
|
|
func validateVaultHeaderValue(method string, headers http.Header, parsedUrl *url.URL, requiredHeaderValue string) error {
|
|
providedValue := ""
|
|
for k, v := range headers {
|
|
if strings.EqualFold(iamServerIdHeader, k) {
|
|
providedValue = strings.Join(v, ",")
|
|
break
|
|
}
|
|
}
|
|
if providedValue == "" {
|
|
return fmt.Errorf("missing header %q", iamServerIdHeader)
|
|
}
|
|
|
|
// NOT doing a constant time compare here since the value is NOT intended to be secret
|
|
if providedValue != requiredHeaderValue {
|
|
return fmt.Errorf("expected %q but got %q", requiredHeaderValue, providedValue)
|
|
}
|
|
switch method {
|
|
case http.MethodPost:
|
|
if authzHeaders, ok := headers["Authorization"]; ok {
|
|
// authzHeader looks like AWS4-HMAC-SHA256 Credential=AKI..., SignedHeaders=host;x-amz-date;x-vault-awsiam-id, Signature=...
|
|
// We need to extract out the SignedHeaders
|
|
re := regexp.MustCompile(".*SignedHeaders=([^,]+)")
|
|
authzHeader := strings.Join(authzHeaders, ",")
|
|
matches := re.FindSubmatch([]byte(authzHeader))
|
|
if len(matches) < 1 {
|
|
return fmt.Errorf("vault header wasn't signed")
|
|
}
|
|
if len(matches) > 2 {
|
|
return fmt.Errorf("found multiple SignedHeaders components")
|
|
}
|
|
signedHeaders := string(matches[1])
|
|
return ensureHeaderIsSigned(signedHeaders, iamServerIdHeader)
|
|
}
|
|
return fmt.Errorf("missing Authorization header")
|
|
case http.MethodGet:
|
|
return ensureHeaderIsSigned(parsedUrl.Query().Get(amzSignedHeaders), iamServerIdHeader)
|
|
default:
|
|
return fmt.Errorf("unsupported method, %s", method)
|
|
}
|
|
}
|
|
|
|
func buildHttpRequest(method, endpoint string, parsedUrl *url.URL, body string, headers http.Header) *http.Request {
|
|
// This is all a bit complicated because the AWS signature algorithm requires that
|
|
// the Host header be included in the signed headers. See
|
|
// http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
|
|
// The use cases we want to support, in order of increasing complexity, are:
|
|
// 1. All defaults (client assumes sts.amazonaws.com and server has no override)
|
|
// 2. Alternate STS regions: client wants to go to a specific region, in which case
|
|
// Vault must be configured with that endpoint as well. The client's signed request
|
|
// will include a signature over what the client expects the Host header to be,
|
|
// so we cannot change that and must match.
|
|
// 3. Alternate STS regions with a proxy that is transparent to Vault's clients.
|
|
// In this case, Vault is aware of the proxy, as the proxy is configured as the
|
|
// endpoint, but the clients should NOT be aware of the proxy (because STS will
|
|
// not be aware of the proxy)
|
|
// It's also annoying because:
|
|
// 1. The AWS Sigv4 algorithm requires the Host header to be defined
|
|
// 2. Some of the official SDKs (at least botocore and aws-sdk-go) don't actually
|
|
// include an explicit Host header in the HTTP requests they generate, relying on
|
|
// the underlying HTTP library to do that for them.
|
|
// 3. To get a validly signed request, the SDKs check if a Host header has been set
|
|
// and, if not, add an inferred host header (based on the URI) to the internal
|
|
// data structure used for calculating the signature, but never actually expose
|
|
// that to clients. So then they just "hope" that the underlying library actually
|
|
// adds the right Host header which was included in the signature calculation.
|
|
// We could either explicitly require all Vault clients to explicitly add the Host header
|
|
// in the encoded request, or we could also implicitly infer it from the URI.
|
|
// We choose to support both -- allow you to explicitly set a Host header, but if not,
|
|
// infer one from the URI.
|
|
// HOWEVER, we have to preserve the request URI portion of the client's
|
|
// URL because the GetCallerIdentity Action can be encoded in either the body
|
|
// or the URL. So, we need to rebuild the URL sent to the http library to have the
|
|
// custom, Vault-specified endpoint with the client-side request parameters.
|
|
targetUrl := fmt.Sprintf("%s/%s", endpoint, parsedUrl.RequestURI())
|
|
request, err := http.NewRequest(method, targetUrl, strings.NewReader(body))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
request.Host = parsedUrl.Host
|
|
for k, vals := range headers {
|
|
for _, val := range vals {
|
|
request.Header.Add(k, val)
|
|
}
|
|
}
|
|
return request
|
|
}
|
|
|
|
func ensureHeaderIsSigned(signedHeaders, headerToSign string) error {
|
|
// Not doing a constant time compare here, the values aren't secret
|
|
for _, header := range strings.Split(signedHeaders, ";") {
|
|
if header == strings.ToLower(headerToSign) {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("vault header wasn't signed")
|
|
}
|
|
|
|
func parseGetCallerIdentityResponse(response string) (GetCallerIdentityResponse, error) {
|
|
result := GetCallerIdentityResponse{}
|
|
response = strings.TrimSpace(response)
|
|
if !strings.HasPrefix(response, "<GetCallerIdentityResponse") && !strings.HasPrefix(response, "<?xml") {
|
|
return result, errInvalidGetCallerIdentityResponse
|
|
}
|
|
decoder := xml.NewDecoder(strings.NewReader(response))
|
|
err := decoder.Decode(&result)
|
|
return result, err
|
|
}
|
|
|
|
func submitCallerIdentityRequest(ctx context.Context, maxRetries int, method, endpoint string, parsedUrl *url.URL, body string, headers http.Header) (*GetCallerIdentityResult, error) {
|
|
// NOTE: We need to ensure we're calling STS, instead of acting as an unintended network proxy
|
|
// The protection against this is that this method will only call the endpoint specified in the
|
|
// client config (defaulting to sts.amazonaws.com), so it would require a Vault admin to override
|
|
// the endpoint to talk to alternate web addresses
|
|
request := buildHttpRequest(method, endpoint, parsedUrl, body, headers)
|
|
retryableReq, err := retryablehttp.FromRequest(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
retryableReq = retryableReq.WithContext(ctx)
|
|
client := cleanhttp.DefaultClient()
|
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
retryingClient := &retryablehttp.Client{
|
|
HTTPClient: client,
|
|
RetryWaitMin: retryWaitMin,
|
|
RetryWaitMax: retryWaitMax,
|
|
RetryMax: maxRetries,
|
|
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
|
Backoff: retryablehttp.DefaultBackoff,
|
|
}
|
|
|
|
response, err := retryingClient.Do(retryableReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error making request: %w", err)
|
|
}
|
|
if response != nil {
|
|
defer response.Body.Close()
|
|
}
|
|
// Validate that the response type is XML
|
|
if ct := response.Header.Get("Content-Type"); ct != "text/xml" {
|
|
return nil, errInvalidGetCallerIdentityResponse
|
|
}
|
|
|
|
// we check for status code afterwards to also print out response body
|
|
responseBody, err := ioutil.ReadAll(response.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if response.StatusCode != 200 {
|
|
return nil, fmt.Errorf("received error code %d from STS: %s", response.StatusCode, string(responseBody))
|
|
}
|
|
callerIdentityResponse, err := parseGetCallerIdentityResponse(string(responseBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error parsing STS response")
|
|
}
|
|
return &callerIdentityResponse.GetCallerIdentityResult[0], nil
|
|
}
|
|
|
|
type GetCallerIdentityResponse struct {
|
|
XMLName xml.Name `xml:"GetCallerIdentityResponse"`
|
|
GetCallerIdentityResult []GetCallerIdentityResult `xml:"GetCallerIdentityResult"`
|
|
ResponseMetadata []ResponseMetadata `xml:"ResponseMetadata"`
|
|
}
|
|
|
|
type GetCallerIdentityResult struct {
|
|
Arn string `xml:"Arn"`
|
|
UserId string `xml:"UserId"`
|
|
Account string `xml:"Account"`
|
|
}
|
|
|
|
type ResponseMetadata struct {
|
|
RequestId string `xml:"RequestId"`
|
|
}
|
|
|
|
// identityDocument represents the items of interest from the EC2 instance
|
|
// identity document
|
|
type identityDocument struct {
|
|
Tags map[string]interface{} `json:"tags,omitempty"`
|
|
InstanceID string `json:"instanceId,omitempty"`
|
|
AmiID string `json:"imageId,omitempty"`
|
|
AccountID string `json:"accountId,omitempty"`
|
|
Region string `json:"region,omitempty"`
|
|
PendingTime string `json:"pendingTime,omitempty"`
|
|
}
|
|
|
|
// roleTagLoginResponse represents the return values required after the process
|
|
// of verifying a role tag login
|
|
type roleTagLoginResponse struct {
|
|
Policies []string `json:"policies"`
|
|
MaxTTL time.Duration `json:"max_ttl"`
|
|
DisallowReauthentication bool `json:"disallow_reauthentication"`
|
|
}
|
|
|
|
type iamEntity struct {
|
|
Partition string
|
|
AccountNumber string
|
|
Type string
|
|
Path string
|
|
FriendlyName string
|
|
SessionInfo string
|
|
}
|
|
|
|
// Returns a Vault-internal canonical ARN for referring to an IAM entity
|
|
func (e *iamEntity) canonicalArn() string {
|
|
entityType := e.Type
|
|
// canonicalize "assumed-role" into "role"
|
|
if entityType == "assumed-role" {
|
|
entityType = "role"
|
|
}
|
|
// Annoyingly, the assumed-role entity type doesn't have the Path of the role which was assumed
|
|
// So, we "canonicalize" it by just completely dropping the path. The other option would be to
|
|
// make an AWS API call to look up the role by FriendlyName, which introduces more complexity to
|
|
// code and test, and it also breaks backwards compatibility in an area where we would really want
|
|
// it
|
|
return fmt.Sprintf("arn:%s:iam::%s:%s/%s", e.Partition, e.AccountNumber, entityType, e.FriendlyName)
|
|
}
|
|
|
|
// This returns the "full" ARN of an iamEntity, how it would be referred to in AWS proper
|
|
func (b *backend) fullArn(ctx context.Context, e *iamEntity, s logical.Storage) (string, error) {
|
|
// Not assuming path is reliable for any entity types
|
|
|
|
region := b.partitionToRegionMap[e.Partition]
|
|
if region == nil {
|
|
return "", fmt.Errorf("unable to resolve partition %q to a region", e.Partition)
|
|
}
|
|
|
|
client, err := b.clientIAM(ctx, s, region.ID(), e.AccountNumber)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error creating IAM client: %w", err)
|
|
}
|
|
|
|
switch e.Type {
|
|
case "user":
|
|
input := iam.GetUserInput{
|
|
UserName: aws.String(e.FriendlyName),
|
|
}
|
|
resp, err := client.GetUserWithContext(ctx, &input)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error fetching user %q: %w", e.FriendlyName, err)
|
|
}
|
|
if resp == nil {
|
|
return "", fmt.Errorf("nil response from GetUser")
|
|
}
|
|
return *(resp.User.Arn), nil
|
|
case "assumed-role":
|
|
fallthrough
|
|
case "role":
|
|
input := iam.GetRoleInput{
|
|
RoleName: aws.String(e.FriendlyName),
|
|
}
|
|
resp, err := client.GetRoleWithContext(ctx, &input)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error fetching role %q: %w", e.FriendlyName, err)
|
|
}
|
|
if resp == nil {
|
|
return "", fmt.Errorf("nil response form GetRole")
|
|
}
|
|
return *(resp.Role.Arn), nil
|
|
default:
|
|
return "", fmt.Errorf("unrecognized entity type: %s", e.Type)
|
|
}
|
|
}
|
|
|
|
// getMetadataValue attempts to get a metadata key from
|
|
// auth.InternalData and if unset, auth.Metadata. If not
|
|
// found, returns "".
|
|
func getMetadataValue(fromAuth *logical.Auth, forKey string) (string, error) {
|
|
if raw, ok := fromAuth.InternalData[forKey]; ok {
|
|
if val, ok := raw.(string); ok {
|
|
return val, nil
|
|
} else {
|
|
return "", fmt.Errorf("unable to fetch %q from auth metadata due to type of %T", forKey, raw)
|
|
}
|
|
}
|
|
if val, ok := fromAuth.Metadata[forKey]; ok {
|
|
return val, nil
|
|
}
|
|
return "", fmt.Errorf("%q not found in auth metadata", forKey)
|
|
}
|
|
|
|
func awsRegionFromHeader(authorizationHeader string) (string, error) {
|
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-auth-using-authorization-header.html
|
|
// The Authorization header takes the following form.
|
|
// Authorization: AWS4-HMAC-SHA256
|
|
// Credential=AKIAIOSFODNN7EXAMPLE/20230719/us-east-1/sts/aws4_request,
|
|
// SignedHeaders=content-length;content-type;host;x-amz-date,
|
|
// Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024
|
|
//
|
|
// The credential is in the form of "<your-access-key-id>/<date>/<aws-region>/<aws-service>/aws4_request"
|
|
fields := strings.Split(authorizationHeader, " ")
|
|
for _, field := range fields {
|
|
if strings.HasPrefix(field, "Credential=") {
|
|
fields := strings.Split(field, "/")
|
|
if len(fields) < 3 {
|
|
return "", fmt.Errorf("invalid header format")
|
|
}
|
|
|
|
region := fields[2]
|
|
return region, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("invalid header format")
|
|
}
|
|
|
|
func stsRegionalEndpoint(region string) (string, error) {
|
|
stsService := sts.EndpointsID
|
|
resolver := endpoints.DefaultResolver()
|
|
resolvedEndpoint, err := resolver.EndpointFor(stsService, region,
|
|
endpoints.STSRegionalEndpointOption,
|
|
endpoints.StrictMatchingOption)
|
|
if err != nil {
|
|
return "", fmt.Errorf("unable to get regional STS endpoint for region: %v", region)
|
|
}
|
|
return resolvedEndpoint.URL, nil
|
|
}
|
|
|
|
const iamServerIdHeader = "X-Vault-AWS-IAM-Server-ID"
|
|
|
|
const pathLoginSyn = `
|
|
Authenticates an EC2 instance with Vault.
|
|
`
|
|
|
|
const pathLoginDesc = `
|
|
Authenticate AWS entities, either an arbitrary IAM principal or EC2 instances.
|
|
|
|
IAM principals are authenticated by processing a signed sts:GetCallerIdentity
|
|
request and then parsing the response to see who signed the request. Optionally,
|
|
the caller can be inferred to be another AWS entity type, with EC2 instances
|
|
the only currently supported entity type, and additional filtering can be
|
|
implemented based on that inferred type.
|
|
|
|
An EC2 instance is authenticated using the PKCS#7 signature of the instance identity
|
|
document and a client created nonce. This nonce should be unique and should be used by
|
|
the instance for all future logins, unless 'disallow_reauthentication' option on the
|
|
registered role is enabled, in which case client nonce is optional.
|
|
|
|
First login attempt, creates a access list entry in Vault associating the instance to the nonce
|
|
provided. All future logins will succeed only if the client nonce matches the nonce in the
|
|
access list entry.
|
|
|
|
By default, a cron task will periodically look for expired entries in the access list
|
|
and deletes them. The duration to periodically run this, is one hour by default.
|
|
However, this can be configured using the 'config/tidy/identities' endpoint. This tidy
|
|
action can be triggered via the API as well, using the 'tidy/identities' endpoint.
|
|
`
|