Skip to content

Commit

Permalink
Require client cert auth for gRPC, allow for JSON-RPC
Browse files Browse the repository at this point in the history
Client authentication for JSON-RPC previously required configuring a
user and password, with both client and server holding knowledge of
the secret.  By allowing client authentication to be performed with
TLS client certificates, only the client side must hold a private key,
as long as the dcrwallet server is configured to trust the public key.

The gRPC server had no client authentication at all previously, and
the only reason this was marginally safe was that all requests that
could use a wallet key also required supplying and checking the wallet
private passhprase in the request.  However, with per-account
passphrases, this is no longer a suitable mechanism, and instead the
entire transport layer must be authenticated.  The simplest way to
perform this is by requiring and verifying client certificates.

TLS client certificate authentication must be enabled with the
--authtype=clientcerts flag or config setting.  The gRPC server will
no longer start without this setting, and enabling this also allows
the JSON-RPC server to be started without any user or password.

There are two ways in which a client certificate may be trusted:

1. A certificate authority is created which adds trust for a client
   cert, or certs signed by the authority.  This file defaults to
   clients.pem in the dcrwallet application data directory and can
   be modified to use other paths with the --clientcafile option.
   Certificates can be created using gencerts, OpenSSL, and similar
   tooling.

2. A parent process can read an issued ephemeral certificate and key
   through a pipe.  These certs and keys never reach the filesystem,
   and this is the expected mechanism by which Decrediton will
   authenticate itself to the gRPC server.  This behavior is enabled
   with the --issueclientcert flag.
  • Loading branch information
