From b47cca0329e9ebb2307d0062cbd1b2bc0c7c3989 Mon Sep 17 00:00:00 2001 From: Jeff Mitchell Date: Thu, 20 Aug 2020 13:34:03 -0400 Subject: [PATCH] Add (non-db aspects of) the recovery key workflow (#286) --- globals/globals.go | 13 +- go.mod | 4 - go.sum | 8 -- internal/auth/auth.go | 59 ++++++++- internal/auth/auth_test.go | 2 +- internal/auth/option.go | 9 ++ internal/auth/testing.go | 2 +- internal/cmd/base/base.go | 114 ++++++++++++++---- internal/cmd/base/dev_flags.go | 23 ++++ internal/cmd/base/servers.go | 25 +++- .../cmd/commands/controller/controller.go | 12 +- internal/cmd/commands/controller/dev_flags.go | 18 --- internal/cmd/commands/dev/dev.go | 22 ++-- internal/cmd/commands/dev/dev_flags.go | 18 --- internal/cmd/commands/worker/worker.go | 2 +- internal/cmd/config/config.go | 29 +++-- internal/cmd/config/config_test.go | 10 ++ internal/db/migrations/postgres.gen.go | 46 ++++++- .../postgres/01_domain_types.up.sql | 2 +- .../db/migrations/postgres/06_iam.down.sql | 3 +- internal/db/migrations/postgres/06_iam.up.sql | 41 ++++++- internal/iam/repository_group_test.go | 8 ++ .../iam/repository_principal_role_test.go | 20 ++- internal/iam/repository_scope.go | 2 +- internal/iam/repository_user_test.go | 2 +- internal/kms/const.go | 11 +- internal/kms/kms.go | 15 +++ internal/kms/options.go | 10 +- internal/servers/controller/controller.go | 1 + internal/servers/controller/handler.go | 5 +- .../handlers/groups/group_service.go | 10 ++ .../handlers/groups/group_service_test.go | 6 + .../controller/handlers/roles/role_service.go | 10 ++ .../handlers/roles/role_service_test.go | 12 ++ internal/servers/controller/testing.go | 9 +- internal/servers/worker/handler.go | 4 +- internal/servers/worker/testing.go | 2 +- .../cluster/multi_controller_worker_test.go | 2 +- recovery/recovery.go | 112 +++++++++++++++++ recovery/recovery_test.go | 90 ++++++++++++++ 40 files changed, 650 insertions(+), 143 deletions(-) create mode 100644 internal/cmd/base/dev_flags.go delete mode 100644 internal/cmd/commands/controller/dev_flags.go delete mode 100644 internal/cmd/commands/dev/dev_flags.go create mode 100644 recovery/recovery.go create mode 100644 recovery/recovery_test.go diff --git a/globals/globals.go b/globals/globals.go index fa2ad8e25..c9ace2341 100644 --- a/globals/globals.go +++ b/globals/globals.go @@ -9,9 +9,8 @@ import "time" // import issues. These values should only ever be set at startup, but simply // available to reference from anywhere. -const ( - ContextUserIdValue = "wt_user_id" -) +type ContextMaxRequestSizeType int +type ContextOriginalRequestPathType int var ( // DefaultMaxRequestDuration is the amount of time we'll wait for a request @@ -19,4 +18,12 @@ var ( // DefaultMaxRequestSize is the maximum size of a request we allow by default DefaultMaxRequestSize = int64(1024 * 1024) + + // ContextMaxRequestSizeTypeKey is a value to keep linters from complaining + // about clashing string identifiers + ContextMaxRequestSizeTypeKey ContextMaxRequestSizeType + + // ContextOriginalRequestPathTypeKey is a value to keep linters from complaining + // about clashing string identifiers + ContextOriginalRequestPathTypeKey ContextOriginalRequestPathType ) diff --git a/go.mod b/go.mod index e13459e30..fc71c9276 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,6 @@ module github.com/hashicorp/boundary go 1.13 require ( - github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 - github.com/alecthomas/colour v0.1.0 // indirect - github.com/alecthomas/repr v0.0.0-20200325044227-4184120f674c // indirect github.com/armon/go-metrics v0.3.4 github.com/bufbuild/buf v0.20.5 github.com/fatih/color v1.9.0 @@ -53,7 +50,6 @@ require ( github.com/posener/complete v1.2.3 github.com/ryanuber/columnize v2.1.0+incompatible github.com/ryanuber/go-glob v1.0.0 - github.com/sergi/go-diff v1.1.0 // indirect github.com/stretchr/testify v1.6.1 github.com/zalando/go-keyring v0.1.0 go.uber.org/atomic v1.6.0 diff --git a/go.sum b/go.sum index 473d04a92..cbd5d73a9 100644 --- a/go.sum +++ b/go.sum @@ -99,12 +99,6 @@ github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWX github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= -github.com/alecthomas/colour v0.1.0 h1:nOE9rJm6dsZ66RGWYSFrXw461ZIt9A6+nHgL7FRrDUk= -github.com/alecthomas/colour v0.1.0/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= -github.com/alecthomas/repr v0.0.0-20200325044227-4184120f674c h1:MVVbswUlqicyj8P/JljoocA7AyCo62gzD0O7jfvrhtE= -github.com/alecthomas/repr v0.0.0-20200325044227-4184120f674c/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 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= @@ -954,8 +948,6 @@ github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIH github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 813904040..3124afb10 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -6,14 +6,17 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/hashicorp/boundary/internal/gen/controller/api/resources/scopes" + "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/perms" "github.com/hashicorp/boundary/internal/servers/controller/common" "github.com/hashicorp/boundary/internal/servers/controller/handlers" "github.com/hashicorp/boundary/internal/types/action" "github.com/hashicorp/boundary/internal/types/resource" "github.com/hashicorp/boundary/internal/types/scope" + "github.com/hashicorp/boundary/recovery" "github.com/hashicorp/go-hclog" "github.com/kr/pretty" ) @@ -30,11 +33,18 @@ const ( AuthTokenTypeUnknown TokenFormat = iota AuthTokenTypeBearer AuthTokenTypeSplitCookie + AuthTokenRecoveryKms ) type key int -var verifierKey key +var ( + // RecoveryTokenValidityPeriod is exported so we can modify it in tests if + // we want + RecoveryTokenValidityPeriod = 5 * time.Minute + + verifierKey key +) // RequestInfo contains request parameters necessary for checking authn/authz type RequestInfo struct { @@ -63,6 +73,7 @@ type verifier struct { logger hclog.Logger iamRepoFn common.IamRepoFactory authTokenRepoFn common.AuthTokenRepoFactory + kms *kms.Kms requestInfo RequestInfo res *perms.Resource act action.Type @@ -77,11 +88,13 @@ func NewVerifierContext(ctx context.Context, logger hclog.Logger, iamRepoFn common.IamRepoFactory, authTokenRepoFn common.AuthTokenRepoFactory, + kms *kms.Kms, requestInfo RequestInfo) context.Context { return context.WithValue(ctx, verifierKey, &verifier{ logger: logger, iamRepoFn: iamRepoFn, authTokenRepoFn: authTokenRepoFn, + kms: kms, requestInfo: requestInfo, }) } @@ -115,6 +128,7 @@ func Verify(ctx context.Context, opt ...Option) (ret VerifyResults) { ret.Error = nil return } + v.ctx = ctx v.requestInfo.scopeIdOverride = opts.withScopeId if err := v.parseAuthParams(); err != nil { @@ -377,13 +391,13 @@ func (v verifier) performAuthCheck() (aclResults *perms.ACLResults, userId strin userId = "u_anon" // Validate the token and fetch the corresponding user ID - if v.requestInfo.TokenFormat != AuthTokenTypeUnknown { + switch v.requestInfo.TokenFormat { + case AuthTokenTypeBearer, AuthTokenTypeSplitCookie: tokenRepo, err := v.authTokenRepoFn() if err != nil { retErr = fmt.Errorf("perform auth check: failed to get authtoken repo: %w", err) return } - at, err := tokenRepo.ValidateToken(v.ctx, v.requestInfo.PublicId, v.requestInfo.Token) if err != nil { retErr = fmt.Errorf("perform auth check: failed to validate token: %w", err) @@ -392,6 +406,34 @@ func (v verifier) performAuthCheck() (aclResults *perms.ACLResults, userId strin if at != nil { userId = at.GetIamUserId() } + + case AuthTokenRecoveryKms: + userId = "u_recovery" + if v.kms == nil { + retErr = errors.New("perform auth check: no KMS object available to authz system") + return + } + wrapper := v.kms.GetExternalWrappers().Recovery() + if wrapper == nil { + retErr = errors.New("perform auth check: no recovery KMS is available") + return + } + info, err := recovery.ParseRecoveryToken(v.ctx, wrapper, v.requestInfo.Token) + if err != nil { + retErr = fmt.Errorf("perform auth check: error validating recovery token: %w", err) + return + } + + // If we add the validity period to the creation time (which we've + // verified is before the current time, with a minute of fudging), and + // it's before now, it's expired and might be a replay. + if info.CreationTime.Add(RecoveryTokenValidityPeriod).Before(time.Now()) { + retErr = errors.New("perform auth check: recovery token has expired") + return + } + // TODO: verify nonce hasn't been used via the DB + _ = info + v.logger.Warn("NOTE: recovery KMS was used to authorize a call", "url", v.requestInfo.Path, "method", v.requestInfo.Method) } // Fetch and parse grants for this user ID (which may include grants for @@ -421,6 +463,13 @@ func (v verifier) performAuthCheck() (aclResults *perms.ACLResults, userId strin ParentScopeId: scp.GetParentId(), } + // At this point we don't need to look up grants since it's automatically allowed + if v.requestInfo.TokenFormat == AuthTokenRecoveryKms { + aclResults = &perms.ACLResults{Allowed: true} + retErr = nil + return + } + var parsedGrants []perms.Grant var grantPairs []perms.GrantPair @@ -485,6 +534,10 @@ func GetTokenFromRequest(logger hclog.Logger, req *http.Request) (string, string return "", "", AuthTokenTypeUnknown } + if strings.HasPrefix(fullToken, "r_") { + return "", fullToken, AuthTokenRecoveryKms + } + splitFullToken := strings.Split(fullToken, "_") if len(splitFullToken) != 3 { logger.Trace("get token from request: unexpected number of segments in token", "expected", 3, "found", len(splitFullToken)) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 5a3ee8d65..42e92b1ac 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -362,7 +362,7 @@ func TestAuthTokenAuthenticator(t *testing.T) { if tc.userId == "" { return } - ctx := NewVerifierContext(context.Background(), nil, iamRepoFn, tokenRepoFn, requestInfo) + ctx := NewVerifierContext(context.Background(), nil, iamRepoFn, tokenRepoFn, kms, requestInfo) v, ok := ctx.Value(verifierKey).(*verifier) require.True(t, ok) diff --git a/internal/auth/option.go b/internal/auth/option.go index 1f571b597..66e9b891d 100644 --- a/internal/auth/option.go +++ b/internal/auth/option.go @@ -1,5 +1,7 @@ package auth +import "github.com/hashicorp/boundary/internal/kms" + func getOpts(opt ...Option) options { opts := getDefaultOptions() for _, o := range opt { @@ -15,6 +17,7 @@ type Option func(*options) type options struct { withScopeId string withUserId string + withKms *kms.Kms } func getDefaultOptions() options { @@ -35,3 +38,9 @@ func WithUserId(id string) Option { o.withUserId = id } } + +func WithKms(kms *kms.Kms) Option { + return func(o *options) { + o.withKms = kms + } +} diff --git a/internal/auth/testing.go b/internal/auth/testing.go index a7c1f9c4b..f274f4d63 100644 --- a/internal/auth/testing.go +++ b/internal/auth/testing.go @@ -8,5 +8,5 @@ func DisabledAuthTestContext(opt ...Option) context.Context { opts := getOpts(opt...) reqInfo.scopeIdOverride = opts.withScopeId reqInfo.userIdOverride = opts.withUserId - return NewVerifierContext(context.Background(), nil, nil, nil, reqInfo) + return NewVerifierContext(context.Background(), nil, nil, nil, opts.withKms, reqInfo) } diff --git a/internal/cmd/base/base.go b/internal/cmd/base/base.go index eafc29128..829bde269 100644 --- a/internal/cmd/base/base.go +++ b/internal/cmd/base/base.go @@ -18,6 +18,10 @@ import ( "github.com/hashicorp/boundary/api" "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/recovery" + wrapping "github.com/hashicorp/go-kms-wrapping" + "github.com/hashicorp/shared-secure-libs/configutil" + "github.com/hashicorp/vault/sdk/helper/strutil" "github.com/mitchellh/cli" "github.com/pkg/errors" "github.com/posener/complete" @@ -31,13 +35,16 @@ const ( // NotSetValue is a flag value for a not-set value NotSetValue = "(not set)" - envTokenName = "BOUNDARY_TOKEN_NAME" + envTokenName = "BOUNDARY_TOKEN_NAME" + envRecoveryConfig = "BOUNDARY_RECOVERY_CONFIG" ) // reRemoveWhitespace is a regular expression for stripping whitespace from // a string. var reRemoveWhitespace = regexp.MustCompile(`[\s]+`) +var DevOnlyControllerFlags = func(*Command, *FlagSet) {} + type Command struct { Context context.Context UI cli.Ui @@ -60,6 +67,7 @@ type Command struct { flagFormat string flagField string FlagTokenName string + FlagRecoveryConfig string flagOutputCurlString bool FlagId string @@ -67,6 +75,9 @@ type Command struct { FlagDescription string FlagVersion int + FlagDevPassthroughDirectory string + FlagDevRecoveryKey string + client *api.Client } @@ -172,32 +183,55 @@ func (c *Command) Client(opt ...Option) (*api.Client, error) { } tokenName := "default" - if c.client.Token() == "" { - if c.FlagTokenName != "" { - tokenName = c.FlagTokenName + switch { + case c.FlagRecoveryConfig != "": + wrapper, err := getWrapper(c.Context, c.FlagRecoveryConfig) + if err != nil { + return nil, err } - if tokenName != "none" { - token, err := keyring.Get("HashiCorp Boundary Auth Token", tokenName) - if err != nil { - if err == keyring.ErrNotFound { - c.UI.Info("No saved credential found, continuing without") - } else { - c.UI.Error(fmt.Sprintf("Error reading auth token from system credential store: %s", err)) - } - token = "" + if err := wrapper.Init(c.Context); err != nil { + return nil, fmt.Errorf("Error initializing kms: %w", err) + } + defer func() { + if err := wrapper.Finalize(c.Context); err != nil { + c.UI.Error(fmt.Errorf("An error was encountered finalizing the kms: %w", err).Error()) } - if token != "" { - tokenBytes, err := base64.RawStdEncoding.DecodeString(token) + }() + + token, err := recovery.GenerateRecoveryToken(c.Context, wrapper) + if err != nil { + return nil, fmt.Errorf("Error generating recovery config: %w", err) + } + c.client.SetToken(token) + + case c.client.Token() == "": + if c.client.Token() == "" { + if c.FlagTokenName != "" { + tokenName = c.FlagTokenName + } + if tokenName != "none" { + token, err := keyring.Get("HashiCorp Boundary Auth Token", tokenName) if err != nil { - c.UI.Error(fmt.Sprintf("Error unmarshaling stored token from system credential store: %s", err)) - } else { - var authToken authtokens.AuthToken - if err := json.Unmarshal(tokenBytes, &authToken); err != nil { - c.UI.Error(fmt.Sprintf("Error unmarshaling stored token information after reading from system credential store: %s", err)) + if err == keyring.ErrNotFound { + c.UI.Info("No saved credential found, continuing without") } else { - c.client.SetToken(authToken.Token) - if !opts.withNoTokenScope { - c.client.SetScopeId(authToken.Scope.Id) + c.UI.Error(fmt.Sprintf("Error reading auth token from system credential store: %s", err)) + } + token = "" + } + if token != "" { + tokenBytes, err := base64.RawStdEncoding.DecodeString(token) + if err != nil { + c.UI.Error(fmt.Sprintf("Error unmarshaling stored token from system credential store: %s", err)) + } else { + var authToken authtokens.AuthToken + if err := json.Unmarshal(tokenBytes, &authToken); err != nil { + c.UI.Error(fmt.Sprintf("Error unmarshaling stored token information after reading from system credential store: %s", err)) + } else { + c.client.SetToken(authToken.Token) + if !opts.withNoTokenScope { + c.client.SetScopeId(authToken.Scope.Id) + } } } } @@ -345,6 +379,13 @@ func (c *Command) FlagSet(bit FlagSetBit) *FlagSets { Usage: `If specified, the given value will be used as the name when storing the token in the system credential store. This can allow switching user identities for different commands. Set to "none" to disable storing the token.`, }) + f.StringVar(&StringVar{ + Name: "recovery-config", + Target: &c.FlagRecoveryConfig, + EnvVar: envRecoveryConfig, + Usage: `If specified, the given config file will be parsed for a "kms" block with purpose "recovery" and will use the recovery mechanism to authorize the call."`, + }) + f.BoolVar(&BoolVar{ Name: "output-curl-string", Target: &c.flagOutputCurlString, @@ -517,3 +558,30 @@ func printFlagDetail(w io.Writer, f *flag.Flag) { indented := WrapAtLengthWithPadding(usage, 6) fmt.Fprintf(w, "%s\n\n", indented) } + +func getWrapper(ctx context.Context, path string) (wrapping.Wrapper, error) { + kmses, err := configutil.LoadConfigKMSes(path) + if err != nil { + return nil, fmt.Errorf("Error parsing config file: %w", err) + } + + var kms *configutil.KMS + for _, v := range kmses { + if strutil.StrListContains(v.Purpose, "recovery") { + if kms != nil { + return nil, fmt.Errorf("Only one %q block marked for %q purpose is allowed", "kms", "recovery") + } + kms = v + } + } + if kms == nil { + return nil, fmt.Errorf("No %q block marked for %q purpose is allowed", "kms", "recovery") + } + + wrapper, err := configutil.ConfigureWrapper(kms, nil, nil, nil) + if err != nil { + return nil, fmt.Errorf("Error configuring kms: %w", err) + } + + return wrapper, nil +} diff --git a/internal/cmd/base/dev_flags.go b/internal/cmd/base/dev_flags.go new file mode 100644 index 000000000..79e1aeac7 --- /dev/null +++ b/internal/cmd/base/dev_flags.go @@ -0,0 +1,23 @@ +// +build dev + +package base + +func init() { + DevOnlyControllerFlags = addDevOnlyControllerFlags +} + +func addDevOnlyControllerFlags(c *Command, f *FlagSet) { + f.StringVar(&StringVar{ + Name: "dev-passthrough-directory", + Target: &c.FlagDevPassthroughDirectory, + EnvVar: "BOUNDARY_DEV_PASSTHROUGH_DIRECTORY", + Usage: "Enables a passthrough directory in the webserver at /", + }) + + f.StringVar(&StringVar{ + Name: "dev-recovery-key", + Target: &c.FlagDevRecoveryKey, + EnvVar: "BOUNDARY_DEV_RECOVERY_KEY", + Usage: "Specifies the base64'd 256-bit AES key to use for recovery operations", + }) +} diff --git a/internal/cmd/base/servers.go b/internal/cmd/base/servers.go index 8b5d6ae18..827993ef5 100644 --- a/internal/cmd/base/servers.go +++ b/internal/cmd/base/servers.go @@ -16,6 +16,7 @@ import ( "github.com/armon/go-metrics" "github.com/hashicorp/boundary/globals" "github.com/hashicorp/boundary/internal/auth/password" + "github.com/hashicorp/boundary/internal/cmd/config" "github.com/hashicorp/boundary/internal/db" "github.com/hashicorp/boundary/internal/iam" "github.com/hashicorp/boundary/internal/kms" @@ -53,6 +54,7 @@ type Server struct { RootKms wrapping.Wrapper WorkerAuthKms wrapping.Wrapper + RecoveryKms wrapping.Wrapper Kms *kms.Kms SecureRandomReader io.Reader @@ -323,14 +325,19 @@ func (b *Server) SetupListeners(ui cli.Ui, config *configutil.SharedConfig, allo return nil } -func (b *Server) SetupKMSes(ui cli.Ui, config *configutil.SharedConfig, purposes []string) error { - for _, kms := range config.Seals { +func (b *Server) SetupKMSes(ui cli.Ui, config *config.Config) error { + sharedConfig := config.SharedConfig + for _, kms := range sharedConfig.Seals { for _, purpose := range kms.Purpose { purpose = strings.ToLower(purpose) switch purpose { case "": return errors.New("KMS block missing 'purpose'") case "root", "worker-auth", "config": + case "recovery": + if config.Controller != nil && config.Controller.DevRecoveryKey != "" { + kms.Config["key"] = config.Controller.DevRecoveryKey + } default: return fmt.Errorf("Unknown KMS purpose %q", kms.Purpose) } @@ -349,10 +356,17 @@ func (b *Server) SetupKMSes(ui cli.Ui, config *configutil.SharedConfig, purposes "After configuration nil KMS returned, KMS type was %s", kms.Type) } - if purpose == "root" { + switch purpose { + case "root": b.RootKms = wrapper - } else { + case "worker-auth": b.WorkerAuthKms = wrapper + case "recovery": + b.RecoveryKms = wrapper + case "config": + // Do nothing, can be set in same file but not needed at runtime + default: + return fmt.Errorf("KMS purpose of %q is unknown", purpose) } // Ensure that the seal finalizer is called, even if using verify-only @@ -368,7 +382,7 @@ func (b *Server) SetupKMSes(ui cli.Ui, config *configutil.SharedConfig, purposes // prepare a secure random reader var err error - b.SecureRandomReader, err = configutil.CreateSecureRandomReaderFunc(config, b.RootKms) + b.SecureRandomReader, err = configutil.CreateSecureRandomReaderFunc(config.SharedConfig, b.RootKms) if err != nil { return err } @@ -447,7 +461,6 @@ func (b *Server) CreateDevDatabase(dialect string) error { } if err := kmsCache.AddExternalWrappers( kms.WithRootWrapper(b.RootKms), - kms.WithWorkerAuthWrapper(b.WorkerAuthKms), ); err != nil { return fmt.Errorf("error adding config keys to kms: %w", err) } diff --git a/internal/cmd/commands/controller/controller.go b/internal/cmd/commands/controller/controller.go index 016f3f66a..71a1cb7b3 100644 --- a/internal/cmd/commands/controller/controller.go +++ b/internal/cmd/commands/controller/controller.go @@ -23,8 +23,6 @@ import ( var _ cli.Command = (*Command)(nil) var _ cli.CommandAutocomplete = (*Command)(nil) -var devOnlyControllerFlags = func(*Command, *base.FlagSet) {} - type Command struct { *base.Server @@ -51,7 +49,6 @@ type Command struct { flagDevControllerClusterListenAddr string flagDevOrgId string flagDevAuthMethodId string - flagDevPassthroughDirectory string } func (c *Command) Synopsis() string { @@ -166,7 +163,7 @@ func (c *Command) Flags() *base.FlagSets { Usage: "If set, both startup information and logs will be sent to stdout. If not set (the default), startup information will go to stdout and logs will be sent to stderr.", }) - devOnlyControllerFlags(c, f) + base.DevOnlyControllerFlags(c.Command, f) return set } @@ -198,7 +195,10 @@ func (c *Command) Run(args []string) int { return 1 } - if err := c.SetupKMSes(c.UI, c.Config.SharedConfig, []string{"root", "worker-auth"}); err != nil { + if c.FlagDevRecoveryKey != "" { + c.Config.Controller.DevRecoveryKey = c.FlagDevRecoveryKey + } + if err := c.SetupKMSes(c.UI, c.Config); err != nil { c.UI.Error(err.Error()) return 1 } @@ -382,7 +382,7 @@ func (c *Command) ParseFlagsAndConfig(args []string) int { c.DevPassword = c.flagDevPassword } - c.Config.PassthroughDirectory = c.flagDevPassthroughDirectory + c.Config.PassthroughDirectory = c.FlagDevPassthroughDirectory for _, l := range c.Config.Listeners { if len(l.Purpose) != 1 { diff --git a/internal/cmd/commands/controller/dev_flags.go b/internal/cmd/commands/controller/dev_flags.go deleted file mode 100644 index 305b746ef..000000000 --- a/internal/cmd/commands/controller/dev_flags.go +++ /dev/null @@ -1,18 +0,0 @@ -// +build dev - -package controller - -import "github.com/hashicorp/boundary/internal/cmd/base" - -func init() { - devOnlyControllerFlags = addDevOnlyControllerFlags -} - -func addDevOnlyControllerFlags(c *Command, f *base.FlagSet) { - f.StringVar(&base.StringVar{ - Name: "dev-passthrough-directory", - Target: &c.flagDevPassthroughDirectory, - EnvVar: "BOUNDARY_DEV_PASSTHROUGH_DIRECTORY", - Usage: "Enables a passthrough directory in the webserver at /", - }) -} diff --git a/internal/cmd/commands/dev/dev.go b/internal/cmd/commands/dev/dev.go index 0b0aeeea1..8c1d70a1e 100644 --- a/internal/cmd/commands/dev/dev.go +++ b/internal/cmd/commands/dev/dev.go @@ -18,8 +18,6 @@ import ( var _ cli.Command = (*Command)(nil) var _ cli.CommandAutocomplete = (*Command)(nil) -var devOnlyControllerFlags = func(*Command, *base.FlagSet) {} - type Command struct { *base.Server @@ -28,20 +26,15 @@ type Command struct { ReloadedCh chan struct{} SigUSR2Ch chan struct{} - cleanupGuard sync.Once - - flagConfig string flagLogLevel string flagLogFormat string flagCombineLogs bool - flagDev bool flagDevLoginName string flagDevPassword string flagDevOrgId string flagDevAuthMethodId string flagDevControllerAPIListenAddr string flagDevControllerClusterListenAddr string - flagDevPassthroughDirectory string } func (c *Command) Synopsis() string { @@ -139,7 +132,7 @@ func (c *Command) Flags() *base.FlagSets { Usage: "If set, both startup information and logs will be sent to stdout. If not set (the default), startup information will go to stdout and logs will be sent to stderr.", }) - devOnlyControllerFlags(c, f) + base.DevOnlyControllerFlags(c.Command, f) return set } @@ -190,7 +183,7 @@ func (c *Command) Run(args []string) int { c.DevPassword = c.flagDevPassword } - devConfig.PassthroughDirectory = c.flagDevPassthroughDirectory + devConfig.PassthroughDirectory = c.FlagDevPassthroughDirectory for _, l := range devConfig.Listeners { if len(l.Purpose) != 1 { @@ -221,7 +214,10 @@ func (c *Command) Run(args []string) int { return 1 } - if err := c.SetupKMSes(c.UI, devConfig.SharedConfig, []string{"root", "worker-auth"}); err != nil { + if c.FlagDevRecoveryKey != "" { + devConfig.Controller.DevRecoveryKey = c.FlagDevRecoveryKey + } + if err := c.SetupKMSes(c.UI, devConfig); err != nil { c.UI.Error(err.Error()) return 1 } @@ -237,10 +233,8 @@ func (c *Command) Run(args []string) int { c.Info["[Controller] AEAD Key Bytes"] = devConfig.Controller.DevControllerKey c.InfoKeys = append(c.InfoKeys, "[Worker-Auth] AEAD Key Bytes") c.Info["[Worker-Auth] AEAD Key Bytes"] = devConfig.Controller.DevWorkerAuthKey - if c.WorkerAuthKms == nil { - c.UI.Error("Worker Auth KMS not found after parsing KMS blocks") - return 1 - } + c.InfoKeys = append(c.InfoKeys, "[Recovery] AEAD Key Bytes") + c.Info["[Recovery] AEAD Key Bytes"] = devConfig.Controller.DevRecoveryKey // Initialize the listeners if err := c.SetupListeners(c.UI, devConfig.SharedConfig, []string{"api", "cluster", "worker-alpn-tls"}); err != nil { diff --git a/internal/cmd/commands/dev/dev_flags.go b/internal/cmd/commands/dev/dev_flags.go deleted file mode 100644 index 9c34b6f90..000000000 --- a/internal/cmd/commands/dev/dev_flags.go +++ /dev/null @@ -1,18 +0,0 @@ -// +build dev - -package dev - -import "github.com/hashicorp/boundary/internal/cmd/base" - -func init() { - devOnlyControllerFlags = addDevOnlyControllerFlags -} - -func addDevOnlyControllerFlags(c *Command, f *base.FlagSet) { - f.StringVar(&base.StringVar{ - Name: "dev-passthrough-directory", - Target: &c.flagDevPassthroughDirectory, - EnvVar: "BOUNDARY_DEV_PASSTHROUGH_DIRECTORY", - Usage: "Enables a passthrough directory in the webserver at /", - }) -} diff --git a/internal/cmd/commands/worker/worker.go b/internal/cmd/commands/worker/worker.go index b52858fd4..8d30b16a7 100644 --- a/internal/cmd/commands/worker/worker.go +++ b/internal/cmd/commands/worker/worker.go @@ -147,7 +147,7 @@ func (c *Command) Run(args []string) int { return 1 } - if err := c.SetupKMSes(c.UI, c.Config.SharedConfig, []string{"worker-auth"}); err != nil { + if err := c.SetupKMSes(c.UI, c.Config); err != nil { c.UI.Error(err.Error()) return 1 } diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 246e8a083..9dac034f5 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -43,6 +43,13 @@ kms "aead" { key_id = "global_worker-auth" } +kms "aead" { + purpose = "recovery" + aead_type = "aes-gcm" + key = "%s" + key_id = "global_recovery" +} + listener "tcp" { purpose = "api" tls_disable = true @@ -91,6 +98,7 @@ type Controller struct { Description string `hcl:"description"` DevControllerKey string `hcl:"-"` DevWorkerAuthKey string `hcl:"-"` + DevRecoveryKey string `hcl:"-"` } type Worker struct { @@ -109,29 +117,32 @@ func DevWorker() (*Config, error) { return parsed, nil } -func devKeyGeneration() (string, string) { +func devKeyGeneration() (string, string, string) { + var numBytes int64 = 96 randBuf := new(bytes.Buffer) n, err := randBuf.ReadFrom(&io.LimitedReader{ R: rand.Reader, - N: 64, + N: numBytes, }) if err != nil { panic(err) } - if n != 64 { + if n != numBytes { panic(fmt.Errorf("expected to read 64 bytes, read %d", n)) } controllerKey := base64.StdEncoding.EncodeToString(randBuf.Bytes()[0:32]) workerAuthKey := base64.StdEncoding.EncodeToString(randBuf.Bytes()[32:64]) - return controllerKey, workerAuthKey + recoveryKey := base64.StdEncoding.EncodeToString(randBuf.Bytes()[64:numBytes]) + + return controllerKey, workerAuthKey, recoveryKey } // DevController is a Config that is used for dev mode of Boundary // controllers func DevController() (*Config, error) { - controllerKey, workerAuthKey := devKeyGeneration() + controllerKey, workerAuthKey, recoveryKey := devKeyGeneration() - hclStr := fmt.Sprintf(devConfig+devControllerExtraConfig, controllerKey, workerAuthKey) + hclStr := fmt.Sprintf(devConfig+devControllerExtraConfig, controllerKey, workerAuthKey, recoveryKey) parsed, err := Parse(hclStr) if err != nil { return nil, fmt.Errorf("error parsing dev config: %w", err) @@ -139,12 +150,13 @@ func DevController() (*Config, error) { parsed.DevController = true parsed.Controller.DevControllerKey = controllerKey parsed.Controller.DevWorkerAuthKey = workerAuthKey + parsed.Controller.DevRecoveryKey = recoveryKey return parsed, nil } func DevCombined() (*Config, error) { - controllerKey, workerAuthKey := devKeyGeneration() - hclStr := fmt.Sprintf(devConfig+devControllerExtraConfig+devWorkerExtraConfig, controllerKey, workerAuthKey) + controllerKey, workerAuthKey, recoveryKey := devKeyGeneration() + hclStr := fmt.Sprintf(devConfig+devControllerExtraConfig+devWorkerExtraConfig, controllerKey, workerAuthKey, recoveryKey) parsed, err := Parse(hclStr) if err != nil { return nil, fmt.Errorf("error parsing dev config: %w", err) @@ -152,6 +164,7 @@ func DevCombined() (*Config, error) { parsed.DevController = true parsed.Controller.DevControllerKey = controllerKey parsed.Controller.DevWorkerAuthKey = workerAuthKey + parsed.Controller.DevRecoveryKey = recoveryKey return parsed, nil } diff --git a/internal/cmd/config/config_test.go b/internal/cmd/config/config_test.go index acd3edaa7..98cfe21ef 100644 --- a/internal/cmd/config/config_test.go +++ b/internal/cmd/config/config_test.go @@ -63,6 +63,14 @@ func TestDevController(t *testing.T) { "key_id": "global_worker-auth", }, }, + { + Type: "aead", + Purpose: []string{"recovery"}, + Config: map[string]string{ + "aead_type": "aes-gcm", + "key_id": "global_recovery", + }, + }, }, Telemetry: &configutil.Telemetry{ DisableHostname: true, @@ -82,8 +90,10 @@ func TestDevController(t *testing.T) { exp.Listeners[1].RawConfig = actual.Listeners[1].RawConfig exp.Seals[0].Config["key"] = actual.Seals[0].Config["key"] exp.Seals[1].Config["key"] = actual.Seals[1].Config["key"] + exp.Seals[2].Config["key"] = actual.Seals[2].Config["key"] exp.Controller.DevControllerKey = actual.Seals[0].Config["key"] exp.Controller.DevWorkerAuthKey = actual.Seals[1].Config["key"] + exp.Controller.DevRecoveryKey = actual.Seals[2].Config["key"] assert.Equal(t, exp, actual) } diff --git a/internal/db/migrations/postgres.gen.go b/internal/db/migrations/postgres.gen.go index 813a4c80e..4c812ea0f 100644 --- a/internal/db/migrations/postgres.gen.go +++ b/internal/db/migrations/postgres.gen.go @@ -56,7 +56,7 @@ comment on domain wt_scope_id is create domain wt_user_id as text not null check( - length(trim(value)) > 10 or value = 'u_anon' or value = 'u_auth' + length(trim(value)) > 10 or value = 'u_anon' or value = 'u_auth' or value = 'u_recovery' ); comment on domain wt_scope_id is '"u_anon", "u_auth", or random ID generated with github.com/hashicorp/vault/sdk/helper/base62'; @@ -506,7 +506,8 @@ drop function grant_scope_id_valid cascade; drop function disallow_global_scope_deletion cascade; drop function user_scope_id_valid cascade; drop function iam_immutable_role_grant cascade; -drop function disallow_iam_anon_auth_deletion cascade; +drop function disallow_iam_predefined_user_deletion cascade; +drop function recovery_user_not_allowed cascade; COMMIT; @@ -812,7 +813,7 @@ end; $$ language plpgsql; create or replace function - disallow_iam_anon_auth_deletion() + disallow_iam_predefined_user_deletion() returns trigger as $$ begin @@ -821,6 +822,9 @@ begin end if; if old.public_id = 'u_auth' then raise exception 'deletion of authenticated user not allowed'; + end if; + if old.public_id = 'u_recovery' then + raise exception 'deletion of recovery user not allowed'; end if; return old; end; @@ -849,10 +853,10 @@ insert on iam_user for each row execute procedure default_create_time(); create trigger - iam_user_disallow_anon_auth_deletion + iam_user_disallow_predefined_user_deletion before delete on iam_user - for each row execute procedure disallow_iam_anon_auth_deletion(); + for each row execute procedure disallow_iam_predefined_user_deletion(); -- TODO: Do we want to disallow changing the name or description? insert into iam_user (public_id, name, description, scope_id) @@ -861,6 +865,9 @@ insert into iam_user (public_id, name, description, scope_id) insert into iam_user (public_id, name, description, scope_id) values ('u_auth', 'authenticated', 'The authenticated user matches any user that has a valid token', 'global'); +insert into iam_user (public_id, name, description, scope_id) + values ('u_recovery', 'recovery', 'The recovery user is used for any request that was performed with the recovery KMS workflow', 'global'); + -- define the immutable fields for iam_user create trigger immutable_columns @@ -960,6 +967,21 @@ before update on iam_role for each row execute procedure immutable_columns('public_id', 'create_time', 'scope_id'); +create or replace function + recovery_user_not_allowed() + returns trigger +as $$ +declare + new_value text; +begin + execute format('SELECT $1.%I', tg_argv[0]) into new_value using new; + if new_value = 'u_recovery' then + raise exception '"u_recovery" not allowed here"'; + end if; + return new; +end; +$$ language plpgsql; + create table iam_group ( public_id wt_public_id primary key, @@ -1112,6 +1134,12 @@ before update on iam_user_role for each row execute procedure iam_immutable_role_principal(); +create trigger + recovery_user_not_allowed_user_role +before +insert on iam_user_role + for each row execute procedure recovery_user_not_allowed('principal_id'); + create trigger default_create_time_column before @@ -1134,7 +1162,7 @@ insert on iam_group_role create table iam_group_member_user ( create_time wt_timestamp, group_id wt_public_id references iam_group(public_id) on delete cascade on update cascade, - member_id wt_public_id references iam_user(public_id) on delete cascade on update cascade, + member_id wt_user_id references iam_user(public_id) on delete cascade on update cascade, primary key (group_id, member_id) ); @@ -1159,6 +1187,12 @@ before update on iam_group_member_user for each row execute procedure iam_immutable_group_member(); +create trigger + recovery_user_not_allowed_group_member +before +insert on iam_group_member_user + for each row execute procedure recovery_user_not_allowed('member_id'); + -- get_scoped_principal_id is used by the iam_group_member view as a convient -- way to create : to reference members from -- other scopes than the group's scope. diff --git a/internal/db/migrations/postgres/01_domain_types.up.sql b/internal/db/migrations/postgres/01_domain_types.up.sql index 272274d04..cdbc0a64e 100644 --- a/internal/db/migrations/postgres/01_domain_types.up.sql +++ b/internal/db/migrations/postgres/01_domain_types.up.sql @@ -25,7 +25,7 @@ comment on domain wt_scope_id is create domain wt_user_id as text not null check( - length(trim(value)) > 10 or value = 'u_anon' or value = 'u_auth' + length(trim(value)) > 10 or value = 'u_anon' or value = 'u_auth' or value = 'u_recovery' ); comment on domain wt_scope_id is '"u_anon", "u_auth", or random ID generated with github.com/hashicorp/vault/sdk/helper/base62'; diff --git a/internal/db/migrations/postgres/06_iam.down.sql b/internal/db/migrations/postgres/06_iam.down.sql index a472ac02a..a8a7b5692 100644 --- a/internal/db/migrations/postgres/06_iam.down.sql +++ b/internal/db/migrations/postgres/06_iam.down.sql @@ -28,6 +28,7 @@ drop function grant_scope_id_valid cascade; drop function disallow_global_scope_deletion cascade; drop function user_scope_id_valid cascade; drop function iam_immutable_role_grant cascade; -drop function disallow_iam_anon_auth_deletion cascade; +drop function disallow_iam_predefined_user_deletion cascade; +drop function recovery_user_not_allowed cascade; COMMIT; diff --git a/internal/db/migrations/postgres/06_iam.up.sql b/internal/db/migrations/postgres/06_iam.up.sql index 73ab3c0e5..d65116a36 100644 --- a/internal/db/migrations/postgres/06_iam.up.sql +++ b/internal/db/migrations/postgres/06_iam.up.sql @@ -295,7 +295,7 @@ end; $$ language plpgsql; create or replace function - disallow_iam_anon_auth_deletion() + disallow_iam_predefined_user_deletion() returns trigger as $$ begin @@ -304,6 +304,9 @@ begin end if; if old.public_id = 'u_auth' then raise exception 'deletion of authenticated user not allowed'; + end if; + if old.public_id = 'u_recovery' then + raise exception 'deletion of recovery user not allowed'; end if; return old; end; @@ -332,10 +335,10 @@ insert on iam_user for each row execute procedure default_create_time(); create trigger - iam_user_disallow_anon_auth_deletion + iam_user_disallow_predefined_user_deletion before delete on iam_user - for each row execute procedure disallow_iam_anon_auth_deletion(); + for each row execute procedure disallow_iam_predefined_user_deletion(); -- TODO: Do we want to disallow changing the name or description? insert into iam_user (public_id, name, description, scope_id) @@ -344,6 +347,9 @@ insert into iam_user (public_id, name, description, scope_id) insert into iam_user (public_id, name, description, scope_id) values ('u_auth', 'authenticated', 'The authenticated user matches any user that has a valid token', 'global'); +insert into iam_user (public_id, name, description, scope_id) + values ('u_recovery', 'recovery', 'The recovery user is used for any request that was performed with the recovery KMS workflow', 'global'); + -- define the immutable fields for iam_user create trigger immutable_columns @@ -443,6 +449,21 @@ before update on iam_role for each row execute procedure immutable_columns('public_id', 'create_time', 'scope_id'); +create or replace function + recovery_user_not_allowed() + returns trigger +as $$ +declare + new_value text; +begin + execute format('SELECT $1.%I', tg_argv[0]) into new_value using new; + if new_value = 'u_recovery' then + raise exception '"u_recovery" not allowed here"'; + end if; + return new; +end; +$$ language plpgsql; + create table iam_group ( public_id wt_public_id primary key, @@ -595,6 +616,12 @@ before update on iam_user_role for each row execute procedure iam_immutable_role_principal(); +create trigger + recovery_user_not_allowed_user_role +before +insert on iam_user_role + for each row execute procedure recovery_user_not_allowed('principal_id'); + create trigger default_create_time_column before @@ -617,7 +644,7 @@ insert on iam_group_role create table iam_group_member_user ( create_time wt_timestamp, group_id wt_public_id references iam_group(public_id) on delete cascade on update cascade, - member_id wt_public_id references iam_user(public_id) on delete cascade on update cascade, + member_id wt_user_id references iam_user(public_id) on delete cascade on update cascade, primary key (group_id, member_id) ); @@ -642,6 +669,12 @@ before update on iam_group_member_user for each row execute procedure iam_immutable_group_member(); +create trigger + recovery_user_not_allowed_group_member +before +insert on iam_group_member_user + for each row execute procedure recovery_user_not_allowed('member_id'); + -- get_scoped_principal_id is used by the iam_group_member view as a convient -- way to create : to reference members from -- other scopes than the group's scope. diff --git a/internal/iam/repository_group_test.go b/internal/iam/repository_group_test.go index d4894b215..4714e309a 100644 --- a/internal/iam/repository_group_test.go +++ b/internal/iam/repository_group_test.go @@ -804,6 +804,14 @@ func TestRepository_AddGroupMembers(t *testing.T) { }, wantErr: true, }, + { + name: "recovery-user", + args: args{ + groupId: group.PublicId, + userIds: []string{"u_recovery"}, + }, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/iam/repository_principal_role_test.go b/internal/iam/repository_principal_role_test.go index f53d1ab0c..4a5c16f82 100644 --- a/internal/iam/repository_principal_role_test.go +++ b/internal/iam/repository_principal_role_test.go @@ -51,10 +51,11 @@ func TestRepository_AddPrincipalRoles(t *testing.T) { return results } type args struct { - roleVersion uint32 - wantUserIds bool - wantGroupIds bool - opt []Option + roleVersion uint32 + wantUserIds bool + specificUserIds []string + wantGroupIds bool + opt []Option } tests := []struct { name string @@ -112,6 +113,14 @@ func TestRepository_AddPrincipalRoles(t *testing.T) { }, wantErr: true, }, + { + name: "recovery-user", + args: args{ + roleVersion: 1, + specificUserIds: []string{"u_recovery"}, + }, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -143,6 +152,9 @@ func TestRepository_AddPrincipalRoles(t *testing.T) { groupIds = append(groupIds, g.PublicId) } } + if len(tt.args.specificUserIds) > 0 { + userIds = tt.args.specificUserIds + } principalIds := append(userIds, groupIds...) got, err := repo.AddPrincipalRoles(context.Background(), roleId, tt.args.roleVersion, principalIds, tt.args.opt...) if tt.wantErr { diff --git a/internal/iam/repository_scope.go b/internal/iam/repository_scope.go index baad9091f..9c9f9a0dc 100644 --- a/internal/iam/repository_scope.go +++ b/internal/iam/repository_scope.go @@ -87,7 +87,7 @@ func (r *Repository) CreateScope(ctx context.Context, s *Scope, userId string, o var role *Role var roleRaw interface{} switch userId { - case "", "u_anon", "u_auth": + case "", "u_anon", "u_auth", "u_recovery": // TODO: Cause a log entry. The repo doesn't have a logger right now, // and ideally we will be using context to pass around log info scoped // to this request for grouped display in the server log. The only diff --git a/internal/iam/repository_user_test.go b/internal/iam/repository_user_test.go index 0bd08e3c5..932706a5a 100644 --- a/internal/iam/repository_user_test.go +++ b/internal/iam/repository_user_test.go @@ -508,7 +508,7 @@ func TestRepository_ListUsers(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) - require.NoError(conn.Where("public_id != 'u_anon' and public_id != 'u_auth'").Delete(allocUser()).Error) + require.NoError(conn.Where("public_id != 'u_anon' and public_id != 'u_auth' and public_id != 'u_recovery'").Delete(allocUser()).Error) testUsers := []*User{} for i := 0; i < tt.createCnt; i++ { testUsers = append(testUsers, TestUser(t, repo, org.PublicId)) diff --git a/internal/kms/const.go b/internal/kms/const.go index 93fd7016b..00153d097 100644 --- a/internal/kms/const.go +++ b/internal/kms/const.go @@ -7,14 +7,17 @@ type KeyPurpose uint const ( // KeyPurposeUnknown is the default, and indicates that a correct purpose // wasn't specified - KeyPurposeUnknown KeyPurpose = 0 + KeyPurposeUnknown KeyPurpose = iota // KeyPurposeDatabase is used for general encryption needs for most values // in the database, excluding the oplog - KeyPurposeDatabase KeyPurpose = 1 + KeyPurposeDatabase // KeyPurposeOplog is used for oplogs - KeyPurposeOplog KeyPurpose = 2 + KeyPurposeOplog + + // KeyPurposeRecovery is used for recovery access + KeyPurposeRecovery ) // String returns the key purpose cast as a string, just so it can be called as @@ -25,6 +28,8 @@ func (k KeyPurpose) String() string { return "database" case KeyPurposeOplog: return "oplog" + case KeyPurposeRecovery: + return "recovery" default: return "unknown" } diff --git a/internal/kms/kms.go b/internal/kms/kms.go index d4f313d03..c7e16bcc5 100644 --- a/internal/kms/kms.go +++ b/internal/kms/kms.go @@ -21,6 +21,7 @@ type ExternalWrappers struct { m sync.RWMutex root wrapping.Wrapper workerAuth wrapping.Wrapper + recovery wrapping.Wrapper } // Root returns the wrapper for root keys @@ -37,6 +38,13 @@ func (e *ExternalWrappers) WorkerAuth() wrapping.Wrapper { return e.workerAuth } +// Recovery returns the wrapper for recovery operations +func (e *ExternalWrappers) Recovery() wrapping.Wrapper { + e.m.RLock() + defer e.m.RUnlock() + return e.recovery +} + // Kms is a way to access wrappers for a given scope and purpose. Since keys can // never change, only be added or (eventually) removed, it opportunistically // caches, going to the database as needed. @@ -104,6 +112,12 @@ func (k *Kms) AddExternalWrappers(opt ...Option) error { return fmt.Errorf("worker auth wrapper has no key ID") } } + if opts.withRecoveryWrapper != nil { + ext.recovery = opts.withRecoveryWrapper + if ext.recovery.KeyID() == "" { + return fmt.Errorf("recovery wrapper has no key ID") + } + } k.externalScopeCache[scope.Global.String()] = ext return nil @@ -120,6 +134,7 @@ func (k *Kms) GetExternalWrappers() *ExternalWrappers { ret := &ExternalWrappers{ root: ext.root, workerAuth: ext.workerAuth, + recovery: ext.recovery, } return ret } diff --git a/internal/kms/options.go b/internal/kms/options.go index 95c016f2f..a078ae230 100644 --- a/internal/kms/options.go +++ b/internal/kms/options.go @@ -23,6 +23,7 @@ type options struct { withLogger hclog.Logger withRootWrapper wrapping.Wrapper withWorkerAuthWrapper wrapping.Wrapper + withRecoveryWrapper wrapping.Wrapper withRepository *Repository withOrder string withKeyId string @@ -56,13 +57,20 @@ func WithRootWrapper(w wrapping.Wrapper) Option { } // WithWorkerAuthWrapper sets the external worker authentication wrapper for a -// given scope. +// given scope func WithWorkerAuthWrapper(w wrapping.Wrapper) Option { return func(o *options) { o.withWorkerAuthWrapper = w } } +// WithRecoveryWrapper sets the recovery wrapper for a given scope +func WithRecoveryWrapper(w wrapping.Wrapper) Option { + return func(o *options) { + o.withRecoveryWrapper = w + } +} + // WithRepository sets a repository for a given wrapper lookup, useful if in the // middle of a transaction where the reader/writer need to be specified func WithRepository(repo *Repository) Option { diff --git a/internal/servers/controller/controller.go b/internal/servers/controller/controller.go index 318727e1f..2612727c7 100644 --- a/internal/servers/controller/controller.go +++ b/internal/servers/controller/controller.go @@ -99,6 +99,7 @@ func New(conf *Config) (*Controller, error) { if err := c.kms.AddExternalWrappers( kms.WithRootWrapper(c.conf.RootKms), kms.WithWorkerAuthWrapper(c.conf.WorkerAuthKms), + kms.WithRecoveryWrapper(c.conf.RecoveryKms), ); err != nil { return nil, fmt.Errorf("error adding config keys to kms: %w", err) } diff --git a/internal/servers/controller/handler.go b/internal/servers/controller/handler.go index bffb9e0fd..42536976f 100644 --- a/internal/servers/controller/handler.go +++ b/internal/servers/controller/handler.go @@ -187,7 +187,7 @@ func wrapHandlerWithCommonFuncs(h http.Handler, c *Controller, props HandlerProp // Add a size limiter if desired if maxRequestSize > 0 { - ctx = context.WithValue(ctx, "max_request_size", maxRequestSize) + ctx = context.WithValue(ctx, globals.ContextMaxRequestSizeTypeKey, maxRequestSize) } // Add values for authn/authz checking @@ -196,8 +196,9 @@ func wrapHandlerWithCommonFuncs(h http.Handler, c *Controller, props HandlerProp Method: r.Method, DisableAuthzFailures: disableAuthzFailures, } + requestInfo.PublicId, requestInfo.Token, requestInfo.TokenFormat = auth.GetTokenFromRequest(c.logger, r) - ctx = auth.NewVerifierContext(ctx, c.logger, c.IamRepoFn, c.AuthTokenRepoFn, requestInfo) + ctx = auth.NewVerifierContext(ctx, c.logger, c.IamRepoFn, c.AuthTokenRepoFn, c.kms, requestInfo) // Set the context back on the request r = r.WithContext(ctx) diff --git a/internal/servers/controller/handlers/groups/group_service.go b/internal/servers/controller/handlers/groups/group_service.go index 914d416d3..fb0c2d1cb 100644 --- a/internal/servers/controller/handlers/groups/group_service.go +++ b/internal/servers/controller/handlers/groups/group_service.go @@ -468,6 +468,11 @@ func validateAddGroupMembersRequest(req *pbs.AddGroupMembersRequest) error { if len(req.GetMemberIds()) == 0 { badFields["member_ids"] = "Must be non-empty." } + for _, id := range req.GetMemberIds() { + if id == "u_recovery" { + badFields["member_ids"] = "u_recovery cannot be assigned to a group" + } + } if len(badFields) > 0 { return handlers.InvalidArgumentErrorf("Errors in provided fields.", badFields) } @@ -482,6 +487,11 @@ func validateSetGroupMembersRequest(req *pbs.SetGroupMembersRequest) error { if req.GetVersion() == 0 { badFields["version"] = "Required field." } + for _, id := range req.GetMemberIds() { + if id == "u_recovery" { + badFields["member_ids"] = "u_recovery cannot be assigned to a group" + } + } if len(badFields) > 0 { return handlers.InvalidArgumentErrorf("Errors in provided fields.", badFields) } diff --git a/internal/servers/controller/handlers/groups/group_service_test.go b/internal/servers/controller/handlers/groups/group_service_test.go index 700e69840..03c825045 100644 --- a/internal/servers/controller/handlers/groups/group_service_test.go +++ b/internal/servers/controller/handlers/groups/group_service_test.go @@ -961,6 +961,12 @@ func TestAddMember(t *testing.T) { }, wantErr: true, }, + { + name: "Add invalid u_recovery to group", + setup: func(g *iam.Group) {}, + addUsers: []string{"u_recovery"}, + wantErr: true, + }, } for _, tc := range addCases { diff --git a/internal/servers/controller/handlers/roles/role_service.go b/internal/servers/controller/handlers/roles/role_service.go index b79bfd5f7..adc9f9b31 100644 --- a/internal/servers/controller/handlers/roles/role_service.go +++ b/internal/servers/controller/handlers/roles/role_service.go @@ -648,6 +648,11 @@ func validateAddRolePrincipalsRequest(req *pbs.AddRolePrincipalsRequest) error { if len(req.GetPrincipalIds()) == 0 { badFields["principal_ids"] = "Must be non-empty." } + for _, id := range req.GetPrincipalIds() { + if id == "u_recovery" { + badFields["principal_ids"] = "u_recovery cannot be assigned to a role" + } + } if len(badFields) > 0 { return handlers.InvalidArgumentErrorf("Errors in provided fields.", badFields) } @@ -662,6 +667,11 @@ func validateSetRolePrincipalsRequest(req *pbs.SetRolePrincipalsRequest) error { if req.GetVersion() == 0 { badFields["version"] = "Required field." } + for _, id := range req.GetPrincipalIds() { + if id == "u_recovery" { + badFields["principal_ids"] = "u_recovery cannot be assigned to a role" + } + } if len(badFields) > 0 { return handlers.InvalidArgumentErrorf("Errors in provided fields.", badFields) } diff --git a/internal/servers/controller/handlers/roles/role_service_test.go b/internal/servers/controller/handlers/roles/role_service_test.go index 19507a902..b18ad1a45 100644 --- a/internal/servers/controller/handlers/roles/role_service_test.go +++ b/internal/servers/controller/handlers/roles/role_service_test.go @@ -992,6 +992,12 @@ func TestAddPrincipal(t *testing.T) { addGroups: []string{groups[1].GetPublicId()}, resultGroups: []string{groups[0].GetPublicId(), groups[1].GetPublicId()}, }, + { + name: "Add invalid u_recovery on role", + setup: func(r *iam.Role) {}, + addUsers: []string{"u_recovery"}, + wantErr: true, + }, } for _, tc := range addCases { @@ -1112,6 +1118,12 @@ func TestSetPrincipal(t *testing.T) { setGroups: []string{groups[1].GetPublicId()}, resultGroups: []string{groups[1].GetPublicId()}, }, + { + name: "Set invalid u_recovery on role", + setup: func(r *iam.Role) {}, + setUsers: []string{"u_recovery"}, + wantErr: true, + }, } for _, tc := range setCases { diff --git a/internal/servers/controller/testing.go b/internal/servers/controller/testing.go index 4ebc1d850..27cd73ff1 100644 --- a/internal/servers/controller/testing.go +++ b/internal/servers/controller/testing.go @@ -193,6 +193,9 @@ type TestControllerOpts struct { // The worker auth KMS to use, or one will be created WorkerAuthKms wrapping.Wrapper + // The recovery KMS to use, or one will be created + RecoveryKms wrapping.Wrapper + // The name to use for the controller, otherwise one will be randomly // generated, unless provided in a non-nil Config Name string @@ -266,12 +269,15 @@ func NewTestController(t *testing.T, opts *TestControllerOpts) *TestController { tc.b.RootKms = opts.RootKms tc.b.WorkerAuthKms = opts.WorkerAuthKms case opts.RootKms == nil && opts.WorkerAuthKms == nil: - if err := tc.b.SetupKMSes(nil, opts.Config.SharedConfig, []string{"root", "worker-auth"}); err != nil { + if err := tc.b.SetupKMSes(nil, opts.Config); err != nil { t.Fatal(err) } default: t.Fatal("either controller and worker auth KMS must both be set, or neither") } + if opts.RecoveryKms != nil { + tc.b.RecoveryKms = opts.RecoveryKms + } // Ensure the listeners use random port allocation for _, listener := range opts.Config.Listeners { @@ -325,6 +331,7 @@ func (tc *TestController) AddClusterControllerMember(t *testing.T, opts *TestCon DefaultAuthMethodId: tc.c.conf.DevAuthMethodId, RootKms: tc.c.conf.RootKms, WorkerAuthKms: tc.c.conf.WorkerAuthKms, + RecoveryKms: tc.c.conf.RecoveryKms, Name: opts.Name, Logger: tc.c.conf.Logger, } diff --git a/internal/servers/worker/handler.go b/internal/servers/worker/handler.go index 8b57ce9cf..c215577c8 100644 --- a/internal/servers/worker/handler.go +++ b/internal/servers/worker/handler.go @@ -57,9 +57,9 @@ func (c *Worker) wrapGenericHandler(h http.Handler, props HandlerProperties) htt ctx, cancelFunc = context.WithTimeout(ctx, maxRequestDuration) // Add a size limiter if desired if maxRequestSize > 0 { - ctx = context.WithValue(ctx, "max_request_size", maxRequestSize) + ctx = context.WithValue(ctx, globals.ContextMaxRequestSizeTypeKey, maxRequestSize) } - ctx = context.WithValue(ctx, "original_request_path", r.URL.Path) + ctx = context.WithValue(ctx, globals.ContextOriginalRequestPathTypeKey, r.URL.Path) r = r.WithContext(ctx) h.ServeHTTP(w, r) diff --git a/internal/servers/worker/testing.go b/internal/servers/worker/testing.go index 2b1f272ca..5a919abc8 100644 --- a/internal/servers/worker/testing.go +++ b/internal/servers/worker/testing.go @@ -181,7 +181,7 @@ func NewTestWorker(t *testing.T, opts *TestWorkerOpts) *TestWorker { case opts.WorkerAuthKms != nil: tw.b.WorkerAuthKms = opts.WorkerAuthKms default: - if err := tw.b.SetupKMSes(nil, opts.Config.SharedConfig, []string{"worker-auth"}); err != nil { + if err := tw.b.SetupKMSes(nil, opts.Config); err != nil { t.Fatal(err) } } diff --git a/internal/tests/cluster/multi_controller_worker_test.go b/internal/tests/cluster/multi_controller_worker_test.go index 7aaaee58d..bf013fe12 100644 --- a/internal/tests/cluster/multi_controller_worker_test.go +++ b/internal/tests/cluster/multi_controller_worker_test.go @@ -4,10 +4,10 @@ import ( "testing" "time" - "github.com/alecthomas/assert" "github.com/hashicorp/boundary/internal/servers/controller" "github.com/hashicorp/boundary/internal/servers/worker" "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/recovery/recovery.go b/recovery/recovery.go new file mode 100644 index 000000000..0d0df976a --- /dev/null +++ b/recovery/recovery.go @@ -0,0 +1,112 @@ +package recovery + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + wrapping "github.com/hashicorp/go-kms-wrapping" + "github.com/hashicorp/go-uuid" + "google.golang.org/protobuf/proto" +) + +const nonceLength = 32 + +// FutureLeeway indicates how far in the future we should allow the creation +// time of the token to be, in order to account for clock drift +var FutureLeeway = time.Minute + +// Info is the struct required to be marshaled to be used as a token +// for the recovery workflow. +type Info struct { + Nonce string `json:"nonce"` + NonceBytes []byte `json:"-"` + CreationTime time.Time `json:"creation_time"` +} + +func GenerateRecoveryToken(ctx context.Context, wrapper wrapping.Wrapper) (string, error) { + b, err := uuid.GenerateRandomBytes(nonceLength) + if err != nil { + return "", fmt.Errorf("error generating random bytes for recovery token nonce: %w", err) + } + info := &Info{ + Nonce: base64.RawStdEncoding.EncodeToString(b), + CreationTime: time.Now(), + } + + return formatToken(ctx, wrapper, info) +} + +func formatToken(ctx context.Context, wrapper wrapping.Wrapper, info *Info) (string, error) { + marshaledInfo, err := json.Marshal(info) + if err != nil { + return "", fmt.Errorf("error marshaling recovery info: %w", err) + } + + blobInfo, err := wrapper.Encrypt(ctx, marshaledInfo, nil) + if err != nil { + return "", fmt.Errorf("error encrypting recovery info: %w", err) + } + + marshaledBlob, err := proto.Marshal(blobInfo) + if err != nil { + return "", fmt.Errorf("error marshaling encrypted blob: %w", err) + } + + return fmt.Sprintf("r_%s", base64.RawStdEncoding.EncodeToString(marshaledBlob)), nil +} + +func ParseRecoveryToken(ctx context.Context, wrapper wrapping.Wrapper, token string) (*Info, error) { + token = strings.TrimSpace(token) + if token == "" { + return nil, errors.New("empty token") + } + if !strings.HasPrefix(token, "r_") { + return nil, errors.New("token has wrong format") + } + token = strings.TrimPrefix(token, "r_") + + marshaledBlob, err := base64.RawStdEncoding.DecodeString(token) + if err != nil { + return nil, fmt.Errorf("error base64-decoding token: %w", err) + } + + blobInfo := new(wrapping.EncryptedBlobInfo) + if err := proto.Unmarshal(marshaledBlob, blobInfo); err != nil { + return nil, fmt.Errorf("error decoding encrypted blob: %w", err) + } + + marshaledInfo, err := wrapper.Decrypt(ctx, blobInfo, nil) + if err != nil { + return nil, fmt.Errorf("error decrypting recovery info: %w", err) + } + + info := new(Info) + if err := json.Unmarshal(marshaledInfo, info); err != nil { + return nil, fmt.Errorf("error unmarshaling recovery info: %w", err) + } + + info.NonceBytes, err = base64.RawStdEncoding.DecodeString(info.Nonce) + if err != nil { + return nil, fmt.Errorf("error decoding nonce bytes: %w", err) + } + if len(info.NonceBytes) != nonceLength { + return nil, errors.New("nonce has incorrect length, must be 32 bytes") + } + + if info.CreationTime.IsZero() { + return nil, errors.New("recovery token creation time is zero") + } + // It must be before the current time. This means someone can't create + // one way in the future and keep using it. We fudge this by 1 minute to + // account for time discrepancies between systems. + if info.CreationTime.After(time.Now().Add(FutureLeeway)) { + return nil, errors.New("recovery token creation time is invalid") + } + + return info, nil +} diff --git a/recovery/recovery_test.go b/recovery/recovery_test.go new file mode 100644 index 000000000..5d7cd82b9 --- /dev/null +++ b/recovery/recovery_test.go @@ -0,0 +1,90 @@ +package recovery + +import ( + "context" + "encoding/base64" + "testing" + "time" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/go-uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRecoveryTokens(t *testing.T) { + ctx := context.Background() + wrapper := db.TestWrapper(t) + b, err := uuid.GenerateRandomBytes(nonceLength) + require.NoError(t, err) + assert.Len(t, b, 32) + b64Nonce := base64.RawStdEncoding.EncodeToString(b) + + tests := []struct { + name string + withTime time.Time + withNonce string + wantErr bool + }{ + { + name: "normal", + }, + { + name: "zero time", + withNonce: b64Nonce, + wantErr: true, + }, + { + name: "future time", + withNonce: b64Nonce, + withTime: time.Now().Add(5 * time.Minute), + wantErr: true, + }, + { + name: "empty nonce", + withNonce: string(""), + withTime: time.Now(), + wantErr: true, + }, + { + name: "wrong nonce length", + withNonce: base64.RawStdEncoding.EncodeToString(append(b, b...)), + withTime: time.Now(), + wantErr: true, + }, + { + name: "bad nonce format", + withNonce: string(b), + withTime: time.Now(), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + if tt.withTime.IsZero() && tt.withNonce == "" { + token, err := GenerateRecoveryToken(ctx, wrapper) + require.NoError(err) + assert.NotEmpty(token) + info, err := ParseRecoveryToken(ctx, wrapper, token) + require.NoError(err) + assert.NotNil(info) + return + } + + token, err := formatToken(ctx, wrapper, &Info{ + Nonce: tt.withNonce, + CreationTime: tt.withTime, + }) + require.NoError(err) + assert.NotEmpty(token) + info, err := ParseRecoveryToken(ctx, wrapper, token) + if tt.wantErr { + require.Error(err) + return + } + require.NoError(err) + assert.NotNil(info) + }) + } +}