add support for rpc auth

example:

	monerod --rpc-login foo:foo  (...)
	monero daemon -u foo -p foo get-version

new flags
	  -p, --password string    password to supply for rpc auth
	  -u, --username string    name of the user to use during rpc auth

Signed-off-by: Ciro S. Costa <utxobr@protonmail.com>
This commit is contained in:
Ciro S. Costa 2021-07-18 08:27:44 -04:00
parent 096ad758c2
commit e78b200d02
6 changed files with 351 additions and 2 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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=<username>:[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=<username>:[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
}
}

270
pkg/http/digest_auth.go Normal file
View file

@ -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))
}

View file

@ -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)
}

3
pkg/http/export_test.go Normal file
View file

@ -0,0 +1,3 @@
package http
var ParseChallenge = parseChallenge