diff --git a/command/agent/approle_end_to_end_test.go b/command/agent/approle_end_to_end_test.go index 6d8085750f..83bf4f4aa1 100644 --- a/command/agent/approle_end_to_end_test.go +++ b/command/agent/approle_end_to_end_test.go @@ -294,3 +294,254 @@ func testAppRoleEndToEnd(t *testing.T, removeSecretIDFile bool) { } } } + +func TestAppRoleWithWrapping(t *testing.T) { + var err error + logger := logging.NewVaultLogger(log.Trace) + coreConfig := &vault.CoreConfig{ + DisableMlock: true, + DisableCache: true, + Logger: log.NewNullLogger(), + CredentialBackends: map[string]logical.Factory{ + "approle": credAppRole.Factory, + }, + } + + cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + }) + + cluster.Start() + defer cluster.Cleanup() + + cores := cluster.Cores + + vault.TestWaitActive(t, cores[0].Core) + + client := cores[0].Client + origToken := client.Token() + + err = client.Sys().EnableAuthWithOptions("approle", &api.EnableAuthOptions{ + Type: "approle", + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("auth/approle/role/test1", map[string]interface{}{ + "bind_secret_id": "true", + "token_ttl": "3s", + "token_max_ttl": "10s", + }) + if err != nil { + t.Fatal(err) + } + + client.SetWrappingLookupFunc(func(operation, path string) string { + if path == "auth/approle/role/test1/secret-id" { + return "10s" + } + return "" + }) + + resp, err := client.Logical().Write("auth/approle/role/test1/secret-id", nil) + if err != nil { + t.Fatal(err) + } + secretID1 := resp.WrapInfo.Token + + resp, err = client.Logical().Read("auth/approle/role/test1/role-id") + if err != nil { + t.Fatal(err) + } + roleID1 := resp.Data["role_id"].(string) + + rolef, err := ioutil.TempFile("", "auth.role-id.test.") + if err != nil { + t.Fatal(err) + } + role := rolef.Name() + rolef.Close() // WriteFile doesn't need it open + defer os.Remove(role) + t.Logf("input role_id_file_path: %s", role) + + secretf, err := ioutil.TempFile("", "auth.secret-id.test.") + if err != nil { + t.Fatal(err) + } + secret := secretf.Name() + secretf.Close() + defer os.Remove(secret) + t.Logf("input secret_id_file_path: %s", secret) + + // We close these right away because we're just basically testing + // permissions and finding a usable file name + ouf, err := ioutil.TempFile("", "auth.tokensink.test.") + if err != nil { + t.Fatal(err) + } + out := ouf.Name() + ouf.Close() + os.Remove(out) + t.Logf("output: %s", out) + + ctx, cancelFunc := context.WithCancel(context.Background()) + timer := time.AfterFunc(30*time.Second, func() { + cancelFunc() + }) + defer timer.Stop() + + conf := map[string]interface{}{ + "role_id_file_path": role, + "secret_id_file_path": secret, + "secret_id_response_wrapping_path": "auth/approle/role/test1/secret-id", + "remove_secret_id_file_after_reading": true, + } + + am, err := agentapprole.NewApproleAuthMethod(&auth.AuthConfig{ + Logger: logger.Named("auth.approle"), + MountPath: "auth/approle", + Config: conf, + }) + if err != nil { + t.Fatal(err) + } + ahConfig := &auth.AuthHandlerConfig{ + Logger: logger.Named("auth.handler"), + Client: client, + } + ah := auth.NewAuthHandler(ahConfig) + go ah.Run(ctx, am) + defer func() { + <-ah.DoneCh + }() + + config := &sink.SinkConfig{ + Logger: logger.Named("sink.file"), + Config: map[string]interface{}{ + "path": out, + }, + } + fs, err := file.NewFileSink(config) + if err != nil { + t.Fatal(err) + } + config.Sink = fs + + ss := sink.NewSinkServer(&sink.SinkServerConfig{ + Logger: logger.Named("sink.server"), + Client: client, + }) + go ss.Run(ctx, ah.OutputCh, []*sink.SinkConfig{config}) + defer func() { + <-ss.DoneCh + }() + + // This has to be after the other defers so it happens first + defer cancelFunc() + + // Check that no sink file exists + _, err = os.Lstat(out) + if err == nil { + t.Fatal("expected err") + } + if !os.IsNotExist(err) { + t.Fatal("expected notexist err") + } + + if err := ioutil.WriteFile(role, []byte(roleID1), 0600); err != nil { + t.Fatal(err) + } else { + logger.Trace("wrote test role 1", "path", role) + } + + if err := ioutil.WriteFile(secret, []byte(secretID1), 0600); err != nil { + t.Fatal(err) + } else { + logger.Trace("wrote test secret 1", "path", secret) + } + + checkToken := func() string { + timeout := time.Now().Add(5 * time.Second) + for { + if time.Now().After(timeout) { + t.Fatal("did not find a written token after timeout") + } + val, err := ioutil.ReadFile(out) + if err == nil { + os.Remove(out) + if len(val) == 0 { + t.Fatal("written token was empty") + } + + if _, err := os.Stat(secret); err == nil { + t.Fatal("secret ID file does not exist but was not supposed to be removed") + } + + client.SetToken(string(val)) + secret, err := client.Auth().Token().LookupSelf() + if err != nil { + t.Fatal(err) + } + return secret.Data["entity_id"].(string) + } + time.Sleep(250 * time.Millisecond) + } + } + origEntity := checkToken() + + // Make sure it gets renewed + timeout := time.Now().Add(4 * time.Second) + for { + if time.Now().After(timeout) { + break + } + secret, err := client.Auth().Token().LookupSelf() + if err != nil { + t.Fatal(err) + } + ttl, err := secret.Data["ttl"].(json.Number).Int64() + if err != nil { + t.Fatal(err) + } + if ttl > 3 { + t.Fatalf("unexpected ttl: %v", secret.Data["ttl"]) + } + } + + // Write new values + client.SetToken(origToken) + resp, err = client.Logical().Write("auth/approle/role/test1/secret-id", nil) + if err != nil { + t.Fatal(err) + } + secretID2 := resp.WrapInfo.Token + if err := ioutil.WriteFile(secret, []byte(secretID2), 0600); err != nil { + t.Fatal(err) + } else { + logger.Trace("wrote test secret 2", "path", secret) + } + + newEntity := checkToken() + if newEntity != origEntity { + t.Fatal("did not find same entity") + } + + timeout = time.Now().Add(4 * time.Second) + for { + if time.Now().After(timeout) { + break + } + secret, err := client.Auth().Token().LookupSelf() + if err != nil { + t.Fatal(err) + } + ttl, err := secret.Data["ttl"].(json.Number).Int64() + if err != nil { + t.Fatal(err) + } + if ttl > 3 { + t.Fatalf("unexpected ttl: %v", secret.Data["ttl"]) + } + } +} diff --git a/command/agent/auth/approle/approle.go b/command/agent/auth/approle/approle.go index 398399558f..a4aa85e330 100644 --- a/command/agent/auth/approle/approle.go +++ b/command/agent/auth/approle/approle.go @@ -24,6 +24,7 @@ type approleMethod struct { cachedRoleID string cachedSecretID string removeSecretIDFileAfterReading bool + secretIDResponseWrappingPath string } func NewApproleAuthMethod(conf *auth.AuthConfig) (auth.AuthMethod, error) { @@ -73,6 +74,17 @@ func NewApproleAuthMethod(conf *auth.AuthConfig) (auth.AuthMethod, error) { a.removeSecretIDFileAfterReading = removeSecretIDFileAfterReading } + secretIDResponseWrappingPathRaw, ok := conf.Config["secret_id_response_wrapping_path"] + if ok { + a.secretIDResponseWrappingPath, ok = secretIDResponseWrappingPathRaw.(string) + if !ok { + return nil, errors.New("could not convert 'secret_id_response_wrapping_path' config value to string") + } + if a.secretIDResponseWrappingPath == "" { + return nil, errors.New("'secret_id_response_wrapping_path' value is empty") + } + } + return a, nil } @@ -108,7 +120,58 @@ func (a *approleMethod) Authenticate(ctx context.Context, client *api.Client) (s } a.logger.Warn("secret ID file exists but read empty value, re-using cached value") } else { - a.cachedSecretID = strings.TrimSpace(string(secretID)) + stringSecretID := strings.TrimSpace(string(secretID)) + if a.secretIDResponseWrappingPath != "" { + clonedClient, err := client.Clone() + if err != nil { + return "", nil, errwrap.Wrapf("error cloning client to unwrap secret ID: {{err}}", err) + } + clonedClient.SetToken(stringSecretID) + // Validate the creation path + resp, err := clonedClient.Logical().Read("sys/wrapping/lookup") + if err != nil { + return "", nil, errwrap.Wrapf("error looking up wrapped secret ID: {{err}}", err) + } + if resp == nil { + return "", nil, errors.New("response nil when looking up wrapped secret ID") + } + if resp.Data == nil { + return "", nil, errors.New("data in repsonse nil when looking up wrapped secret ID") + } + creationPathRaw, ok := resp.Data["creation_path"] + if !ok { + return "", nil, errors.New("creation_path in repsonse nil when looking up wrapped secret ID") + } + creationPath, ok := creationPathRaw.(string) + if !ok { + return "", nil, errors.New("creation_path in repsonse could not be parsed as string when looking up wrapped secret ID") + } + if creationPath != a.secretIDResponseWrappingPath { + a.logger.Error("SECURITY: unable to validate wrapping token creation path", "expected", a.secretIDResponseWrappingPath, "found", creationPath) + return "", nil, errors.New("unable to validate wrapping token creation path") + } + // Now get the secret ID + resp, err = clonedClient.Logical().Unwrap("") + if err != nil { + return "", nil, errwrap.Wrapf("error unwrapping secret ID: {{err}}", err) + } + if resp == nil { + return "", nil, errors.New("response nil when unwrapping secret ID") + } + if resp.Data == nil { + return "", nil, errors.New("data in repsonse nil when unwrapping secret ID") + } + secretIDRaw, ok := resp.Data["secret_id"] + if !ok { + return "", nil, errors.New("secret_id in repsonse nil when unwrapping secret ID") + } + secretID, ok := secretIDRaw.(string) + if !ok { + return "", nil, errors.New("secret_id in repsonse could not be parsed as string when unwrapping secret ID") + } + stringSecretID = secretID + } + a.cachedSecretID = stringSecretID if a.removeSecretIDFileAfterReading { if err := os.Remove(a.secretIDFilePath); err != nil { a.logger.Error("error removing secret ID file after reading", "error", err) diff --git a/website/source/docs/agent/autoauth/methods/approle.html.md b/website/source/docs/agent/autoauth/methods/approle.html.md index 85d2587af4..d50ca38834 100644 --- a/website/source/docs/agent/autoauth/methods/approle.html.md +++ b/website/source/docs/agent/autoauth/methods/approle.html.md @@ -29,3 +29,10 @@ cached. * `remove_secret_id_file_after_reading` `(bool: optional, defaults to true)` - This can be set to `false` to disable the default behavior of removing the secret ID file after it's been read. + +* `secret_id_response_wrapping_path` `(string: optional)` - If set, the value + at `secret_id_file_path` will be expected to be a [Response-Wrapping + Token](https://www.vaultproject.io/docs/concepts/response-wrapping.html) + containing the output of the secret ID retrieval endpoint for the role (e.g. + `auth/approle/role/webservers/secret-id`) and the creation path for the + response-wrapping token must match the value set here.