Add (non-db aspects of) the recovery key workflow (#286)
This commit is contained in:
1 parent
d5678c4f80
commit
b47cca0329
40 files changed
+650
-143
No files matched your search
+10
-3
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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 /",
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 /",
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user