From 61145d5a3b16d46cb918a31cf85b68c673232b9a Mon Sep 17 00:00:00 2001 From: Aaron Turner Date: Thu, 27 Jun 2024 15:51:25 -0700 Subject: [PATCH] Improve bearer token support - Configure the bearer token in the SecureStore - Document using ssh with aws-sso and ECS Server - Bump to v1.17.0 Fixes: #915 --- Makefile | 2 +- cmd/aws-sso/ecs_client_cmd.go | 21 ++++++++----- cmd/aws-sso/ecs_cmd.go | 43 ++++++++++++++++++++----- docs/remote-ssh.md | 49 +++++++++++++++++++++++++++++ internal/storage/json_store.go | 21 ++++++++++++- internal/storage/json_store_test.go | 22 +++++++++++++ internal/storage/keyring.go | 48 +++++++++++++++++++--------- internal/storage/keyring_test.go | 24 +++++++++++++- internal/storage/secure_store.go | 5 +++ 9 files changed, 203 insertions(+), 32 deletions(-) create mode 100644 docs/remote-ssh.md diff --git a/Makefile b/Makefile index c4d593c4..c54cc8d5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PROJECT_VERSION := 1.16.1 +PROJECT_VERSION := 1.17.0 DOCKER_REPO := synfinatic PROJECT_NAME := aws-sso diff --git a/cmd/aws-sso/ecs_client_cmd.go b/cmd/aws-sso/ecs_client_cmd.go index 2e4fe17c..60e47741 100644 --- a/cmd/aws-sso/ecs_client_cmd.go +++ b/cmd/aws-sso/ecs_client_cmd.go @@ -32,7 +32,9 @@ import ( "github.com/synfinatic/gotable" ) -type EcsListCmd struct{} +type EcsListCmd struct { + Auth string `kong:"help='Full HTTP Authorization token to use for ECS Server',env='AWS_CONTAINER_AUTHORIZATION_TOKEN'"` +} type EcsLoadCmd struct { // AWS Params @@ -42,15 +44,18 @@ type EcsLoadCmd struct { Profile string `kong:"short='p',help='Name of AWS Profile to assume',predictor='profile',xor='account,role'"` // Other params - Port int `kong:"help='TCP port of aws-sso ECS Server',env='AWS_SSO_ECS_PORT',default=4144"` // SEE ECS_PORT in ecs_cmd.go - Slotted bool `kong:"short='s',help='Load credentials in a unique slot using the ProfileName as the key'"` + Auth string `kong:"help='Full HTTP Authorization token to use for ECS Server',env='AWS_CONTAINER_AUTHORIZATION_TOKEN'"` + Port int `kong:"help='TCP port of aws-sso ECS Server',env='AWS_SSO_ECS_PORT',default=4144"` // SEE ECS_PORT in ecs_cmd.go + Slotted bool `kong:"short='s',help='Load credentials in a unique slot using the ProfileName as the key'"` } type EcsProfileCmd struct { - Port int `kong:"help='TCP port of aws-sso ECS Server',env='AWS_SSO_ECS_PORT',default=4144"` + Auth string `kong:"help='Full HTTP Authorization token to use for ECS Server',env='AWS_CONTAINER_AUTHORIZATION_TOKEN'"` + Port int `kong:"help='TCP port of aws-sso ECS Server',env='AWS_SSO_ECS_PORT',default=4144"` } type EcsUnloadCmd struct { + Auth string `kong:"help='Full HTTP Authorization token to use for ECS Server',env='AWS_CONTAINER_AUTHORIZATION_TOKEN'"` Port int `kong:"help='TCP port of aws-sso ECS Server',env='AWS_SSO_ECS_PORT',default=4144"` Profile string `kong:"short='p',help='Name of AWS Profile to unload',predictor='profile'"` } @@ -69,7 +74,7 @@ func (cc *EcsLoadCmd) Run(ctx *RunContext) error { } func (cc *EcsProfileCmd) Run(ctx *RunContext) error { - c := client.NewECSClient(ctx.Cli.Ecs.Profile.Port, ctx.Cli.Ecs.SecurityToken) + c := client.NewECSClient(ctx.Cli.Ecs.Profile.Port, ctx.Cli.Ecs.Profile.Auth) profile, err := c.GetProfile() if err != nil { @@ -87,7 +92,7 @@ func (cc *EcsProfileCmd) Run(ctx *RunContext) error { } func (cc *EcsUnloadCmd) Run(ctx *RunContext) error { - c := client.NewECSClient(ctx.Cli.Ecs.Unload.Port, ctx.Cli.Ecs.SecurityToken) + c := client.NewECSClient(ctx.Cli.Ecs.Unload.Port, ctx.Cli.Ecs.Unload.Auth) return c.Delete(ctx.Cli.Ecs.Unload.Profile) } @@ -115,14 +120,14 @@ func ecsLoadCmd(ctx *RunContext, awssso *sso.AWSSSO, accountId int64, role strin } // do something - c := client.NewECSClient(ctx.Cli.Ecs.Load.Port, ctx.Cli.Ecs.SecurityToken) + c := client.NewECSClient(ctx.Cli.Ecs.Load.Port, ctx.Cli.Ecs.Load.Auth) log.Debugf("%s", spew.Sdump(rFlat)) return c.SubmitCreds(creds, rFlat.Profile, ctx.Cli.Ecs.Load.Slotted) } func (cc *EcsListCmd) Run(ctx *RunContext) error { - c := client.NewECSClient(ctx.Cli.Ecs.Profile.Port, ctx.Cli.Ecs.SecurityToken) + c := client.NewECSClient(ctx.Cli.Ecs.Profile.Port, ctx.Cli.Ecs.List.Auth) profiles, err := c.ListProfiles() if err != nil { diff --git a/cmd/aws-sso/ecs_cmd.go b/cmd/aws-sso/ecs_cmd.go index 5e41f199..fc431ea9 100644 --- a/cmd/aws-sso/ecs_cmd.go +++ b/cmd/aws-sso/ecs_cmd.go @@ -22,6 +22,7 @@ import ( "context" "fmt" "net" + "strings" // "github.com/davecgh/go-spew/spew" "github.com/synfinatic/aws-sso-cli/internal/ecs/server" @@ -32,12 +33,12 @@ const ( ) type EcsCmd struct { - Run EcsRunCmd `kong:"cmd,help='Run the ECS Server'"` - List EcsListCmd `kong:"cmd,help='List profiles loaded in the ECS Server'"` - Load EcsLoadCmd `kong:"cmd,help='Load new IAM Role credentials into the ECS Server'"` - Unload EcsUnloadCmd `kong:"cmd,help='Unload the current IAM Role credentials from the ECS Server'"` - Profile EcsProfileCmd `kong:"cmd,help='Get the current role profile name in the default slot'"` - SecurityToken string `kong:"help='Security Token to use for authentication',env='AWS_CONTAINER_AUTHORIZATION_TOKEN'"` + Run EcsRunCmd `kong:"cmd,help='Run the ECS Server'"` + BearerToken EcsBearerTokenCmd `kong:"cmd,help='Configure the ECS Server bearer token'"` + List EcsListCmd `kong:"cmd,help='List profiles loaded in the ECS Server'"` + Load EcsLoadCmd `kong:"cmd,help='Load new IAM Role credentials into the ECS Server'"` + Unload EcsUnloadCmd `kong:"cmd,help='Unload the current IAM Role credentials from the ECS Server'"` + Profile EcsProfileCmd `kong:"cmd,help='Get the current role profile name in the default slot'"` } type EcsRunCmd struct { @@ -49,9 +50,37 @@ func (cc *EcsRunCmd) Run(ctx *RunContext) error { if err != nil { return err } - s, err := server.NewEcsServer(context.TODO(), ctx.Cli.Ecs.SecurityToken, l) + + token, err := ctx.Store.GetEcsBearerToken() + if err != nil { + return err + } + if token == "" { + log.Warnf("No authentication token set, use 'aws-sso ecs bearer-token' to set one") + } + + s, err := server.NewEcsServer(context.TODO(), token, l) if err != nil { return err } return s.Serve() } + +type EcsBearerTokenCmd struct { + Token string `kong:"short=t,help='Bearer token value to use for ECS Server',xor='flag'"` + Delete bool `kong:"short=d,help='Delete the current bearer token',xor='flag'"` +} + +func (cc *EcsBearerTokenCmd) Run(ctx *RunContext) error { + // Store the token in the SecureStore + if ctx.Cli.Ecs.BearerToken.Delete { + return ctx.Store.DeleteEcsBearerToken() + } + if ctx.Cli.Ecs.BearerToken.Token == "" { + return fmt.Errorf("no token provided") + } + if !strings.HasPrefix(ctx.Cli.Ecs.BearerToken.Token, "Bearer ") { + return fmt.Errorf("token should start with 'Bearer '") + } + return ctx.Store.SaveEcsBearerToken(ctx.Cli.Ecs.BearerToken.Token) +} diff --git a/docs/remote-ssh.md b/docs/remote-ssh.md new file mode 100644 index 00000000..1040ebb9 --- /dev/null +++ b/docs/remote-ssh.md @@ -0,0 +1,49 @@ +# Using aws-sso on remote hosts with SSH + +This is intended to show how to use your `aws-sso` credentials on a remote/bastion +host, without requring you to install or configure `aws-sso` on that host, while maintaining +security. + +## Overview + +**Note:** Before going any further, this document assumes you have already +[installed and configured](quickstart.md) aws-sso on your local system. +If not, do that now. :) + +Accessing one or more AWS Identity Center based IAM Roles uses the [ECS Server](ecs-server.md) +feature and then running the ECS Server locally and using ssh to forward the port to the remote +host. Security is provided via a bearer token you configure on each side and all traffic is +encrypted over ssh. + +**Note:** The root user or anyone with [CAP_NET_RAW or CAP_NET_ADMIN](https://man7.org/linux/man-pages/man7/capabilities.7.html) +will be able to intercept the HTTP traffic on the endpoint and obtain the bearer token +and/or any IAM Credentials stored in the ECS Server. As of this time, `aws-sso` does +not support HTTPS for full end-to-end encryption. + +## On your local system + +1. Configure a [bearer token](https://datatracker.ietf.org/doc/html/rfc6750#section-2.1) +for security to prevent unauthorized use of your IAM credentials: +`aws-sso ecs bearer-token -t 'Bearer ` +1. Start the ECS Server (preferably in a screen or tmux session): +`aws-sso ecs run` +1. Load your selected IAM credentials into the ECS Server: +`aws-sso ecs load --profile=` +1. SSH to the remote system using the [-R flag to forward tcp/4144](https://man.openbsd.org/ssh#R): +`ssh -R 4144:localhost:4144 ` + +## On your remote system (once you have logged in as described above) + +1. Tell the AWS SDK how to talk to the ECS Server over SSH: +`export AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:4144/` +1. Tell the AWS SDK the bearer token secret from the first step on your local system: +`export AWS_CONTAINER_AUTHORIZATION_TOKEN='Bearer ` +1. Verify everything works: +`aws sts get-caller-identity` + +**Important:** You must choose a strong secret value for your bearer token secret! This is +what prevents anyone else from using your IAM credentials without your permission. Your bearer +token should be long and random enough to prevent bruteforce attacks. + +See the [ECS Server documentation](ecs-server.md) for more information about the ECS server and +how to use multiple IAM role credentials simultaneously. \ No newline at end of file diff --git a/internal/storage/json_store.go b/internal/storage/json_store.go index 08621a23..2516ffe1 100644 --- a/internal/storage/json_store.go +++ b/internal/storage/json_store.go @@ -37,6 +37,7 @@ type JsonStore struct { CreateTokenResponse map[string]CreateTokenResponse `json:"CreateTokenResponse,omitempty"` RoleCredentials map[string]RoleCredentials `json:"RoleCredentials,omitempty"` // ARN = key StaticCredentials map[string]StaticCredentials `json:"StaticCredentials,omitempty"` // ARN = key + EcsBearerToken string `json:"EcsBearerToken,omitempty"` } // OpenJsonStore opens our insecure JSON storage backend @@ -48,13 +49,14 @@ func OpenJsonStore(filename string) (*JsonStore, error) { CreateTokenResponse: map[string]CreateTokenResponse{}, RoleCredentials: map[string]RoleCredentials{}, StaticCredentials: map[string]StaticCredentials{}, + EcsBearerToken: "", } cacheBytes, err := os.ReadFile(filename) if errors.Is(err, fs.ErrNotExist) { return &cache, nil } else if err != nil { - return &cache, fmt.Errorf("Unable to open %s: %s", filename, err.Error()) + return &cache, fmt.Errorf("unable to open %s: %s", filename, err.Error()) } if len(cacheBytes) > 0 { @@ -184,3 +186,20 @@ func (jc *JsonStore) ListStaticCredentials() []string { } return ret } + +// SaveEcsBearerToken stores the token in the json file +func (jc *JsonStore) SaveEcsBearerToken(token string) error { + jc.EcsBearerToken = token + return jc.save() +} + +// GetEcsBearerToken retrieves the token from the json file +func (jc *JsonStore) GetEcsBearerToken() (string, error) { + return jc.EcsBearerToken, nil +} + +// DeleteEcsBearerToken deletes the token from the json file +func (jc *JsonStore) DeleteEcsBearerToken() error { + jc.EcsBearerToken = "" + return jc.save() +} diff --git a/internal/storage/json_store_test.go b/internal/storage/json_store_test.go index 3a4093e5..7fa2cce9 100644 --- a/internal/storage/json_store_test.go +++ b/internal/storage/json_store_test.go @@ -206,3 +206,25 @@ func (s *JsonStoreTestSuite) TestStaticCredentials() { assert.NoError(t, s.json.GetStaticCredentials("arn:aws:iam::123456789012:user/foobar", &cr)) assert.Equal(t, cr2, cr) } + +func (s *JsonStoreTestSuite) TestEcsBearerToken() { + t := s.T() + + token, err := s.json.GetEcsBearerToken() + assert.NoError(t, err) + assert.Empty(t, token) + + err = s.json.SaveEcsBearerToken("not a real token") + assert.NoError(t, err) + + token, err = s.json.GetEcsBearerToken() + assert.NoError(t, err) + assert.Equal(t, "not a real token", token) + + err = s.json.DeleteEcsBearerToken() + assert.NoError(t, err) + + token, err = s.json.GetEcsBearerToken() + assert.NoError(t, err) + assert.Empty(t, token) +} diff --git a/internal/storage/keyring.go b/internal/storage/keyring.go index 523d6130..ce3e18d1 100644 --- a/internal/storage/keyring.go +++ b/internal/storage/keyring.go @@ -54,6 +54,7 @@ type StorageData struct { CreateTokenResponse map[string]CreateTokenResponse RoleCredentials map[string]RoleCredentials StaticCredentials map[string]StaticCredentials + EcsBearerToken string } func NewStorageData() StorageData { @@ -62,6 +63,7 @@ func NewStorageData() StorageData { CreateTokenResponse: map[string]CreateTokenResponse{}, RoleCredentials: map[string]RoleCredentials{}, StaticCredentials: map[string]StaticCredentials{}, + EcsBearerToken: "", } } @@ -115,14 +117,14 @@ func NewKeyringConfig(name, configDir string) (*keyring.Config, error) { if password := os.Getenv(ENV_SSO_FILE_PASSWORD); password == "" { pass1, err := getPasswordFunc("Select password") if err != nil { - return &c, fmt.Errorf("Password error: %s", err.Error()) + return &c, fmt.Errorf("password error: %s", err.Error()) } pass2, err := getPasswordFunc("Verify password") if err != nil { - return &c, fmt.Errorf("Password error: %s", err.Error()) + return &c, fmt.Errorf("password error: %s", err.Error()) } if pass1 != pass2 { - return &c, fmt.Errorf("Password missmatch") + return &c, fmt.Errorf("password missmatch") } NewPassword = pass1 } @@ -224,7 +226,7 @@ func (kr *KeyringStore) joinAndGetKeyringData(key string) ([]byte, error) { } if len(chunk) < 8 { - return nil, fmt.Errorf("Invalid stored data in Keyring. Only %d bytes", len(chunk)) + return nil, fmt.Errorf("invalid stored data in Keyring. Only %d bytes", len(chunk)) } totalBytes, data := binary.BigEndian.Uint64(chunk[:8]), chunk[8:] @@ -233,14 +235,14 @@ func (kr *KeyringStore) joinAndGetKeyringData(key string) ([]byte, error) { for i := 1; readBytes < totalBytes; i++ { k := fmt.Sprintf("%s_%d", key, i) if chunk, err = kr.getKeyringData(k); err != nil { - return nil, fmt.Errorf("Unable to fetch %s: %s", k, err.Error()) + return nil, fmt.Errorf("unable to fetch %s: %s", k, err.Error()) } data = append(data, chunk...) readBytes += uint64(len(chunk)) } if readBytes != totalBytes { - return nil, fmt.Errorf("Invalid stored data in Keyring. Expected %d bytes, but read %d bytes of data", + return nil, fmt.Errorf("invalid stored data in Keyring. Expected %d bytes, but read %d bytes of data", totalBytes, readBytes) } return data, nil @@ -316,7 +318,7 @@ func (kr *KeyringStore) GetRegisterClientData(region string, client *RegisterCli var ok bool key := kr.RegisterClientKey(region) if *client, ok = kr.cache.RegisterClientData[key]; !ok { - return fmt.Errorf("No RegisterClientData for %s", region) + return fmt.Errorf("no RegisterClientData for %s", region) } return nil } @@ -326,7 +328,7 @@ func (kr *KeyringStore) DeleteRegisterClientData(region string) error { key := kr.RegisterClientKey(region) if _, ok := kr.cache.RegisterClientData[key]; !ok { // return error if key doesn't exist - return fmt.Errorf("No RegisterClientData for key: %s", key) + return fmt.Errorf("no RegisterClientData for key: %s", key) } delete(kr.cache.RegisterClientData, key) @@ -350,7 +352,7 @@ func (kr *KeyringStore) GetCreateTokenResponse(key string, token *CreateTokenRes var ok bool k := kr.CreateTokenResponseKey(key) if *token, ok = kr.cache.CreateTokenResponse[k]; !ok { - return fmt.Errorf("No CreateTokenResponse for %s", k) + return fmt.Errorf("no CreateTokenResponse for %s", k) } return nil } @@ -360,7 +362,7 @@ func (kr *KeyringStore) DeleteCreateTokenResponse(key string) error { k := kr.CreateTokenResponseKey(key) if _, ok := kr.cache.CreateTokenResponse[k]; !ok { // return error if key doesn't exist - return fmt.Errorf("No CreateTokenResponse for key: %s", k) + return fmt.Errorf("no CreateTokenResponse for key: %s", k) } delete(kr.cache.CreateTokenResponse, k) @@ -377,7 +379,7 @@ func (kr *KeyringStore) SaveRoleCredentials(arn string, token RoleCredentials) e func (kr *KeyringStore) GetRoleCredentials(arn string, token *RoleCredentials) error { var ok bool if *token, ok = kr.cache.RoleCredentials[arn]; !ok { - return fmt.Errorf("No RoleCredentials for ARN: %s", arn) + return fmt.Errorf("no RoleCredentials for ARN: %s", arn) } return nil } @@ -386,7 +388,7 @@ func (kr *KeyringStore) GetRoleCredentials(arn string, token *RoleCredentials) e func (kr *KeyringStore) DeleteRoleCredentials(arn string) error { if _, ok := kr.cache.RoleCredentials[arn]; !ok { // return error if key doesn't exist - return fmt.Errorf("No RoleCredentials for ARN: %s", arn) + return fmt.Errorf("no RoleCredentials for ARN: %s", arn) } delete(kr.cache.RoleCredentials, arn) @@ -403,7 +405,7 @@ func (kr *KeyringStore) SaveStaticCredentials(arn string, creds StaticCredential func (kr *KeyringStore) GetStaticCredentials(arn string, creds *StaticCredentials) error { var ok bool if *creds, ok = kr.cache.StaticCredentials[arn]; !ok { - return fmt.Errorf("No StaticCredentials for ARN: %s", arn) + return fmt.Errorf("no StaticCredentials for ARN: %s", arn) } return nil } @@ -412,13 +414,14 @@ func (kr *KeyringStore) GetStaticCredentials(arn string, creds *StaticCredential func (kr *KeyringStore) DeleteStaticCredentials(arn string) error { if _, ok := kr.cache.StaticCredentials[arn]; !ok { // return error if key doesn't exist - return fmt.Errorf("No StaticCredentials for ARN: %s", arn) + return fmt.Errorf("no StaticCredentials for ARN: %s", arn) } delete(kr.cache.StaticCredentials, arn) return kr.saveStorageData() } +// ListStaticCredentials returns a list of all the ARNs in the keyring func (kr *KeyringStore) ListStaticCredentials() []string { ret := make([]string, len(kr.cache.StaticCredentials)) i := 0 @@ -428,3 +431,20 @@ func (kr *KeyringStore) ListStaticCredentials() []string { } return ret } + +// SaveEcsBearerToken stores the token in the keyring +func (kr *KeyringStore) SaveEcsBearerToken(token string) error { + kr.cache.EcsBearerToken = token + return kr.saveStorageData() +} + +// GetEcsBearerToken retrieves the token from the keyring +func (kr *KeyringStore) GetEcsBearerToken() (string, error) { + return kr.cache.EcsBearerToken, nil +} + +// DeleteEcsBearerToken deletes the token from the keyring +func (kr *KeyringStore) DeleteEcsBearerToken() error { + kr.cache.EcsBearerToken = "" + return kr.saveStorageData() +} diff --git a/internal/storage/keyring_test.go b/internal/storage/keyring_test.go index 7e4b670d..bb493699 100644 --- a/internal/storage/keyring_test.go +++ b/internal/storage/keyring_test.go @@ -150,6 +150,28 @@ func (suite *KeyringSuite) TestRoleCredentials() { assert.Error(t, err) } +func (suite *KeyringSuite) TestEcsBearerToken() { + t := suite.T() + + token, err := suite.store.GetEcsBearerToken() + assert.NoError(t, err) + assert.Empty(t, token) + + err = suite.store.SaveEcsBearerToken("not a real token") + assert.NoError(t, err) + + token, err = suite.store.GetEcsBearerToken() + assert.NoError(t, err) + assert.Equal(t, "not a real token", token) + + err = suite.store.DeleteEcsBearerToken() + assert.NoError(t, err) + + token, err = suite.store.GetEcsBearerToken() + assert.NoError(t, err) + assert.Empty(t, token) +} + func (suite *KeyringSuite) TestErrorReadKeyring() { t := suite.T() // Read non existent key @@ -509,5 +531,5 @@ func TestSplitCredentials(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, hook.LastEntry()) assert.Equal(t, logrus.WarnLevel, hook.LastEntry().Level) - assert.Contains(t, hook.LastEntry().Message, "Unable to fetch") + assert.Contains(t, hook.LastEntry().Message, "unable to fetch") } diff --git a/internal/storage/secure_store.go b/internal/storage/secure_store.go index b8e976bd..a2c98722 100644 --- a/internal/storage/secure_store.go +++ b/internal/storage/secure_store.go @@ -38,4 +38,9 @@ type SecureStorage interface { GetStaticCredentials(string, *StaticCredentials) error DeleteStaticCredentials(string) error ListStaticCredentials() []string + + // ECS Server Bearer Token + SaveEcsBearerToken(string) error + GetEcsBearerToken() (string, error) + DeleteEcsBearerToken() error }