| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: MPL-2.0 |
| |
| 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/service/ec2" |
| "github.com/aws/aws-sdk-go/service/iam" |
| "github.com/hashicorp/errwrap" |
| cleanhttp "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" |
| uuid "github.com/hashicorp/go-uuid" |
| "github.com/hashicorp/vault/builtin/credential/aws/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. Currently, POST is the only supported value`, |
| }, |
| |
| "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 |
| } |
| |
| // In the future, might consider supporting GET |
| if method != "POST" { |
| return "", nil, nil, logical.ErrorResponse("invalid iam_http_request_method; currently only 'POST' is 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 parsedUrl.RawQuery != "" { |
| // Should be no query parameters |
| return "", nil, nil, logical.ErrorResponse(logical.ErrInvalidRequest.Error()), nil |
| } |
| // TODO: There are two potentially valid cases we're not yet supporting that would |
| // necessitate this check being changed. First, if we support GET requests. |
| // Second if we support presigned POST requests |
| bodyB64 := data.Get("iam_request_body").(string) |
| if bodyB64 == "" { |
| return "", nil, nil, logical.ErrorResponse("missing iam_request_body"), 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, logical.ErrorResponse("error getting configuration"), nil |
| } |
| |
| endpoint := "https://sts.amazonaws.com" |
| |
| maxRetries := awsClient.DefaultRetryerMaxNumRetries |
| if config != nil { |
| if config.IAMServerIdHeaderValue != "" { |
| err = validateVaultHeaderValue(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 config.STSEndpoint != "" { |
| endpoint = config.STSEndpoint |
| } |
| if config.MaxRetries >= 0 { |
| maxRetries = config.MaxRetries |
| } |
| } |
| |
| 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_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) { |
| _, 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 && 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(headers http.Header, _ *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) |
| } |
| |
| 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) |
| } |
| // TODO: If we support GET requests, then we need to parse the X-Amz-SignedHeaders |
| // argument out of the query string and search in there for the header value |
| return fmt.Errorf("missing Authorization header") |
| } |
| |
| 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) |
| } |
| |
| 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. |
| ` |