Skip to content

Commit 00dd009

Browse files
Implement Admin Password hashing using sha256
1 parent 68047dc commit 00dd009

File tree

8 files changed

+125
-14
lines changed

8 files changed

+125
-14
lines changed

cmd/vspd/config.go

+1-7
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ type config struct {
6666
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."`
6767
VspClosed bool `long:"vspclosed" ini-name:"vspclosed" description:"Closed prevents the VSP from accepting new tickets."`
6868
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."`
69-
AdminPass string `long:"adminpass" ini-name:"adminpass" description:"Password for accessing admin page."`
69+
AdminPass string `long:"adminpass" ini-name:"adminpass" description:"Password for accessing admin page. INSECURE. Do not set unless absolutely necessary."`
7070
Designation string `long:"designation" ini-name:"designation" description:"Short name for the VSP. Customizes the logo in the top toolbar."`
7171

7272
// The following flags should be set on CLI only, not via config file.
@@ -170,7 +170,6 @@ func normalizeAddress(addr, defaultPort string) string {
170170
// while still allowing the user to override settings with config files and
171171
// command line options. Command line options always take precedence.
172172
func loadConfig() (*config, error) {
173-
174173
// Default config.
175174
cfg := config{
176175
Listen: defaultListen,
@@ -307,11 +306,6 @@ func loadConfig() (*config, error) {
307306
return nil, errors.New("the supportemail option is not set")
308307
}
309308

310-
// Ensure the administrator password is set.
311-
if cfg.AdminPass == "" {
312-
return nil, errors.New("the adminpass option is not set")
313-
}
314-
315309
// Ensure the dcrd RPC username is set.
316310
if cfg.DcrdUser == "" {
317311
return nil, errors.New("the dcrduser option is not set")

cmd/vspd/main.go

+16-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package main
66

77
import (
88
"context"
9+
"crypto/sha256"
910
"fmt"
1011
"os"
1112
"runtime"
@@ -56,6 +57,20 @@ func run() int {
5657
shutdownCtx := withShutdownCancel(context.Background())
5758
go shutdownListener(log)
5859

60+
// Request admin password if admin password is not set in config.
61+
var adminAuthSHA [32]byte
62+
if cfg.AdminPass == "" {
63+
adminAuthSHA, err = passwordHashPrompt(shutdownCtx, "Admin password for accessing admin page: ")
64+
if err != nil {
65+
fmt.Fprintf(os.Stderr, "cannot use password: %v\n", err)
66+
return 1
67+
}
68+
} else {
69+
adminAuthSHA = sha256.Sum256([]byte(cfg.AdminPass))
70+
// Clear password string
71+
cfg.AdminPass = ""
72+
}
73+
5974
// Show version at startup.
6075
log.Criticalf("Version %s (Go version %s %s/%s)", version.String(), runtime.Version(),
6176
runtime.GOOS, runtime.GOARCH)
@@ -175,7 +190,7 @@ func run() int {
175190
SupportEmail: cfg.SupportEmail,
176191
VspClosed: cfg.VspClosed,
177192
VspClosedMsg: cfg.VspClosedMsg,
178-
AdminPass: cfg.AdminPass,
193+
AdminAuthSHA: adminAuthSHA,
179194
Debug: cfg.WebServerDebug,
180195
Designation: cfg.Designation,
181196
MaxVoteChangeRecords: maxVoteChangeRecords,

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ require (
2424
github.com/jrick/logrotate v1.0.0
2525
github.com/jrick/wsrpc/v2 v2.3.5
2626
go.etcd.io/bbolt v1.3.6
27+
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
2728
)
2829

2930
require (

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
253253
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
254254
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
255255
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
256+
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
257+
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
256258
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
257259
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
258260
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

prompt.go

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2021 The Decred developers
2+
// Use of this source code is governed by an ISC
3+
// license that can be found in the LICENSE file.
4+
5+
package main
6+
7+
import (
8+
"context"
9+
"crypto/sha256"
10+
"fmt"
11+
"os"
12+
13+
"golang.org/x/term"
14+
)
15+
16+
type passwordReadResponse struct {
17+
password []byte
18+
err error
19+
}
20+
21+
// clearBytes zeroes the byte slice.
22+
func clearBytes(b []byte) {
23+
for i := range b {
24+
b[i] = 0
25+
}
26+
}
27+
28+
// passwordPrompt prompts the user to enter a password. Password must not be an
29+
// empty string.
30+
func passwordPrompt(ctx context.Context, prompt string) ([]byte, error) {
31+
// Get the initial state of the terminal.
32+
initialTermState, err := term.GetState(int(os.Stdin.Fd()))
33+
if err != nil {
34+
return nil, err
35+
}
36+
37+
passwordReadChan := make(chan passwordReadResponse, 1)
38+
39+
go func() {
40+
fmt.Print(prompt)
41+
pass, err := term.ReadPassword(int(os.Stdin.Fd()))
42+
fmt.Println()
43+
passwordReadChan <- passwordReadResponse{
44+
password: pass,
45+
err: err,
46+
}
47+
}()
48+
49+
select {
50+
case <-ctx.Done():
51+
_ = term.Restore(int(os.Stdin.Fd()), initialTermState)
52+
return nil, ctx.Err()
53+
54+
case res := <-passwordReadChan:
55+
if res.err != nil {
56+
return nil, res.err
57+
}
58+
return res.password, nil
59+
}
60+
}
61+
62+
// passwordHashPrompt prompts the user to enter a password and returns its
63+
// SHA256 hash. Password must not be an empty string.
64+
func passwordHashPrompt(ctx context.Context, prompt string) ([sha256.Size]byte, error) {
65+
var passBytes []byte
66+
var err error
67+
var authSHA [sha256.Size]byte
68+
69+
// Ensure passBytes is not empty.
70+
for len(passBytes) == 0 {
71+
passBytes, err = passwordPrompt(ctx, prompt)
72+
if err != nil {
73+
return authSHA, err
74+
}
75+
}
76+
77+
authSHA = sha256.Sum256(passBytes)
78+
// Zero password bytes.
79+
clearBytes(passBytes)
80+
return authSHA, nil
81+
}

webapi/admin.go

+4-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
package webapi
66

77
import (
8+
"crypto/sha256"
9+
"crypto/subtle"
810
"net/http"
911

1012
"github.com/decred/vspd/database"
@@ -196,8 +198,8 @@ func (s *Server) ticketSearch(c *gin.Context) {
196198
// the current session will be authenticated as an admin.
197199
func (s *Server) adminLogin(c *gin.Context) {
198200
password := c.PostForm("password")
199-
200-
if password != s.cfg.AdminPass {
201+
authSHA := sha256.Sum256([]byte(password))
202+
if subtle.ConstantTimeCompare(s.cfg.AdminAuthSHA[:], authSHA[:]) != 1 {
201203
s.log.Warnf("Failed login attempt from %s", c.ClientIP())
202204
c.HTML(http.StatusUnauthorized, "login.html", gin.H{
203205
"WebApiCache": s.cache.getData(),

webapi/middleware.go

+18
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ package webapi
66

77
import (
88
"bytes"
9+
"crypto/sha256"
10+
"crypto/subtle"
911
"errors"
1012
"io"
1113
"net/http"
@@ -373,3 +375,19 @@ func (s *Server) vspAuth(c *gin.Context) {
373375
c.Set(knownTicketKey, ticketFound)
374376
c.Set(commitmentAddressKey, commitmentAddress)
375377
}
378+
379+
// authMiddleware checks incoming requests for authentication.
380+
func (s *Server) authMiddleware() gin.HandlerFunc {
381+
return func(c *gin.Context) {
382+
// User is ignored
383+
_, password, ok := c.Request.BasicAuth()
384+
passAuthSHA := sha256.Sum256([]byte(password))
385+
if !ok || subtle.ConstantTimeCompare(passAuthSHA[:], s.cfg.AdminAuthSHA[:]) != 1 {
386+
s.log.Warnf("Failed authentication attempt from %s", c.ClientIP())
387+
// Credentials doesn't match, we return 401 and abort handlers chain.
388+
c.Header("WWW-Authenticate", `Basic realm="Authorization Required"`)
389+
c.AbortWithStatus(http.StatusUnauthorized)
390+
return
391+
}
392+
}
393+
}

webapi/webapi.go

+2-4
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ type Config struct {
3535
SupportEmail string
3636
VspClosed bool
3737
VspClosedMsg string
38-
AdminPass string
38+
AdminAuthSHA [32]byte
3939
Debug bool
4040
Designation string
4141
MaxVoteChangeRecords int
@@ -261,9 +261,7 @@ func (s *Server) router(cookieSecret []byte, dcrd rpc.DcrdConnect, wallets rpc.W
261261

262262
// Require Basic HTTP Auth on /admin/status endpoint.
263263
basic := router.Group("/admin").Use(
264-
s.withDcrdClient(dcrd), s.withWalletClients(wallets), gin.BasicAuth(gin.Accounts{
265-
"admin": s.cfg.AdminPass,
266-
}),
264+
withDcrdClient(dcrd), withWalletClients(wallets), s.authMiddleware(),
267265
)
268266
basic.GET("/status", s.statusJSON)
269267

0 commit comments

Comments
 (0)