diff --git a/changelog/_9395.txt b/changelog/_9395.txt new file mode 100644 index 0000000000..1de8da72ab --- /dev/null +++ b/changelog/_9395.txt @@ -0,0 +1,4 @@ +```release-note:improvement +core (enterprise): Allow setting of an entropy source on password generation +policies, and with it the selection of "seal" to use entropy augmentation. +``` \ No newline at end of file diff --git a/vault/core_util.go b/vault/core_util.go index 6f847c1155..0e1f1c094d 100644 --- a/vault/core_util.go +++ b/vault/core_util.go @@ -8,6 +8,7 @@ package vault import ( "context" "fmt" + "io" "github.com/hashicorp/go-hclog" "github.com/hashicorp/vault/helper/activationflags" @@ -228,3 +229,16 @@ func (c *Core) ReloadRequestLimiter() {} // createSnapshotManager is a no-op on CE. func (c *Core) createSnapshotManager() {} + +func (c *Core) GetConfigurableRNG(source string, defaultSource io.Reader) (io.Reader, error) { + var rng io.Reader + switch source { + case "platform": + rng = defaultSource + case "": + rng = defaultSource + default: + return nil, fmt.Errorf("unsupported entropy source: %s", source) + } + return rng, nil +} diff --git a/vault/dynamic_system_view.go b/vault/dynamic_system_view.go index 5ed0822ad1..74530d9b09 100644 --- a/vault/dynamic_system_view.go +++ b/vault/dynamic_system_view.go @@ -5,6 +5,7 @@ package vault import ( "context" + "crypto/rand" "fmt" "time" @@ -321,7 +322,11 @@ func (d dynamicSystemView) GeneratePasswordFromPolicy(ctx context.Context, polic return "", fmt.Errorf("stored password policy is invalid: %w", err) } - return passPolicy.Generate(ctx, nil) + rng, err := d.core.GetConfigurableRNG(policyCfg.EntropySource, rand.Reader) + if err != nil { + return "", fmt.Errorf("stored password policy is invalid: %w", err) + } + return passPolicy.Generate(ctx, rng) } func (d dynamicSystemView) ClusterID(ctx context.Context) (string, error) { diff --git a/vault/logical_system.go b/vault/logical_system.go index 2707c83120..b7668e3eb5 100644 --- a/vault/logical_system.go +++ b/vault/logical_system.go @@ -5,6 +5,7 @@ package vault import ( "context" + crand "crypto/rand" "crypto/sha256" "crypto/sha512" "encoding/base64" @@ -3651,7 +3652,8 @@ func (b *SystemBackend) handlePoliciesDelete(policyType PolicyType) framework.Op } type passwordPolicyConfig struct { - HCLPolicy string `json:"policy"` + HCLPolicy string `json:"policy"` + EntropySource string `json:"entropy_source,omitempty"` } func getPasswordPolicyKey(policyName string) string { @@ -3704,6 +3706,15 @@ func (*SystemBackend) handlePoliciesPasswordSet(ctx context.Context, req *logica fmt.Sprintf("passwords must be between %d and %d characters", minPasswordLength, maxPasswordLength)) } + entropySource := data.Get("entropy_source").(string) + switch entropySource { + case "": + case "seal": + case "platform": + default: + return nil, logical.CodedError(http.StatusBadRequest, fmt.Sprintf("unsupported entropy source %s", entropySource)) + } + // Attempt to construct a test password from the rules to ensure that the policy isn't impossible var testPassword []rune @@ -3745,7 +3756,8 @@ func (*SystemBackend) handlePoliciesPasswordSet(ctx context.Context, req *logica } cfg := passwordPolicyConfig{ - HCLPolicy: rawPolicy, + HCLPolicy: rawPolicy, + EntropySource: entropySource, } entry, err := logical.StorageEntryJSON(getPasswordPolicyKey(policyName), cfg) if err != nil { @@ -3782,6 +3794,10 @@ func (*SystemBackend) handlePoliciesPasswordGet(ctx context.Context, req *logica }, } + if cfg.EntropySource != "" { + resp.Data["entropy_source"] = cfg.EntropySource + } + return resp, nil } @@ -3821,7 +3837,7 @@ func (*SystemBackend) handlePoliciesPasswordDelete(ctx context.Context, req *log } // handlePoliciesPasswordGenerate generates a password from the specified password policy -func (*SystemBackend) handlePoliciesPasswordGenerate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { +func (b *SystemBackend) handlePoliciesPasswordGenerate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { policyName := data.Get("name").(string) if policyName == "" { return nil, logical.CodedError(http.StatusBadRequest, "missing policy name") @@ -3841,7 +3857,11 @@ func (*SystemBackend) handlePoliciesPasswordGenerate(ctx context.Context, req *l "stored password policy configuration failed to parse") } - password, err := policy.Generate(ctx, nil) + rng, err := b.Core.GetConfigurableRNG(cfg.EntropySource, crand.Reader) + if err != nil { + return nil, logical.CodedError(http.StatusBadRequest, fmt.Sprintf("failed to retrieve rng: %v", err)) + } + password, err := policy.Generate(ctx, rng) if err != nil { return nil, logical.CodedError(http.StatusInternalServerError, fmt.Sprintf("failed to generate password from policy: %s", err)) diff --git a/vault/logical_system_paths.go b/vault/logical_system_paths.go index 592ce7c0b5..baf14c8937 100644 --- a/vault/logical_system_paths.go +++ b/vault/logical_system_paths.go @@ -4112,17 +4112,7 @@ func (b *SystemBackend) policyPaths() []*framework.Path { OperationSuffix: "password-policy", }, - Fields: map[string]*framework.FieldSchema{ - "name": { - Type: framework.TypeString, - Description: "The name of the password policy.", - }, - "policy": { - Type: framework.TypeString, - Description: "The password policy", - }, - }, - + Fields: passwordPolicySchema, Operations: map[logical.Operation]framework.OperationHandler{ logical.UpdateOperation: &framework.PathOperation{ Callback: b.handlePoliciesPasswordSet, @@ -4144,6 +4134,10 @@ func (b *SystemBackend) policyPaths() []*framework.Path { Type: framework.TypeString, Required: true, }, + "entropy_source": { + Type: framework.TypeString, + Required: false, + }, }, }}, }, diff --git a/vault/logical_system_test.go b/vault/logical_system_test.go index 49086e922a..198770fe19 100644 --- a/vault/logical_system_test.go +++ b/vault/logical_system_test.go @@ -5,6 +5,7 @@ package vault import ( "context" + crand "crypto/rand" "encoding/base64" "encoding/hex" "errors" @@ -30,6 +31,7 @@ import ( "github.com/hashicorp/vault/audit" credUserpass "github.com/hashicorp/vault/builtin/credential/userpass" "github.com/hashicorp/vault/helper/builtinplugins" + "github.com/hashicorp/vault/helper/constants" "github.com/hashicorp/vault/helper/experiments" "github.com/hashicorp/vault/helper/identity" "github.com/hashicorp/vault/helper/namespace" @@ -5144,7 +5146,7 @@ func TestHandlePoliciesPasswordSet(t *testing.T) { expectedStore: makeStorageMap(storageEntry(t, "testpolicy", "length = 20\n"+ "rule \"charset\" {\n"+ " charset=\"abcdefghij\"\n"+ - "}")), + "}", "")), }, "base64 encoded": { inputData: passwordPoliciesFieldData(map[string]interface{}{ @@ -5169,7 +5171,47 @@ func TestHandlePoliciesPasswordSet(t *testing.T) { "length = 20\n"+ "rule \"charset\" {\n"+ " charset=\"abcdefghij\"\n"+ - "}")), + "}", "")), + }, + "invalid entropy source": { + inputData: passwordPoliciesFieldData(map[string]interface{}{ + "name": "testpolicy", + "policy": base64Encode( + "length = 20\n" + + "rule \"charset\" {\n" + + " charset=\"abcdefghij\"\n" + + "}"), + "entropy_source": "bad", + }), + + storage: new(logical.InmemStorage), + expectErr: true, + expectedStore: map[string]*logical.StorageEntry{}, + }, + "seal source": { + inputData: passwordPoliciesFieldData(map[string]interface{}{ + "name": "testpolicy", + "policy": base64Encode( + "length = 20\n" + + "rule \"charset\" {\n" + + " charset=\"abcdefghij\"\n" + + "}"), + "entropy_source": "seal", + }), + + storage: new(logical.InmemStorage), + + expectedResp: &logical.Response{ + Data: map[string]interface{}{ + logical.HTTPContentType: "application/json", + logical.HTTPStatusCode: http.StatusNoContent, + }, + }, + expectedStore: makeStorageMap(storageEntry(t, "testpolicy", + "length = 20\n"+ + "rule \"charset\" {\n"+ + " charset=\"abcdefghij\"\n"+ + "}", "seal")), }, } @@ -5255,7 +5297,7 @@ func TestHandlePoliciesPasswordGet(t *testing.T) { "length = 20\n"+ "rule \"charset\" {\n"+ " charset=\"abcdefghij\"\n"+ - "}")), + "}", "")), expectedResp: &logical.Response{ Data: map[string]interface{}{ @@ -5270,7 +5312,34 @@ func TestHandlePoliciesPasswordGet(t *testing.T) { "length = 20\n"+ "rule \"charset\" {\n"+ " charset=\"abcdefghij\"\n"+ - "}")), + "}", "")), + }, + "good value, seal source": { + inputData: passwordPoliciesFieldData(map[string]interface{}{ + "name": "testpolicy", + }), + + storage: makeStorage(t, storageEntry(t, "testpolicy", + "length = 20\n"+ + "rule \"charset\" {\n"+ + " charset=\"abcdefghij\"\n"+ + "}", "seal")), + + expectedResp: &logical.Response{ + Data: map[string]interface{}{ + "policy": "length = 20\n" + + "rule \"charset\" {\n" + + " charset=\"abcdefghij\"\n" + + "}", + "entropy_source": "seal", + }, + }, + expectErr: false, + expectedStore: makeStorageMap(storageEntry(t, "testpolicy", + "length = 20\n"+ + "rule \"charset\" {\n"+ + " charset=\"abcdefghij\"\n"+ + "}", "seal")), }, } @@ -5370,7 +5439,7 @@ func TestHandlePoliciesPasswordDelete(t *testing.T) { "length = 20\n"+ "rule \"charset\" {\n"+ " charset=\"abcdefghij\"\n"+ - "}")), + "}", "")), }, } @@ -5578,6 +5647,20 @@ func TestHandlePoliciesPasswordGenerate(t *testing.T) { } tests := map[string]testCase{ + "success via seal": { + expectErr: !constants.IsEnterprise, // Only works on ENT, CE seal is an unknown source + timeout: 1 * time.Second, // Timeout immediately + + inputData: passwordPoliciesFieldData(map[string]interface{}{ + "name": "testpolicy", + }), + + storage: makeStorage(t, storageEntry(t, "testpolicy", + "length = 20\n"+ + "rule \"charset\" {\n"+ + " charset=\"abcdefghij\"\n"+ + "}", "seal")), + }, "missing policy name": { inputData: passwordPoliciesFieldData(map[string]interface{}{}), @@ -5593,8 +5676,7 @@ func TestHandlePoliciesPasswordGenerate(t *testing.T) { storage: new(logical.InmemStorage).FailGet(true), - expectedResp: nil, - expectErr: true, + expectErr: true, }, "policy does not exist": { inputData: passwordPoliciesFieldData(map[string]interface{}{ @@ -5603,18 +5685,16 @@ func TestHandlePoliciesPasswordGenerate(t *testing.T) { storage: new(logical.InmemStorage), - expectedResp: nil, - expectErr: true, + expectErr: true, }, "policy improperly saved": { inputData: passwordPoliciesFieldData(map[string]interface{}{ "name": "testpolicy", }), - storage: makeStorage(t, storageEntry(t, "testpolicy", "badpolicy")), + storage: makeStorage(t, storageEntry(t, "testpolicy", "badpolicy", "")), - expectedResp: nil, - expectErr: true, + expectErr: true, }, "failed to generate": { timeout: 0 * time.Second, // Timeout immediately @@ -5626,10 +5706,9 @@ func TestHandlePoliciesPasswordGenerate(t *testing.T) { "length = 20\n"+ "rule \"charset\" {\n"+ " charset=\"abcdefghij\"\n"+ - "}")), + "}", "")), - expectedResp: nil, - expectErr: true, + expectErr: true, }, } @@ -5642,18 +5721,19 @@ func TestHandlePoliciesPasswordGenerate(t *testing.T) { Storage: test.storage, } - b := &SystemBackend{} + b := &SystemBackend{ + Core: &Core{ + secureRandomReader: crand.Reader, + }, + } - actualResp, err := b.handlePoliciesPasswordGenerate(ctx, req, test.inputData) + _, err := b.handlePoliciesPasswordGenerate(ctx, req, test.inputData) if test.expectErr && err == nil { t.Fatalf("err expected, got nil") } if !test.expectErr && err != nil { t.Fatalf("no error expected, got: %s", err) } - if !reflect.DeepEqual(actualResp, test.expectedResp) { - t.Fatalf("Actual response: %#v\nExpected response: %#v", actualResp, test.expectedResp) - } }) } }) @@ -5666,7 +5746,7 @@ func TestHandlePoliciesPasswordGenerate(t *testing.T) { "length = 20\n"+ "rule \"charset\" {\n"+ " charset=\"abcdefghij\"\n"+ - "}") + "}", "") storage := makeStorage(t, policyEntry) inputData := passwordPoliciesFieldData(map[string]interface{}{ @@ -5751,17 +5831,8 @@ func assertIsString(t *testing.T, val interface{}, f string, vals ...interface{} func passwordPoliciesFieldData(raw map[string]interface{}) *framework.FieldData { return &framework.FieldData{ - Raw: raw, - Schema: map[string]*framework.FieldSchema{ - "name": { - Type: framework.TypeString, - Description: "The name of the password policy.", - }, - "policy": { - Type: framework.TypeString, - Description: "The password policy", - }, - }, + Raw: raw, + Schema: passwordPolicySchema, } } @@ -5779,11 +5850,12 @@ func toJson(t *testing.T, val interface{}) []byte { return b } -func storageEntry(t *testing.T, key string, policy string) *logical.StorageEntry { +func storageEntry(t *testing.T, key string, policy string, entropySource string) *logical.StorageEntry { return &logical.StorageEntry{ Key: getPasswordPolicyKey(key), Value: toJson(t, passwordPolicyConfig{ - HCLPolicy: policy, + HCLPolicy: policy, + EntropySource: entropySource, }), } }