diff --git a/README.md b/README.md index 5a513e2..beea25b 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,13 @@ Available Commands: Flags: -a, --address string full address of the monero node to reach out to (default "http://localhost:18081") -h, --help help for daemon - --request-timeout duration how long to wait until considering the request a failure (default 1m0s) + -p, --password string password to supply for rpc auth + --request-timeout duration max wait time until considering the request a failure (default 1m0s) + --tls-ca-cert string certificate authority to load + --tls-client-cert string tls client certificate to use when connecting + --tls-client-key string tls client key to use when connecting + -k, --tls-skip-verify skip verification of certificate chain and host name + -u, --username string name of the user to use during rpc auth -v, --verbose dump http requests and responses to stderr Use "monero daemon [command] --help" for more information about a command. diff --git a/cmd/monero/options/options.go b/cmd/monero/options/options.go index 0302459..81c4da2 100644 --- a/cmd/monero/options/options.go +++ b/cmd/monero/options/options.go @@ -83,6 +83,16 @@ func Bind(cmd *cobra.Command) { "http://localhost:18081", "full address of the monero node to reach out to") + cmd.PersistentFlags().StringVarP(&RootOptions.Username, + "username", "u", + "", + "name of the user to use during rpc auth") + + cmd.PersistentFlags().StringVarP(&RootOptions.Password, + "password", "p", + "", + "password to supply for rpc auth") + cmd.PersistentFlags().BoolVarP(&RootOptions.TLSSkipVerify, "tls-skip-verify", "k", false, diff --git a/pkg/http/client.go b/pkg/http/client.go index 0ef3ec0..740aa94 100644 --- a/pkg/http/client.go +++ b/pkg/http/client.go @@ -44,6 +44,23 @@ type ClientConfig struct { // client. // RequestTimeout time.Duration + + // Username is the name of the user to send in the header of every HTTP + // call - must match the first portion of + // `--rpc-login=:[password]` provided to `monerod`. + // + Username string + + // Password is the user's password to send in the header of every HTTP + // call - must match the second portion of + // `--rpc-login=:[password]` provided to `monerod` or the + // password interactively supplied during the daemon's startup. + // + // Note that because the `monerod` performs digest auth, the password + // won't be sent solely in plain base64 encoding, but the rest of the + // body of every request and response will still be cleartext. + // + Password string } func (c ClientConfig) Validate() error { @@ -57,6 +74,14 @@ func (c ClientConfig) Validate() error { "tls client key") } + if c.Username != "" && c.Password == "" { + return fmt.Errorf("username specified but password not") + } + + if c.Password != "" && c.Username == "" { + return fmt.Errorf("password specified but username not") + } + return nil } @@ -100,8 +125,21 @@ func NewClient(cfg ClientConfig) (*http.Client, error) { } if cfg.Verbose { - client.Transport = NewDumpTransport(client.Transport) + WithTransport(NewDumpTransport(client.Transport))(client) + } + + if cfg.Username != "" { + WithTransport(NewDigestAuthTransport( + cfg.Username, cfg.Password, + client.Transport, + ))(client) } return client, nil } + +func WithTransport(rt http.RoundTripper) func(*http.Client) { + return func(c *http.Client) { + c.Transport = rt + } +} diff --git a/pkg/http/digest_auth.go b/pkg/http/digest_auth.go new file mode 100644 index 0000000..debf5d5 --- /dev/null +++ b/pkg/http/digest_auth.go @@ -0,0 +1,270 @@ +// +// This is a derived work based on `code.google.com/p/mlab-ns2/gae/ns/digest` +// (original work of Bipasa Chattopadhyay bipasa@cs.unc.edu Eric Gavaletz +// gavaletz@gmail.com Seon-Wook Park seon.wook@swook.net, from the fork +// maintained by Bob Ziuchkovski @bobziuchkovski +// (https://github.com/rkl-/digest). +// +package http + +import ( + "bytes" + "crypto/md5" + "crypto/rand" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" +) + +var ( + ErrNilTransport = errors.New("Transport is nil") + ErrBadChallenge = errors.New("Challenge is bad") + ErrAlgNotImplemented = errors.New("algorithm not implemented") +) + +// DigestAuthTransport is an implementation of http.RoundTripper that takes +// care of http digest authentication. +// +type DigestAuthTransport struct { + Username string + Password string + transport http.RoundTripper +} + +// NewDigestAuthTransport creates a new digest transport using the +// http.DefaultTransport. +// +func NewDigestAuthTransport( + username, password string, rt http.RoundTripper, +) *DigestAuthTransport { + return &DigestAuthTransport{ + Username: username, + Password: password, + transport: rt, + } +} + +func (t *DigestAuthTransport) newCredentials( + req *http.Request, c *challenge, +) *credentials { + return &credentials{ + Algorithm: c.Algorithm, + DigestURI: req.URL.RequestURI(), + MessageQop: c.Qop, // "auth" must be a single value + Nonce: c.Nonce, + NonceCount: 0, + Opaque: c.Opaque, + Realm: c.Realm, + Username: t.Username, + + method: req.Method, + password: t.Password, + } +} + +// RoundTrip makes a request expecting a 401 response that will require digest +// authentication. It creates the credentials it needs and makes a follow-up +// request. +// +func (t *DigestAuthTransport) RoundTrip( + req *http.Request, +) (*http.Response, error) { + // copy the request so we don't modify the input. + req2 := new(http.Request) + *req2 = *req + req2.Header = make(http.Header) + for k, s := range req.Header { + req2.Header[k] = s + } + + // we need two readers for the body. + if req.Body != nil { + tmp, err := ioutil.ReadAll(req.Body) + if err != nil { + return nil, fmt.Errorf("read all body: %w", err) + } + + reqBody01 := ioutil.NopCloser(bytes.NewBuffer(tmp)) + reqBody02 := ioutil.NopCloser(bytes.NewBuffer(tmp)) + + req.Body = reqBody01 + req2.Body = reqBody02 + } + + // make a request to get the 401 that contains the challenge. + resp, err := t.transport.RoundTrip(req) + if err != nil { + return nil, fmt.Errorf("round trip err: %w", err) + } + if resp.StatusCode != 401 { + return resp, nil + } + + chal := resp.Header.Get("WWW-Authenticate") + c, err := parseChallenge(chal) + if err != nil { + return nil, fmt.Errorf("parse challange: %w", err) + } + + // form credentials based on the challenge. + cr := t.newCredentials(req2, c) + auth, err := cr.authorize() + if err != nil { + return nil, fmt.Errorf("authorize: %w", err) + } + + // we'll no longer use the initial response, so close it + resp.Body.Close() + // Make authenticated request. + req2.Header.Set("Authorization", auth) + return t.transport.RoundTrip(req2) +} + +type challenge struct { + Realm string + Domain string + Nonce string + Opaque string + Stale string + Algorithm string + Qop string +} + +func parseChallenge(input string) (*challenge, error) { + const challengePrefix = "Digest " + const whitespaceDelimiters = " \n\r\t" + const quotation = `"` + + str := strings.Trim(input, whitespaceDelimiters) + if !strings.HasPrefix(str, challengePrefix) { + return nil, fmt.Errorf("bad challange: "+ + "input doesn't start with '%s'", challengePrefix) + } + + str = strings.Trim(str[len(challengePrefix):], whitespaceDelimiters) + fields := strings.Split(str, ",") + if len(fields) != 5 { + return nil, fmt.Errorf("split: expected 5 fields, got %d", + len(fields)) + } + + c := &challenge{} + + for _, field := range fields { + kv := strings.SplitN(field, "=", 2) + if len(kv) != 2 { + return nil, fmt.Errorf("split: expected to 2 parts "+ + "in entry, got %d. field: '%s'", + len(kv), field) + } + + key, value := kv[0], kv[1] + value = strings.Trim(value, quotation) + switch key { + case "qop": + c.Qop = value + case "algorithm": + c.Algorithm = value + case "realm": + c.Realm = value + case "nonce": + c.Nonce = value + case "stale": + c.Stale = value + default: + return nil, fmt.Errorf("unknown field '%s'", key) + } + } + + return c, nil +} + +type credentials struct { + Algorithm string + Cnonce string + DigestURI string + MessageQop string + Nonce string + NonceCount int + Opaque string + Realm string + Username string + + method string + password string +} + +func (c *credentials) ha1() string { + return h(fmt.Sprintf("%s:%s:%s", c.Username, c.Realm, c.password)) +} + +func (c *credentials) ha2() string { + return h(fmt.Sprintf("%s:%s", c.method, c.DigestURI)) +} + +func (c *credentials) resp(cnonce string) (string, error) { + c.NonceCount++ + + if c.MessageQop == "auth" { + if cnonce != "" { + c.Cnonce = cnonce + } else { + b := make([]byte, 8) + io.ReadFull(rand.Reader, b) + c.Cnonce = fmt.Sprintf("%x", b)[:16] + } + return kd(c.ha1(), fmt.Sprintf("%s:%08x:%s:%s:%s", + c.Nonce, c.NonceCount, c.Cnonce, c.MessageQop, c.ha2())), nil + } else if c.MessageQop == "" { + return kd(c.ha1(), fmt.Sprintf("%s:%s", c.Nonce, c.ha2())), nil + } + + return "", ErrAlgNotImplemented +} + +func (c *credentials) authorize() (string, error) { + // Note that this is only implemented for MD5 and NOT MD5-sess. + // MD5-sess is rarely supported and those that do are a big mess. + if c.Algorithm != "MD5" { + return "", ErrAlgNotImplemented + } + // Note that this is NOT implemented for "qop=auth-int". Similarly the + // auth-int server side implementations that do exist are a mess. + if c.MessageQop != "auth" && c.MessageQop != "" { + return "", ErrAlgNotImplemented + } + resp, err := c.resp("") + if err != nil { + return "", ErrAlgNotImplemented + } + sl := []string{fmt.Sprintf(`username="%s"`, c.Username)} + sl = append(sl, fmt.Sprintf(`realm="%s"`, c.Realm)) + sl = append(sl, fmt.Sprintf(`nonce="%s"`, c.Nonce)) + sl = append(sl, fmt.Sprintf(`uri="%s"`, c.DigestURI)) + sl = append(sl, fmt.Sprintf(`response="%s"`, resp)) + if c.Algorithm != "" { + sl = append(sl, fmt.Sprintf(`algorithm="%s"`, c.Algorithm)) + } + if c.Opaque != "" { + sl = append(sl, fmt.Sprintf(`opaque="%s"`, c.Opaque)) + } + if c.MessageQop != "" { + sl = append(sl, fmt.Sprintf("qop=%s", c.MessageQop)) + sl = append(sl, fmt.Sprintf("nc=%08x", c.NonceCount)) + sl = append(sl, fmt.Sprintf(`cnonce="%s"`, c.Cnonce)) + } + return fmt.Sprintf("Digest %s", strings.Join(sl, ", ")), nil +} + +func h(data string) string { + hf := md5.New() + io.WriteString(hf, data) + return fmt.Sprintf("%x", hf.Sum(nil)) +} + +func kd(secret, data string) string { + return h(fmt.Sprintf("%s:%s", secret, data)) +} diff --git a/pkg/http/digest_auth_test.go b/pkg/http/digest_auth_test.go new file mode 100644 index 0000000..4172e0a --- /dev/null +++ b/pkg/http/digest_auth_test.go @@ -0,0 +1,22 @@ +package http_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + mhttp "github.com/cirocosta/go-monero/pkg/http" +) + +func TestParseChallenge(t *testing.T) { + input := `Digest qop="auth",algorithm=MD5,realm="monero-rpc",nonce="IdDHjxbfpLYP/KzjaxaOqA==",stale=false` + + challenge, err := mhttp.ParseChallenge(input) + assert.NoError(t, err) + + assert.Equal(t, "auth", challenge.Qop) + assert.Equal(t, "MD5", challenge.Algorithm) + assert.Equal(t, "monero-rpc", challenge.Realm) + assert.Equal(t, `IdDHjxbfpLYP/KzjaxaOqA==`, challenge.Nonce) + assert.Equal(t, "false", challenge.Stale) +} diff --git a/pkg/http/export_test.go b/pkg/http/export_test.go new file mode 100644 index 0000000..af54e1c --- /dev/null +++ b/pkg/http/export_test.go @@ -0,0 +1,3 @@ +package http + +var ParseChallenge = parseChallenge