diff --git a/config.go b/config.go index 155a10e9..75f6deff 100644 --- a/config.go +++ b/config.go @@ -62,7 +62,7 @@ type config struct { BackupInterval time.Duration `long:"backupinterval" ini-name:"backupinterval" description:"Time period between automatic database backups. Valid time units are {s,m,h}. Minimum 30 seconds."` VspClosed bool `long:"vspclosed" ini-name:"vspclosed" description:"Closed prevents the VSP from accepting new tickets."` VspClosedMsg string `long:"vspclosedmsg" ini-name:"vspclosedmsg" description:"A short message displayed on the webpage and returned by the status API endpoint if vspclosed is true."` - AdminPass string `long:"adminpass" ini-name:"adminpass" description:"Password for accessing admin page."` + AdminPass string `long:"adminpass" ini-name:"adminpass" description:"Password for accessing admin page. INSECURE. Do not set unless absolutely necessary."` Designation string `long:"designation" ini-name:"designation" description:"Short name for the VSP. Customizes the logo in the top toolbar."` // The following flags should be set on CLI only, not via config file. @@ -163,7 +163,6 @@ func normalizeAddress(addr, defaultPort string) string { // while still allowing the user to override settings with config files and // command line options. Command line options always take precedence. func loadConfig() (*config, error) { - // Default config. cfg := config{ Listen: defaultListen, @@ -302,11 +301,6 @@ func loadConfig() (*config, error) { return nil, errors.New("the supportemail option is not set") } - // Ensure the administrator password is set. - if cfg.AdminPass == "" { - return nil, errors.New("the adminpass option is not set") - } - // Ensure the dcrd RPC username is set. if cfg.DcrdUser == "" { return nil, errors.New("the dcrduser option is not set") diff --git a/go.mod b/go.mod index 614820a5..f7935054 100644 --- a/go.mod +++ b/go.mod @@ -22,4 +22,5 @@ require ( github.com/jrick/logrotate v1.0.0 github.com/jrick/wsrpc/v2 v2.3.4 go.etcd.io/bbolt v1.3.6 + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 ) diff --git a/go.sum b/go.sum index 710a7fa4..c1136473 100644 --- a/go.sum +++ b/go.sum @@ -198,6 +198,8 @@ golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= diff --git a/prompt.go b/prompt.go new file mode 100644 index 00000000..489f2637 --- /dev/null +++ b/prompt.go @@ -0,0 +1,81 @@ +// Copyright (c) 2021 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + + "golang.org/x/term" +) + +type passwordReadResponse struct { + password []byte + err error +} + +// clearBytes zeroes the byte slice. +func clearBytes(b []byte) { + for i := range b { + b[i] = 0 + } +} + +// passwordPrompt prompts the user to enter a password. Password must not be an +// empty string. +func passwordPrompt(ctx context.Context, prompt string) ([]byte, error) { + // Get the initial state of the terminal. + initialTermState, err := term.GetState(int(os.Stdin.Fd())) + if err != nil { + return nil, err + } + + passwordReadChan := make(chan passwordReadResponse, 1) + + go func() { + fmt.Print(prompt) + pass, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + passwordReadChan <- passwordReadResponse{ + password: pass, + err: err, + } + }() + + select { + case <-ctx.Done(): + _ = term.Restore(int(os.Stdin.Fd()), initialTermState) + return nil, ctx.Err() + + case res := <-passwordReadChan: + if res.err != nil { + return nil, res.err + } + return res.password, nil + } +} + +// passwordHashPrompt prompts the user to enter a password and returns its +// SHA256 hash. Password must not be an empty string. +func passwordHashPrompt(ctx context.Context, prompt string) ([sha256.Size]byte, error) { + var passBytes []byte + var err error + var authSHA [sha256.Size]byte + + // Ensure passBytes is not empty. + for len(passBytes) == 0 { + passBytes, err = passwordPrompt(ctx, prompt) + if err != nil { + return authSHA, err + } + } + + authSHA = sha256.Sum256(passBytes) + // Zero password bytes. + clearBytes(passBytes) + return authSHA, nil +} diff --git a/vspd.exe b/vspd.exe new file mode 100644 index 00000000..4bb7c5b7 Binary files /dev/null and b/vspd.exe differ diff --git a/vspd.go b/vspd.go index f9922e15..2eb5cf7a 100644 --- a/vspd.go +++ b/vspd.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020 The Decred developers +// Copyright (c) 2020-2021 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -6,6 +6,7 @@ package main import ( "context" + "crypto/sha256" "errors" "fmt" "os" @@ -51,6 +52,19 @@ func run(ctx context.Context) error { return err } + // Request admin password if admin password is not set in config. + var adminAuthSHA [32]byte + if cfg.AdminPass == "" { + adminAuthSHA, err = passwordHashPrompt(ctx, "Admin password for accessing admin page: ") + if err != nil { + return fmt.Errorf("cannot use password: %v", err) + } + } else { + adminAuthSHA = sha256.Sum256([]byte(cfg.AdminPass)) + // Clear password string + cfg.AdminPass = "" + } + // Show version at startup. log.Infof("Version %s (Go version %s %s/%s)", version.String(), runtime.Version(), runtime.GOOS, runtime.GOARCH) @@ -98,7 +112,7 @@ func run(ctx context.Context) error { SupportEmail: cfg.SupportEmail, VspClosed: cfg.VspClosed, VspClosedMsg: cfg.VspClosedMsg, - AdminPass: cfg.AdminPass, + AdminAuthSHA: adminAuthSHA, Debug: cfg.WebServerDebug, Designation: cfg.Designation, MaxVoteChangeRecords: maxVoteChangeRecords, diff --git a/webapi/admin.go b/webapi/admin.go index ecd0f145..a06c7911 100644 --- a/webapi/admin.go +++ b/webapi/admin.go @@ -5,6 +5,8 @@ package webapi import ( + "crypto/sha256" + "crypto/subtle" "net/http" "github.com/decred/vspd/database" @@ -196,8 +198,8 @@ func ticketSearch(c *gin.Context) { // the current session will be authenticated as an admin. func adminLogin(c *gin.Context) { password := c.PostForm("password") - - if password != cfg.AdminPass { + authSHA := sha256.Sum256([]byte(password)) + if subtle.ConstantTimeCompare(cfg.AdminAuthSHA[:], authSHA[:]) != 1 { log.Warnf("Failed login attempt from %s", c.ClientIP()) c.HTML(http.StatusUnauthorized, "login.html", gin.H{ "WebApiCache": getCache(), diff --git a/webapi/middleware.go b/webapi/middleware.go index 0b66e63c..7356acc9 100644 --- a/webapi/middleware.go +++ b/webapi/middleware.go @@ -6,6 +6,8 @@ package webapi import ( "bytes" + "crypto/sha256" + "crypto/subtle" "errors" "io" "net/http" @@ -384,3 +386,18 @@ func vspAuth() gin.HandlerFunc { } } + +// authMiddleware checks incoming requests for authentication. +func authMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // User is ignored + _, password, ok := c.Request.BasicAuth() + passAuthSHA := sha256.Sum256([]byte(password)) + if !ok || subtle.ConstantTimeCompare(passAuthSHA[:], cfg.AdminAuthSHA[:]) != 1 { + // Credentials doesn't match, we return 401 and abort handlers chain. + c.Header("WWW-Authenticate", `Basic realm="Authorization Required"`) + c.AbortWithStatus(http.StatusUnauthorized) + return + } + } +} diff --git a/webapi/webapi.go b/webapi/webapi.go index c5083b95..16755de6 100644 --- a/webapi/webapi.go +++ b/webapi/webapi.go @@ -33,7 +33,7 @@ type Config struct { SupportEmail string VspClosed bool VspClosedMsg string - AdminPass string + AdminAuthSHA [32]byte Debug bool Designation string MaxVoteChangeRecords int @@ -253,9 +253,7 @@ func router(debugMode bool, cookieSecret []byte, dcrd rpc.DcrdConnect, wallets r // Require Basic HTTP Auth on /admin/status endpoint. basic := router.Group("/admin").Use( - withDcrdClient(dcrd), withWalletClients(wallets), gin.BasicAuth(gin.Accounts{ - "admin": cfg.AdminPass, - }), + withDcrdClient(dcrd), withWalletClients(wallets), authMiddleware(), ) basic.GET("/status", statusJSON)