diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 4bdfc347a..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: 2 -updates: -- package-ecosystem: gomod - directory: "/" - schedule: - interval: "daily" -- package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 094510623..000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 180 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - pinned - - security -# Label to use when marking an issue as stale -staleLabel: stale -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 4bbf18bce..6018f7c35 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,36 +1,41 @@ name: Continuous Integration - on: push: pull_request: branches: - - master + - master +permissions: + contents: read jobs: - - build: - name: Build + test: + name: test + strategy: + matrix: + os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} + steps: + - uses: actions/setup-go@v3 + with: + go-version: '1.20' + - uses: actions/checkout@v3 + - name: Run tests + run: go test -race ./... + lint: + permissions: + contents: read # for actions/checkout to fetch code + pull-requests: read # for golangci/golangci-lint-action to fetch pull requests + name: lint strategy: matrix: - os: [ubuntu-latest, macOS-latest] - + os: [macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} steps: - - - name: Set up Go 1.17 - uses: actions/setup-go@v2.2.0 - with: - go-version: 1.17 - id: go - - - name: Check out code into the Go module directory - uses: actions/checkout@v2.4.0 - - - name: Run tests - run: go test -race ./... - - - name: Check go vet - run: go vet ./... - - - name: Check go fmt - run: diff -u <(echo -n) <(gofmt -s -d .) + - uses: actions/setup-go@v3 + with: + go-version: '1.20' + - uses: actions/checkout@v3 + - name: golangci-lint + uses: golangci/golangci-lint-action@v3.4.0 + with: + version: v1.52.0 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..f1c293299 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,17 @@ +# See https://github.com/actions/stale +name: Mark and close stale issues +on: + schedule: + - cron: '15 10 * * *' +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/stale@v7 + with: + days-before-stale: 180 + days-before-close: 7 + stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.' + exempt-issue-labels: pinned,security,feature diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 000000000..20f3f8bc4 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,28 @@ +linters: + enable: + - bodyclose + - contextcheck + - depguard + - durationcheck + - dupl + - errchkjson + - errname + - exhaustive + - exportloopref + - gofmt + - goimports + - makezero + - misspell + - nakedret + - nilerr + - nilnil + - noctx + - prealloc + - revive + # - rowserrcheck + - thelper + - tparallel + - unconvert + - unparam + # - wastedassign + - whitespace diff --git a/README.md b/README.md index 7d0625668..7066a6f16 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ You can install AWS Vault: - on Windows with [Scoop](https://scoop.sh/): `scoop install aws-vault` - on Linux with [Homebrew on Linux](https://formulae.brew.sh/formula/aws-vault): `brew install aws-vault` - on [Arch Linux](https://www.archlinux.org/packages/community/x86_64/aws-vault/): `pacman -S aws-vault` +- on [Gentoo Linux](https://github.com/gentoo/guru/tree/master/app-admin/aws-vault): `emerge --ask app-admin/aws-vault` ([enable Guru first](https://wiki.gentoo.org/wiki/Project:GURU/Information_for_End_Users)) - on [FreeBSD](https://www.freshports.org/security/aws-vault/): `pkg install aws-vault` - on [OpenSUSE](https://software.opensuse.org/package/aws-vault): enable devel:languages:go repo then `zypper install aws-vault` -- with [Nix](https://nixos.org/nixos/packages.html?attr=aws-vault): `nix-env -i aws-vault` +- with [Nix](https://search.nixos.org/packages?show=aws-vault&query=aws-vault): `nix-env -i aws-vault` - with [asdf-vm](https://github.com/karancode/asdf-aws-vault): `asdf plugin-add aws-vault https://github.com/karancode/asdf-aws-vault.git && asdf install aws-vault ` ## Documentation @@ -62,6 +63,13 @@ $ aws-vault list Profile Credentials Sessions ======= =========== ======== jonsmith jonsmith - + +# Start a subshell with temporary credentials +$ aws-vault exec jonsmith +Starting subshell /bin/zsh, use `exit` to exit the subshell +$ aws s3 ls +bucket_1 +bucket_2 ``` ## How it works @@ -79,10 +87,17 @@ AWS Vault then exposes the temporary credentials to the sub-process in one of tw AWS_ACCESS_KEY_ID=%%% AWS_SECRET_ACCESS_KEY=%%% AWS_SESSION_TOKEN=%%% - AWS_SECURITY_TOKEN=%%% - AWS_SESSION_EXPIRATION=2020-04-16T11:16:27Z + AWS_CREDENTIAL_EXPIRATION=2020-04-16T11:16:27Z + ``` +2. **Local metadata server** is started. This approach has the advantage that anything that uses Amazon's SDKs will automatically refresh credentials as needed, so session times can be as short as possible. + ```shell + $ aws-vault exec --server jonsmith -- env | grep AWS + AWS_VAULT=jonsmith + AWS_DEFAULT_REGION=us-east-1 + AWS_REGION=us-east-1 + AWS_CONTAINER_CREDENTIALS_FULL_URI=%%% + AWS_CONTAINER_AUTHORIZATION_TOKEN=%%% ``` -2. **Local [EC2 Instance Metadata server](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html)** is started. This approach has the advantage that anything that uses Amazon's SDKs will automatically refresh credentials as needed, so session times can be as short as possible. The downside is that only one can run per host and because it binds to `169.254.169.254:80`, your sudo password is required. The default is to use environment variables, but you can opt-in to the local instance metadata server with the `--server` flag on the `exec` command. diff --git a/USAGE.md b/USAGE.md index b936bb9c0..298cf0cc6 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1,38 +1,50 @@ # Usage -* [Getting Help](#getting-help) -* [Config](#config) - * [AWS config file](#aws-config-file) - * [`include_profile`](#include_profile) - * [`session_tags` and `transitive_session_tags`](#session_tags-and-transitive_session_tags) - * [Environment variables](#environment-variables) -* [Backends](#backends) - * [Keychain](#keychain) -* [Managing credentials](#managing-credentials) - * [Using multiple profiles](#using-multiple-profiles) - * [Listing profiles and credentials](#listing-profiles-and-credentials) - * [Removing credentials](#removing-credentials) - * [Rotating credentials](#rotating-credentials) -* [Managing Sessions](#managing-sessions) - * [Executing a command](#executing-a-command) - * [Logging into AWS console](#logging-into-aws-console) - * [Removing stored sessions](#removing-stored-sessions) - * [Using --no-session](#using---no-session) - * [Session duration](#session-duration) - * [Using `--server`](#using---server) - * [`--ecs-server`](#--ecs-server) - * [Temporary credentials limitations with STS, IAM](#temporary-credentials-limitations-with-sts-iam) -* [MFA](#mfa) - * [Gotchas with MFA config](#gotchas-with-mfa-config) -* [AWS Single Sign-On (AWS SSO)](#aws-single-sign-on-aws-sso) -* [Assuming roles with web identities](#assuming-roles-with-web-identities) -* [Using `credential_process`](#using-credential_process) -* [Using a Yubikey](#using-a-yubikey) - * [Prerequisites](#prerequisites) - * [Setup](#setup) - * [Usage](#usage) -* [Shell completion](#shell-completion) -* [Desktop apps](#desktop-apps) +- [Usage](#usage) + - [Getting Help](#getting-help) + - [Typical use-cases for aws-vault](#typical-use-cases-for-aws-vault) + - [Use-case 1: aws-vault is the executor and provides the environment](#use-case-1-aws-vault-is-the-executor-and-provides-the-environment) + - [Use-case 2: aws-vault is a "master credentials vault" for AWS SDK](#use-case-2-aws-vault-is-a-master-credentials-vault-for-aws-sdk) + - [Use-case 3: aws-vault is a "MFA session cache" for AWS SDK](#use-case-3-aws-vault-is-a-mfa-session-cache-for-aws-sdk) + - [Use-case 4: aws-vault caches alternative credential sources](#use-case-4-aws-vault-caches-alternative-credential-sources) + - [Config](#config) + - [AWS config file](#aws-config-file) + - [`include_profile`](#include_profile) + - [`session_tags` and `transitive_session_tags`](#session_tags-and-transitive_session_tags) + - [`source_identity`](#source_identity) + - [`mfa_process`](#mfa_process) + - [Environment variables](#environment-variables) + - [Backends](#backends) + - [Keychain](#keychain) + - [Managing credentials](#managing-credentials) + - [Using multiple profiles](#using-multiple-profiles) + - [Listing profiles and credentials](#listing-profiles-and-credentials) + - [Removing credentials](#removing-credentials) + - [Rotating credentials](#rotating-credentials) + - [Managing Sessions](#managing-sessions) + - [Executing a command](#executing-a-command) + - [Logging into AWS console](#logging-into-aws-console) + - [Removing stored sessions](#removing-stored-sessions) + - [Using --no-session](#using---no-session) + - [Session duration](#session-duration) + - [Using `--server`](#using---server) + - [`--ec2-server`](#--ec2-server) + - [`--ecs-server`](#--ecs-server) + - [Temporary credentials limitations with STS, IAM](#temporary-credentials-limitations-with-sts-iam) + - [MFA](#mfa) + - [Gotchas with MFA config](#gotchas-with-mfa-config) + - [Single Sign On (SSO)](#single-sign-on-sso) + - [Assuming roles with web identities](#assuming-roles-with-web-identities) + - [Using `credential_process`](#using-credential_process) + - [Invoking `aws-vault` via `credential_process`](#invoking-aws-vault-via-credential_process) + - [Invoking `credential_process` via `aws-vault`](#invoking-credential_process-via-aws-vault) + - [Using a Yubikey](#using-a-yubikey) + - [Prerequisites](#prerequisites) + - [Setup](#setup) + - [Usage](#usage-1) + - [Shell completion](#shell-completion) + - [Desktop apps](#desktop-apps) + - [Docker](#docker) ## Getting Help @@ -50,6 +62,108 @@ $ aws-vault --help-long $ aws-vault exec --help ``` +## Typical use-cases for aws-vault + +There are a few different ways aws-vault can be used + +### Use-case 1: aws-vault is the executor and provides the environment + +Use aws-vault exclusively as a command executor, where aws-vault provides the environment and runs a command. + +```ini +; master creds added with 'aws-vault add my_profile_master' +[profile my_profile_master] + +[profile my_profile_role] +source_profile=my_profile_master +role_arn=xxx +``` + +```bash +aws-vault exec my_profile_master ./my-command # success, uses sts session generated by aws-vault +aws-vault exec my_profile_role ./my-command # success, uses role creds generated by aws-vault + +AWS_PROFILE=my_profile_master ./my-command # Not expected to be functional +AWS_PROFILE=my_profile_role ./my-command # Not expected to be functional +``` + +In this scenario, the profile name and aws config is used exclusively by aws-vault, which provides the environment for the command to run in. + +This is a very unix-y and 12-factor approach. It's the original and the primary use-case of aws-vault - it's why `aws-vault exec` exists. + + +### Use-case 2: aws-vault is a "master credentials vault" for AWS SDK + +aws-vault can be used in `credential_process` in the AWS config to provide master creds. This is more in-line with the AWS SDK way of approaching the problem via `credential_process` and `AWS_PROFILE` + +```ini +; master creds added with 'aws-vault add my_profile_master' +[profile my_profile_master] +credential_process = aws-vault export --format=json --no-session my_profile_master + +[profile my_profile_role] +source_profile=my_profile_master +role_arn=xxx +``` + +```bash +aws-vault exec my_profile_master ./my-command # success (uses master creds) +aws-vault exec my_profile_role ./my-command # success (aws-vault role) + +AWS_PROFILE=my_profile_master ./my-command # success (uses credential_process to get aws-vault master creds) +AWS_PROFILE=my_profile_role ./my-command # success (SDK role) +``` + +### Use-case 3: aws-vault is a "MFA session cache" for AWS SDK + +Very similar to Use-case 2, aws-vault can be used to cache STS MFA credentials between profiles. This means you are not forced to re-authenticate with MFA every time you switch profiles + +```ini +; master creds added with 'aws-vault add my_profile_master' +[profile my_profile_master] +mfa_serial=mmm +credential_process = aws-vault export --format=json my_profile_master + +[profile my_profile_role] +source_profile=my_profile_master +mfa_serial=mmm +role_arn=xxx1 + +[profile my_profile_role2] +source_profile=my_profile_master +mfa_serial=mmm +role_arn=xxx2 +``` + +```bash +aws-vault exec my_profile_master ./my-command # success (STS session) +aws-vault exec my_profile_role ./my-command # success (role) + +AWS_PROFILE=my_profile_master ./my-command # success (uses credential_process to get aws-vault session) +AWS_PROFILE=my_profile_role ./my-command # success (uses aws-vault session + SDK role) +``` + + +### Use-case 4: aws-vault caches alternative credential sources + +aws-vault caches credentials from alternative credential sources like `sso_start_url`, `web_identity_token_process`, `credential_process` + +```ini +[profile my_profile_using_sso] +sso_start_url = https://mycompany.awsapps.com/start + +[profile my_profile_using_process] +credential_process = my-custom-creds-cmd +``` + +```bash +aws-vault exec my_profile_using_sso ./my-command # success, uses aws-vault caching +aws-vault exec my_profile_using_process ./my-command # success, uses aws-vault caching + +AWS_PROFILE=my_profile_using_sso ./my-command # success, no caching +AWS_PROFILE=my_profile_using_process ./my-command # success, no caching +``` + ## Config @@ -132,6 +246,26 @@ role_arn=arn:aws:iam::123456789:role/developers source_identity=your_user_name ``` +#### `mfa_process` +If you have a method to generate an MFA token, you can use it with `aws-vault` by specifying the `mfa_process` option in a profile of your `~/.aws/config` file. The value of `mfa_process` should be a command that will output the MFA token to stdout. + +For example, to use `pass` to retrieve an MFA token from a password store entry, you could use the following: + +```ini +[profile foo] +mfa_serial=arn:aws:iam::123456789:mfa/johnsmith +mfa_process=pass otp my_aws_mfa +``` + +Or another example using 1Password + +```ini +[profile foo] +mfa_serial=arn:aws:iam::123456789:mfa/johnsmith +mfa_process=op item get my_aws_mfa --otp +``` + +WARNING: Use of this option runs against security best practices. It is recommended that you use a dedicated MFA device. ### Environment variables @@ -259,7 +393,7 @@ Deleted credentials. ### Rotating credentials -Regularly rotating your access keys is a critical part of credential management. You can do this with the `aws-vault rotate ` command as often as you like. +Regularly rotating your access keys is a critical part of credential management. You can do this with the `aws-vault rotate ` command as often as you like. [Restrictions on IAM access](#temporary-credentials-limitations-with-sts-iam) using `GetSessionToken` means you will need to have [configured MFA](#mfa) or use the `--no-session` flag. The minimal IAM policy required to rotate your own credentials is: @@ -304,6 +438,15 @@ You can use the `aws-vault login` command to open a browser window and login to $ aws-vault login work ``` +If you have credentials already available in your environment, aws-vault will use these credentials to sign you in to the AWS console. + +```shell +$ export AWS_ACCESS_KEY_ID=%%% +$ export AWS_SECRET_ACCESS_KEY=%%% +$ export AWS_SESSION_TOKEN=%%% +$ aws-vault login +``` + ### Removing stored sessions If you want to remove sessions managed by `aws-vault` before they expire, you can do this with `aws-vault clear` command. @@ -344,31 +487,42 @@ For that reason, AWS Vault will not use `GetSessionToken` if `--duration` or the There may be scenarios where you'd like to assume a role for a long length of time, or perhaps when using a tool where using temporary sessions on demand is preferable. For example, when using a tool like [Terraform](https://www.terraform.io/), you need to have AWS credentials available to the application for the entire duration of the infrastructure change. -AWS Vault can run a background server to imitate the [metadata endpoint](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html) that you would have on an EC2 instance. When your application uses the AWS SDK to locate credentials, it will automatically connect to this server that will issue a new set of temporary credentials (using the same profile as the one the server was started with). This server will continue to generate temporary credentials any time the application requests it. +AWS Vault can run a background server to imitate the metadata endpoint that you would have on an EC2 or ECS instance. When your application uses the AWS SDK to locate credentials, it will automatically connect to this server that will issue a new set of temporary credentials (using the same profile as the one the server was started with). This server will continue to generate temporary credentials any time the application requests it. + +#### `--ec2-server` This approach has the major security drawback that while this `aws-vault` server runs, any application wanting to connect to AWS will be able to do so, using the profile the server was started with. Thanks to `aws-vault`, the credentials are not exposed, but the ability to use them to connect to AWS is! -To use `--server`, AWS Vault needs root/administrator privileges in order to bind to the privileged port. AWS Vault runs a minimal proxy as the root user, proxying through to the real aws-vault instance. +To use `--ec2-server`, AWS Vault needs root/administrator privileges in order to bind to the privileged port. AWS Vault runs a minimal proxy as the root user, proxying through to the real aws-vault instance. #### `--ecs-server` -An ECS credential server can also be used instead of the ec2 metadata server. The ECS Credential provider binds to a random, ephemeral port and requires an authorization token, which offer the following advantages over the EC2 Metadata provider: +The ECS Credential provider binds to a random, ephemeral port and requires an authorization token, which offers the following advantages over the EC2 Metadata provider: 1. Does not require root/administrator privileges 2. Allows multiple providers simultaneously for discrete processes 3. Mitigates the security issues that accompany the EC2 Metadata Service because the address is not well-known and the authorization token is only exposed to the subprocess via environment variables -However, this will only work with the AWS SDKs that support `AWS_CONTAINER_CREDENTIALS_FULL_URI`. The Ruby, .NET and PHP SDKs do not currently support it. +However, this will only work with the AWS SDKs [that support `AWS_CONTAINER_CREDENTIALS_FULL_URI`](https://docs.aws.amazon.com/sdkref/latest/guide/feature-container-credentials.html). The C++ and PHP SDKs do not currently support it. + +The ECS server also responds to requests on `/role-arn/YOUR_ROLE_ARN` with the role credentials, making it usable with `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` when combined with a reverse proxy (see the Docker section below). ### Temporary credentials limitations with STS, IAM -When using temporary credentials you are restricted from using some STS and IAM APIs (see [here](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_request.html#stsapi_comparison)). You may need to avoid the temporary session by using `--no-session`. +When using temporary credentials you are restricted from using some STS and IAM APIs (see [here](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_request.html#stsapi_comparison)). The restriction is enforced with `InvalidClientTokenId` error response. + +```shell +$ aws-vault exec -- aws iam get-user +An error occurred (InvalidClientTokenId) when calling the GetUser operation: The security token included in the request is invalid +``` + +For restricted IAM operation you can add MFA to the IAM User and update your ~/.aws/config file with [MFA configuration](#mfa). Alternately you may avoid the temporary session entirely by using `--no-session`. ## MFA -To enable MFA for a profile, specify the `mfa_serial` in `~/.aws/config`. You can retrieve the MFA's serial (ARN) in the web console, or you can usually derive it pretty easily using the format `arn:aws:iam::[account-id]:mfa/[your-iam-username]`. If you have an account with an MFA associated, but you don't provide the IAM, you are unable to call IAM services, even if you have the correct permissions to do so. +To enable MFA for a profile, specify the `mfa_serial` in `~/.aws/config`. You can retrieve the MFA's serial (ARN) in the web console, under IAM > Users > `` > Security Configuration. If you have an account with an MFA associated, but you don't provide the ARN, you are unable to call IAM services, even if you have the correct permissions to do so. -AWS Vault will attempt to re-use a `GetSessionToken` between profiles that share a common `mfa_serial`. In the following example, aws-vault will cache and re-use sessions between role1 and role2. This means you don't have to continually enter MFA codes if the user is the same. +AWS Vault will attempt to re-use a `GetSessionToken` between profiles that share a common `mfa_serial`. In the following example, aws-vault will cache and re-use sessions between role1 and role2. This means you don't have to continually enter MFA codes if the MFA method is the same. ```ini [profile tom] @@ -409,15 +563,18 @@ role_arn = arn:aws:iam::33333333333:role/role2 include_profile = jon ``` -## AWS Single Sign-On (AWS SSO) +## Single Sign On (SSO) + +_AWS IAM Identity Center provides single sign on, and was previously known as AWS SSO._ -If your organization uses AWS Single Sign-On ([AWS SSO](https://aws.amazon.com/single-sign-on/)), AWS Vault provides a method for using the credential information defined by [AWS SSO CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html). The configuration options are as follows: -* `sso_start_url` The URL that points to the organization's AWS SSO user portal. -* `sso_region` The AWS Region that contains the AWS SSO portal host. This is separate from, and can be a different region than the default CLI region parameter. +If your organization uses [AWS IAM Identity Center](https://aws.amazon.com/iam/identity-center/) for single sign on, AWS Vault provides a method for using the credential information defined by [`aws sso`](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html) from v2 of the AWS CLI. The configuration options are as follows: +* `sso_session` Name of the `[sso-session]` section in the same file with the common options, or: +* `sso_start_url` The URL that points to the organization's AWS IAM Identity Center user portal. +* `sso_region` The AWS Region that contains the AWS IAM Identity Center user portal host. This is separate from, and can be a different region than the default CLI region parameter. * `sso_account_id` The AWS account ID that contains the IAM role that you want to use with this profile. * `sso_role_name` The name of the IAM role that defines the user's permissions when using this profile. -Here is an example configuration using AWS SSO. +Here is an example configuration using AWS IAM Identity Center for single sign on. ```ini [profile Administrator-123456789012] @@ -453,9 +610,11 @@ web_identity_token_process = oidccli raw The [AWS CLI config](https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes) supports sourcing credentials directly from an external process, using `credential_process`. +### Invoking `aws-vault` via `credential_process` + ```ini [profile home] -credential_process = aws-vault exec home --json +credential_process = aws-vault export --format=json home ``` If `mfa_serial` is set, please define the prompt driver (for example `osascript` for macOS), else the prompt will not show up. @@ -463,14 +622,14 @@ If `mfa_serial` is set, please define the prompt driver (for example `osascript` ```ini [profile work] mfa_serial = arn:aws:iam::123456789012:mfa/jonsmith -credential_process = aws-vault exec work --json --prompt=osascript +credential_process = aws-vault --prompt=osascript export --format=json work ``` Note that `credential_process` is designed for retrieving master credentials, while aws-vault outputs STS credentials by default. If a role is present, the AWS CLI/SDK uses the master credentials from the `credential_process` to generate STS credentials itself. So depending on your use-case, it might make sense for aws-vault to output master credentials by using a profile without a role and the `--no-session` argument. For example: ```ini [profile jon] -credential_process = aws-vault exec --no-session --json jon +credential_process = aws-vault export --no-session --format=json jon [profile work] mfa_serial = arn:aws:iam::123456789012:mfa/jonsmith @@ -478,16 +637,19 @@ role_arn = arn:aws:iam::33333333333:role/role2 source_profile = jon ``` -If you're using `credential_process` in your config you should not use `aws-vault exec` on the command line to execute commands directly - the AWS SDK executes `aws-vault` for you. +If you're using `credential_process` in your config to invoke `aws-vault exec` you should not use `aws-vault exec` on the command line to execute commands directly - the AWS SDK executes `aws-vault` for you. + +### Invoking `credential_process` via `aws-vault` +When executing a profile via `aws-vault exec` that has `credential_process` set, `aws-vault` will execute the specified command to obtain a credential. This will allow `aws-vault` to cache credentials obtained via `credential_process`. ## Using a Yubikey -Yubikeys can be used with AWS Vault via Yubikey's OATH-TOTP support. TOTP is necessary because FIDO-U2F is unsupported on the AWS API. +Yubikeys can be used with AWS Vault via Yubikey's OATH-TOTP support. TOTP is necessary because FIDO-U2F is unsupported on the AWS CLI and SDKs; even though it's supported on the AWS Console. ### Prerequisites 1. [A Yubikey that supports OATH-TOTP](https://support.yubico.com/support/solutions/articles/15000006419-using-your-yubikey-with-authenticator-codes) - 2. `ykman`, the [YubiKey Manager CLI](https://github.com/Yubico/yubikey-manager) tool + 2. `ykman`, the [YubiKey Manager CLI](https://github.com/Yubico/yubikey-manager) tool. You can verify these prerequisites by running `ykman info` and checking `OATH` is enabled. @@ -497,45 +659,87 @@ You can verify these prerequisites by running `ykman info` and checking `OATH` i 3. Instead of showing the QR code, click on `Show secret key` and copy the key. 4. On a command line, run: ```shell - ykman oath accounts add -t arn:aws:iam::${ACCOUNT_ID}:mfa/${IAM_USERNAME} + ykman oath accounts add -t arn:aws:iam::${ACCOUNT_ID}:mfa/${MFA_DEVICE_NAME} ``` - replacing `${ACCOUNT_ID}` with your AWS account ID and `${IAM_USERNAME}` with your IAM username. It will prompt you for a base32 text and you can input the key from step 3. Notice the above command uses `-t` which requires you to touch your YubiKey to generate authentication codes. - 5. Now you have to enter two consecutive MFA codes into the AWS website to assign your key to your AWS login. Just run `ykman oath accounts code arn:aws:iam::${ACCOUNT_ID}:mfa/${IAM_USERNAME}` to get an authentication code. The codes are re-generated every 30 seconds, so you have to run this command twice with about 30 seconds in between to get two distinct codes. Enter the two codes in the AWS form and click `Assign MFA` + replacing `${ACCOUNT_ID}` with your AWS account ID and `${MFA_DEVICE_NAME}` with the name you gave to the MFA device. It will prompt you for a base32 text and you can input the key from step 3. Notice the above command uses `-t` which requires you to touch your YubiKey to generate authentication codes. + 5. Now you have to enter two consecutive MFA codes into the AWS website to assign your key to your AWS login. Just run `ykman oath accounts code arn:aws:iam::${ACCOUNT_ID}:mfa/${MFA_DEVICE_NAME}` to get an authentication code. The codes are re-generated every 30 seconds, so you have to run this command twice with about 30 seconds in between to get two distinct codes. Enter the two codes in the AWS form and click `Assign MFA`. + +A script can be found at [contrib/scripts/aws-iam-create-yubikey-mfa.sh](contrib/scripts/aws-iam-create-yubikey-mfa.sh) to automate the process. Note that this script requires your `$MFA_DEVICE_NAME` to be your IAM username as the `aws iam enable-mfa-device` command in the CLI does not yet offer specifying the name. When only one MFA device was allowed per IAM user, the `$MFA_DEVICE_NAME` would always be your IAM username. -A script can be found at [contrib/scripts/aws-iam-create-yubikey-mfa.sh](contrib/scripts/aws-iam-create-yubikey-mfa.sh) to automate the process. +In case of TOTP being out of sync (AWS API doesn't accept MFA codes), a yubikey resync script can be found at [contrib/scripts/aws-iam-resync-yubikey-mfa.sh](contrib/scripts/aws-iam-resync-yubikey-mfa.sh) to resync the yubikey with AWS. As above, this script requires your `$MFA_DEVICE_NAME` to be your IAM username. -In case of TOTP being out of sync (AWS API doesn't accept MFA codes), yubikey resync script can be found at [contrib/scripts/aws-iam-resync-yubikey-mfa.sh](contrib/scripts/aws-iam-resync-yubikey-mfa.sh) to resync the yubikey with AWS. +Note that each `[profile ]` in your `~/.aws/config` only supports one `mfa_serial` entry. If you wish to use multiple Yubikeys, or mix and match MFA devices, you'll need to add a profile for each method. ### Usage Using the `ykman` prompt driver, aws-vault will execute `ykman` to generate tokens for any profile in your `.aws/config` using an `mfa_device`. ```shell aws-vault exec --prompt ykman ${AWS_VAULT_PROFILE_USING_MFA} -- aws s3 ls ``` + +An alternative to manually supplying the prompt driver as a CLI argument to `aws-vault` is setting the [`mfa_process`](#mfa_process) parameter in your `.aws/config` for the profiles that should use a YubiKey to generate tokens. Example: + +(Note: Remember to swap out the name of the OATH account used in `mfa_process` below with the name you gave it during [YubiKey setup](#setup)) + +```ini +[profile jon] +mfa_serial = arn:aws:iam::123456789012:mfa/jonsmith +mfa_process = ykman oath accounts code --single arn:aws:iam::123456789012:mfa/jonsmith +``` + Further config: - `AWS_VAULT_PROMPT=ykman`: to avoid specifying `--prompt` each time - `YKMAN_OATH_CREDENTIAL_NAME`: to use an alternative ykman credential - `AWS_VAULT_YKMAN_VERSION`: to set the major version of the ykman cli being used. Defaults to "4" + - `YKMAN_OATH_DEVICE_SERIAL`: to set the device serial of a specific Yubikey if you have multiple Yubikeys plugged into your computer. ## Shell completion -You can generate shell completions with -```shell -# for bash -eval "$(aws-vault --completion-script-bash)" - -# for zsh -eval "$(aws-vault --completion-script-zsh)" -``` +You can generate shell completions for + - bash: `eval "$(curl -fs https://raw.githubusercontent.com/99designs/aws-vault/master/contrib/completions/bash/aws-vault.bash)"` + - zsh: `eval "$(curl -fs https://raw.githubusercontent.com/99designs/aws-vault/master/contrib/completions/zsh/aws-vault.zsh)"` + - fish: `eval "$(curl -fs https://raw.githubusercontent.com/99designs/aws-vault/master/contrib/completions/fish/aws-vault.fish)"` -There are more completion scripts at [contrib/completions](contrib/completions). +Find the completion scripts at [contrib/completions](contrib/completions). ## Desktop apps You can use desktop apps with temporary credentials from AWS Vault too! For example on macOS run ```shell -aws-vault exec --server --prompt=osascript jonsmith -- open -a Lens +aws-vault exec --server jonsmith -- open -W -a Lens ``` * `--server`: starts the background server so that temporary credentials get refreshed automatically -* `--prompt=osascript`: pop up a GUI for MFA prompts -* `open -a Lens`: run the applications +* `open -W -a Lens`: run the applications, waiting for it to exit + +## Docker + +It's possible for Docker containers to retrieve credentials from aws-vault running on the host. + +![Screen Shot 2022-03-03 at 12 16 15 pm](https://user-images.githubusercontent.com/980499/156477380-423f4eb9-f10e-4568-afa8-7fa525a1f3a3.png) + +The ECS server responds to requests on `/role-arn/YOUR_ROLE_ARN` with the role credentials, making it usable with the `AWS_CONTAINER_CREDENTIALS_FULL_URI` or `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` environment +variables. These environment variables are used by the AWS SDKs as part of the [default credential provider chain](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html#credentials-default). + +In particular, this is designed to allow aws-vault to run on your local host while docker images access role credentials dynamically. This is achieved via a reverse-proxy container (started with `aws-vault exec --ecs-server --lazy PROFILE -- docker-compose up ...`) using the default ECS IP address `169.254.170.2`. Docker containers no longer need AWS keys at all - instead they can specify the role they want to assume with `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`. + +This use-case is similar to the goal of [amazon-ecs-local-container-endpoints](https://github.com/awslabs/amazon-ecs-local-container-endpoints/blob/mainline/docs/features.md#vend-credentials-to-containers), however the difference here is that the long-lived AWS credentials are getting sourced from your keychain via aws-vault. + +To test it out: +1. Add a base role to your `~/.aws/config` (replacing with valid values) + ```ini + [profile base-role] + source_profile=myprofile + role_arn=arn:aws:iam::222222222222:role/aws-vault-test + mfa_serial=arn:aws:iam::222222222222:mfa/ + ``` +2. Start a reverse proxy: + ```shell + $ cd contrib/_aws-vault-proxy + $ aws-vault --debug exec --server --lazy base-role -- docker compose up --build aws-vault-proxy + ``` +3. In a new terminal, assume a new role + ```shell + $ export AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/role-arn/arn:aws:iam::222222222222:role/another-role-that-can-be-assumed-by-base-role + $ docker-compose run testapp + testapp $ aws sts get-caller-identity + ``` diff --git a/cli/add.go b/cli/add.go index fd1b57479..9c77b69dd 100644 --- a/cli/add.go +++ b/cli/add.go @@ -5,10 +5,10 @@ import ( "log" "os" - "github.com/99designs/aws-vault/v6/prompt" - "github.com/99designs/aws-vault/v6/vault" + "github.com/99designs/aws-vault/v7/prompt" + "github.com/99designs/aws-vault/v7/vault" "github.com/99designs/keyring" - "github.com/alecthomas/kingpin" + "github.com/alecthomas/kingpin/v2" "github.com/aws/aws-sdk-go-v2/aws" ) @@ -21,7 +21,7 @@ type AddCommandInput struct { func ConfigureAddCommand(app *kingpin.Application, a *AwsVault) { input := AddCommandInput{} - cmd := app.Command("add", "Adds credentials to the secure keystore") + cmd := app.Command("add", "Add credentials to the secure keystore.") cmd.Arg("profile", "Name of the profile"). Required(). @@ -50,7 +50,7 @@ func ConfigureAddCommand(app *kingpin.Application, a *AwsVault) { } func AddCommand(input AddCommandInput, keyring keyring.Keyring, awsConfigFile *vault.ConfigFile) error { - var accessKeyId, secretKey, sessionToken string + var accessKeyID, secretKey, sessionToken string p, _ := awsConfigFile.ProfileSection(input.ProfileName) if p.SourceProfile != "" { @@ -59,7 +59,7 @@ func AddCommand(input AddCommandInput, keyring keyring.Keyring, awsConfigFile *v } if input.FromEnv { - if accessKeyId = os.Getenv("AWS_ACCESS_KEY_ID"); accessKeyId == "" { + if accessKeyID = os.Getenv("AWS_ACCESS_KEY_ID"); accessKeyID == "" { return fmt.Errorf("Missing value for AWS_ACCESS_KEY_ID") } if secretKey = os.Getenv("AWS_SECRET_ACCESS_KEY"); secretKey == "" { @@ -70,7 +70,7 @@ func AddCommand(input AddCommandInput, keyring keyring.Keyring, awsConfigFile *v } } else { var err error - if accessKeyId, err = prompt.TerminalPrompt("Enter Access Key ID: "); err != nil { + if accessKeyID, err = prompt.TerminalPrompt("Enter Access Key ID: "); err != nil { return err } if secretKey, err = prompt.TerminalSecretPrompt("Enter Secret Access Key: "); err != nil { @@ -78,7 +78,7 @@ func AddCommand(input AddCommandInput, keyring keyring.Keyring, awsConfigFile *v } } - creds := aws.Credentials{AccessKeyID: accessKeyId, SecretAccessKey: secretKey, SessionToken: sessionToken} + creds := aws.Credentials{AccessKeyID: accessKeyID, SecretAccessKey: secretKey, SessionToken: sessionToken} ckr := &vault.CredentialKeyring{Keyring: keyring} if err := ckr.Set(input.ProfileName, creds); err != nil { diff --git a/cli/add_test.go b/cli/add_test.go index 1d7e25163..73098022d 100644 --- a/cli/add_test.go +++ b/cli/add_test.go @@ -1,15 +1,14 @@ package cli import ( - "io/ioutil" "log" "os" - "github.com/alecthomas/kingpin" + "github.com/alecthomas/kingpin/v2" ) func ExampleAddCommand() { - f, err := ioutil.TempFile("", "aws-config") + f, err := os.CreateTemp("", "aws-config") if err != nil { log.Fatal(err) } diff --git a/cli/clear.go b/cli/clear.go index 64242752d..c62988fb7 100644 --- a/cli/clear.go +++ b/cli/clear.go @@ -3,9 +3,9 @@ package cli import ( "fmt" - "github.com/99designs/aws-vault/v6/vault" + "github.com/99designs/aws-vault/v7/vault" "github.com/99designs/keyring" - "github.com/alecthomas/kingpin" + "github.com/alecthomas/kingpin/v2" ) type ClearCommandInput struct { @@ -15,7 +15,7 @@ type ClearCommandInput struct { func ConfigureClearCommand(app *kingpin.Application, a *AwsVault) { input := ClearCommandInput{} - cmd := app.Command("clear", "Clear temporary credentials from the secure keystore") + cmd := app.Command("clear", "Clear temporary credentials from the secure keystore.") cmd.Arg("profile", "Name of the profile"). HintAction(a.MustGetProfileNames). diff --git a/cli/exec.go b/cli/exec.go index 91d3acea8..47f7f4c54 100644 --- a/cli/exec.go +++ b/cli/exec.go @@ -2,23 +2,22 @@ package cli import ( "context" - "encoding/json" "fmt" "log" + "net/http" "os" + osexec "os/exec" "os/signal" "runtime" "strings" "syscall" "time" - osexec "golang.org/x/sys/execabs" - - "github.com/99designs/aws-vault/v6/iso8601" - "github.com/99designs/aws-vault/v6/server" - "github.com/99designs/aws-vault/v6/vault" + "github.com/99designs/aws-vault/v7/iso8601" + "github.com/99designs/aws-vault/v7/server" + "github.com/99designs/aws-vault/v7/vault" "github.com/99designs/keyring" - "github.com/alecthomas/kingpin" + "github.com/alecthomas/kingpin/v2" "github.com/aws/aws-sdk-go-v2/aws" ) @@ -28,37 +27,49 @@ type ExecCommandInput struct { Args []string StartEc2Server bool StartEcsServer bool - CredentialHelper bool - Config vault.Config + Lazy bool + JSONDeprecated bool + Config vault.ProfileConfig SessionDuration time.Duration NoSession bool UseStdout bool + ShowHelpMessages bool } func (input ExecCommandInput) validate() error { if input.StartEc2Server && input.StartEcsServer { - return fmt.Errorf("Can't use --server with --ecs-server") + return fmt.Errorf("Can't use --ec2-server with --ecs-server") } - if input.StartEc2Server && input.CredentialHelper { - return fmt.Errorf("Can't use --server with --json") + if input.StartEc2Server && input.JSONDeprecated { + return fmt.Errorf("Can't use --ec2-server with --json") } if input.StartEc2Server && input.NoSession { - return fmt.Errorf("Can't use --server with --no-session") + return fmt.Errorf("Can't use --ec2-server with --no-session") } - if input.StartEcsServer && input.CredentialHelper { + if input.StartEcsServer && input.JSONDeprecated { return fmt.Errorf("Can't use --ecs-server with --json") } if input.StartEcsServer && input.NoSession { return fmt.Errorf("Can't use --ecs-server with --no-session") } + if input.StartEcsServer && input.Config.MfaPromptMethod == "terminal" { + return fmt.Errorf("Can't use --prompt=terminal with --ecs-server. Specify a different prompt driver") + } + if input.StartEc2Server && input.Config.MfaPromptMethod == "terminal" { + return fmt.Errorf("Can't use --prompt=terminal with --ec2-server. Specify a different prompt driver") + } return nil } +func hasBackgroundServer(input ExecCommandInput) bool { + return input.StartEcsServer || input.StartEc2Server +} + func ConfigureExecCommand(app *kingpin.Application, a *AwsVault) { input := ExecCommandInput{} - cmd := app.Command("exec", "Executes a command with AWS credentials in the environment") + cmd := app.Command("exec", "Execute a command with AWS credentials.") cmd.Flag("duration", "Duration of the temporary or assume-role session. Defaults to 1h"). Short('d'). @@ -77,11 +88,12 @@ func ConfigureExecCommand(app *kingpin.Application, a *AwsVault) { cmd.Flag("json", "Output credentials in JSON that can be used by credential_process"). Short('j'). - BoolVar(&input.CredentialHelper) + Hidden(). + BoolVar(&input.JSONDeprecated) - cmd.Flag("server", "Alias for --ec2-server. Run a EC2 metadata server in the background for credentials"). + cmd.Flag("server", "Alias for --ecs-server"). Short('s'). - BoolVar(&input.StartEc2Server) + BoolVar(&input.StartEcsServer) cmd.Flag("ec2-server", "Run a EC2 metadata server in the background for credentials"). BoolVar(&input.StartEc2Server) @@ -89,6 +101,9 @@ func ConfigureExecCommand(app *kingpin.Application, a *AwsVault) { cmd.Flag("ecs-server", "Run a ECS credential server in the background for credentials (the SDK or app must support AWS_CONTAINER_CREDENTIALS_FULL_URI)"). BoolVar(&input.StartEcsServer) + cmd.Flag("lazy", "When using --ecs-server, lazily fetch credentials"). + BoolVar(&input.Lazy) + cmd.Flag("stdout", "Print the SSO link to the terminal without automatically opening the browser"). BoolVar(&input.UseStdout) @@ -98,17 +113,17 @@ func ConfigureExecCommand(app *kingpin.Application, a *AwsVault) { StringVar(&input.ProfileName) cmd.Arg("cmd", "Command to execute, defaults to $SHELL"). - Default(os.Getenv("SHELL")). StringVar(&input.Command) cmd.Arg("args", "Command arguments"). StringsVar(&input.Args) cmd.Action(func(c *kingpin.ParseContext) (err error) { - input.Config.MfaPromptMethod = a.PromptDriver + input.Config.MfaPromptMethod = a.PromptDriver(hasBackgroundServer(input)) input.Config.NonChainedGetSessionTokenDuration = input.SessionDuration input.Config.AssumeRoleDuration = input.SessionDuration input.Config.SSOUseStdout = input.UseStdout + input.ShowHelpMessages = !a.Debug && input.Command == "" && isATerminal() && os.Getenv("AWS_VAULT_DISABLE_HELP_MESSAGE") != "1" f, err := a.AwsConfigFile() if err != nil { @@ -119,56 +134,110 @@ func ConfigureExecCommand(app *kingpin.Application, a *AwsVault) { return err } - err = ExecCommand(input, f, keyring) + exitcode := 0 + if input.JSONDeprecated { + exportCommandInput := ExportCommandInput{ + ProfileName: input.ProfileName, + Format: "json", + Config: input.Config, + SessionDuration: input.SessionDuration, + NoSession: input.NoSession, + } + + err = ExportCommand(exportCommandInput, f, keyring) + } else { + exitcode, err = ExecCommand(input, f, keyring) + } + app.FatalIfError(err, "exec") + + // override exit code if not err + os.Exit(exitcode) + return nil }) } -func ExecCommand(input ExecCommandInput, f *vault.ConfigFile, keyring keyring.Keyring) error { +func ExecCommand(input ExecCommandInput, f *vault.ConfigFile, keyring keyring.Keyring) (exitcode int, err error) { if os.Getenv("AWS_VAULT") != "" { - return fmt.Errorf("aws-vault sessions should be nested with care, unset AWS_VAULT to force") + return 0, fmt.Errorf("running in an existing aws-vault subshell; 'exit' from the subshell or unset AWS_VAULT to force") } - err := input.validate() - if err != nil { - return err + if err := input.validate(); err != nil { + return 0, err } - vault.UseSession = !input.NoSession - - configLoader := vault.ConfigLoader{ - File: f, - BaseConfig: input.Config, - ActiveProfile: input.ProfileName, - } - config, err := configLoader.LoadFromProfile(input.ProfileName) + config, err := vault.NewConfigLoader(input.Config, f, input.ProfileName).GetProfileConfig(input.ProfileName) if err != nil { - return fmt.Errorf("Error loading config: %w", err) + return 0, fmt.Errorf("Error loading config: %w", err) } - ckr := &vault.CredentialKeyring{Keyring: keyring} - credsProvider, err := vault.NewTempCredentialsProvider(config, ckr) + credsProvider, err := vault.NewTempCredentialsProvider(config, &vault.CredentialKeyring{Keyring: keyring}, input.NoSession, false) if err != nil { - return fmt.Errorf("Error getting temporary credentials: %w", err) + return 0, fmt.Errorf("Error getting temporary credentials: %w", err) } - if input.StartEc2Server { - return execEc2Server(input, config, credsProvider) + subshellHelp := "" + if input.Command == "" { + input.Command = getDefaultShell() + subshellHelp = fmt.Sprintf("Starting subshell %s, use `exit` to exit the subshell", input.Command) } - if input.StartEcsServer { - return execEcsServer(input, config, credsProvider) + cmdEnv := createEnv(input.ProfileName, config.Region) + + if input.StartEc2Server { + if server.IsProxyRunning() { + return 0, fmt.Errorf("Another process is already bound to 169.254.169.254:80") + } + + printHelpMessage("Warning: Starting a local EC2 credential server on 169.254.169.254:80; AWS credentials will be accessible to any process while it is running", input.ShowHelpMessages) + if err := server.StartEc2EndpointProxyServerProcess(); err != nil { + return 0, err + } + defer server.StopProxy() + + if err = server.StartEc2CredentialsServer(context.TODO(), credsProvider, config.Region); err != nil { + return 0, fmt.Errorf("Failed to start credential server: %w", err) + } + printHelpMessage(subshellHelp, input.ShowHelpMessages) + } else if input.StartEcsServer { + printHelpMessage("Starting a local ECS credential server; your app's AWS sdk must support AWS_CONTAINER_CREDENTIALS_FULL_URI.", input.ShowHelpMessages) + if err = startEcsServerAndSetEnv(credsProvider, config, input.Lazy, &cmdEnv); err != nil { + return 0, err + } + printHelpMessage(subshellHelp, input.ShowHelpMessages) + } else { + if err = addCredsToEnv(credsProvider, input.ProfileName, &cmdEnv); err != nil { + return 0, err + } + printHelpMessage(subshellHelp, input.ShowHelpMessages) + + err = doExecSyscall(input.Command, input.Args, cmdEnv) // will not return if exec syscall succeeds + if err != nil { + log.Println("Error doing execve syscall:", err.Error()) + log.Println("Falling back to running a subprocess") + } } - if input.CredentialHelper { - return execCredentialHelper(input, config, credsProvider) + return runSubProcess(input.Command, input.Args, cmdEnv) +} + +func printHelpMessage(helpMsg string, showHelpMessages bool) { + if helpMsg != "" { + if showHelpMessages { + printToStderr(helpMsg) + } else { + log.Println(helpMsg) + } } +} - return execEnvironment(input, config, credsProvider) +func printToStderr(helpMsg string) { + fmt.Fprint(os.Stderr, helpMsg, "\n") } -func updateEnvForAwsVault(env environ, profileName string, region string) environ { +func createEnv(profileName string, region string) environ { + env := environ(os.Environ()) env.Unset("AWS_ACCESS_KEY_ID") env.Unset("AWS_SECRET_ACCESS_KEY") env.Unset("AWS_SESSION_TOKEN") @@ -181,107 +250,55 @@ func updateEnvForAwsVault(env environ, profileName string, region string) enviro env.Set("AWS_VAULT", profileName) if region != "" { - log.Printf("Setting subprocess env: AWS_DEFAULT_REGION=%s, AWS_REGION=%s", region, region) - env.Set("AWS_DEFAULT_REGION", region) + // AWS_REGION is used by most SDKs. But boto3 (Python SDK) uses AWS_DEFAULT_REGION + // See https://docs.aws.amazon.com/sdkref/latest/guide/feature-region.html + log.Printf("Setting subprocess env: AWS_REGION=%s, AWS_DEFAULT_REGION=%s", region, region) env.Set("AWS_REGION", region) + env.Set("AWS_DEFAULT_REGION", region) } return env } -func execEc2Server(input ExecCommandInput, config *vault.Config, credsProvider aws.CredentialsProvider) error { - if err := server.StartEc2CredentialsServer(credsProvider, config.Region); err != nil { - return fmt.Errorf("Failed to start credential server: %w", err) - } - - env := environ(os.Environ()) - env = updateEnvForAwsVault(env, input.ProfileName, config.Region) - - return execCmd(input.Command, input.Args, env) -} - -func execEcsServer(input ExecCommandInput, config *vault.Config, credsProvider aws.CredentialsProvider) error { - uri, token, err := server.StartEcsCredentialServer(credsProvider) +func startEcsServerAndSetEnv(credsProvider aws.CredentialsProvider, config *vault.ProfileConfig, lazy bool, cmdEnv *environ) error { + ecsServer, err := server.NewEcsServer(context.TODO(), credsProvider, config, "", 0, lazy) if err != nil { - return fmt.Errorf("Failed to start credential server: %w", err) + return err } - - env := environ(os.Environ()) - env = updateEnvForAwsVault(env, input.ProfileName, config.Region) + go func() { + err = ecsServer.Serve() + if err != http.ErrServerClosed { // ErrServerClosed is a graceful close + log.Fatalf("ecs server: %s", err.Error()) + } + }() log.Println("Setting subprocess env AWS_CONTAINER_CREDENTIALS_FULL_URI, AWS_CONTAINER_AUTHORIZATION_TOKEN") - env.Set("AWS_CONTAINER_CREDENTIALS_FULL_URI", uri) - env.Set("AWS_CONTAINER_AUTHORIZATION_TOKEN", token) - - return execCmd(input.Command, input.Args, env) -} - -func execCredentialHelper(input ExecCommandInput, config *vault.Config, credsProvider aws.CredentialsProvider) error { - - // AwsCredentialHelperData is metadata for AWS CLI credential process - // See https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes - type AwsCredentialHelperData struct { - Version int `json:"Version"` - AccessKeyID string `json:"AccessKeyId"` - SecretAccessKey string `json:"SecretAccessKey"` - SessionToken string `json:"SessionToken,omitempty"` - Expiration string `json:"Expiration,omitempty"` - } - - creds, err := credsProvider.Retrieve(context.TODO()) - if err != nil { - return fmt.Errorf("Failed to get credentials for %s: %w", input.ProfileName, err) - } - - credentialData := AwsCredentialHelperData{ - Version: 1, - AccessKeyID: creds.AccessKeyID, - SecretAccessKey: creds.SecretAccessKey, - SessionToken: creds.SessionToken, - } - - if creds.CanExpire { - credentialData.Expiration = iso8601.Format(creds.Expires) - } - - json, err := json.Marshal(&credentialData) - if err != nil { - return fmt.Errorf("Error creating credential json: %w", err) - } - - fmt.Print(string(json)) + cmdEnv.Set("AWS_CONTAINER_CREDENTIALS_FULL_URI", ecsServer.BaseURL()) + cmdEnv.Set("AWS_CONTAINER_AUTHORIZATION_TOKEN", ecsServer.AuthToken()) return nil } -func execEnvironment(input ExecCommandInput, config *vault.Config, credsProvider aws.CredentialsProvider) error { +func addCredsToEnv(credsProvider aws.CredentialsProvider, profileName string, cmdEnv *environ) error { creds, err := credsProvider.Retrieve(context.TODO()) if err != nil { - return fmt.Errorf("Failed to get credentials for %s: %w", input.ProfileName, err) + return fmt.Errorf("Failed to get credentials for %s: %w", profileName, err) } - env := environ(os.Environ()) - env = updateEnvForAwsVault(env, input.ProfileName, config.Region) - log.Println("Setting subprocess env: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY") - env.Set("AWS_ACCESS_KEY_ID", creds.AccessKeyID) - env.Set("AWS_SECRET_ACCESS_KEY", creds.SecretAccessKey) + cmdEnv.Set("AWS_ACCESS_KEY_ID", creds.AccessKeyID) + cmdEnv.Set("AWS_SECRET_ACCESS_KEY", creds.SecretAccessKey) if creds.SessionToken != "" { - log.Println("Setting subprocess env: AWS_SESSION_TOKEN, AWS_SECURITY_TOKEN") - env.Set("AWS_SESSION_TOKEN", creds.SessionToken) - env.Set("AWS_SECURITY_TOKEN", creds.SessionToken) + log.Println("Setting subprocess env: AWS_SESSION_TOKEN") + cmdEnv.Set("AWS_SESSION_TOKEN", creds.SessionToken) } if creds.CanExpire { - log.Println("Setting subprocess env: AWS_SESSION_EXPIRATION") - env.Set("AWS_SESSION_EXPIRATION", iso8601.Format(creds.Expires)) + log.Println("Setting subprocess env: AWS_CREDENTIAL_EXPIRATION") + cmdEnv.Set("AWS_CREDENTIAL_EXPIRATION", iso8601.Format(creds.Expires)) } - if !supportsExecSyscall() { - return execCmd(input.Command, input.Args, env) - } - - return execSyscall(input.Command, input.Args, env) + return nil } // environ is a slice of strings representing the environment, in the form "key=value". @@ -304,8 +321,20 @@ func (e *environ) Set(key, val string) { *e = append(*e, key+"="+val) } -func execCmd(command string, args []string, env []string) error { - log.Printf("Starting child process: %s %s", command, strings.Join(args, " ")) +func getDefaultShell() string { + command := os.Getenv("SHELL") + if command == "" { + if runtime.GOOS == "windows" { + command = "cmd.exe" + } else { + command = "/bin/sh" + } + } + return command +} + +func runSubProcess(command string, args []string, env []string) (int, error) { + log.Printf("Starting a subprocess: %s %s", command, strings.Join(args, " ")) cmd := osexec.Command(command, args...) cmd.Stdin = os.Stdin @@ -317,9 +346,10 @@ func execCmd(command string, args []string, env []string) error { signal.Notify(sigChan) if err := cmd.Start(); err != nil { - return err + return 0, err } + // proxy signals to process go func() { for { sig := <-sigChan @@ -329,19 +359,15 @@ func execCmd(command string, args []string, env []string) error { if err := cmd.Wait(); err != nil { _ = cmd.Process.Signal(os.Kill) - return fmt.Errorf("Failed to wait for command termination: %v", err) + return 0, fmt.Errorf("Failed to wait for command termination: %v", err) } waitStatus := cmd.ProcessState.Sys().(syscall.WaitStatus) - os.Exit(waitStatus.ExitStatus()) - return nil -} -func supportsExecSyscall() bool { - return runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" || runtime.GOOS == "openbsd" + return waitStatus.ExitStatus(), nil } -func execSyscall(command string, args []string, env []string) error { +func doExecSyscall(command string, args []string, env []string) error { log.Printf("Exec command %s %s", command, strings.Join(args, " ")) argv0, err := osexec.LookPath(command) diff --git a/cli/exec_test.go b/cli/exec_test.go index 02d624f7c..c2d259185 100644 --- a/cli/exec_test.go +++ b/cli/exec_test.go @@ -1,7 +1,7 @@ package cli import ( - "github.com/alecthomas/kingpin" + "github.com/alecthomas/kingpin/v2" "github.com/99designs/keyring" ) diff --git a/cli/export.go b/cli/export.go new file mode 100644 index 000000000..82dca9229 --- /dev/null +++ b/cli/export.go @@ -0,0 +1,210 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "time" + + "github.com/99designs/aws-vault/v7/iso8601" + "github.com/99designs/aws-vault/v7/vault" + "github.com/99designs/keyring" + "github.com/alecthomas/kingpin/v2" + "github.com/aws/aws-sdk-go-v2/aws" + ini "gopkg.in/ini.v1" +) + +type ExportCommandInput struct { + ProfileName string + Format string + Config vault.ProfileConfig + SessionDuration time.Duration + NoSession bool + UseStdout bool +} + +var ( + FormatTypeEnv = "env" + FormatTypeExportEnv = "export-env" + FormatTypeExportJSON = "json" + FormatTypeExportINI = "ini" +) + +func ConfigureExportCommand(app *kingpin.Application, a *AwsVault) { + input := ExportCommandInput{} + + cmd := app.Command("export", "Export AWS credentials.") + + cmd.Flag("duration", "Duration of the temporary or assume-role session. Defaults to 1h"). + Short('d'). + DurationVar(&input.SessionDuration) + + cmd.Flag("no-session", "Skip creating STS session with GetSessionToken"). + Short('n'). + BoolVar(&input.NoSession) + + cmd.Flag("region", "The AWS region"). + StringVar(&input.Config.Region) + + cmd.Flag("mfa-token", "The MFA token to use"). + Short('t'). + StringVar(&input.Config.MfaToken) + + cmd.Flag("format", fmt.Sprintf("Format to output credentials. Valid formats: %s, %s, %s, %s", FormatTypeEnv, FormatTypeExportEnv, FormatTypeExportJSON, FormatTypeExportINI)). + Default(FormatTypeEnv). + EnumVar(&input.Format, FormatTypeEnv, FormatTypeExportEnv, FormatTypeExportJSON, FormatTypeExportINI) + + cmd.Flag("stdout", "Print the SSO link to the terminal without automatically opening the browser"). + BoolVar(&input.UseStdout) + + cmd.Arg("profile", "Name of the profile"). + Required(). + HintAction(a.MustGetProfileNames). + StringVar(&input.ProfileName) + + cmd.Action(func(c *kingpin.ParseContext) (err error) { + input.Config.MfaPromptMethod = a.PromptDriver(false) + input.Config.NonChainedGetSessionTokenDuration = input.SessionDuration + input.Config.AssumeRoleDuration = input.SessionDuration + input.Config.SSOUseStdout = input.UseStdout + + f, err := a.AwsConfigFile() + if err != nil { + return err + } + keyring, err := a.Keyring() + if err != nil { + return err + } + + err = ExportCommand(input, f, keyring) + app.FatalIfError(err, "exec") + return nil + }) +} + +func ExportCommand(input ExportCommandInput, f *vault.ConfigFile, keyring keyring.Keyring) error { + if os.Getenv("AWS_VAULT") != "" { + return fmt.Errorf("in an existing aws-vault subshell; 'exit' from the subshell or unset AWS_VAULT to force") + } + + config, err := vault.NewConfigLoader(input.Config, f, input.ProfileName).GetProfileConfig(input.ProfileName) + if err != nil { + return fmt.Errorf("Error loading config: %w", err) + } + + ckr := &vault.CredentialKeyring{Keyring: keyring} + credsProvider, err := vault.NewTempCredentialsProvider(config, ckr, input.NoSession, false) + if err != nil { + return fmt.Errorf("Error getting temporary credentials: %w", err) + } + + if input.Format == FormatTypeExportJSON { + return printJSON(input, credsProvider) + } else if input.Format == FormatTypeExportINI { + return printINI(credsProvider, input.ProfileName, config.Region) + } else if input.Format == FormatTypeExportEnv { + return printEnv(input, credsProvider, config.Region, "export ") + } else { + return printEnv(input, credsProvider, config.Region, "") + } +} + +func printJSON(input ExportCommandInput, credsProvider aws.CredentialsProvider) error { + // AwsCredentialHelperData is metadata for AWS CLI credential process + // See https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes + type AwsCredentialHelperData struct { + Version int `json:"Version"` + AccessKeyID string `json:"AccessKeyId"` + SecretAccessKey string `json:"SecretAccessKey"` + SessionToken string `json:"SessionToken,omitempty"` + Expiration string `json:"Expiration,omitempty"` + } + + creds, err := credsProvider.Retrieve(context.TODO()) + if err != nil { + return fmt.Errorf("Failed to get credentials for %s: %w", input.ProfileName, err) + } + + credentialData := AwsCredentialHelperData{ + Version: 1, + AccessKeyID: creds.AccessKeyID, + SecretAccessKey: creds.SecretAccessKey, + SessionToken: creds.SessionToken, + } + + if creds.CanExpire { + credentialData.Expiration = iso8601.Format(creds.Expires) + } + + json, err := json.MarshalIndent(&credentialData, "", " ") + if err != nil { + return fmt.Errorf("Error creating credential json: %w", err) + } + + fmt.Print(string(json) + "\n") + + return nil +} + +func mustNewKey(s *ini.Section, name, val string) { + if val != "" { + _, err := s.NewKey(name, val) + if err != nil { + log.Fatalln("Failed to create ini key:", err.Error()) + } + } +} + +func printINI(credsProvider aws.CredentialsProvider, profilename, region string) error { + creds, err := credsProvider.Retrieve(context.TODO()) + if err != nil { + return fmt.Errorf("Failed to get credentials for %s: %w", profilename, err) + } + + f := ini.Empty() + s, err := f.NewSection(profilename) + if err != nil { + return fmt.Errorf("Failed to create ini section: %w", err) + } + + mustNewKey(s, "aws_access_key_id", creds.AccessKeyID) + mustNewKey(s, "aws_secret_access_key", creds.SecretAccessKey) + mustNewKey(s, "aws_session_token", creds.SessionToken) + if creds.CanExpire { + mustNewKey(s, "aws_credential_expiration", iso8601.Format(creds.Expires)) + } + mustNewKey(s, "region", region) + + _, err = f.WriteTo(os.Stdout) + if err != nil { + return fmt.Errorf("Failed to output ini: %w", err) + } + + return nil +} + +func printEnv(input ExportCommandInput, credsProvider aws.CredentialsProvider, region, prefix string) error { + creds, err := credsProvider.Retrieve(context.TODO()) + if err != nil { + return fmt.Errorf("Failed to get credentials for %s: %w", input.ProfileName, err) + } + + fmt.Printf("%sAWS_ACCESS_KEY_ID=%s\n", prefix, creds.AccessKeyID) + fmt.Printf("%sAWS_SECRET_ACCESS_KEY=%s\n", prefix, creds.SecretAccessKey) + + if creds.SessionToken != "" { + fmt.Printf("%sAWS_SESSION_TOKEN=%s\n", prefix, creds.SessionToken) + } + if creds.CanExpire { + fmt.Printf("%sAWS_CREDENTIAL_EXPIRATION=%s\n", prefix, iso8601.Format(creds.Expires)) + } + if region != "" { + fmt.Printf("%sAWS_REGION=%s\n", prefix, region) + fmt.Printf("%sAWS_DEFAULT_REGION=%s\n", prefix, region) + } + + return nil +} diff --git a/cli/export_test.go b/cli/export_test.go new file mode 100644 index 000000000..9037923df --- /dev/null +++ b/cli/export_test.go @@ -0,0 +1,25 @@ +package cli + +import ( + "github.com/alecthomas/kingpin/v2" + + "github.com/99designs/keyring" +) + +func ExampleExportCommand() { + app := kingpin.New("aws-vault", "") + awsVault := ConfigureGlobals(app) + awsVault.keyringImpl = keyring.NewArrayKeyring([]keyring.Item{ + {Key: "llamas", Data: []byte(`{"AccessKeyID":"ABC","SecretAccessKey":"XYZ"}`)}, + }) + ConfigureExportCommand(app, awsVault) + kingpin.MustParse(app.Parse([]string{ + "export", "--format=ini", "--no-session", "llamas", + })) + + // Output: + // [llamas] + // aws_access_key_id=ABC + // aws_secret_access_key=XYZ + // region=us-east-1 +} diff --git a/cli/global.go b/cli/global.go index 02593591a..10561f82d 100644 --- a/cli/global.go +++ b/cli/global.go @@ -2,15 +2,17 @@ package cli import ( "fmt" - "io/ioutil" + "io" "log" "os" + "strings" - "github.com/99designs/aws-vault/v6/prompt" - "github.com/99designs/aws-vault/v6/vault" + "github.com/99designs/aws-vault/v7/prompt" + "github.com/99designs/aws-vault/v7/vault" "github.com/99designs/keyring" - "github.com/alecthomas/kingpin" - "golang.org/x/crypto/ssh/terminal" + "github.com/alecthomas/kingpin/v2" + isatty "github.com/mattn/go-isatty" + "golang.org/x/term" ) var keyringConfigDefaults = keyring.Config{ @@ -27,12 +29,36 @@ type AwsVault struct { Debug bool KeyringConfig keyring.Config KeyringBackend string - PromptDriver string + promptDriver string keyringImpl keyring.Keyring awsConfigFile *vault.ConfigFile } +func isATerminal() bool { + fd := os.Stdout.Fd() + return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) +} + +func (a *AwsVault) PromptDriver(avoidTerminalPrompt bool) string { + if a.promptDriver == "" { + a.promptDriver = "terminal" + + if !isATerminal() || avoidTerminalPrompt { + for _, driver := range prompt.Available() { + a.promptDriver = driver + if driver != "terminal" { + break + } + } + } + } + + log.Println("Using prompt driver: " + a.promptDriver) + + return a.promptDriver +} + func (a *AwsVault) Keyring() (keyring.Keyring, error) { if a.keyringImpl == nil { if a.KeyringBackend != "" { @@ -89,9 +115,25 @@ func ConfigureGlobals(app *kingpin.Application) *AwsVault { EnumVar(&a.KeyringBackend, backendsAvailable...) app.Flag("prompt", fmt.Sprintf("Prompt driver to use %v", promptsAvailable)). - Default("terminal"). Envar("AWS_VAULT_PROMPT"). - EnumVar(&a.PromptDriver, promptsAvailable...) + StringVar(&a.promptDriver) + + app.Validate(func(app *kingpin.Application) error { + if a.promptDriver == "" { + return nil + } + if a.promptDriver == "pass" { + kingpin.Fatalf("--prompt=pass (or AWS_VAULT_PROMPT=pass) has been removed from aws-vault as using TOTPs without " + + "a dedicated device goes against security best practices. If you wish to continue using pass, " + + "add `mfa_process = pass otp ` to profiles in your ~/.aws/config file.") + } + for _, v := range promptsAvailable { + if v == a.promptDriver { + return nil + } + } + return fmt.Errorf("--prompt value must be one of %s, got '%s'", strings.Join(promptsAvailable, ","), a.promptDriver) + }) app.Flag("keychain", "Name of macOS keychain to use, if it doesn't exist it will be created"). Default("aws-vault"). @@ -122,7 +164,7 @@ func ConfigureGlobals(app *kingpin.Application) *AwsVault { app.PreAction(func(c *kingpin.ParseContext) error { if !a.Debug { - log.SetOutput(ioutil.Discard) + log.SetOutput(io.Discard) } keyring.Debug = a.Debug log.Printf("aws-vault %s", app.Model().Version) @@ -138,7 +180,7 @@ func fileKeyringPassphrasePrompt(prompt string) (string, error) { } fmt.Fprintf(os.Stderr, "%s: ", prompt) - b, err := terminal.ReadPassword(int(os.Stdin.Fd())) + b, err := term.ReadPassword(int(os.Stdin.Fd())) if err != nil { return "", err } diff --git a/cli/list.go b/cli/list.go index e3cec0746..2a0aae253 100644 --- a/cli/list.go +++ b/cli/list.go @@ -7,9 +7,9 @@ import ( "text/tabwriter" "time" - "github.com/99designs/aws-vault/v6/vault" + "github.com/99designs/aws-vault/v7/vault" "github.com/99designs/keyring" - "github.com/alecthomas/kingpin" + "github.com/alecthomas/kingpin/v2" ) type ListCommandInput struct { @@ -21,7 +21,7 @@ type ListCommandInput struct { func ConfigureListCommand(app *kingpin.Application, a *AwsVault) { input := ListCommandInput{} - cmd := app.Command("list", "List profiles, along with their credentials and sessions") + cmd := app.Command("list", "List profiles, along with their credentials and sessions.") cmd.Alias("ls") cmd.Flag("profiles", "Show only the profile names"). @@ -184,9 +184,5 @@ func ListCommand(input ListCommandInput, awsConfigFile *vault.ConfigFile, keyrin fmt.Fprintf(w, "-\t-\t%s\t\n", s) } - if err = w.Flush(); err != nil { - return err - } - - return nil + return w.Flush() } diff --git a/cli/list_test.go b/cli/list_test.go index ec6d872c9..161757ec5 100644 --- a/cli/list_test.go +++ b/cli/list_test.go @@ -1,7 +1,7 @@ package cli import ( - "github.com/alecthomas/kingpin" + "github.com/alecthomas/kingpin/v2" "github.com/99designs/keyring" ) diff --git a/cli/login.go b/cli/login.go index 3d1b6e65f..7e248d7e9 100644 --- a/cli/login.go +++ b/cli/login.go @@ -4,17 +4,20 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "io" "log" "net/http" "net/url" "strings" "time" - "github.com/99designs/aws-vault/v6/vault" + "github.com/99designs/aws-vault/v7/vault" "github.com/99designs/keyring" - "github.com/alecthomas/kingpin" + "github.com/alecthomas/kingpin/v2" "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/skratchdot/open-golang/open" ) @@ -22,7 +25,7 @@ type LoginCommandInput struct { ProfileName string UseStdout bool Path string - Config vault.Config + Config vault.ProfileConfig SessionDuration time.Duration NoSession bool } @@ -30,7 +33,7 @@ type LoginCommandInput struct { func ConfigureLoginCommand(app *kingpin.Application, a *AwsVault) { input := LoginCommandInput{} - cmd := app.Command("login", "Generate a login link for the AWS Console") + cmd := app.Command("login", "Generate a login link for the AWS Console.") cmd.Flag("duration", "Duration of the assume-role or federated session. Defaults to 1h"). Short('d'). @@ -54,13 +57,12 @@ func ConfigureLoginCommand(app *kingpin.Application, a *AwsVault) { Short('s'). BoolVar(&input.UseStdout) - cmd.Arg("profile", "Name of the profile"). - Required(). + cmd.Arg("profile", "Name of the profile. If none given, credentials will be sourced from env vars"). HintAction(a.MustGetProfileNames). StringVar(&input.ProfileName) cmd.Action(func(c *kingpin.ParseContext) (err error) { - input.Config.MfaPromptMethod = a.PromptDriver + input.Config.MfaPromptMethod = a.PromptDriver(false) input.Config.NonChainedGetSessionTokenDuration = input.SessionDuration input.Config.AssumeRoleDuration = input.SessionDuration input.Config.GetFederationTokenDuration = input.SessionDuration @@ -73,104 +75,106 @@ func ConfigureLoginCommand(app *kingpin.Application, a *AwsVault) { return err } - err = LoginCommand(input, f, keyring) + err = LoginCommand(context.Background(), input, f, keyring) app.FatalIfError(err, "login") return nil }) } -func LoginCommand(input LoginCommandInput, f *vault.ConfigFile, keyring keyring.Keyring) error { - vault.UseSession = !input.NoSession - - configLoader := vault.ConfigLoader{ - File: f, - BaseConfig: input.Config, - ActiveProfile: input.ProfileName, - } - config, err := configLoader.LoadFromProfile(input.ProfileName) - if err != nil { - return fmt.Errorf("Error loading config: %w", err) - } +func getCredsProvider(input LoginCommandInput, config *vault.ProfileConfig, keyring keyring.Keyring) (credsProvider aws.CredentialsProvider, err error) { + if input.ProfileName == "" { + // When no profile is specified, source credentials from the environment + configFromEnv, err := awsconfig.NewEnvConfig() + if err != nil { + return nil, fmt.Errorf("unable to authenticate to AWS through your environment variables: %w", err) + } - var credsProvider aws.CredentialsProvider + if configFromEnv.Credentials.AccessKeyID == "" { + return nil, fmt.Errorf("argument 'profile' not provided, nor any AWS env vars found. Try --help") + } - ckr := &vault.CredentialKeyring{Keyring: keyring} - // If AssumeRole or sso.GetRoleCredentials isn't used, GetFederationToken has to be used for IAM credentials - if config.HasRole() || config.HasSSOStartURL() { - credsProvider, err = vault.NewTempCredentialsProvider(config, ckr) + credsProvider = credentials.StaticCredentialsProvider{Value: configFromEnv.Credentials} } else { - credsProvider, err = vault.NewFederationTokenCredentialsProvider(input.ProfileName, ckr, config) - } - if err != nil { - return fmt.Errorf("profile %s: %w", input.ProfileName, err) + // Use a profile from the AWS config file + ckr := &vault.CredentialKeyring{Keyring: keyring} + t := vault.TempCredentialsCreator{ + Keyring: ckr, + DisableSessions: input.NoSession, + DisableSessionsForProfile: config.ProfileName, + } + credsProvider, err = t.GetProviderForProfile(config) + if err != nil { + return nil, fmt.Errorf("profile %s: %w", input.ProfileName, err) + } } - creds, err := credsProvider.Retrieve(context.TODO()) + return credsProvider, err +} + +// LoginCommand creates a login URL for the AWS Management Console using the method described at +// https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html +func LoginCommand(ctx context.Context, input LoginCommandInput, f *vault.ConfigFile, keyring keyring.Keyring) error { + config, err := vault.NewConfigLoader(input.Config, f, input.ProfileName).GetProfileConfig(input.ProfileName) if err != nil { - return fmt.Errorf("Failed to get credentials for %s: %w", config.ProfileName, err) + return fmt.Errorf("Error loading config: %w", err) } - jsonBytes, err := json.Marshal(map[string]string{ - "sessionId": creds.AccessKeyID, - "sessionKey": creds.SecretAccessKey, - "sessionToken": creds.SessionToken, - }) + credsProvider, err := getCredsProvider(input, config, keyring) if err != nil { return err } - loginURLPrefix, destination := generateLoginURL(config.Region, input.Path) - - req, err := http.NewRequest("GET", loginURLPrefix, nil) + // if we already know the type of credentials being created, avoid calling isCallerIdentityAssumedRole + canCredsBeUsedInLoginURL, err := canProviderBeUsedForLogin(credsProvider) if err != nil { return err } - if creds.CanExpire { - log.Printf("Creating login token, expires in %s", time.Until(creds.Expires)) - } + if !canCredsBeUsedInLoginURL { + // use a static creds provider so that we don't request credentials from AWS more than once + credsProvider, err = createStaticCredentialsProvider(ctx, credsProvider) + if err != nil { + return err + } - q := req.URL.Query() - q.Add("Action", "getSigninToken") - q.Add("Session", string(jsonBytes)) - req.URL.RawQuery = q.Encode() + // if the credentials have come from an unknown source like credential_process, check the + // caller identity to see if it's an assumed role + isAssumedRole, err := isCallerIdentityAssumedRole(ctx, credsProvider, config) + if err != nil { + return err + } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err + if !isAssumedRole { + log.Println("Creating a federated session") + credsProvider, err = vault.NewFederationTokenProvider(ctx, credsProvider, config) + if err != nil { + return err + } + } } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) + creds, err := credsProvider.Retrieve(ctx) if err != nil { return err } - if resp.StatusCode != http.StatusOK { - log.Printf("Response body was %s", body) - return fmt.Errorf("Call to getSigninToken failed with %v", resp.Status) + if creds.CanExpire { + log.Printf("Requesting a signin token for session expiring in %s", time.Until(creds.Expires)) } - var respParsed map[string]string - - err = json.Unmarshal([]byte(body), &respParsed) + loginURLPrefix, destination := generateLoginURL(config.Region, input.Path) + signinToken, err := requestSigninToken(ctx, creds, loginURLPrefix) if err != nil { return err } - signinToken, ok := respParsed["SigninToken"] - if !ok { - return fmt.Errorf("Expected a response with SigninToken") - } - loginURL := fmt.Sprintf("%s?Action=login&Issuer=aws-vault&Destination=%s&SigninToken=%s", loginURLPrefix, url.QueryEscape(destination), url.QueryEscape(signinToken)) if input.UseStdout { fmt.Println(loginURL) } else if err = open.Run(loginURL); err != nil { - log.Println(err) - fmt.Println(loginURL) + return fmt.Errorf("Failed to open %s: %w", loginURL, err) } return nil @@ -200,3 +204,99 @@ func generateLoginURL(region string, path string) (string, string) { } return loginURLPrefix, destination } + +func isCallerIdentityAssumedRole(ctx context.Context, credsProvider aws.CredentialsProvider, config *vault.ProfileConfig) (bool, error) { + cfg := vault.NewAwsConfigWithCredsProvider(credsProvider, config.Region, config.STSRegionalEndpoints) + client := sts.NewFromConfig(cfg) + id, err := client.GetCallerIdentity(ctx, nil) + if err != nil { + return false, err + } + arn := aws.ToString(id.Arn) + arnParts := strings.Split(arn, ":") + if len(arnParts) < 6 { + return false, fmt.Errorf("unable to parse ARN: %s", arn) + } + if strings.HasPrefix(arnParts[5], "assumed-role") { + return true, nil + } + return false, nil +} + +func createStaticCredentialsProvider(ctx context.Context, credsProvider aws.CredentialsProvider) (sc credentials.StaticCredentialsProvider, err error) { + creds, err := credsProvider.Retrieve(ctx) + if err != nil { + return sc, err + } + return credentials.StaticCredentialsProvider{Value: creds}, nil +} + +// canProviderBeUsedForLogin returns true if the credentials produced by the provider is known to be usable by the login URL endpoint +func canProviderBeUsedForLogin(credsProvider aws.CredentialsProvider) (bool, error) { + if _, ok := credsProvider.(*vault.AssumeRoleProvider); ok { + return true, nil + } + if _, ok := credsProvider.(*vault.SSORoleCredentialsProvider); ok { + return true, nil + } + if _, ok := credsProvider.(*vault.AssumeRoleWithWebIdentityProvider); ok { + return true, nil + } + if c, ok := credsProvider.(*vault.CachedSessionProvider); ok { + return canProviderBeUsedForLogin(c.SessionProvider) + } + + return false, nil +} + +// Create a signin token +func requestSigninToken(ctx context.Context, creds aws.Credentials, loginURLPrefix string) (string, error) { + jsonSession, err := json.Marshal(map[string]string{ + "sessionId": creds.AccessKeyID, + "sessionKey": creds.SecretAccessKey, + "sessionToken": creds.SessionToken, + }) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, "GET", loginURLPrefix, nil) + if err != nil { + return "", err + } + + q := req.URL.Query() + q.Add("Action", "getSigninToken") + q.Add("Session", string(jsonSession)) + req.URL.RawQuery = q.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + log.Printf("Response body was %s", body) + return "", fmt.Errorf("Call to getSigninToken failed with %v", resp.Status) + } + + var respParsed map[string]string + + err = json.Unmarshal(body, &respParsed) + if err != nil { + return "", err + } + + signinToken, ok := respParsed["SigninToken"] + if !ok { + return "", fmt.Errorf("Expected a response with SigninToken") + } + + return signinToken, nil +} diff --git a/cli/proxy.go b/cli/proxy.go index 277b8ec83..1ab260614 100644 --- a/cli/proxy.go +++ b/cli/proxy.go @@ -5,14 +5,14 @@ import ( "os/signal" "syscall" - "github.com/99designs/aws-vault/v6/server" - "github.com/alecthomas/kingpin" + "github.com/99designs/aws-vault/v7/server" + "github.com/alecthomas/kingpin/v2" ) -func ConfigureProxyCommand(app *kingpin.Application, a *AwsVault) { +func ConfigureProxyCommand(app *kingpin.Application) { stop := false - cmd := app.Command("proxy", "Start a proxy for the ec2 instance role server locally"). + cmd := app.Command("proxy", "Start a proxy for the ec2 instance role server locally."). Alias("server"). Hidden() @@ -23,10 +23,9 @@ func ConfigureProxyCommand(app *kingpin.Application, a *AwsVault) { if stop { server.StopProxy() return nil - } else { - handleSigTerm() - return server.StartProxy() } + handleSigTerm() + return server.StartProxy() }) } diff --git a/cli/remove.go b/cli/remove.go index fd2b965d7..d9d7f47f0 100644 --- a/cli/remove.go +++ b/cli/remove.go @@ -4,10 +4,10 @@ import ( "fmt" "strings" - "github.com/99designs/aws-vault/v6/prompt" - "github.com/99designs/aws-vault/v6/vault" + "github.com/99designs/aws-vault/v7/prompt" + "github.com/99designs/aws-vault/v7/vault" "github.com/99designs/keyring" - "github.com/alecthomas/kingpin" + "github.com/alecthomas/kingpin/v2" ) type RemoveCommandInput struct { @@ -19,7 +19,7 @@ type RemoveCommandInput struct { func ConfigureRemoveCommand(app *kingpin.Application, a *AwsVault) { input := RemoveCommandInput{} - cmd := app.Command("remove", "Removes credentials from the secure keystore") + cmd := app.Command("remove", "Remove credentials from the secure keystore.") cmd.Alias("rm") cmd.Arg("profile", "Name of the profile"). diff --git a/cli/rotate.go b/cli/rotate.go index 1fc2e256f..372e1f1f9 100644 --- a/cli/rotate.go +++ b/cli/rotate.go @@ -6,9 +6,9 @@ import ( "log" "time" - "github.com/99designs/aws-vault/v6/vault" + "github.com/99designs/aws-vault/v7/vault" "github.com/99designs/keyring" - "github.com/alecthomas/kingpin" + "github.com/alecthomas/kingpin/v2" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/iam" ) @@ -16,13 +16,13 @@ import ( type RotateCommandInput struct { NoSession bool ProfileName string - Config vault.Config + Config vault.ProfileConfig } func ConfigureRotateCommand(app *kingpin.Application, a *AwsVault) { input := RotateCommandInput{} - cmd := app.Command("rotate", "Rotates credentials") + cmd := app.Command("rotate", "Rotate credentials.") cmd.Flag("no-session", "Use master credentials, no session or role used"). Short('n'). @@ -34,7 +34,7 @@ func ConfigureRotateCommand(app *kingpin.Application, a *AwsVault) { StringVar(&input.ProfileName) cmd.Action(func(c *kingpin.ParseContext) (err error) { - input.Config.MfaPromptMethod = a.PromptDriver + input.Config.MfaPromptMethod = a.PromptDriver(false) keyring, err := a.Keyring() if err != nil { return err @@ -51,16 +51,8 @@ func ConfigureRotateCommand(app *kingpin.Application, a *AwsVault) { } func RotateCommand(input RotateCommandInput, f *vault.ConfigFile, keyring keyring.Keyring) error { - // Can't disable sessions completely, might need to use session for MFA-Protected API Access - vault.UseSession = !input.NoSession - vault.UseSessionCache = false - - configLoader := &vault.ConfigLoader{ - File: f, - BaseConfig: input.Config, - ActiveProfile: input.ProfileName, - } - config, err := configLoader.LoadFromProfile(input.ProfileName) + configLoader := vault.NewConfigLoader(input.Config, f, input.ProfileName) + config, err := configLoader.GetProfileConfig(input.ProfileName) if err != nil { return fmt.Errorf("Error loading config: %w", err) } @@ -92,7 +84,8 @@ func RotateCommand(input RotateCommandInput, f *vault.ConfigFile, keyring keyrin if input.NoSession { credsProvider = vault.NewMasterCredentialsProvider(ckr, config.ProfileName) } else { - credsProvider, err = vault.NewTempCredentialsProvider(config, ckr) + // Can't always disable sessions completely, might need to use session for MFA-Protected API Access + credsProvider, err = vault.NewTempCredentialsProvider(config, ckr, input.NoSession, true) if err != nil { return fmt.Errorf("Error getting temporary credentials: %w", err) } @@ -101,7 +94,7 @@ func RotateCommand(input RotateCommandInput, f *vault.ConfigFile, keyring keyrin cfg := vault.NewAwsConfigWithCredsProvider(credsProvider, config.Region, config.STSRegionalEndpoints) // A username is needed for some IAM calls if the credentials have assumed a role - iamUserName, err := getUsernameIfAssumingRole(cfg, config) + iamUserName, err := getUsernameIfAssumingRole(context.TODO(), cfg, config) if err != nil { return err } @@ -162,7 +155,7 @@ func retry(maxTime time.Duration, sleep time.Duration, f func() error) (err erro err = f() if err == nil { - return + return // nolint } elapsed := time.Since(t0) @@ -175,22 +168,22 @@ func retry(maxTime time.Duration, sleep time.Duration, f func() error) (err erro } } -func getUsernameIfAssumingRole(awsCfg aws.Config, config *vault.Config) (*string, error) { +func getUsernameIfAssumingRole(ctx context.Context, awsCfg aws.Config, config *vault.ProfileConfig) (*string, error) { if config.RoleARN != "" { - n, err := vault.GetUsernameFromSession(awsCfg) + n, err := vault.GetUsernameFromSession(ctx, awsCfg) if err != nil { return nil, fmt.Errorf("Error getting IAM username from session: %w", err) } log.Printf("Found IAM username '%s'", n) return &n, nil } - return nil, nil + return nil, nil //nolint } func getProfilesInChain(profileName string, configLoader *vault.ConfigLoader) (profileNames []string, err error) { profileNames = append(profileNames, profileName) - config, err := configLoader.LoadFromProfile(profileName) + config, err := configLoader.GetProfileConfig(profileName) if err != nil { return profileNames, err } diff --git a/contrib/_aws-vault-proxy/Dockerfile b/contrib/_aws-vault-proxy/Dockerfile new file mode 100644 index 000000000..40e40650f --- /dev/null +++ b/contrib/_aws-vault-proxy/Dockerfile @@ -0,0 +1,5 @@ +FROM golang:1.17 +WORKDIR /usr/src/aws-vault-proxy +COPY . /usr/src/aws-vault-proxy +RUN go build -v -o /usr/local/bin/aws-vault-proxy ./... +CMD ["/usr/local/bin/aws-vault-proxy"] diff --git a/contrib/_aws-vault-proxy/docker-compose.yml b/contrib/_aws-vault-proxy/docker-compose.yml new file mode 100644 index 000000000..657a9e15b --- /dev/null +++ b/contrib/_aws-vault-proxy/docker-compose.yml @@ -0,0 +1,28 @@ +version: "2.4" +networks: + aws-vault: + driver: bridge + ipam: + config: + - subnet: "169.254.170.0/24" + gateway: "169.254.170.1" +services: + aws-vault-proxy: + build: . + environment: + - AWS_CONTAINER_CREDENTIALS_FULL_URI + - AWS_CONTAINER_AUTHORIZATION_TOKEN + networks: + aws-vault: + ipv4_address: "169.254.170.2" # This special IP address is recognized by the AWS SDKs and AWS CLI + healthcheck: + test: pgrep aws-vault-proxy + testapp: + image: amazon/aws-cli + entrypoint: "" + command: /bin/bash + environment: + - AWS_CONTAINER_CREDENTIALS_RELATIVE_URI + networks: + aws-vault: {} + default: {} diff --git a/contrib/_aws-vault-proxy/go.mod b/contrib/_aws-vault-proxy/go.mod new file mode 100644 index 000000000..8bc6e921e --- /dev/null +++ b/contrib/_aws-vault-proxy/go.mod @@ -0,0 +1,7 @@ +module aws-vault-ecs-server-reverse-proxy + +go 1.17 + +require github.com/gorilla/handlers v1.5.1 + +require github.com/felixge/httpsnoop v1.0.1 // indirect diff --git a/contrib/_aws-vault-proxy/go.sum b/contrib/_aws-vault-proxy/go.sum new file mode 100644 index 000000000..f27a04f83 --- /dev/null +++ b/contrib/_aws-vault-proxy/go.sum @@ -0,0 +1,4 @@ +github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= diff --git a/contrib/_aws-vault-proxy/main.go b/contrib/_aws-vault-proxy/main.go new file mode 100644 index 000000000..02a0c34a6 --- /dev/null +++ b/contrib/_aws-vault-proxy/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + + "github.com/gorilla/handlers" +) + +func GetReverseProxyTarget() *url.URL { + url, err := url.Parse(os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI")) + if err != nil { + log.Fatalln("Bad AWS_CONTAINER_CREDENTIALS_FULL_URI:", err.Error()) + } + url.Host = "host.docker.internal:" + url.Port() + return url +} + +func addAuthorizationHeader(authToken string, next http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r.Header.Add("Authorization", authToken) + next.ServeHTTP(w, r) + } +} + +func main() { + target := GetReverseProxyTarget() + authToken := os.Getenv("AWS_CONTAINER_AUTHORIZATION_TOKEN") + log.Printf("reverse proxying target:%s auth:%s\n", target, authToken) + + handler := handlers.LoggingHandler(os.Stderr, + addAuthorizationHeader(authToken, + httputil.NewSingleHostReverseProxy(target))) + + _ = http.ListenAndServe(":80", handler) +} diff --git a/contrib/scripts/aws-iam-create-yubikey-mfa.sh b/contrib/scripts/aws-iam-create-yubikey-mfa.sh index e5834b5bf..76f0bf2ff 100755 --- a/contrib/scripts/aws-iam-create-yubikey-mfa.sh +++ b/contrib/scripts/aws-iam-create-yubikey-mfa.sh @@ -1,5 +1,6 @@ #!/bin/sh -# Adds a Yubikey TOTP device to IAM +# Adds a Yubikey TOTP device to IAM using your IAM User as the $MFA_DEVICE_NAME +# Currently, aws iam enable-mfa-device doesn't support specifying your MFA Device Name. set -eu diff --git a/contrib/scripts/aws-iam-resync-yubikey-mfa.sh b/contrib/scripts/aws-iam-resync-yubikey-mfa.sh index 4625a76ee..4ce8d6519 100755 --- a/contrib/scripts/aws-iam-resync-yubikey-mfa.sh +++ b/contrib/scripts/aws-iam-resync-yubikey-mfa.sh @@ -1,5 +1,6 @@ #!/bin/sh -# Resync a Yubikey TOTP device to IAM +# Resync a Yubikey TOTP device to IAM using your IAM User as the $MFA_DEVICE_NAME +# Currently, aws iam resync-mfa-device doesn't support specifying your MFA Device Name. set -eu diff --git a/go.mod b/go.mod index 324c796da..70522e8f0 100644 --- a/go.mod +++ b/go.mod @@ -1,34 +1,39 @@ -module github.com/99designs/aws-vault/v6 +module github.com/99designs/aws-vault/v7 -go 1.17 +go 1.20 require ( - github.com/99designs/keyring v1.2.0 - github.com/alecthomas/kingpin v0.0.0-20200323085623-b6657d9477a6 - github.com/aws/aws-sdk-go-v2 v1.13.0 - github.com/aws/aws-sdk-go-v2/service/iam v1.16.0 - github.com/aws/aws-sdk-go-v2/service/sso v1.9.0 - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.10.0 - github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 - github.com/google/go-cmp v0.5.6 + github.com/99designs/keyring v1.2.2 + github.com/alecthomas/kingpin/v2 v2.3.2 + github.com/aws/aws-sdk-go-v2 v1.17.7 + github.com/aws/aws-sdk-go-v2/config v1.18.19 + github.com/aws/aws-sdk-go-v2/credentials v1.13.18 + github.com/aws/aws-sdk-go-v2/service/iam v1.19.8 + github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 + github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 + github.com/google/go-cmp v0.5.9 + github.com/mattn/go-isatty v0.0.18 + github.com/mattn/go-tty v0.0.4 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 - golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 - golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 - gopkg.in/ini.v1 v1.66.3 + golang.org/x/term v0.6.0 + gopkg.in/ini.v1 v1.67.0 ) require ( github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect - github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 // indirect - github.com/aws/smithy-go v1.10.0 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 // indirect + github.com/aws/smithy-go v1.13.5 // indirect github.com/danieljoos/wincred v1.1.2 // indirect github.com/dvsekhvalnov/jose2go v1.5.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/mtibben/percent v0.2.1 // indirect + github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + golang.org/x/sys v0.6.0 // indirect ) diff --git a/go.sum b/go.sum index 109aa656a..605baf9a8 100644 --- a/go.sum +++ b/go.sum @@ -1,45 +1,48 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= -github.com/99designs/keyring v1.2.0 h1:MbMWdfhzVw1DfC7ak+PGnWiu1b1oUkiQANyfAEMVaPA= -github.com/99designs/keyring v1.2.0/go.mod h1:ETJn2A9cfvJKq1Q4FeOc+eetK52Ik0kUGog7Uy+xvX8= -github.com/alecthomas/kingpin v0.0.0-20200323085623-b6657d9477a6 h1:0fwkEPHxb5V+KZZLxWmOknl4oHWo60+TnhmKOi4BIkU= -github.com/alecthomas/kingpin v0.0.0-20200323085623-b6657d9477a6/go.mod h1:b6br6/pDFSfMkBgC96TbpOji05q5pa+v5rIlS0Y6XtI= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= +github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= +github.com/alecthomas/kingpin/v2 v2.3.2 h1:H0aULhgmSzN8xQ3nX1uxtdlTHYoPLu5AhHxWrKI6ocU= +github.com/alecthomas/kingpin/v2 v2.3.2/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= -github.com/aws/aws-sdk-go-v2 v1.13.0 h1:1XIXAfxsEmbhbj5ry3D3vX+6ZcUYvIqSm4CWWEuGZCA= -github.com/aws/aws-sdk-go-v2 v1.13.0/go.mod h1:L6+ZpqHaLbAaxsqV0L4cvxZY7QupWJB4fhkf8LXvC7w= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4 h1:CRiQJ4E2RhfDdqbie1ZYDo8QtIo75Mk7oTdJSfwJTMQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4/go.mod h1:XHgQ7Hz2WY2GAn//UXHofLfPXWh+s62MbMOijrg12Lw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0 h1:3ADoioDMOtF4uiK59vCpplpCwugEU+v4ZFD29jDL3RQ= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0/go.mod h1:BsCSJHx5DnDXIrOcqB8KN1/B+hXLG/bi4Y6Vjcx/x9E= -github.com/aws/aws-sdk-go-v2/service/iam v1.16.0 h1:A4sCxN1jRqmF90FXjYpai1H4z2jeii4USIh12PAv9VQ= -github.com/aws/aws-sdk-go-v2/service/iam v1.16.0/go.mod h1:Nz3L2VG2bK1gJqZejQpBNpMHORGHre5GRAC2v8v8ZDM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 h1:4QAOB3KrvI1ApJK14sliGr3Ie2pjyvNypn/lfzDHfUw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0/go.mod h1:K/qPe6AP2TGYv4l6n7c88zh9jWBDf6nHhvg1fx/EWfU= -github.com/aws/aws-sdk-go-v2/service/sso v1.9.0 h1:1qLJeQGBmNQW3mBNzK2CFmrQNmoXWrscPqsrAaU1aTA= -github.com/aws/aws-sdk-go-v2/service/sso v1.9.0/go.mod h1:vCV4glupK3tR7pw7ks7Y4jYRL86VvxS+g5qk04YeWrU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.10.0 h1:RxUpNEWDczDplbjNsrrDqh7D5RLaqSTcor7QOets/LY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.10.0/go.mod h1:IF/CmGmVhuN32BZCByapqjxTjM4GWuRgofb07XL4qbM= -github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 h1:ksiDXhvNYg0D2/UFkLejsaz3LqpW5yjNQ8Nx9Sn2c0E= -github.com/aws/aws-sdk-go-v2/service/sts v1.14.0/go.mod h1:u0xMJKDvvfocRjiozsoZglVNXRG19043xzp3r2ivLIk= -github.com/aws/smithy-go v1.10.0 h1:gsoZQMNHnX+PaghNw4ynPsyGP7aUCqx5sY2dlPQsZ0w= -github.com/aws/smithy-go v1.10.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= +github.com/aws/aws-sdk-go-v2 v1.17.7 h1:CLSjnhJSTSogvqUGhIC6LqFKATMRexcxLZ0i/Nzk9Eg= +github.com/aws/aws-sdk-go-v2 v1.17.7/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= +github.com/aws/aws-sdk-go-v2/config v1.18.19 h1:AqFK6zFNtq4i1EYu+eC7lcKHYnZagMn6SW171la0bGw= +github.com/aws/aws-sdk-go-v2/config v1.18.19/go.mod h1:XvTmGMY8d52ougvakOv1RpiTLPz9dlG/OQHsKU/cMmY= +github.com/aws/aws-sdk-go-v2/credentials v1.13.18 h1:EQMdtHwz0ILTW1hoP+EwuWhwCG1hD6l3+RWFQABET4c= +github.com/aws/aws-sdk-go-v2/credentials v1.13.18/go.mod h1:vnwlwjIe+3XJPBYKu1et30ZPABG3VaXJYr8ryohpIyM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 h1:gt57MN3liKiyGopcqgNzJb2+d9MJaKT/q1OksHNXVE4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1/go.mod h1:lfUx8puBRdM5lVVMQlwt2v+ofiG/X6Ms+dy0UkG/kXw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 h1:sJLYcS+eZn5EeNINGHSCRAwUJMFVqklwkH36Vbyai7M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31/go.mod h1:QT0BqUvX1Bh2ABdTGnjqEjvjzrCfIniM9Sc8zn9Yndo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 h1:1mnRASEKnkqsntcxHaysxwgVoUUp5dkiB+l3llKnqyg= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25/go.mod h1:zBHOPwhBc3FlQjQJE/D3IfPWiWaQmT06Vq9aNukDo0k= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 h1:p5luUImdIqywn6JpQsW3tq5GNOxKmOnEpybzPx+d1lk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32/go.mod h1:XGhIBZDEgfqmFIugclZ6FU7v75nHhBDtzuB4xB/tEi4= +github.com/aws/aws-sdk-go-v2/service/iam v1.19.8 h1:kQsBeGgm68kT0xc90spgC5qEOQGH74V2bFqgBgG21Bo= +github.com/aws/aws-sdk-go-v2/service/iam v1.19.8/go.mod h1:lf/oAjt//UvPsmnOgPT61F+q4K6U0q4zDd1s1yx2NZs= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 h1:5LHn8JQ0qvjD9L9JhMtylnkcw7j05GDZqM9Oin6hpr0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25/go.mod h1:/95IA+0lMnzW6XzqYJRpjjsAbKEORVeO0anQqjd2CNU= +github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 h1:5V7DWLBd7wTELVz5bPpwzYy/sikk0gsgZfj40X+l5OI= +github.com/aws/aws-sdk-go-v2/service/sso v1.12.6/go.mod h1:Y1VOmit/Fn6Tz1uFAeCO6Q7M2fmfXSCLeL5INVYsLuY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 h1:B8cauxOH1W1v7rd8RdI/MWnoR4Ze0wIHWrb90qczxj4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6/go.mod h1:Lh/bc9XUf8CfOY6Jp5aIkQtN+j1mc+nExc+KXj9jx2s= +github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 h1:bWNgNdRko2x6gqa0blfATqAZKZokPIeM1vfmQt2pnvM= +github.com/aws/aws-sdk-go-v2/service/sts v1.18.7/go.mod h1:JuTnSoeePXmMVe9G8NcjjwgOKEfZ4cOjMuT2IBT/2eI= +github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= +github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM= github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= @@ -47,6 +50,14 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-tty v0.0.4 h1:NVikla9X8MN0SQAqCYzpGyXv0jY7MNl3HOWD2dkle7E= +github.com/mattn/go-tty v0.0.4/go.mod h1:u5GGXBtZU6RQoKV8gY5W6UhMudbR5vXnUe7j3pxse28= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= @@ -57,35 +68,25 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= -github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 h1:71vQrMauZZhcTVK6KdYM+rklehEEwb3E+ZhaE5jrPrE= -golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a h1:ppl5mZgokTT8uPkmYOyEUmPTr3ypaKkg5eFOGrAmxxE= -golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.66.3 h1:jRskFVxYaMGAMUbN0UZ7niA9gzL9B49DOqE78vg0k3w= -gopkg.in/ini.v1 v1.66.3/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go index 7e1f10fa5..fc6b1f708 100644 --- a/main.go +++ b/main.go @@ -3,8 +3,8 @@ package main import ( "os" - "github.com/99designs/aws-vault/v6/cli" - "github.com/alecthomas/kingpin" + "github.com/99designs/aws-vault/v7/cli" + "github.com/alecthomas/kingpin/v2" ) // Version is provided at compile time @@ -16,13 +16,14 @@ func main() { a := cli.ConfigureGlobals(app) cli.ConfigureAddCommand(app, a) + cli.ConfigureRemoveCommand(app, a) cli.ConfigureListCommand(app, a) cli.ConfigureRotateCommand(app, a) cli.ConfigureExecCommand(app, a) + cli.ConfigureExportCommand(app, a) cli.ConfigureClearCommand(app, a) - cli.ConfigureRemoveCommand(app, a) cli.ConfigureLoginCommand(app, a) - cli.ConfigureProxyCommand(app, a) + cli.ConfigureProxyCommand(app) kingpin.MustParse(app.Parse(os.Args[1:])) } diff --git a/prompt/kdialog.go b/prompt/kdialog.go index 352136132..576b43b39 100644 --- a/prompt/kdialog.go +++ b/prompt/kdialog.go @@ -1,9 +1,8 @@ package prompt import ( + "os/exec" "strings" - - exec "golang.org/x/sys/execabs" ) func KDialogMfaPrompt(mfaSerial string) (string, error) { @@ -18,5 +17,7 @@ func KDialogMfaPrompt(mfaSerial string) (string, error) { } func init() { - Methods["kdialog"] = KDialogMfaPrompt + if _, err := exec.LookPath("kdialog"); err == nil { + Methods["kdialog"] = KDialogMfaPrompt + } } diff --git a/prompt/osascript.go b/prompt/osascript.go index 1979c2bef..559fa30e5 100644 --- a/prompt/osascript.go +++ b/prompt/osascript.go @@ -22,5 +22,7 @@ func OSAScriptMfaPrompt(mfaSerial string) (string, error) { } func init() { - Methods["osascript"] = OSAScriptMfaPrompt + if _, err := exec.LookPath("osascript"); err == nil { + Methods["osascript"] = OSAScriptMfaPrompt + } } diff --git a/prompt/passotp.go b/prompt/passotp.go deleted file mode 100644 index a7ebc34e9..000000000 --- a/prompt/passotp.go +++ /dev/null @@ -1,35 +0,0 @@ -package prompt - -import ( - "fmt" - "log" - "os" - "strings" - - exec "golang.org/x/sys/execabs" -) - -// PassOTPProvider uses the pass otp extension to generate a OATH-TOTP token -// To set up pass otp, first create a pass otp credential with a name of your -// mfaSerial, or set PASS_OATH_CREDENTIAL_NAME. -func PassMfaProvider(mfaSerial string) (string, error) { - passOathCredName := os.Getenv("PASS_OATH_CREDENTIAL_NAME") - if passOathCredName == "" { - passOathCredName = mfaSerial - } - - log.Printf("Fetching MFA code using `pass otp %s`", passOathCredName) - cmd := exec.Command("pass", "otp", passOathCredName) - cmd.Stderr = os.Stderr - - out, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("pass: %w", err) - } - - return strings.TrimSpace(string(out)), nil -} - -func init() { - Methods["pass"] = PassMfaProvider -} diff --git a/prompt/prompt.go b/prompt/prompt.go index e37273de2..55dab17aa 100644 --- a/prompt/prompt.go +++ b/prompt/prompt.go @@ -5,9 +5,9 @@ import ( "sort" ) -type PromptFunc func(string) (string, error) +type Func func(string) (string, error) -var Methods = map[string]PromptFunc{} +var Methods = map[string]Func{} func Available() []string { methods := []string{} @@ -18,7 +18,7 @@ func Available() []string { return methods } -func Method(s string) PromptFunc { +func Method(s string) Func { m, ok := Methods[s] if !ok { panic(fmt.Sprintf("Prompt method %q doesn't exist", s)) diff --git a/prompt/terminal.go b/prompt/terminal.go index 5aafbdd21..2fa80e1aa 100644 --- a/prompt/terminal.go +++ b/prompt/terminal.go @@ -1,19 +1,22 @@ package prompt import ( - "bufio" "fmt" - "os" "strings" - "golang.org/x/term" + "github.com/mattn/go-tty" ) func TerminalPrompt(message string) (string, error) { - fmt.Fprint(os.Stderr, message) + tty, err := tty.Open() + if err != nil { + return "", err + } + defer tty.Close() - reader := bufio.NewReader(os.Stdin) - text, err := reader.ReadString('\n') + fmt.Fprint(tty.Output(), message) + + text, err := tty.ReadString() if err != nil { return "", err } @@ -22,16 +25,20 @@ func TerminalPrompt(message string) (string, error) { } func TerminalSecretPrompt(message string) (string, error) { - fmt.Fprint(os.Stderr, message) - - text, err := term.ReadPassword(int(os.Stdin.Fd())) + tty, err := tty.Open() if err != nil { return "", err } + defer tty.Close() - fmt.Println() + fmt.Fprint(tty.Output(), message) - return strings.TrimSpace(string(text)), nil + text, err := tty.ReadPassword() + if err != nil { + return "", err + } + + return strings.TrimSpace(text), nil } func TerminalMfaPrompt(mfaSerial string) (string, error) { diff --git a/prompt/ykman.go b/prompt/ykman.go index 82eea3096..5c32ac50b 100644 --- a/prompt/ykman.go +++ b/prompt/ykman.go @@ -4,9 +4,8 @@ import ( "fmt" "log" "os" + "os/exec" "strings" - - exec "golang.org/x/sys/execabs" ) // YkmanProvider runs ykman to generate a OATH-TOTP token from the Yubikey device @@ -47,5 +46,7 @@ func YkmanMfaProvider(mfaSerial string) (string, error) { } func init() { - Methods["ykman"] = YkmanMfaProvider + if _, err := exec.LookPath("ykman"); err == nil { + Methods["ykman"] = YkmanMfaProvider + } } diff --git a/prompt/zenity.go b/prompt/zenity.go index 21f1c20c3..8b3b234a9 100644 --- a/prompt/zenity.go +++ b/prompt/zenity.go @@ -1,9 +1,8 @@ package prompt import ( + "os/exec" "strings" - - exec "golang.org/x/sys/execabs" ) func ZenityMfaPrompt(mfaSerial string) (string, error) { @@ -18,5 +17,7 @@ func ZenityMfaPrompt(mfaSerial string) (string, error) { } func init() { - Methods["zenity"] = ZenityMfaPrompt + if _, err := exec.LookPath("zenity"); err == nil { + Methods["zenity"] = ZenityMfaPrompt + } } diff --git a/server/ec2alias_windows.go b/server/ec2alias_windows.go index d72ddafc7..adf948f75 100644 --- a/server/ec2alias_windows.go +++ b/server/ec2alias_windows.go @@ -5,9 +5,8 @@ package server import ( "fmt" + "os/exec" "strings" - - exec "golang.org/x/sys/execabs" ) var alreadyRegisteredLocalised = []string{ diff --git a/server/ec2proxy.go b/server/ec2proxy.go index 2cb0ecc35..ca8b92882 100644 --- a/server/ec2proxy.go +++ b/server/ec2proxy.go @@ -45,7 +45,7 @@ func StartProxy() error { return http.Serve(l, handler) } -func isProxyRunning() bool { +func IsProxyRunning() bool { _, err := net.DialTimeout("tcp", ec2MetadataEndpointAddr, time.Millisecond*10) return err == nil } @@ -60,7 +60,7 @@ func Shutdown() { // StopProxy stops the http proxy server on the standard EC2 Instance Metadata endpoint func StopProxy() { - _, _ = http.Get(fmt.Sprintf("http://%s/stop", ec2MetadataEndpointAddr)) + _, _ = http.Get(fmt.Sprintf("http://%s/stop", ec2MetadataEndpointAddr)) //nolint } func awsVaultExecutable() string { diff --git a/server/ec2proxy_default.go b/server/ec2proxy_default.go index aecd090f1..6a6d2a46a 100644 --- a/server/ec2proxy_default.go +++ b/server/ec2proxy_default.go @@ -7,9 +7,8 @@ import ( "errors" "log" "os" + "os/exec" "time" - - exec "golang.org/x/sys/execabs" ) // StartEc2EndpointProxyServerProcess starts a `aws-vault proxy` process @@ -23,7 +22,7 @@ func StartEc2EndpointProxyServerProcess() error { return err } time.Sleep(time.Second * 1) - if !isProxyRunning() { + if !IsProxyRunning() { return errors.New("The EC2 Instance Metadata endpoint proxy server isn't running. Run `aws-vault proxy` as Administrator or root in the background and then try this command again") } return nil diff --git a/server/ec2server.go b/server/ec2server.go index 73a117c6f..63ccd2969 100644 --- a/server/ec2server.go +++ b/server/ec2server.go @@ -9,25 +9,19 @@ import ( "net/http" "time" - "github.com/99designs/aws-vault/v6/iso8601" + "github.com/99designs/aws-vault/v7/iso8601" "github.com/aws/aws-sdk-go-v2/aws" ) const ec2CredentialsServerAddr = "127.0.0.1:9099" // StartEc2CredentialsServer starts a EC2 Instance Metadata server and endpoint proxy -func StartEc2CredentialsServer(credsProvider aws.CredentialsProvider, region string) error { - if !isProxyRunning() { - if err := StartEc2EndpointProxyServerProcess(); err != nil { - return err - } - } - +func StartEc2CredentialsServer(ctx context.Context, credsProvider aws.CredentialsProvider, region string) error { credsCache := aws.NewCredentialsCache(credsProvider) // pre-fetch credentials so that we can respond quickly to the first request // SDKs seem to very aggressively timeout - _, _ = credsCache.Retrieve(context.TODO()) + _, _ = credsCache.Retrieve(ctx) go startEc2CredentialsServer(credsCache, region) @@ -35,7 +29,6 @@ func StartEc2CredentialsServer(credsProvider aws.CredentialsProvider, region str } func startEc2CredentialsServer(credsProvider aws.CredentialsProvider, region string) { - log.Printf("Starting EC2 Instance Metadata server on %s", ec2CredentialsServerAddr) router := http.NewServeMux() @@ -54,7 +47,7 @@ func startEc2CredentialsServer(credsProvider aws.CredentialsProvider, region str }) // used by AWS SDK to determine region - router.HandleFunc("/latest/meta-data/dynamic/instance-identity/document", func(w http.ResponseWriter, r *http.Request) { + router.HandleFunc("/latest/dynamic/instance-identity/document", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"region": "`+region+`"}`) }) @@ -82,7 +75,7 @@ func withSecurityChecks(next *http.ServeMux) http.HandlerFunc { // Check that the request is to 169.254.169.254 // Without this it's possible for an attacker to mount a DNS rebinding attack // See https://github.com/99designs/aws-vault/issues/578 - if r.Host != ec2MetadataEndpointIP { + if r.Host != ec2MetadataEndpointIP && r.Host != ec2MetadataEndpointAddr { http.Error(w, fmt.Sprintf("Access denied for host '%s'", r.Host), http.StatusUnauthorized) return } diff --git a/server/ecsserver.go b/server/ecsserver.go index 838d9c6d5..488d3f767 100644 --- a/server/ecsserver.go +++ b/server/ecsserver.go @@ -9,9 +9,13 @@ import ( "log" "net" "net/http" + "strings" + "sync" - "github.com/99designs/aws-vault/v6/iso8601" + "github.com/99designs/aws-vault/v7/iso8601" + "github.com/99designs/aws-vault/v7/vault" "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/sts" ) func writeErrorMessage(w http.ResponseWriter, msg string, statusCode int) { @@ -22,9 +26,9 @@ func writeErrorMessage(w http.ResponseWriter, msg string, statusCode int) { } } -func withAuthorizationCheck(token string, next http.HandlerFunc) http.HandlerFunc { +func withAuthorizationCheck(authToken string, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != token { + if r.Header.Get("Authorization") != authToken { writeErrorMessage(w, "invalid Authorization token", http.StatusForbidden) return } @@ -32,61 +36,114 @@ func withAuthorizationCheck(token string, next http.HandlerFunc) http.HandlerFun } } -// StartEcsCredentialServer starts an ECS credential server on a random port -func StartEcsCredentialServer(credsProvider aws.CredentialsProvider) (string, string, error) { - listener, err := net.Listen("tcp", "127.0.0.1:0") +func writeCredsToResponse(creds aws.Credentials, w http.ResponseWriter) { + err := json.NewEncoder(w).Encode(map[string]string{ + "AccessKeyId": creds.AccessKeyID, + "SecretAccessKey": creds.SecretAccessKey, + "Token": creds.SessionToken, + "Expiration": iso8601.Format(creds.Expires), + }) if err != nil { - return "", "", err + writeErrorMessage(w, err.Error(), http.StatusInternalServerError) + return } - token, err := generateRandomString() - if err != nil { - return "", "", err +} + +func generateRandomString() string { + b := make([]byte, 30) + if _, err := rand.Read(b); err != nil { + panic(err) } - credsCache := aws.NewCredentialsCache(credsProvider) + return base64.RawURLEncoding.EncodeToString(b) +} + +type EcsServer struct { + listener net.Listener + authToken string + server http.Server + cache sync.Map + baseCredsProvider aws.CredentialsProvider + config *vault.ProfileConfig +} - // Retrieve credentials eagerly to support MFA prompts - _, err = credsCache.Retrieve(context.Background()) +func NewEcsServer(ctx context.Context, baseCredsProvider aws.CredentialsProvider, config *vault.ProfileConfig, authToken string, port int, lazyLoadBaseCreds bool) (*EcsServer, error) { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) if err != nil { - return "", "", err + return nil, err + } + if authToken == "" { + authToken = generateRandomString() } - go func() { - err := http.Serve(listener, withLogging(withAuthorizationCheck(token, ecsCredsHandler(credsCache)))) - // returns ErrServerClosed on graceful close - if err != http.ErrServerClosed { - log.Fatalf("ecs server: %s", err.Error()) + credsCache := aws.NewCredentialsCache(baseCredsProvider) + if !lazyLoadBaseCreds { + _, err := credsCache.Retrieve(ctx) + if err != nil { + return nil, fmt.Errorf("Retrieving creds: %w", err) } - }() + } + + e := &EcsServer{ + listener: listener, + authToken: authToken, + baseCredsProvider: credsCache, + config: config, + } + + router := http.NewServeMux() + router.HandleFunc("/", e.DefaultRoute) + router.HandleFunc("/role-arn/", e.AssumeRoleArnRoute) + e.server.Handler = withLogging(withAuthorizationCheck(e.authToken, router.ServeHTTP)) - uri := fmt.Sprintf("http://%s", listener.Addr().String()) - return uri, token, nil + return e, nil } -func ecsCredsHandler(credsCache *aws.CredentialsCache) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - creds, err := credsCache.Retrieve(r.Context()) - if err != nil { - writeErrorMessage(w, err.Error(), http.StatusInternalServerError) - return - } +func (e *EcsServer) BaseURL() string { + return fmt.Sprintf("http://%s", e.listener.Addr().String()) +} +func (e *EcsServer) AuthToken() string { + return e.authToken +} - err = json.NewEncoder(w).Encode(map[string]string{ - "AccessKeyId": creds.AccessKeyID, - "SecretAccessKey": creds.SecretAccessKey, - "Token": creds.SessionToken, - "Expiration": iso8601.Format(creds.Expires), - }) - if err != nil { - writeErrorMessage(w, err.Error(), http.StatusInternalServerError) - return +func (e *EcsServer) Serve() error { + return e.server.Serve(e.listener) +} + +func (e *EcsServer) DefaultRoute(w http.ResponseWriter, r *http.Request) { + creds, err := e.baseCredsProvider.Retrieve(r.Context()) + if err != nil { + writeErrorMessage(w, err.Error(), http.StatusInternalServerError) + return + } + writeCredsToResponse(creds, w) +} + +func (e *EcsServer) getRoleProvider(roleArn string) aws.CredentialsProvider { + var roleProviderCache *aws.CredentialsCache + + v, ok := e.cache.Load(roleArn) + if ok { + roleProviderCache = v.(*aws.CredentialsCache) + } else { + cfg := vault.NewAwsConfigWithCredsProvider(e.baseCredsProvider, e.config.Region, e.config.STSRegionalEndpoints) + roleProvider := &vault.AssumeRoleProvider{ + StsClient: sts.NewFromConfig(cfg), + RoleARN: roleArn, + Duration: e.config.AssumeRoleDuration, } + roleProviderCache = aws.NewCredentialsCache(roleProvider) + e.cache.Store(roleArn, roleProviderCache) } + return roleProviderCache } -func generateRandomString() (string, error) { - b := make([]byte, 30) - if _, err := rand.Read(b); err != nil { - return "", err +func (e *EcsServer) AssumeRoleArnRoute(w http.ResponseWriter, r *http.Request) { + roleArn := strings.TrimPrefix(r.URL.Path, "/role-arn/") + roleProvider := e.getRoleProvider(roleArn) + creds, err := roleProvider.Retrieve(r.Context()) + if err != nil { + writeErrorMessage(w, err.Error(), http.StatusInternalServerError) + return } - return base64.RawURLEncoding.EncodeToString(b), nil + writeCredsToResponse(creds, w) } diff --git a/vault/assumeroleprovider.go b/vault/assumeroleprovider.go index 288be4a7a..87db054ac 100644 --- a/vault/assumeroleprovider.go +++ b/vault/assumeroleprovider.go @@ -26,7 +26,7 @@ type AssumeRoleProvider struct { // Retrieve generates a new set of temporary credentials using STS AssumeRole func (p *AssumeRoleProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { - role, err := p.assumeRole() + role, err := p.RetrieveStsCredentials(ctx) if err != nil { return aws.Credentials{}, err } @@ -49,7 +49,7 @@ func (p *AssumeRoleProvider) roleSessionName() string { return p.RoleSessionName } -func (p *AssumeRoleProvider) assumeRole() (*ststypes.Credentials, error) { +func (p *AssumeRoleProvider) RetrieveStsCredentials(ctx context.Context) (*ststypes.Credentials, error) { var err error input := &sts.AssumeRoleInput{ @@ -89,7 +89,7 @@ func (p *AssumeRoleProvider) assumeRole() (*ststypes.Credentials, error) { input.SourceIdentity = aws.String(p.SourceIdentity) } - resp, err := p.StsClient.AssumeRole(context.TODO(), input) + resp, err := p.StsClient.AssumeRole(ctx, input) if err != nil { return nil, err } diff --git a/vault/assumerolewithwebidentityprovider.go b/vault/assumerolewithwebidentityprovider.go index 1565815d8..ec2537eb8 100644 --- a/vault/assumerolewithwebidentityprovider.go +++ b/vault/assumerolewithwebidentityprovider.go @@ -3,14 +3,10 @@ package vault import ( "context" "fmt" - "io/ioutil" "log" "os" - "runtime" "time" - exec "golang.org/x/sys/execabs" - "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sts" ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types" @@ -29,7 +25,7 @@ type AssumeRoleWithWebIdentityProvider struct { // Retrieve generates a new set of temporary credentials using STS AssumeRoleWithWebIdentity func (p *AssumeRoleWithWebIdentityProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { - creds, err := p.assumeRole() + creds, err := p.RetrieveStsCredentials(ctx) if err != nil { return aws.Credentials{}, err } @@ -52,7 +48,7 @@ func (p *AssumeRoleWithWebIdentityProvider) roleSessionName() string { return p.RoleSessionName } -func (p *AssumeRoleWithWebIdentityProvider) assumeRole() (*ststypes.Credentials, error) { +func (p *AssumeRoleWithWebIdentityProvider) RetrieveStsCredentials(ctx context.Context) (*ststypes.Credentials, error) { var err error webIdentityToken, err := p.webIdentityToken() @@ -60,7 +56,7 @@ func (p *AssumeRoleWithWebIdentityProvider) assumeRole() (*ststypes.Credentials, return nil, err } - resp, err := p.StsClient.AssumeRoleWithWebIdentity(context.TODO(), &sts.AssumeRoleWithWebIdentityInput{ + resp, err := p.StsClient.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityInput{ RoleArn: aws.String(p.RoleARN), RoleSessionName: aws.String(p.roleSessionName()), DurationSeconds: aws.Int32(int32(p.Duration.Seconds())), @@ -78,7 +74,7 @@ func (p *AssumeRoleWithWebIdentityProvider) assumeRole() (*ststypes.Credentials, func (p *AssumeRoleWithWebIdentityProvider) webIdentityToken() (string, error) { // Read OpenID Connect token from WebIdentityTokenFile if p.WebIdentityTokenFile != "" { - b, err := ioutil.ReadFile(p.WebIdentityTokenFile) + b, err := os.ReadFile(p.WebIdentityTokenFile) if err != nil { return "", fmt.Errorf("unable to read file at %s: %v", p.WebIdentityTokenFile, err) } @@ -87,22 +83,5 @@ func (p *AssumeRoleWithWebIdentityProvider) webIdentityToken() (string, error) { } // Exec WebIdentityTokenProcess to retrieve OpenID Connect token - var cmdArgs []string - if runtime.GOOS == "windows" { - cmdArgs = []string{"cmd.exe", "/C", p.WebIdentityTokenProcess} - } else { - cmdArgs = []string{"/bin/sh", "-c", p.WebIdentityTokenProcess} - } - - cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) - cmd.Env = os.Environ() - cmd.Stdin = os.Stdin - cmd.Stderr = os.Stderr - - b, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to run command %q: %v", p.WebIdentityTokenProcess, err) - } - - return string(b), err + return executeProcess(p.WebIdentityTokenProcess) } diff --git a/vault/cachedsessionprovider.go b/vault/cachedsessionprovider.go index 0293ac722..1a382d6b3 100644 --- a/vault/cachedsessionprovider.go +++ b/vault/cachedsessionprovider.go @@ -9,34 +9,48 @@ import ( ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types" ) +type StsSessionProvider interface { + aws.CredentialsProvider + RetrieveStsCredentials(ctx context.Context) (*ststypes.Credentials, error) +} + // CachedSessionProvider retrieves cached credentials from the keyring, or if no credentials are cached // retrieves temporary credentials using the CredentialsFunc type CachedSessionProvider struct { SessionKey SessionMetadata - CredentialsFunc func() (*ststypes.Credentials, error) + SessionProvider StsSessionProvider Keyring *SessionKeyring ExpiryWindow time.Duration } -// Retrieve returns cached credentials from the keyring, or if no credentials are cached -// generates a new set of temporary credentials using the CredentialsFunc -func (p *CachedSessionProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { +func (p *CachedSessionProvider) RetrieveStsCredentials(ctx context.Context) (*ststypes.Credentials, error) { creds, err := p.Keyring.Get(p.SessionKey) if err != nil || time.Until(*creds.Expiration) < p.ExpiryWindow { // lookup missed, we need to create a new one. - creds, err = p.CredentialsFunc() + creds, err = p.SessionProvider.RetrieveStsCredentials(ctx) if err != nil { - return aws.Credentials{}, err + return nil, err } err = p.Keyring.Set(p.SessionKey, creds) if err != nil { - return aws.Credentials{}, err + return nil, err } } else { log.Printf("Re-using cached credentials %s from %s, expires in %s", FormatKeyForDisplay(*creds.AccessKeyId), p.SessionKey.Type, time.Until(*creds.Expiration).String()) } + return creds, nil +} + +// Retrieve returns cached credentials from the keyring, or if no credentials are cached +// generates a new set of temporary credentials using the CredentialsFunc +func (p *CachedSessionProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { + creds, err := p.RetrieveStsCredentials(ctx) + if err != nil { + return aws.Credentials{}, err + } + return aws.Credentials{ AccessKeyID: aws.ToString(creds.AccessKeyId), SecretAccessKey: aws.ToString(creds.SecretAccessKey), diff --git a/vault/config.go b/vault/config.go index a850decf3..a4fdc1e7c 100644 --- a/vault/config.go +++ b/vault/config.go @@ -23,9 +23,6 @@ const ( roleChainingMaximumDuration = 1 * time.Hour ) -// UseSession will disable the use of GetSessionToken when set to false -var UseSession = true - func init() { ini.PrettyFormat = false } @@ -135,8 +132,8 @@ type ProfileSection struct { RoleSessionName string `ini:"role_session_name,omitempty"` DurationSeconds uint `ini:"duration_seconds,omitempty"` SourceProfile string `ini:"source_profile,omitempty"` - ParentProfile string `ini:"parent_profile,omitempty"` // deprecated IncludeProfile string `ini:"include_profile,omitempty"` + SSOSession string `ini:"sso_session,omitempty"` SSOStartURL string `ini:"sso_start_url,omitempty"` SSORegion string `ini:"sso_region,omitempty"` SSOAccountID string `ini:"sso_account_id,omitempty"` @@ -147,6 +144,16 @@ type ProfileSection struct { SessionTags string `ini:"session_tags,omitempty"` TransitiveSessionTags string `ini:"transitive_session_tags,omitempty"` SourceIdentity string `ini:"source_identity,omitempty"` + CredentialProcess string `ini:"credential_process,omitempty"` + MfaProcess string `ini:"mfa_process,omitempty"` +} + +// SSOSessionSection is a [sso-session] section of the config file +type SSOSessionSection struct { + Name string `ini:"-"` + SSOStartURL string `ini:"sso_start_url,omitempty"` + SSORegion string `ini:"sso_region,omitempty"` + SSORegistrationScopes string `ini:"sso_registration_scopes,omitempty"` } func (s ProfileSection) IsEmpty() bool { @@ -156,26 +163,28 @@ func (s ProfileSection) IsEmpty() bool { // ProfileSections returns all the profile sections in the config func (c *ConfigFile) ProfileSections() []ProfileSection { - var result []ProfileSection + result := []ProfileSection{} if c.iniFile == nil { return result } - for _, section := range c.iniFile.SectionStrings() { - if section != defaultSectionName && !strings.HasPrefix(section, "profile ") { - log.Printf("Unrecognised ini file section: %s", section) - continue - } + if section == defaultSectionName || strings.HasPrefix(section, "profile ") { + profile, _ := c.ProfileSection(strings.TrimPrefix(section, "profile ")) - profile, _ := c.ProfileSection(strings.TrimPrefix(section, "profile ")) + // ignore the default profile if it's empty + if section == defaultSectionName && profile.IsEmpty() { + continue + } - // ignore the default profile if it's empty - if section == defaultSectionName && profile.IsEmpty() { + result = append(result, profile) + } else if strings.HasPrefix(section, "sso-session ") { + // Not a profile + continue + } else { + log.Printf("Unrecognised ini file section: %s", section) continue } - - result = append(result, profile) } return result @@ -205,6 +214,26 @@ func (c *ConfigFile) ProfileSection(name string) (ProfileSection, bool) { return profile, true } +// SSOSessionSection returns the [sso-session] section with the matching name. If there isn't any, +// an empty sso-session with the provided name is returned, along with false. +func (c *ConfigFile) SSOSessionSection(name string) (SSOSessionSection, bool) { + ssoSession := SSOSessionSection{ + Name: name, + } + if c.iniFile == nil { + return ssoSession, false + } + sectionName := "sso-session " + name + section, err := c.iniFile.GetSection(sectionName) + if err != nil { + return ssoSession, false + } + if err = section.MapTo(&ssoSession); err != nil { + panic(err) + } + return ssoSession, true +} + func (c *ConfigFile) Save() error { return c.iniFile.SaveTo(c.Path) } @@ -231,7 +260,7 @@ func (c *ConfigFile) Add(profile ProfileSection) error { // ProfileNames returns a slice of profile names from the AWS config func (c *ConfigFile) ProfileNames() []string { - var profileNames []string + profileNames := []string{} for _, profile := range c.ProfileSections() { profileNames = append(profileNames, profile.Name) } @@ -240,12 +269,21 @@ func (c *ConfigFile) ProfileNames() []string { // ConfigLoader loads config from configfile and environment variables type ConfigLoader struct { - BaseConfig Config - File *ConfigFile - ActiveProfile string + BaseConfig ProfileConfig + File *ConfigFile + ActiveProfile string + visitedProfiles []string } +func NewConfigLoader(baseConfig ProfileConfig, file *ConfigFile, activeProfile string) *ConfigLoader { + return &ConfigLoader{ + BaseConfig: baseConfig, + File: file, + ActiveProfile: activeProfile, + } +} + func (cl *ConfigLoader) visitProfile(name string) bool { for _, p := range cl.visitedProfiles { if p == name { @@ -260,7 +298,7 @@ func (cl *ConfigLoader) resetLoopDetection() { cl.visitedProfiles = []string{} } -func (cl *ConfigLoader) populateFromDefaults(config *Config) { +func (cl *ConfigLoader) populateFromDefaults(config *ProfileConfig) { if config.AssumeRoleDuration == 0 { config.AssumeRoleDuration = DefaultSessionDuration } @@ -275,7 +313,7 @@ func (cl *ConfigLoader) populateFromDefaults(config *Config) { } } -func (cl *ConfigLoader) populateFromConfigFile(config *Config, profileName string) error { +func (cl *ConfigLoader) populateFromConfigFile(config *ProfileConfig, profileName string) error { if !cl.visitProfile(profileName) { return fmt.Errorf("Loop detected in config file for profile '%s'", profileName) } @@ -307,6 +345,21 @@ func (cl *ConfigLoader) populateFromConfigFile(config *Config, profileName strin if config.SourceProfileName == "" { config.SourceProfileName = psection.SourceProfile } + if config.SSOSession == "" { + config.SSOSession = psection.SSOSession + if psection.SSOSession != "" { + // Populate profile with values from [sso-session]. + ssoSection, ok := cl.File.SSOSessionSection(psection.SSOSession) + if ok { + config.SSOStartURL = ssoSection.SSOStartURL + config.SSORegion = ssoSection.SSORegion + config.SSORegistrationScopes = ssoSection.SSORegistrationScopes + } else { + // ignore missing profiles + log.Printf("[sso-session] '%s' missing in config file", psection.SSOSession) + } + } + } if config.SSOStartURL == "" { config.SSOStartURL = psection.SSOStartURL } @@ -331,6 +384,12 @@ func (cl *ConfigLoader) populateFromConfigFile(config *Config, profileName strin if config.SourceIdentity == "" { config.SourceIdentity = psection.SourceIdentity } + if config.CredentialProcess == "" { + config.CredentialProcess = psection.CredentialProcess + } + if config.MfaProcess == "" { + config.MfaProcess = psection.MfaProcess + } if sessionTags := psection.SessionTags; sessionTags != "" && config.SessionTags == nil { err := config.SetSessionTags(sessionTags) if err != nil { @@ -341,20 +400,11 @@ func (cl *ConfigLoader) populateFromConfigFile(config *Config, profileName strin config.SetTransitiveSessionTags(transitiveSessionTags) } - if psection.ParentProfile != "" { - fmt.Fprint(os.Stderr, "Warning: parent_profile is deprecated, please use include_profile instead in your AWS config\n") - } - if psection.IncludeProfile != "" { err := cl.populateFromConfigFile(config, psection.IncludeProfile) if err != nil { return err } - } else if psection.ParentProfile != "" { - err := cl.populateFromConfigFile(config, psection.ParentProfile) - if err != nil { - return err - } } else if profileName != defaultSectionName { err := cl.populateFromConfigFile(config, defaultSectionName) if err != nil { @@ -370,7 +420,7 @@ func (cl *ConfigLoader) populateFromConfigFile(config *Config, profileName strin return nil } -func (cl *ConfigLoader) populateFromEnv(profile *Config) { +func (cl *ConfigLoader) populateFromEnv(profile *ProfileConfig) { if region := os.Getenv("AWS_REGION"); region != "" && profile.Region == "" { log.Printf("Using region %q from AWS_REGION", region) profile.Region = region @@ -452,9 +502,9 @@ func (cl *ConfigLoader) populateFromEnv(profile *Config) { } } -func (cl *ConfigLoader) hydrateSourceConfig(config *Config) error { +func (cl *ConfigLoader) hydrateSourceConfig(config *ProfileConfig) error { if config.SourceProfileName != "" { - sc, err := cl.LoadFromProfile(config.SourceProfileName) + sc, err := cl.GetProfileConfig(config.SourceProfileName) if err != nil { return err } @@ -464,8 +514,8 @@ func (cl *ConfigLoader) hydrateSourceConfig(config *Config) error { return nil } -// LoadFromProfile loads the profile from the config file and environment variables into config -func (cl *ConfigLoader) LoadFromProfile(profileName string) (*Config, error) { +// GetProfileConfig loads the profile from the config file and environment variables into config +func (cl *ConfigLoader) GetProfileConfig(profileName string) (*ProfileConfig, error) { config := cl.BaseConfig config.ProfileName = profileName cl.populateFromEnv(&config) @@ -486,8 +536,8 @@ func (cl *ConfigLoader) LoadFromProfile(profileName string) (*Config, error) { return &config, nil } -// Config is a collection of configuration options for creating temporary credentials -type Config struct { +// ProfileConfig is a collection of configuration options for creating temporary credentials +type ProfileConfig struct { // ProfileName specifies the name of the profile config ProfileName string @@ -495,10 +545,10 @@ type Config struct { SourceProfileName string // SourceProfile is the profile where credentials come from - SourceProfile *Config + SourceProfile *ProfileConfig - // ChainedFromProfile is the profile that used this profile as it's source profile - ChainedFromProfile *Config + // ChainedFromProfile is the profile that used this profile as its source profile + ChainedFromProfile *ProfileConfig // Region is the AWS region Region string @@ -511,6 +561,9 @@ type Config struct { MfaToken string MfaPromptMethod string + // MfaProcess specifies external command to run to get an MFA token + MfaProcess string + // AssumeRole config RoleARN string RoleSessionName string @@ -532,16 +585,22 @@ type Config struct { // GetFederationTokenDuration specifies the wanted duration for credentials generated with GetFederationToken GetFederationTokenDuration time.Duration - // SSOStartURL specifies the URL for the AWS SSO user portal. + // SSOSession specifies the [sso-session] section name. + SSOSession string + + // SSOStartURL specifies the URL for the AWS IAM Identity Center user portal, legacy option. SSOStartURL string - // SSORegion specifies the region for the AWS SSO user portal. + // SSORegion specifies the region for the AWS IAM Identity Center user portal, legacy option. SSORegion string + // SSORegistrationScopes specifies registration scopes for the AWS IAM Identity Center user portal. + SSORegistrationScopes string + // SSOAccountID specifies the AWS account ID for the profile. SSOAccountID string - // SSORoleName specifies the AWS SSO Role name to target. + // SSORoleName specifies the AWS IAM Role name to target. SSORoleName string // SSOUseStdout specifies that the system browser should not be automatically opened @@ -555,10 +614,13 @@ type Config struct { // SourceIdentity specifies assumed role Source Identity SourceIdentity string + + // CredentialProcess specifies external command to run to get an AWS credential + CredentialProcess string } // SetSessionTags parses a comma separated key=vaue string and sets Config.SessionTags map -func (c *Config) SetSessionTags(s string) error { +func (c *ProfileConfig) SetSessionTags(s string) error { c.SessionTags = make(map[string]string) for _, tag := range strings.Split(s, ",") { kvPair := strings.SplitN(tag, "=", 2) @@ -572,7 +634,7 @@ func (c *Config) SetSessionTags(s string) error { } // SetTransitiveSessionTags parses a comma separated string and sets Config.TransitiveSessionTags -func (c *Config) SetTransitiveSessionTags(s string) { +func (c *ProfileConfig) SetTransitiveSessionTags(s string) { for _, tag := range strings.Split(s, ",") { if tag = strings.TrimSpace(tag); tag != "" { c.TransitiveSessionTags = append(c.TransitiveSessionTags, tag) @@ -580,69 +642,41 @@ func (c *Config) SetTransitiveSessionTags(s string) { } } -func (c *Config) IsChained() bool { +func (c *ProfileConfig) IsChained() bool { return c.ChainedFromProfile != nil } -func (c *Config) HasSourceProfile() bool { +func (c *ProfileConfig) HasSourceProfile() bool { return c.SourceProfile != nil } -func (c *Config) HasMfaSerial() bool { +func (c *ProfileConfig) HasMfaSerial() bool { return c.MfaSerial != "" } -func (c *Config) HasRole() bool { +func (c *ProfileConfig) HasRole() bool { return c.RoleARN != "" } -func (c *Config) HasSSOStartURL() bool { - return c.SSOStartURL != "" +func (c *ProfileConfig) HasSSOSession() bool { + return c.SSOSession != "" } -func (c *Config) HasWebIdentityTokenFile() bool { - return c.WebIdentityTokenFile != "" +func (c *ProfileConfig) HasSSOStartURL() bool { + return c.SSOStartURL != "" } -func (c *Config) HasWebIdentityTokenProcess() bool { - return c.WebIdentityTokenProcess != "" +func (c *ProfileConfig) HasWebIdentity() bool { + return c.WebIdentityTokenFile != "" || c.WebIdentityTokenProcess != "" } -// CanUseGetSessionToken determines if GetSessionToken should be used, and if not returns a reason -func (c *Config) CanUseGetSessionToken() (bool, string) { - if !UseSession { - return false, "disabled" - } - - if c.HasRole() { - if c.AssumeRoleDuration > roleChainingMaximumDuration { - return false, fmt.Sprintf("duration %s is greater than the AWS maximum %s for chaining MFA", c.AssumeRoleDuration, roleChainingMaximumDuration) - } - } else if c.IsChained() { - if !c.ChainedFromProfile.HasMfaSerial() { - return false, fmt.Sprintf("profile '%s' has no MFA serial defined", c.ChainedFromProfile.ProfileName) - } - - if !c.HasMfaSerial() && c.ChainedFromProfile.HasMfaSerial() { - return false, fmt.Sprintf("profile '%s' has no MFA serial defined", c.ProfileName) - } - - if c.ChainedFromProfile.MfaSerial != c.MfaSerial { - return false, fmt.Sprintf("MFA serial doesn't match profile '%s'", c.ChainedFromProfile.ProfileName) - } - - if c.ChainedFromProfile.AssumeRoleDuration > roleChainingMaximumDuration { - return false, fmt.Sprintf("duration %s in profile '%s' is greater than the AWS maximum %s for chaining MFA", c.ChainedFromProfile.AssumeRoleDuration, c.ChainedFromProfile.ProfileName, roleChainingMaximumDuration) - } - } - - return true, "" +func (c *ProfileConfig) HasCredentialProcess() bool { + return c.CredentialProcess != "" } -func (c *Config) GetSessionTokenDuration() time.Duration { +func (c *ProfileConfig) GetSessionTokenDuration() time.Duration { if c.IsChained() { return c.ChainedGetSessionTokenDuration - } else { - return c.NonChainedGetSessionTokenDuration } + return c.NonChainedGetSessionTokenDuration } diff --git a/vault/config_test.go b/vault/config_test.go index 5a8a86f27..b9f10375b 100644 --- a/vault/config_test.go +++ b/vault/config_test.go @@ -3,12 +3,11 @@ package vault_test import ( "bytes" "fmt" - "io/ioutil" "os" "reflect" "testing" - "github.com/99designs/aws-vault/v6/vault" + "github.com/99designs/aws-vault/v7/vault" "github.com/google/go-cmp/cmp" ) @@ -39,6 +38,16 @@ region=us-east-1 [profile testincludeprofile2] include_profile=testincludeprofile1 + +[profile with-sso-session] +sso_session = moon-sso +sso_account_id=123456 +region = moon-1 # Different from sso region + +[sso-session moon-sso] +sso_start_url = https://d-123456789.example.com/start +sso_region = moon-2 # Different from profile region +sso_registration_scopes = sso:account:access `) var nestedConfig = []byte(`[default] @@ -55,15 +64,15 @@ s3= var defaultsOnlyConfigWithHeader = []byte(`[default] region=us-west-2 output=json - `) func newConfigFile(t *testing.T, b []byte) string { - f, err := ioutil.TempFile("", "aws-config") + t.Helper() + f, err := os.CreateTemp("", "aws-config") if err != nil { t.Fatal(err) } - if err := ioutil.WriteFile(f.Name(), b, 0600); err != nil { + if err := os.WriteFile(f.Name(), b, 0600); err != nil { t.Fatal(err) } return f.Name() @@ -161,6 +170,7 @@ func TestProfilesFromConfig(t *testing.T) { {Name: "withMFA", MfaSerial: "arn:aws:iam::1234513441:mfa/blah", RoleARN: "arn:aws:iam::4451234513441615400570:role/aws_admin", Region: "us-east-1", DurationSeconds: 1200, SourceProfile: "user2", STSRegionalEndpoints: "legacy"}, {Name: "testincludeprofile1", Region: "us-east-1"}, {Name: "testincludeprofile2", IncludeProfile: "testincludeprofile1"}, + {Name: "with-sso-session", SSOSession: "moon-sso", Region: "moon-1", SSOAccountID: "123456"}, } actual := cfg.ProfileSections() @@ -195,6 +205,7 @@ func TestAddProfileToExistingConfig(t *testing.T) { {Name: "withMFA", MfaSerial: "arn:aws:iam::1234513441:mfa/blah", RoleARN: "arn:aws:iam::4451234513441615400570:role/aws_admin", Region: "us-east-1", DurationSeconds: 1200, SourceProfile: "user2", STSRegionalEndpoints: "legacy"}, {Name: "testincludeprofile1", Region: "us-east-1"}, {Name: "testincludeprofile2", IncludeProfile: "testincludeprofile1"}, + {Name: "with-sso-session", SSOSession: "moon-sso", Region: "moon-1", SSOAccountID: "123456"}, {Name: "llamas", MfaSerial: "testserial", Region: "us-east-1", SourceProfile: "default"}, } actual := cfg.ProfileSections() @@ -223,15 +234,14 @@ func TestAddProfileToExistingNestedConfig(t *testing.T) { } expected := append(nestedConfig, []byte( - "\n[profile llamas]\nmfa_serial=testserial\nregion=us-east-1\n\n", + "\n[profile llamas]\nmfa_serial=testserial\nregion=us-east-1\n", )...) - b, _ := ioutil.ReadFile(f) + b, _ := os.ReadFile(f) if !bytes.Equal(expected, b) { t.Fatalf("Expected:\n%q\nGot:\n%q", expected, b) } - } func TestIncludeProfile(t *testing.T) { @@ -244,7 +254,7 @@ func TestIncludeProfile(t *testing.T) { } configLoader := &vault.ConfigLoader{File: configFile} - config, err := configLoader.LoadFromProfile("testincludeprofile2") + config, err := configLoader.GetProfileConfig("testincludeprofile2") if err != nil { t.Fatalf("Should have found a profile: %v", err) } @@ -254,6 +264,36 @@ func TestIncludeProfile(t *testing.T) { } } +func TestIncludeSsoSession(t *testing.T) { + f := newConfigFile(t, exampleConfig) + defer os.Remove(f) + + configFile, err := vault.LoadConfig(f) + if err != nil { + t.Fatal(err) + } + + configLoader := &vault.ConfigLoader{File: configFile} + config, err := configLoader.GetProfileConfig("with-sso-session") + if err != nil { + t.Fatalf("Should have found a profile: %v", err) + } + + if config.Region != "moon-1" { // Test not the same as SSO region + t.Fatalf("Expected region %q, got %q", "moon-1", config.Region) + } + + ssoStartURL := "https://d-123456789.example.com/start" + if config.SSOStartURL != ssoStartURL { + t.Fatalf("Expected sso_start_url %q, got %q", ssoStartURL, config.Region) + } + + if config.SSORegion != "moon-2" { // Test not the same as profile region + t.Fatalf("Expected sso_region %q, got %q", "moon-2", config.Region) + } + // Not checking sso_registration_scopes as it seems to be unused by aws-cli. +} + func TestProfileIsEmpty(t *testing.T) { p := vault.ProfileSection{Name: "foo"} if !p.IsEmpty() { @@ -277,12 +317,11 @@ func TestIniWithHeaderSavesWithHeader(t *testing.T) { expected := defaultsOnlyConfigWithHeader - b, _ := ioutil.ReadFile(f) + b, _ := os.ReadFile(f) if !bytes.Equal(expected, b) { t.Fatalf("Expected:\n%q\nGot:\n%q", expected, b) } - } func TestIniWithDEFAULTHeader(t *testing.T) { @@ -330,7 +369,7 @@ source_profile=foo } configLoader := &vault.ConfigLoader{File: configFile} - config, err := configLoader.LoadFromProfile("foo") + config, err := configLoader.GetProfileConfig("foo") if err != nil { t.Fatalf("Should have found a profile: %v", err) } @@ -367,7 +406,7 @@ source_profile=root } configLoader := &vault.ConfigLoader{File: configFile} - config, err := configLoader.LoadFromProfile("foo") + config, err := configLoader.GetProfileConfig("foo") if err != nil { t.Fatalf("Should have found a profile: %v", err) } @@ -402,7 +441,7 @@ func TestSetSessionTags(t *testing.T) { } for _, tc := range testCases { - config := vault.Config{} + config := vault.ProfileConfig{} err := config.SetSessionTags(tc.stringValue) if tc.ok { if err != nil { @@ -434,7 +473,7 @@ func TestSetTransitiveSessionTags(t *testing.T) { } for _, tc := range testCases { - config := vault.Config{} + config := vault.ProfileConfig{} config.SetTransitiveSessionTags(tc.stringValue) if !reflect.DeepEqual(tc.expected, config.TransitiveSessionTags) { t.Fatalf("Expected TransitiveSessionTags: %+v, got %+v", tc.expected, config.TransitiveSessionTags) @@ -457,7 +496,7 @@ transitive_session_tags = tagOne ,tagTwo,tagThree t.Fatal(err) } configLoader := &vault.ConfigLoader{File: configFile, ActiveProfile: "tagged"} - config, err := configLoader.LoadFromProfile("tagged") + config, err := configLoader.GetProfileConfig("tagged") if err != nil { t.Fatalf("Should have found a profile: %v", err) } @@ -494,7 +533,7 @@ transitive_session_tags = tagOne ,tagTwo,tagThree t.Fatal(err) } configLoader := &vault.ConfigLoader{File: configFile, ActiveProfile: "tagged"} - config, err := configLoader.LoadFromProfile("tagged") + config, err := configLoader.GetProfileConfig("tagged") if err != nil { t.Fatalf("Should have found a profile: %v", err) } @@ -539,7 +578,7 @@ source_profile = interim t.Fatal(err) } configLoader := &vault.ConfigLoader{File: configFile, ActiveProfile: "target"} - config, err := configLoader.LoadFromProfile("target") + config, err := configLoader.GetProfileConfig("target") if err != nil { t.Fatalf("Should have found a profile: %v", err) } diff --git a/vault/credentialprocessprovider.go b/vault/credentialprocessprovider.go new file mode 100644 index 000000000..d9a7d00fe --- /dev/null +++ b/vault/credentialprocessprovider.go @@ -0,0 +1,79 @@ +package vault + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + + "github.com/aws/aws-sdk-go-v2/aws" + ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types" +) + +var credentialProcessRequiredFields = []string{"AccessKeyId", "Expiration", "SecretAccessKey", "SessionToken"} + +// CredentialProcessProvider implements interface aws.CredentialsProvider to retrieve credentials from an external executable +// as described in https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes +type CredentialProcessProvider struct { + CredentialProcess string +} + +func (p *CredentialProcessProvider) validateJSONCredential(cred *ststypes.Credentials) error { + var missing []string + + h := reflect.ValueOf(cred).Elem() + for _, requiredField := range credentialProcessRequiredFields { + if h.FieldByName(requiredField).IsNil() { + missing = append(missing, requiredField) + } + } + + if len(missing) > 0 { + return fmt.Errorf("JSON credential from command %q missing the following fields: %v", p.CredentialProcess, missing) + } + + return nil +} + +// Retrieve obtains a new set of temporary credentials using an external process, required to satisfy interface aws.CredentialsProvider +func (p *CredentialProcessProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { + return p.retrieveWith(ctx, executeProcess) +} + +func (p *CredentialProcessProvider) retrieveWith(ctx context.Context, fn func(string) (string, error)) (aws.Credentials, error) { + creds, err := p.callCredentialProcessWith(ctx, fn) + if err != nil { + return aws.Credentials{}, err + } + + return aws.Credentials{ + AccessKeyID: aws.ToString(creds.AccessKeyId), + SecretAccessKey: aws.ToString(creds.SecretAccessKey), + SessionToken: aws.ToString(creds.SessionToken), + CanExpire: true, + Expires: aws.ToTime(creds.Expiration), + }, nil +} + +func (p *CredentialProcessProvider) RetrieveStsCredentials(ctx context.Context) (*ststypes.Credentials, error) { + return p.callCredentialProcessWith(ctx, executeProcess) +} + +func (p *CredentialProcessProvider) callCredentialProcessWith(_ context.Context, fn func(string) (string, error)) (*ststypes.Credentials, error) { + // Exec CredentialProcess to retrieve AWS creds in JSON format as described in + // https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes + output, err := fn(p.CredentialProcess) + + if err != nil { + return nil, err + } + + // Unmarshal the JSON into a ststypes.Credentials object + var value ststypes.Credentials + if err := json.Unmarshal([]byte(output), &value); err != nil { + return &ststypes.Credentials{}, fmt.Errorf("invalid JSON format from command %q: %v", p.CredentialProcess, err) + } + + // Validate that all required fields were present in JSON before returning + return &value, p.validateJSONCredential(&value) +} diff --git a/vault/credentialprocessprovider_test.go b/vault/credentialprocessprovider_test.go new file mode 100644 index 000000000..abfa6043a --- /dev/null +++ b/vault/credentialprocessprovider_test.go @@ -0,0 +1,109 @@ +package vault + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types" +) + +func executeFail(_ string) (string, error) { + return "", errors.New("executing process failed") +} + +func executeGetBadJSON(_ string) (string, error) { + return "Junk", nil +} + +func executeGetCredential(accessKeyID *string, expiration *time.Time, secretAccesKey *string, sessionToken *string) (string, error) { + v, err := json.Marshal(ststypes.Credentials{ + AccessKeyId: accessKeyID, + Expiration: expiration, + SecretAccessKey: secretAccesKey, + SessionToken: sessionToken, + }) + return string(v), err +} + +func TestCredentialProcessProvider_Retrieve(t *testing.T) { + accessKeyID := "abcd" + expiration := time.Time{} + secretAccessKey := "0123" + sessionToken := "4567" + + want := aws.Credentials{ + AccessKeyID: accessKeyID, + Expires: expiration, + CanExpire: true, + SecretAccessKey: secretAccessKey, + SessionToken: sessionToken, + } + + tests := []struct { + name string + execFunc func(string) (string, error) + wantErr bool + expectMissingFields bool + }{ + { + name: "process execution fails", + execFunc: executeFail, + wantErr: true, + expectMissingFields: false, + }, + { + name: "bad json", + execFunc: executeGetBadJSON, + wantErr: true, + expectMissingFields: false, + }, + { + name: "successful execution, good cred", + execFunc: func(string) (string, error) { + return executeGetCredential(&accessKeyID, &expiration, &secretAccessKey, &sessionToken) + }, + wantErr: false, + expectMissingFields: false, + }, + { + name: "fields missing", + execFunc: func(string) (string, error) { + return executeGetCredential(nil, nil, nil, nil) + }, + wantErr: true, + expectMissingFields: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + provider := CredentialProcessProvider{ + CredentialProcess: "", + } + got, err := provider.retrieveWith(ctx, tt.execFunc) + + if (err != nil) != tt.wantErr { + t.Errorf("CredentialProcessProvider.Retrieve() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && !reflect.DeepEqual(got, want) { + t.Errorf("CredentialProcessProvider.Retrieve() = %v, want %v", got, want) + } + + if tt.wantErr && tt.expectMissingFields { + for _, expectedMissingField := range credentialProcessRequiredFields { + if !strings.Contains(err.Error(), expectedMissingField) { + t.Errorf("expected field '%v' not present in error: %v'", expectedMissingField, err) + } + } + } + }) + } +} diff --git a/vault/executeprocess.go b/vault/executeprocess.go new file mode 100644 index 000000000..0f3bde878 --- /dev/null +++ b/vault/executeprocess.go @@ -0,0 +1,28 @@ +package vault + +import ( + "fmt" + "os" + "os/exec" + "runtime" +) + +func executeProcess(process string) (string, error) { + var cmdArgs []string + if runtime.GOOS == "windows" { + cmdArgs = []string{"cmd.exe", "/C", process} + } else { + cmdArgs = []string{"/bin/sh", "-c", process} + } + + cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) + cmd.Env = os.Environ() + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("running command %q: %v", process, err) + } + return string(output), nil +} diff --git a/vault/federationtokenprovider.go b/vault/federationtokenprovider.go index c69a90b8a..6b678dc83 100644 --- a/vault/federationtokenprovider.go +++ b/vault/federationtokenprovider.go @@ -28,7 +28,7 @@ func (f *FederationTokenProvider) name() string { // Retrieve generates a new set of temporary credentials using STS GetFederationToken func (f *FederationTokenProvider) Retrieve(ctx context.Context) (creds aws.Credentials, err error) { - resp, err := f.StsClient.GetFederationToken(context.TODO(), &sts.GetFederationTokenInput{ + resp, err := f.StsClient.GetFederationToken(ctx, &sts.GetFederationTokenInput{ Name: aws.String(f.name()), DurationSeconds: aws.Int32(int32(f.Duration.Seconds())), Policy: aws.String(allowAllIAMPolicy), diff --git a/vault/getuser.go b/vault/getuser.go index 34597b772..15feafa10 100644 --- a/vault/getuser.go +++ b/vault/getuser.go @@ -13,9 +13,9 @@ import ( var getUserErrorRegexp = regexp.MustCompile(`^AccessDenied: User: arn:aws:iam::(\d+):user/(.+) is not`) // GetUsernameFromSession returns the IAM username (or root) associated with the current aws session -func GetUsernameFromSession(cfg aws.Config) (string, error) { +func GetUsernameFromSession(ctx context.Context, cfg aws.Config) (string, error) { iamClient := iam.NewFromConfig(cfg) - resp, err := iamClient.GetUser(context.TODO(), &iam.GetUserInput{}) + resp, err := iamClient.GetUser(ctx, &iam.GetUserInput{}) if err != nil { // Even if GetUser fails, the current user is included in the error. This happens when you have o IAM permissions // on the master credentials, but have permission to use assumeRole later diff --git a/vault/keyringprovider.go b/vault/keyringprovider.go index 046c3c764..e9ea2ac84 100644 --- a/vault/keyringprovider.go +++ b/vault/keyringprovider.go @@ -13,7 +13,7 @@ type KeyringProvider struct { CredentialsName string } -func (p *KeyringProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { +func (p *KeyringProvider) Retrieve(_ context.Context) (aws.Credentials, error) { log.Printf("Looking up keyring for '%s'", p.CredentialsName) return p.Keyring.Get(p.CredentialsName) } diff --git a/vault/mfa.go b/vault/mfa.go new file mode 100644 index 000000000..eb5de34ba --- /dev/null +++ b/vault/mfa.go @@ -0,0 +1,59 @@ +package vault + +import ( + "errors" + "fmt" + "log" + "os" + "os/exec" + "strings" + + "github.com/99designs/aws-vault/v7/prompt" + "github.com/aws/aws-sdk-go-v2/aws" +) + +// Mfa contains options for an MFA device +type Mfa struct { + MfaSerial string + mfaPromptFunc prompt.Func +} + +// GetMfaToken returns the MFA token +func (m Mfa) GetMfaToken() (*string, error) { + if m.mfaPromptFunc != nil { + token, err := m.mfaPromptFunc(m.MfaSerial) + return aws.String(token), err + } + + return nil, errors.New("No prompt found") +} + +func NewMfa(config *ProfileConfig) Mfa { + m := Mfa{ + MfaSerial: config.MfaSerial, + } + if config.MfaToken != "" { + m.mfaPromptFunc = func(_ string) (string, error) { return config.MfaToken, nil } + } else if config.MfaProcess != "" { + m.mfaPromptFunc = func(_ string) (string, error) { + log.Println("Executing mfa_process") + return ProcessMfaProvider(config.MfaProcess) + } + } else { + m.mfaPromptFunc = prompt.Method(config.MfaPromptMethod) + } + + return m +} + +func ProcessMfaProvider(processCmd string) (string, error) { + cmd := exec.Command("/bin/sh", "-c", processCmd) + cmd.Stderr = os.Stderr + + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("process provider: %w", err) + } + + return strings.TrimSpace(string(out)), nil +} diff --git a/vault/oidctokenkeyring.go b/vault/oidctokenkeyring.go index 6d026e611..4bd13c878 100644 --- a/vault/oidctokenkeyring.go +++ b/vault/oidctokenkeyring.go @@ -76,14 +76,14 @@ func (o OIDCTokenKeyring) Set(startURL string, token *ssooidc.CreateTokenOutput) Expiration: time.Now().Add(time.Duration(token.ExpiresIn) * time.Second), } - valJson, err := json.Marshal(val) + valJSON, err := json.Marshal(val) if err != nil { return err } return o.Keyring.Set(keyring.Item{ Key: o.fmtKey(startURL), - Data: valJson, + Data: valJSON, Label: fmt.Sprintf("aws-vault oidc token for %s (expires %s)", startURL, val.Expiration.Format(time.RFC3339)), Description: "aws-vault oidc token", }) diff --git a/vault/sessionkeyring.go b/vault/sessionkeyring.go index c56dee9e1..dbd984cfb 100644 --- a/vault/sessionkeyring.go +++ b/vault/sessionkeyring.go @@ -148,7 +148,7 @@ func (sk *SessionKeyring) Set(key SessionMetadata, creds *ststypes.Credentials) key.Expiration = *creds.Expiration - valJson, err := json.Marshal(creds) + valJSON, err := json.Marshal(creds) if err != nil { return err } @@ -168,7 +168,7 @@ func (sk *SessionKeyring) Set(key SessionMetadata, creds *ststypes.Credentials) return sk.Keyring.Set(keyring.Item{ Key: key.String(), - Data: valJson, + Data: valJSON, Label: fmt.Sprintf("aws-vault session for %s (expires %s)", key.ProfileName, creds.Expiration.Format(time.RFC3339)), Description: "aws-vault session", }) diff --git a/vault/sessionkeyring_test.go b/vault/sessionkeyring_test.go index 6c142737c..e3583d3e4 100644 --- a/vault/sessionkeyring_test.go +++ b/vault/sessionkeyring_test.go @@ -3,7 +3,7 @@ package vault_test import ( "testing" - "github.com/99designs/aws-vault/v6/vault" + "github.com/99designs/aws-vault/v7/vault" ) func TestIsSessionKey(t *testing.T) { diff --git a/vault/sessiontokenprovider.go b/vault/sessiontokenprovider.go index c1a9f7e9b..b58179d0f 100644 --- a/vault/sessiontokenprovider.go +++ b/vault/sessiontokenprovider.go @@ -19,7 +19,7 @@ type SessionTokenProvider struct { // Retrieve generates a new set of temporary credentials using STS GetSessionToken func (p *SessionTokenProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { - creds, err := p.GetSessionToken() + creds, err := p.RetrieveStsCredentials(ctx) if err != nil { return aws.Credentials{}, err } @@ -34,7 +34,7 @@ func (p *SessionTokenProvider) Retrieve(ctx context.Context) (aws.Credentials, e } // GetSessionToken generates a new set of temporary credentials using STS GetSessionToken -func (p *SessionTokenProvider) GetSessionToken() (*ststypes.Credentials, error) { +func (p *SessionTokenProvider) RetrieveStsCredentials(ctx context.Context) (*ststypes.Credentials, error) { var err error input := &sts.GetSessionTokenInput{ @@ -49,7 +49,7 @@ func (p *SessionTokenProvider) GetSessionToken() (*ststypes.Credentials, error) } } - resp, err := p.StsClient.GetSessionToken(context.TODO(), input) + resp, err := p.StsClient.GetSessionToken(ctx, input) if err != nil { return nil, err } diff --git a/vault/ssorolecredentialsprovider.go b/vault/ssorolecredentialsprovider.go index cd3de1b98..25c008b61 100644 --- a/vault/ssorolecredentialsprovider.go +++ b/vault/ssorolecredentialsprovider.go @@ -5,11 +5,13 @@ import ( "errors" "fmt" "log" + "net/http" "os" "time" "github.com/99designs/keyring" "github.com/aws/aws-sdk-go-v2/aws" + awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" "github.com/aws/aws-sdk-go-v2/service/sso" ssotypes "github.com/aws/aws-sdk-go-v2/service/sso/types" "github.com/aws/aws-sdk-go-v2/service/ssooidc" @@ -21,6 +23,7 @@ import ( type OIDCTokenCacher interface { Get(string) (*ssooidc.CreateTokenOutput, error) Set(string, *ssooidc.CreateTokenOutput) error + Remove(string) error } // SSORoleCredentialsProvider creates temporary credentials for an SSO Role. @@ -40,7 +43,7 @@ func millisecondsTimeValue(v int64) time.Time { // Retrieve generates a new set of temporary credentials using SSO GetRoleCredentials. func (p *SSORoleCredentialsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { - creds, err := p.getRoleCredentials() + creds, err := p.getRoleCredentials(ctx) if err != nil { return aws.Credentials{}, err } @@ -54,18 +57,35 @@ func (p *SSORoleCredentialsProvider) Retrieve(ctx context.Context) (aws.Credenti }, nil } -func (p *SSORoleCredentialsProvider) getRoleCredentials() (*ssotypes.RoleCredentials, error) { - token, err := p.getOIDCToken() +func (p *SSORoleCredentialsProvider) getRoleCredentials(ctx context.Context) (*ssotypes.RoleCredentials, error) { + token, cached, err := p.getOIDCToken(ctx) if err != nil { return nil, err } - resp, err := p.SSOClient.GetRoleCredentials(context.TODO(), &sso.GetRoleCredentialsInput{ + resp, err := p.SSOClient.GetRoleCredentials(ctx, &sso.GetRoleCredentialsInput{ AccessToken: token.AccessToken, AccountId: aws.String(p.AccountID), RoleName: aws.String(p.RoleName), }) if err != nil { + if cached && p.OIDCTokenCache != nil { + var rspError *awshttp.ResponseError + if !errors.As(err, &rspError) { + return nil, err + } + + // If the error is a 401, remove the cached oidc token and try + // again. This is a recursive call but it should only happen once + // due to the cache being cleared before retrying. + if rspError.HTTPStatusCode() == http.StatusUnauthorized { + err = p.OIDCTokenCache.Remove(p.StartURL) + if err != nil { + return nil, err + } + return p.getRoleCredentials(ctx) + } + } return nil, err } log.Printf("Got credentials %s for SSO role %s (account: %s), expires in %s", FormatKeyForDisplay(*resp.RoleCredentials.AccessKeyId), p.RoleName, p.AccountID, time.Until(millisecondsTimeValue(resp.RoleCredentials.Expiration)).String()) @@ -73,9 +93,13 @@ func (p *SSORoleCredentialsProvider) getRoleCredentials() (*ssotypes.RoleCredent return resp.RoleCredentials, nil } +func (p *SSORoleCredentialsProvider) RetrieveStsCredentials(ctx context.Context) (*ststypes.Credentials, error) { + return p.getRoleCredentialsAsStsCredemtials(ctx) +} + // getRoleCredentialsAsStsCredemtials returns getRoleCredentials as sts.Credentials because sessions.Store expects it -func (p *SSORoleCredentialsProvider) getRoleCredentialsAsStsCredemtials() (*ststypes.Credentials, error) { - creds, err := p.getRoleCredentials() +func (p *SSORoleCredentialsProvider) getRoleCredentialsAsStsCredemtials(ctx context.Context) (*ststypes.Credentials, error) { + creds, err := p.getRoleCredentials(ctx) if err != nil { return nil, err } @@ -88,31 +112,32 @@ func (p *SSORoleCredentialsProvider) getRoleCredentialsAsStsCredemtials() (*stst }, nil } -func (p *SSORoleCredentialsProvider) getOIDCToken() (token *ssooidc.CreateTokenOutput, err error) { +func (p *SSORoleCredentialsProvider) getOIDCToken(ctx context.Context) (token *ssooidc.CreateTokenOutput, cached bool, err error) { if p.OIDCTokenCache != nil { token, err = p.OIDCTokenCache.Get(p.StartURL) if err != nil && err != keyring.ErrKeyNotFound { - return nil, err + return nil, false, err } - } - if token == nil { - token, err = p.newOIDCToken() - if err != nil { - return nil, err + if token != nil { + return token, true, nil } + } + token, err = p.newOIDCToken(ctx) + if err != nil { + return nil, false, err + } - if p.OIDCTokenCache != nil { - err = p.OIDCTokenCache.Set(p.StartURL, token) - if err != nil { - return nil, err - } + if p.OIDCTokenCache != nil { + err = p.OIDCTokenCache.Set(p.StartURL, token) + if err != nil { + return nil, false, err } } - return token, err + return token, false, err } -func (p *SSORoleCredentialsProvider) newOIDCToken() (*ssooidc.CreateTokenOutput, error) { - clientCreds, err := p.OIDCClient.RegisterClient(context.TODO(), &ssooidc.RegisterClientInput{ +func (p *SSORoleCredentialsProvider) newOIDCToken(ctx context.Context) (*ssooidc.CreateTokenOutput, error) { + clientCreds, err := p.OIDCClient.RegisterClient(ctx, &ssooidc.RegisterClientInput{ ClientName: aws.String("aws-vault"), ClientType: aws.String("public"), }) @@ -121,7 +146,7 @@ func (p *SSORoleCredentialsProvider) newOIDCToken() (*ssooidc.CreateTokenOutput, } log.Printf("Created new OIDC client (expires at: %s)", time.Unix(clientCreds.ClientSecretExpiresAt, 0)) - deviceCreds, err := p.OIDCClient.StartDeviceAuthorization(context.TODO(), &ssooidc.StartDeviceAuthorizationInput{ + deviceCreds, err := p.OIDCClient.StartDeviceAuthorization(ctx, &ssooidc.StartDeviceAuthorizationInput{ ClientId: clientCreds.ClientId, ClientSecret: clientCreds.ClientSecret, StartUrl: aws.String(p.StartURL), @@ -151,7 +176,7 @@ func (p *SSORoleCredentialsProvider) newOIDCToken() (*ssooidc.CreateTokenOutput, } for { - t, err := p.OIDCClient.CreateToken(context.TODO(), &ssooidc.CreateTokenInput{ + t, err := p.OIDCClient.CreateToken(ctx, &ssooidc.CreateTokenInput{ ClientId: clientCreds.ClientId, ClientSecret: clientCreds.ClientSecret, DeviceCode: deviceCreds.DeviceCode, diff --git a/vault/stsendpointresolver.go b/vault/stsendpointresolver.go index 2ddfa533b..9ffe12ab7 100644 --- a/vault/stsendpointresolver.go +++ b/vault/stsendpointresolver.go @@ -9,8 +9,8 @@ import ( // getEndpointResolver resolves endpoints in accordance with // https://docs.aws.amazon.com/credref/latest/refdocs/setting-global-sts_regional_endpoints.html -func getSTSEndpointResolver(stsRegionalEndpoints string) aws.EndpointResolverFunc { - return func(service, region string) (aws.Endpoint, error) { +func getSTSEndpointResolver(stsRegionalEndpoints string) aws.EndpointResolverWithOptionsFunc { + return func(service, region string, options ...interface{}) (aws.Endpoint, error) { if stsRegionalEndpoints == "legacy" && service == sts.ServiceID { if region == "ap-northeast-1" || region == "ap-south-1" || @@ -28,7 +28,6 @@ func getSTSEndpointResolver(stsRegionalEndpoints string) aws.EndpointResolverFun region == "us-east-2" || region == "us-west-1" || region == "us-west-2" { - log.Println("Using legacy STS endpoint sts.amazonaws.com") return aws.Endpoint{ diff --git a/vault/vault.go b/vault/vault.go index 057771d9a..9fd27fe2a 100644 --- a/vault/vault.go +++ b/vault/vault.go @@ -1,13 +1,12 @@ package vault import ( - "errors" + "context" "fmt" "log" "os" "time" - "github.com/99designs/aws-vault/v6/prompt" "github.com/99designs/keyring" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sso" @@ -23,20 +22,18 @@ func init() { } } -var UseSessionCache = true - func NewAwsConfig(region, stsRegionalEndpoints string) aws.Config { return aws.Config{ - Region: region, - EndpointResolver: getSTSEndpointResolver(stsRegionalEndpoints), + Region: region, + EndpointResolverWithOptions: getSTSEndpointResolver(stsRegionalEndpoints), } } func NewAwsConfigWithCredsProvider(credsProvider aws.CredentialsProvider, region, stsRegionalEndpoints string) aws.Config { return aws.Config{ - Region: region, - Credentials: credsProvider, - EndpointResolver: getSTSEndpointResolver(stsRegionalEndpoints), + Region: region, + Credentials: credsProvider, + EndpointResolverWithOptions: getSTSEndpointResolver(stsRegionalEndpoints), } } @@ -44,26 +41,9 @@ func FormatKeyForDisplay(k string) string { return fmt.Sprintf("****************%s", k[len(k)-4:]) } -// Mfa contains options for an MFA device -type Mfa struct { - MfaToken string - MfaPromptMethod string - MfaSerial string -} - -// GetMfaToken returns the MFA token -func (m *Mfa) GetMfaToken() (*string, error) { - if m.MfaToken != "" { - return aws.String(m.MfaToken), nil - } - - if m.MfaPromptMethod != "" { - promptFunc := prompt.Method(m.MfaPromptMethod) - token, err := promptFunc(m.MfaSerial) - return aws.String(token), err - } - - return nil, errors.New("No prompt found") +func isMasterCredentialsProvider(credsProvider aws.CredentialsProvider) bool { + _, ok := credsProvider.(*KeyringProvider) + return ok } // NewMasterCredentialsProvider creates a provider for the master credentials @@ -71,20 +51,16 @@ func NewMasterCredentialsProvider(k *CredentialKeyring, credentialsName string) return &KeyringProvider{k, credentialsName} } -func NewSessionTokenProvider(credsProvider aws.CredentialsProvider, k keyring.Keyring, config *Config) (aws.CredentialsProvider, error) { +func NewSessionTokenProvider(credsProvider aws.CredentialsProvider, k keyring.Keyring, config *ProfileConfig, useSessionCache bool) (aws.CredentialsProvider, error) { cfg := NewAwsConfigWithCredsProvider(credsProvider, config.Region, config.STSRegionalEndpoints) sessionTokenProvider := &SessionTokenProvider{ StsClient: sts.NewFromConfig(cfg), Duration: config.GetSessionTokenDuration(), - Mfa: Mfa{ - MfaToken: config.MfaToken, - MfaPromptMethod: config.MfaPromptMethod, - MfaSerial: config.MfaSerial, - }, + Mfa: NewMfa(config), } - if UseSessionCache { + if useSessionCache { return &CachedSessionProvider{ SessionKey: SessionMetadata{ Type: "sts.GetSessionToken", @@ -93,7 +69,7 @@ func NewSessionTokenProvider(credsProvider aws.CredentialsProvider, k keyring.Ke }, Keyring: &SessionKeyring{Keyring: k}, ExpiryWindow: defaultExpirationWindow, - CredentialsFunc: sessionTokenProvider.GetSessionToken, + SessionProvider: sessionTokenProvider, }, nil } @@ -101,7 +77,7 @@ func NewSessionTokenProvider(credsProvider aws.CredentialsProvider, k keyring.Ke } // NewAssumeRoleProvider returns a provider that generates credentials using AssumeRole -func NewAssumeRoleProvider(credsProvider aws.CredentialsProvider, k keyring.Keyring, config *Config) (aws.CredentialsProvider, error) { +func NewAssumeRoleProvider(credsProvider aws.CredentialsProvider, k keyring.Keyring, config *ProfileConfig, useSessionCache bool) (aws.CredentialsProvider, error) { cfg := NewAwsConfigWithCredsProvider(credsProvider, config.Region, config.STSRegionalEndpoints) p := &AssumeRoleProvider{ @@ -113,14 +89,10 @@ func NewAssumeRoleProvider(credsProvider aws.CredentialsProvider, k keyring.Keyr Tags: config.SessionTags, TransitiveTagKeys: config.TransitiveSessionTags, SourceIdentity: config.SourceIdentity, - Mfa: Mfa{ - MfaSerial: config.MfaSerial, - MfaToken: config.MfaToken, - MfaPromptMethod: config.MfaPromptMethod, - }, + Mfa: NewMfa(config), } - if UseSessionCache && config.MfaSerial != "" { + if useSessionCache && config.MfaSerial != "" { return &CachedSessionProvider{ SessionKey: SessionMetadata{ Type: "sts.AssumeRole", @@ -129,7 +101,7 @@ func NewAssumeRoleProvider(credsProvider aws.CredentialsProvider, k keyring.Keyr }, Keyring: &SessionKeyring{Keyring: k}, ExpiryWindow: defaultExpirationWindow, - CredentialsFunc: p.assumeRole, + SessionProvider: p, }, nil } @@ -138,7 +110,7 @@ func NewAssumeRoleProvider(credsProvider aws.CredentialsProvider, k keyring.Keyr // NewAssumeRoleWithWebIdentityProvider returns a provider that generates // credentials using AssumeRoleWithWebIdentity -func NewAssumeRoleWithWebIdentityProvider(k keyring.Keyring, config *Config) (aws.CredentialsProvider, error) { +func NewAssumeRoleWithWebIdentityProvider(k keyring.Keyring, config *ProfileConfig, useSessionCache bool) (aws.CredentialsProvider, error) { cfg := NewAwsConfig(config.Region, config.STSRegionalEndpoints) p := &AssumeRoleWithWebIdentityProvider{ @@ -150,7 +122,7 @@ func NewAssumeRoleWithWebIdentityProvider(k keyring.Keyring, config *Config) (aw Duration: config.AssumeRoleDuration, } - if UseSessionCache { + if useSessionCache { return &CachedSessionProvider{ SessionKey: SessionMetadata{ Type: "sts.AssumeRoleWithWebIdentity", @@ -158,7 +130,7 @@ func NewAssumeRoleWithWebIdentityProvider(k keyring.Keyring, config *Config) (aw }, Keyring: &SessionKeyring{Keyring: k}, ExpiryWindow: defaultExpirationWindow, - CredentialsFunc: p.assumeRole, + SessionProvider: p, }, nil } @@ -166,7 +138,7 @@ func NewAssumeRoleWithWebIdentityProvider(k keyring.Keyring, config *Config) (aw } // NewSSORoleCredentialsProvider creates a provider for SSO credentials -func NewSSORoleCredentialsProvider(k keyring.Keyring, config *Config) (aws.CredentialsProvider, error) { +func NewSSORoleCredentialsProvider(k keyring.Keyring, config *ProfileConfig, useSessionCache bool) (aws.CredentialsProvider, error) { cfg := NewAwsConfig(config.SSORegion, config.STSRegionalEndpoints) ssoRoleCredentialsProvider := &SSORoleCredentialsProvider{ @@ -178,7 +150,7 @@ func NewSSORoleCredentialsProvider(k keyring.Keyring, config *Config) (aws.Crede UseStdout: config.SSOUseStdout, } - if UseSessionCache { + if useSessionCache { ssoRoleCredentialsProvider.OIDCTokenCache = OIDCTokenKeyring{Keyring: k} return &CachedSessionProvider{ SessionKey: SessionMetadata{ @@ -188,122 +160,196 @@ func NewSSORoleCredentialsProvider(k keyring.Keyring, config *Config) (aws.Crede }, Keyring: &SessionKeyring{Keyring: k}, ExpiryWindow: defaultExpirationWindow, - CredentialsFunc: ssoRoleCredentialsProvider.getRoleCredentialsAsStsCredemtials, + SessionProvider: ssoRoleCredentialsProvider, }, nil } return ssoRoleCredentialsProvider, nil } -type tempCredsCreator struct { - keyring *CredentialKeyring - chainedMfa string +// NewCredentialProcessProvider creates a provider to retrieve credentials from an external +// executable as described in https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes +func NewCredentialProcessProvider(k keyring.Keyring, config *ProfileConfig, useSessionCache bool) (aws.CredentialsProvider, error) { + credentialProcessProvider := &CredentialProcessProvider{ + CredentialProcess: config.CredentialProcess, + } + + if useSessionCache { + return &CachedSessionProvider{ + SessionKey: SessionMetadata{ + Type: "credential_process", + ProfileName: config.ProfileName, + }, + Keyring: &SessionKeyring{Keyring: k}, + ExpiryWindow: defaultExpirationWindow, + SessionProvider: credentialProcessProvider, + }, nil + } + + return credentialProcessProvider, nil } -func (t *tempCredsCreator) provider(config *Config) (aws.CredentialsProvider, error) { - var sourcecredsProvider aws.CredentialsProvider +func NewFederationTokenProvider(ctx context.Context, credsProvider aws.CredentialsProvider, config *ProfileConfig) (*FederationTokenProvider, error) { + cfg := NewAwsConfigWithCredsProvider(credsProvider, config.Region, config.STSRegionalEndpoints) - hasStoredCredentials, err := t.keyring.Has(config.ProfileName) + name, err := GetUsernameFromSession(ctx, cfg) if err != nil { return nil, err } - if hasStoredCredentials && config.HasSourceProfile() { - return nil, fmt.Errorf("profile %s: have stored credentials but source_profile is defined", config.ProfileName) - } else if hasStoredCredentials { - log.Printf("profile %s: using stored credentials", config.ProfileName) - sourcecredsProvider = NewMasterCredentialsProvider(t.keyring, config.ProfileName) - } else if config.HasSourceProfile() { - sourcecredsProvider, err = t.provider(config.SourceProfile) - if err != nil { - return nil, err - } - } else if config.HasSSOStartURL() { - return NewSSORoleCredentialsProvider(t.keyring.Keyring, config) - } else if config.HasRole() && (config.HasWebIdentityTokenFile() || config.HasWebIdentityTokenProcess()) { - return NewAssumeRoleWithWebIdentityProvider(t.keyring.Keyring, config) - } else { - return nil, fmt.Errorf("profile %s: credentials missing", config.ProfileName) - } - - if hasStoredCredentials || !config.HasRole() { - if canUseGetSessionToken, reason := config.CanUseGetSessionToken(); !canUseGetSessionToken { - log.Printf("profile %s: skipping GetSessionToken because %s", config.ProfileName, reason) - if !config.HasRole() { - return sourcecredsProvider, nil - } - } + log.Printf("Using GetFederationToken for credentials") + return &FederationTokenProvider{ + StsClient: sts.NewFromConfig(cfg), + Name: name, + Duration: config.GetFederationTokenDuration, + }, nil +} - t.chainedMfa = config.MfaSerial - log.Printf("profile %s: using GetSessionToken %s", config.ProfileName, mfaDetails(false, config)) - sourcecredsProvider, err = NewSessionTokenProvider(sourcecredsProvider, t.keyring.Keyring, config) - if !config.HasRole() || err != nil { - return sourcecredsProvider, err - } +func FindMasterCredentialsNameFor(profileName string, keyring *CredentialKeyring, config *ProfileConfig) (string, error) { + hasMasterCreds, err := keyring.Has(profileName) + if err != nil { + return "", err } - isMfaChained := config.MfaSerial != "" && config.MfaSerial == t.chainedMfa - if isMfaChained { - config.MfaSerial = "" + if hasMasterCreds { + return profileName, nil + } + + if profileName == config.SourceProfileName { + return "", fmt.Errorf("No master credentials found") } - log.Printf("profile %s: using AssumeRole %s", config.ProfileName, mfaDetails(isMfaChained, config)) - return NewAssumeRoleProvider(sourcecredsProvider, t.keyring.Keyring, config) + return FindMasterCredentialsNameFor(config.SourceProfileName, keyring, config) } -func mfaDetails(mfaChained bool, config *Config) string { - if mfaChained { - return "(chained MFA)" - } - if config.HasMfaSerial() { - return "(with MFA)" - } - return "" +type TempCredentialsCreator struct { + Keyring *CredentialKeyring + // DisableSessions will disable the use of GetSessionToken + DisableSessions bool + // DisableCache will disable the use of the session cache + DisableCache bool + // DisableSessionsForProfile is a profile for which sessions should not be used + DisableSessionsForProfile string + + chainedMfa string } -// NewTempCredentialsProvider creates a credential provider for the given config -func NewTempCredentialsProvider(config *Config, keyring *CredentialKeyring) (aws.CredentialsProvider, error) { - t := tempCredsCreator{ - keyring: keyring, +func (t *TempCredentialsCreator) getSourceCreds(config *ProfileConfig, hasStoredCredentials bool) (sourcecredsProvider aws.CredentialsProvider, err error) { + if hasStoredCredentials { + log.Printf("profile %s: using stored credentials", config.ProfileName) + return NewMasterCredentialsProvider(t.Keyring, config.ProfileName), nil } - return t.provider(config) + + if config.HasSourceProfile() { + log.Printf("profile %s: sourcing credentials from profile %s", config.ProfileName, config.SourceProfile.ProfileName) + return t.GetProviderForProfile(config.SourceProfile) + } + + return nil, fmt.Errorf("profile %s: credentials missing", config.ProfileName) } -func NewFederationTokenCredentialsProvider(profileName string, k *CredentialKeyring, config *Config) (aws.CredentialsProvider, error) { - credentialsName, err := FindMasterCredentialsNameFor(profileName, k, config) +func (t *TempCredentialsCreator) getSourceCredWithSession(config *ProfileConfig, hasStoredCredentials bool) (sourcecredsProvider aws.CredentialsProvider, err error) { + sourcecredsProvider, err = t.getSourceCreds(config, hasStoredCredentials) if err != nil { return nil, err } - masterCreds := NewMasterCredentialsProvider(k, credentialsName) - cfg := NewAwsConfigWithCredsProvider(masterCreds, config.Region, config.STSRegionalEndpoints) + if config.HasRole() { + isMfaChained := config.MfaSerial != "" && config.MfaSerial == t.chainedMfa + if isMfaChained { + config.MfaSerial = "" + } + log.Printf("profile %s: using AssumeRole %s", config.ProfileName, mfaDetails(isMfaChained, config)) + return NewAssumeRoleProvider(sourcecredsProvider, t.Keyring.Keyring, config, !t.DisableCache) + } - currentUsername, err := GetUsernameFromSession(cfg) + if isMasterCredentialsProvider(sourcecredsProvider) { + canUseGetSessionToken, reason := t.canUseGetSessionToken(config) + if canUseGetSessionToken { + t.chainedMfa = config.MfaSerial + log.Printf("profile %s: using GetSessionToken %s", config.ProfileName, mfaDetails(false, config)) + return NewSessionTokenProvider(sourcecredsProvider, t.Keyring.Keyring, config, !t.DisableCache) + } + log.Printf("profile %s: skipping GetSessionToken because %s", config.ProfileName, reason) + } + + return sourcecredsProvider, nil +} + +func (t *TempCredentialsCreator) GetProviderForProfile(config *ProfileConfig) (aws.CredentialsProvider, error) { + hasStoredCredentials, err := t.Keyring.Has(config.ProfileName) if err != nil { return nil, err } - log.Printf("Using GetFederationToken for credentials") - return &FederationTokenProvider{ - StsClient: sts.NewFromConfig(cfg), - Name: currentUsername, - Duration: config.GetFederationTokenDuration, - }, nil + if hasStoredCredentials || config.HasSourceProfile() { + return t.getSourceCredWithSession(config, hasStoredCredentials) + } + + if config.HasSSOStartURL() { + log.Printf("profile %s: using SSO role credentials", config.ProfileName) + return NewSSORoleCredentialsProvider(t.Keyring.Keyring, config, !t.DisableCache) + } + + if config.HasWebIdentity() { + log.Printf("profile %s: using web identity", config.ProfileName) + return NewAssumeRoleWithWebIdentityProvider(t.Keyring.Keyring, config, !t.DisableCache) + } + + if config.HasCredentialProcess() { + log.Printf("profile %s: using credential process", config.ProfileName) + return NewCredentialProcessProvider(t.Keyring.Keyring, config, !t.DisableCache) + } + + return nil, fmt.Errorf("profile %s: credentials missing", config.ProfileName) } -func FindMasterCredentialsNameFor(profileName string, keyring *CredentialKeyring, config *Config) (string, error) { - hasMasterCreds, err := keyring.Has(profileName) - if err != nil { - return "", err +// canUseGetSessionToken determines if GetSessionToken should be used, and if not returns a reason +func (t *TempCredentialsCreator) canUseGetSessionToken(c *ProfileConfig) (bool, string) { + if t.DisableSessions { + return false, "sessions are disabled" + } + if t.DisableSessionsForProfile == c.ProfileName { + return false, "sessions are disabled for this profile" } - if hasMasterCreds { - return profileName, nil + if c.IsChained() { + if !c.ChainedFromProfile.HasMfaSerial() { + return false, fmt.Sprintf("profile '%s' has no MFA serial defined", c.ChainedFromProfile.ProfileName) + } + + if !c.HasMfaSerial() && c.ChainedFromProfile.HasMfaSerial() { + return false, fmt.Sprintf("profile '%s' has no MFA serial defined", c.ProfileName) + } + + if c.ChainedFromProfile.MfaSerial != c.MfaSerial { + return false, fmt.Sprintf("MFA serial doesn't match profile '%s'", c.ChainedFromProfile.ProfileName) + } + + if c.ChainedFromProfile.AssumeRoleDuration > roleChainingMaximumDuration { + return false, fmt.Sprintf("duration %s in profile '%s' is greater than the AWS maximum %s for chaining MFA", c.ChainedFromProfile.AssumeRoleDuration, c.ChainedFromProfile.ProfileName, roleChainingMaximumDuration) + } } - if profileName == config.SourceProfileName { - return "", fmt.Errorf("No master credentials found") + return true, "" +} + +func mfaDetails(mfaChained bool, config *ProfileConfig) string { + if mfaChained { + return "(chained MFA)" + } + if config.HasMfaSerial() { + return "(with MFA)" } + return "" +} - return FindMasterCredentialsNameFor(config.SourceProfileName, keyring, config) +// NewTempCredentialsProvider creates a credential provider for the given config +func NewTempCredentialsProvider(config *ProfileConfig, keyring *CredentialKeyring, disableSessions bool, disableCache bool) (aws.CredentialsProvider, error) { + t := TempCredentialsCreator{ + Keyring: keyring, + DisableSessions: disableSessions, + DisableCache: disableCache, + } + return t.GetProviderForProfile(config) } diff --git a/vault/vault_test.go b/vault/vault_test.go new file mode 100644 index 000000000..75a047ad8 --- /dev/null +++ b/vault/vault_test.go @@ -0,0 +1,125 @@ +package vault_test + +import ( + "os" + "testing" + + "github.com/99designs/aws-vault/v7/vault" + "github.com/99designs/keyring" +) + +func TestUsageWebIdentityExample(t *testing.T) { + f := newConfigFile(t, []byte(` +[profile role2] +role_arn = arn:aws:iam::33333333333:role/role2 +web_identity_token_process = oidccli raw +`)) + defer os.Remove(f) + configFile, err := vault.LoadConfig(f) + if err != nil { + t.Fatal(err) + } + configLoader := &vault.ConfigLoader{File: configFile, ActiveProfile: "role2"} + config, err := configLoader.GetProfileConfig("role2") + if err != nil { + t.Fatalf("Should have found a profile: %v", err) + } + + ckr := &vault.CredentialKeyring{Keyring: keyring.NewArrayKeyring([]keyring.Item{})} + p, err := vault.NewTempCredentialsProvider(config, ckr, true, true) + if err != nil { + t.Fatal(err) + } + + _, ok := p.(*vault.AssumeRoleWithWebIdentityProvider) + if !ok { + t.Fatalf("Expected AssumeRoleWithWebIdentityProvider, got %T", p) + } +} + +func TestIssue1176(t *testing.T) { + f := newConfigFile(t, []byte(` +[profile my-shared-base-profile] +credential_process=aws-vault exec my-shared-base-profile -j +mfa_serial=arn:aws:iam::1234567890:mfa/danielholz +region=eu-west-1 + +[profile profile-with-role] +source_profile=my-shared-base-profile +include_profile=my-shared-base-profile +region=eu-west-1 +role_arn=arn:aws:iam::12345678901:role/allow-view-only-access-from-other-accounts +`)) + defer os.Remove(f) + configFile, err := vault.LoadConfig(f) + if err != nil { + t.Fatal(err) + } + configLoader := &vault.ConfigLoader{File: configFile, ActiveProfile: "my-shared-base-profile"} + config, err := configLoader.GetProfileConfig("my-shared-base-profile") + if err != nil { + t.Fatalf("Should have found a profile: %v", err) + } + + ckr := &vault.CredentialKeyring{Keyring: keyring.NewArrayKeyring([]keyring.Item{})} + p, err := vault.NewTempCredentialsProvider(config, ckr, true, true) + if err != nil { + t.Fatal(err) + } + + _, ok := p.(*vault.CredentialProcessProvider) + if !ok { + t.Fatalf("Expected CredentialProcessProvider, got %T", p) + } +} + +func TestIssue1195(t *testing.T) { + f := newConfigFile(t, []byte(` +[profile test] +source_profile=dev +region=ap-northeast-2 + +[profile dev] +sso_session=common +sso_account_id=2160xxxx +sso_role_name=AdministratorAccess +region=ap-northeast-2 +output=json + +[default] +sso_session=common +sso_account_id=3701xxxx +sso_role_name=AdministratorAccess +region=ap-northeast-2 +output=json + +[sso-session common] +sso_start_url=https://xxxx.awsapps.com/start +sso_region=ap-northeast-2 +sso_registration_scopes=sso:account:access +`)) + defer os.Remove(f) + configFile, err := vault.LoadConfig(f) + if err != nil { + t.Fatal(err) + } + configLoader := &vault.ConfigLoader{File: configFile, ActiveProfile: "test"} + config, err := configLoader.GetProfileConfig("test") + if err != nil { + t.Fatalf("Should have found a profile: %v", err) + } + + ckr := &vault.CredentialKeyring{Keyring: keyring.NewArrayKeyring([]keyring.Item{})} + p, err := vault.NewTempCredentialsProvider(config, ckr, true, true) + if err != nil { + t.Fatal(err) + } + + ssoProvider, ok := p.(*vault.SSORoleCredentialsProvider) + if !ok { + t.Fatalf("Expected SSORoleCredentialsProvider, got %T", p) + } + if ssoProvider.AccountID != "2160xxxx" { + t.Fatalf("Expected AccountID to be 2160xxxx, got %s", ssoProvider.AccountID) + } +}