jrick committed Oct 13, 2020
1 parent 259369a commit e0ba5fd
Show file tree
Hide file tree
Showing 6 changed files with 266 additions and 21 deletions.
29 changes: 23 additions & 6 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const (
defaultLogFilename = "dcrwallet.log"
defaultRPCMaxClients = 10
defaultRPCMaxWebsockets = 25
defaultAuthType = "basic"
defaultEnableTicketBuyer = false
defaultEnableVoting = false
defaultPurchaseAccount = "default"
Expand All @@ -60,12 +61,13 @@ const (
)

var (
dcrdDefaultCAFile = filepath.Join(dcrutil.AppDataDir("dcrd", false), "rpc.cert")
defaultAppDataDir = dcrutil.AppDataDir("dcrwallet", false)
defaultConfigFile = filepath.Join(defaultAppDataDir, defaultConfigFilename)
defaultRPCKeyFile = filepath.Join(defaultAppDataDir, "rpc.key")
defaultRPCCertFile = filepath.Join(defaultAppDataDir, "rpc.cert")
defaultLogDir = filepath.Join(defaultAppDataDir, defaultLogDirname)
dcrdDefaultCAFile = filepath.Join(dcrutil.AppDataDir("dcrd", false), "rpc.cert")
defaultAppDataDir = dcrutil.AppDataDir("dcrwallet", false)
defaultConfigFile = filepath.Join(defaultAppDataDir, defaultConfigFilename)
defaultRPCKeyFile = filepath.Join(defaultAppDataDir, "rpc.key")
defaultRPCCertFile = filepath.Join(defaultAppDataDir, "rpc.cert")
defaultRPCClientCAFile = filepath.Join(defaultAppDataDir, "clients.pem")
defaultLogDir = filepath.Join(defaultAppDataDir, defaultLogDirname)
)

type config struct {
Expand Down Expand Up @@ -106,6 +108,7 @@ type config struct {
// RPC client options
RPCConnect string `short:"c" long:"rpcconnect" description:"Network address of dcrd RPC server"`
CAFile *cfgutil.ExplicitString `long:"cafile" description:"dcrd RPC Certificate Authority"`
ClientCAFile string `long:"clientcafile" description:"Certficate Authority to verify TLS client certificates"`
DisableClientTLS bool `long:"noclienttls" description:"Disable TLS for dcrd RPC; only allowed when connecting to localhost"`
DcrdUsername string `long:"dcrdusername" description:"dcrd RPC username; overrides --username"`
DcrdPassword string `long:"dcrdpassword" default-mask:"-" description:"dcrd RPC password; overrides --password"`
Expand Down Expand Up @@ -138,11 +141,13 @@ type config struct {
LegacyRPCMaxWebsockets int64 `long:"rpcmaxwebsockets" description:"Max JSON-RPC websocket clients"`
Username string `short:"u" long:"username" description:"JSON-RPC username and default dcrd RPC username"`
Password string `short:"P" long:"password" default-mask:"-" description:"JSON-RPC password and default dcrd RPC password"`
AuthType string `long:"authtype" description:"Method for client authentication (basic or clientcert)"`

// IPC options
PipeTx *uint `long:"pipetx" description:"File descriptor or handle of write end pipe to enable child -> parent process communication"`
PipeRx *uint `long:"piperx" description:"File descriptor or handle of read end pipe to enable parent -> child process communication"`
RPCListenerEvents bool `long:"rpclistenerevents" description:"Notify JSON-RPC and gRPC listener addresses over the TX pipe"`
IssueClientCert bool `long:"issueclientcert" description:"Notify a client cert and key over the TX pipe for RPC authentication"`

// CSPP
CSPPServer string `long:"csppserver" description:"Network address of CoinShuffle++ server"`
Expand Down Expand Up @@ -328,6 +333,7 @@ func loadConfig(ctx context.Context) (*config, []string, error) {
LogDir: cfgutil.NewExplicitString(defaultLogDir),
WalletPass: wallet.InsecurePubPassphrase,
CAFile: cfgutil.NewExplicitString(""),
ClientCAFile: defaultRPCClientCAFile,
dial: new(net.Dialer).DialContext,
lookup: net.LookupIP,
PromptPass: defaultPromptPass,
Expand All @@ -338,6 +344,7 @@ func loadConfig(ctx context.Context) (*config, []string, error) {
TLSCurve: cfgutil.NewCurveFlag(cfgutil.PreferredCurve),
LegacyRPCMaxClients: defaultRPCMaxClients,
LegacyRPCMaxWebsockets: defaultRPCMaxWebsockets,
AuthType: defaultAuthType,
EnableTicketBuyer: defaultEnableTicketBuyer,
EnableVoting: defaultEnableVoting,
PurchaseAccount: defaultPurchaseAccount,
Expand Down Expand Up @@ -914,6 +921,7 @@ func loadConfig(ctx context.Context) (*config, []string, error) {
cfg.CAFile.Value = cleanAndExpandPath(cfg.CAFile.Value)
cfg.RPCCert.Value = cleanAndExpandPath(cfg.RPCCert.Value)
cfg.RPCKey.Value = cleanAndExpandPath(cfg.RPCKey.Value)
cfg.ClientCAFile = cleanAndExpandPath(cfg.ClientCAFile)

// If the dcrd username or password are unset, use the same auth as for
// the client. The two settings were previously shared for dcrd and
Expand All @@ -926,6 +934,15 @@ func loadConfig(ctx context.Context) (*config, []string, error) {
cfg.DcrdPassword = cfg.Password
}

switch cfg.AuthType {
case "basic", "clientcert":
default:
err := fmt.Errorf("unknown authtype %q", cfg.AuthType)
fmt.Fprintln(os.Stderr, err)
fmt.Fprintln(os.Stderr, usageMessage)
return loadConfigError(err)
}

// Warn if user still has an old ticket buyer configuration file.
oldTBConfigFile := filepath.Join(cfg.AppDataDir.Value, "ticketbuyer.conf")
if _, err := os.Stat(oldTBConfigFile); err == nil {
Expand Down
26 changes: 26 additions & 0 deletions internal/cfgutil/curve.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
package cfgutil

import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"io"
"time"

"decred.org/dcrwallet/errors"
Expand Down Expand Up @@ -93,6 +96,29 @@ func (f *CurveFlag) UnmarshalFlag(value string) error {
return nil
}

func (f *CurveFlag) GenerateKeyPair(rand io.Reader) (pub, priv interface{}, err error) {
if ec, ok := f.ECDSACurve(); ok {
var key *ecdsa.PrivateKey
key, err = ecdsa.GenerateKey(ec, rand)
if err != nil {
return
}
pub, priv = key.Public(), key
return
}
if f.curveID == Ed25519 {
seed := make([]byte, ed25519.SeedSize)
_, err = io.ReadFull(rand, seed)
if err != nil {
return
}
key := ed25519.NewKeyFromSeed(seed)
pub, priv = key.Public(), key
return
}
return nil, nil, errors.New("unknown curve ID")
}

func (f *CurveFlag) CertGen(org string, validUntil time.Time, extraHosts []string) (cert, key []byte, err error) {
if ec, ok := f.ECDSACurve(); ok {
return certgen.NewTLSCertPair(ec, org, validUntil, extraHosts)
Expand Down
3 changes: 3 additions & 0 deletions internal/cfgutil/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import "os"

// FileExists reports whether the named file or directory exists.
func FileExists(filePath string) (bool, error) {
if filePath == "" {
return false, nil
}
_, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
Expand Down
16 changes: 13 additions & 3 deletions internal/rpc/jsonrpc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ type Server struct {
httpServer http.Server
walletLoader *loader.Loader
listeners []net.Listener
authsha [sha256.Size]byte
authsha *[sha256.Size]byte // nil when basic auth is disabled
upgrader websocket.Upgrader

cfg Options
Expand Down Expand Up @@ -108,7 +108,6 @@ func NewServer(opts *Options, activeNet *chaincfg.Params, walletLoader *loader.L
listeners: listeners,
// A hash of the HTTP basic auth string is used for a constant
// time comparison.
authsha: sha256.Sum256(httpBasicAuth(opts.Username, opts.Password)),
upgrader: websocket.Upgrader{
// Allow all origins.
CheckOrigin: func(r *http.Request) bool { return true },
Expand All @@ -117,6 +116,10 @@ func NewServer(opts *Options, activeNet *chaincfg.Params, walletLoader *loader.L
requestShutdownChan: make(chan struct{}, 1),
activeNet: activeNet,
}
if opts.Username != "" && opts.Password != "" {
h := sha256.Sum256(httpBasicAuth(opts.Username, opts.Password))
server.authsha = &h
}

serveMux.Handle("/", throttledFn(opts.MaxPOSTClients,
func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -247,11 +250,14 @@ func (s *Server) handlerClosure(ctx context.Context, request *dcrjson.Request) l
// due to a missing Authorization HTTP header.
var errNoAuth = errors.E("missing Authorization header")

// checkAuthHeader checks the HTTP Basic authentication supplied by a client
// checkAuthHeader checks any HTTP Basic authentication supplied by a client
// in the HTTP request r.
//
// The authentication comparison is time constant.
func (s *Server) checkAuthHeader(r *http.Request) error {
if s.authsha == nil {
return nil
}
authhdr := r.Header["Authorization"]
if len(authhdr) == 0 {
return errNoAuth
Expand Down Expand Up @@ -313,6 +319,10 @@ func (s *Server) invalidAuth(req *dcrjson.Request) bool {
if !ok {
return false
}
// Authenticate commands are invalid when no basic auth is used
if s.authsha == nil {
return true
}
// Check credentials.
login := authCmd.Username + ":" + authCmd.Passphrase
auth := "Basic " + base64.StdEncoding.EncodeToString([]byte(login))
Expand Down
36 changes: 31 additions & 5 deletions ipc.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ var outgoingPipeMessages = make(chan pipeMessage)

// serviceControlPipeRx reads from the file descriptor fd of a read end pipe.
// This is intended to be used as a simple control mechanism for parent
// processes to communicate with and and manage the lifetime of a dcrd child
// process using a unidirectional pipe (on Windows, this is an anonymous pipe,
// not a named pipe).
// processes to communicate with and and manage the lifetime of a dcrwallet
// child process using a unidirectional pipe (on Windows, this is an anonymous
// pipe, not a named pipe).
//
// When the pipe is closed or any other errors occur reading the control
// message, shutdown begins. This prevents dcrd from continuing to run
// message, shutdown begins. This prevents dcrwallet from continuing to run
// unsupervised after the parent process closes unexpectedly.
//
// No control messages are currently defined and the only use for the pipe is to
Expand All @@ -62,7 +62,7 @@ func serviceControlPipeRx(fd uintptr) {

// serviceControlPipeTx sends pipe messages to the file descriptor fd of a write
// end pipe. This is intended to be a simple response and notification system
// for a child dcrd process to communicate with a parent process without the
// for a child dcrwallet process to communicate with a parent process without the
// need to go through the RPC server.
//
// See the comment on the pipeMessage interface for the binary encoding of a
Expand Down Expand Up @@ -175,3 +175,29 @@ func (s grpcListenerEventServer) notify(laddr string) {
}
s <- grpcListenerEvent(laddr)
}

type issuedClientCertEvent []byte

func (issuedClientCertEvent) Type() string { return "issuedclientcertificate" }
func (e issuedClientCertEvent) PayloadSize() uint32 { return uint32(len(e)) }
func (e issuedClientCertEvent) WritePayload(w io.Writer) error {
_, err := w.Write(e)
return err
}

type issuedClientCertEventServer chan<- pipeMessage

func newIssuedClientCertEventServer(outChan chan<- pipeMessage) issuedClientCertEventServer {
return issuedClientCertEventServer(outChan)
}

func (s issuedClientCertEventServer) notify(key []byte, certChain ...[]byte) {
if s == nil {
return
}
blocks := key
for _, cert := range certChain {
blocks = append(blocks, cert...)
}
s <- issuedClientCertEvent(blocks)
}
Loading

0 comments on commit e0ba5fd

Please sign in to comment.