Add (non-db aspects of) the recovery key workflow (#286)

This commit is contained in:
Jeff Mitchell authored and GitHub committed 2020-08-20 13:34:03 -04:00
1 parent d5678c4f80
commit b47cca0329
40 files changed
+650 -143

No files matched your search

+10 -3
View File
@@ -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
)
-4
View File
@@ -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
-8
View File
@@ -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=
+56 -3
View File
@@ -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))
+1 -1
View File
@@ -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)
+9
View File
@@ -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
}
}
+1 -1
View File
@@ -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)
}
+91 -23
View File
@@ -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
}
+23
View File
@@ -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",
})
}
+19 -6
View File
@@ -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)
}
@@ -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 {
@@ -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 /",
})
}
+8 -14
View File
@@ -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 {
-18
View File
@@ -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 /",
})
}
+1 -1
View File
@@ -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
}
+21 -8
View File
@@ -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
}
+10
View File
@@ -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)
}
+40 -6
View File
@@ -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 <scope_id>:<member_id> to reference members from
-- other scopes than the group's scope.
@@ -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';
@@ -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;
+37 -4
View File
@@ -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 <scope_id>:<member_id> to reference members from
-- other scopes than the group's scope.
+8
View File
@@ -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) {
+16 -4
View File
@@ -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 {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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))
+8 -3
View File
@@ -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"
}
+15
View File
@@ -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
}
+9 -1
View File
@@ -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 {
@@ -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)
}
+3 -2
View File
@@ -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)
@@ -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)
}
@@ -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 {
@@ -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)
}
@@ -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 {
+8 -1
View File
@@ -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,
}
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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)
}
}
@@ -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"
)
+112
View File
@@ -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
}
+90
View File
@@ -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)
})
}
}