github.com/mailgun/holster/v4@v4.20.0/httpsign/signer.go (about)

     1  /*
     2  Package httpsign provides tools for signing and authenticating HTTP requests between
     3  web services. See README.md for more details.
     4  */
     5  package httpsign
     6  
     7  import (
     8  	"bytes"
     9  	"crypto/hmac"
    10  	"crypto/sha256"
    11  	"encoding/hex"
    12  	"errors"
    13  	"fmt"
    14  	"io"
    15  	"net/http"
    16  	"os"
    17  	"strconv"
    18  
    19  	"github.com/mailgun/holster/v4/clock"
    20  )
    21  
    22  const (
    23  	XMailgunSignature        = "X-Mailgun-Signature"
    24  	XMailgunSignatureVersion = "X-Mailgun-Signature-Version"
    25  	XMailgunNonce            = "X-Mailgun-Nonce"
    26  	XMailgunTimestamp        = "X-Mailgun-Timestamp"
    27  
    28  	defaultCacheTimeout  = 100                        // 100 sec
    29  	defaultCacheCapacity = 5000 * defaultCacheTimeout // 5,000 msg/sec * 100 sec = 500,000 elements
    30  
    31  	maxSkewSec = 5 // 5 sec
    32  )
    33  
    34  var (
    35  	randomPrv randomProvider = &realRandom{}
    36  )
    37  
    38  // Modify NonceCacheCapacity and NonceCacheTimeout if your service needs to
    39  // authenticate more than 5,000 requests per second. For example, if you need
    40  // to handle 10,000 requests per second and timeout after one minute,  you may
    41  // want to set NonceCacheTimeout to 60 and NonceCacheCapacity to
    42  // 10000 * cacheTimeout = 600000.
    43  type Config struct {
    44  	// KeyPath is a path to a file that contains the key to sign requests. If
    45  	// it is an empty string then the key should be provided in `KeyBytes`.
    46  	KeyPath string
    47  
    48  	// KeyBytes is a key that is used by lemma to sign requests. Ignored if
    49  	// `KeyPath` is not an empty string.
    50  	KeyBytes []byte
    51  
    52  	HeadersToSign  []string // list of headers to sign
    53  	SignVerbAndURI bool     // include the http verb and uri in request
    54  
    55  	NonceCacheCapacity int // capacity of the nonce cache
    56  	NonceCacheTimeout  int // nonce cache timeout
    57  
    58  	EmitStats    bool   // toggle emitting metrics or not
    59  	StatsdHost   string // hostname of statsd server
    60  	StatsdPort   int    // port of statsd server
    61  	StatsdPrefix string // prefix to prepend to metrics
    62  
    63  	NonceHeaderName            string // default: X-Mailgun-Nonce
    64  	TimestampHeaderName        string // default: X-Mailgun-Timestamp
    65  	SignatureHeaderName        string // default: X-Mailgun-Signature
    66  	SignatureVersionHeaderName string // default: X-Mailgun-Signature-Version
    67  }
    68  
    69  // Represents an entity that can be used to sign and authenticate requests.
    70  type Signer struct {
    71  	config     *Config
    72  	nonceCache *nonceCache
    73  	secretKey  []byte
    74  }
    75  
    76  // Return a new Signer. Config can not be nil. If you need control over
    77  // setting time and random providers, use NewWithProviders.
    78  func New(config *Config) (*Signer, error) {
    79  	// config is required!
    80  	if config == nil {
    81  		return nil, fmt.Errorf("config is required.") //nolint:stylecheck // TODO(v5): ST1005: error strings should not end with punctuation or newlines
    82  	}
    83  
    84  	// set defaults if not set
    85  	if config.NonceCacheCapacity < 1 {
    86  		config.NonceCacheCapacity = defaultCacheCapacity
    87  	}
    88  	if config.NonceCacheTimeout < 1 {
    89  		config.NonceCacheTimeout = defaultCacheTimeout
    90  	}
    91  	if config.NonceHeaderName == "" {
    92  		config.NonceHeaderName = XMailgunNonce
    93  	}
    94  	if config.TimestampHeaderName == "" {
    95  		config.TimestampHeaderName = XMailgunTimestamp
    96  	}
    97  	if config.SignatureHeaderName == "" {
    98  		config.SignatureHeaderName = XMailgunSignature
    99  	}
   100  	if config.SignatureVersionHeaderName == "" {
   101  		config.SignatureVersionHeaderName = XMailgunSignatureVersion
   102  	}
   103  
   104  	// Commented out this code because it does effectively nothing.
   105  	// // setup metrics service
   106  	// if config.EmitStats {
   107  	// 	// get hostname of box
   108  	// 	hostname, err := os.Hostname()
   109  	// 	if err != nil {
   110  	// 		return nil, fmt.Errorf("failed to obtain hostname: %v", err)
   111  	// 	}
   112  	//
   113  	// 	// build lemma prefix
   114  	// 	prefix := "lemma." + strings.ReplaceAll(hostname, ".", "_")
   115  	// 	if config.StatsdPrefix != "" {
   116  	// 		prefix += "." + config.StatsdPrefix
   117  	// 	}
   118  	// }
   119  
   120  	// Read in key from KeyPath or if not given, try getting them from KeyBytes.
   121  	var keyBytes []byte
   122  	var err error
   123  	if config.KeyPath != "" {
   124  		if keyBytes, err = readKeyFromDisk(config.KeyPath); err != nil {
   125  			return nil, err
   126  		}
   127  	} else {
   128  		if config.KeyBytes == nil {
   129  			return nil, errors.New("no key bytes provided")
   130  		}
   131  		keyBytes = config.KeyBytes
   132  	}
   133  
   134  	// setup nonce cache
   135  	ncache, err := newNonceCache(config.NonceCacheCapacity, config.NonceCacheTimeout)
   136  	if err != nil {
   137  		return nil, err
   138  	}
   139  
   140  	return &Signer{
   141  		config:     config,
   142  		nonceCache: ncache,
   143  		secretKey:  keyBytes,
   144  	}, nil
   145  }
   146  
   147  // Signs a given HTTP request with signature, nonce, and timestamp.
   148  func (s *Signer) SignRequest(r *http.Request) error {
   149  	if s.secretKey == nil {
   150  		return fmt.Errorf("service not loaded with key.") //nolint:stylecheck // TODO(v5): ST1005: error strings should not end with punctuation or newlines
   151  	}
   152  	return s.SignRequestWithKey(r, s.secretKey)
   153  }
   154  
   155  // Signs a given HTTP request with signature, nonce, and timestamp. Signs the
   156  // message with the passed in key not the one initialized with.
   157  func (s *Signer) SignRequestWithKey(r *http.Request, secretKey []byte) error {
   158  	// extract request body bytes
   159  	bodyBytes, err := readBody(r)
   160  	if err != nil {
   161  		return err
   162  	}
   163  
   164  	// extract any headers if requested
   165  	headerValues, err := extractHeaderValues(r, s.config.HeadersToSign)
   166  	if err != nil {
   167  		return err
   168  	}
   169  
   170  	// get 128-bit random number from /dev/urandom and base16 encode it
   171  	nonce, err := randomPrv.hexDigest(16)
   172  	if err != nil {
   173  		return fmt.Errorf("unable to get random : %v", err)
   174  	}
   175  
   176  	// get current timestamp
   177  	timestamp := strconv.FormatInt(clock.Now().Unix(), 10)
   178  
   179  	// compute the hmac and base16 encode it
   180  	computedMAC := computeMAC(secretKey, s.config.SignVerbAndURI, r.Method, r.URL.RequestURI(),
   181  		timestamp, nonce, bodyBytes, headerValues)
   182  	signature := hex.EncodeToString(computedMAC)
   183  
   184  	// set headers
   185  	r.Header.Set(s.config.NonceHeaderName, nonce)
   186  	r.Header.Set(s.config.TimestampHeaderName, timestamp)
   187  	r.Header.Set(s.config.SignatureHeaderName, signature)
   188  	r.Header.Set(s.config.SignatureVersionHeaderName, "2")
   189  	return nil
   190  }
   191  
   192  // VerifyRequest checks that an HTTP request was sent by an authorized sender.
   193  func (s *Signer) VerifyRequest(r *http.Request) error {
   194  	if s.secretKey == nil {
   195  		return fmt.Errorf("service not loaded with key.") //nolint:stylecheck // TODO(v5): ST1005: error strings should not end with punctuation or newlines
   196  	}
   197  	return s.VerifyRequestWithKey(r, s.secretKey)
   198  }
   199  
   200  // VerifyRequestWithKey checks that an HTTP request was sent by an authorized
   201  // sender. The check is performed against the passed in key.
   202  func (s *Signer) VerifyRequestWithKey(r *http.Request, secretKey []byte) (err error) {
   203  	// extract parameters
   204  	signature := r.Header.Get(s.config.SignatureHeaderName)
   205  	if signature == "" {
   206  		return fmt.Errorf("header not found: %v", s.config.SignatureHeaderName)
   207  	}
   208  	nonce := r.Header.Get(s.config.NonceHeaderName)
   209  	if nonce == "" {
   210  		return fmt.Errorf("header not found: %v", s.config.NonceHeaderName)
   211  	}
   212  	timestamp := r.Header.Get(s.config.TimestampHeaderName)
   213  	if timestamp == "" {
   214  		return fmt.Errorf("header not found: %v", s.config.TimestampHeaderName)
   215  	}
   216  
   217  	// extract request body bytes
   218  	bodyBytes, err := readBody(r)
   219  	if err != nil {
   220  		return err
   221  	}
   222  
   223  	// extract any headers if requested
   224  	headerValues, err := extractHeaderValues(r, s.config.HeadersToSign)
   225  	if err != nil {
   226  		return err
   227  	}
   228  
   229  	// check the hmac
   230  	isValid, err := checkMAC(secretKey, s.config.SignVerbAndURI, r.Method, r.URL.RequestURI(),
   231  		timestamp, nonce, bodyBytes, headerValues, signature)
   232  	if !isValid {
   233  		return err
   234  	}
   235  
   236  	// check timestamp
   237  	isValid, err = s.checkTimestamp(timestamp)
   238  	if !isValid {
   239  		return err
   240  	}
   241  
   242  	// check to see if we have seen nonce before
   243  	inCache, err := s.nonceCache.inCache(nonce)
   244  	if err != nil {
   245  		return fmt.Errorf("while reading nonce cache for value %v: %w", nonce, err)
   246  	}
   247  	if inCache {
   248  		return fmt.Errorf("nonce already in cache: %v", nonce)
   249  	}
   250  
   251  	return nil
   252  }
   253  
   254  func (s *Signer) checkTimestamp(timestampHeader string) (bool, error) {
   255  	// convert unix timestamp string into time struct
   256  	timestamp, err := strconv.ParseInt(timestampHeader, 10, 0)
   257  	if err != nil {
   258  		return false, fmt.Errorf("unable to parse %v: %v", s.config.TimestampHeaderName, timestampHeader)
   259  	}
   260  
   261  	now := clock.Now().Unix()
   262  
   263  	// if timestamp is from the future, it's invalid
   264  	if timestamp >= now+maxSkewSec {
   265  		return false, fmt.Errorf("timestamp header from the future; now: %v; %v: %v; difference: %v",
   266  			now, s.config.TimestampHeaderName, timestamp, timestamp-now)
   267  	}
   268  
   269  	// if the timestamp is older than ttl - skew, it's invalid
   270  	if timestamp <= now-int64(s.nonceCache.cacheTTL-maxSkewSec) {
   271  		return false, fmt.Errorf("timestamp header too old; now: %v; %v: %v; difference: %v",
   272  			now, s.config.TimestampHeaderName, timestamp, now-timestamp)
   273  	}
   274  
   275  	return true, nil
   276  }
   277  
   278  func computeMAC(secretKey []byte, signVerbAndURI bool, httpVerb string, httpResourceURI string,
   279  	timestamp string, nonce string, body []byte, headerValues []string) []byte {
   280  
   281  	// use hmac-sha256
   282  	mac := hmac.New(sha256.New, secretKey)
   283  
   284  	// required parameters (timestamp, nonce, body)
   285  	fmt.Fprintf(mac, "%v|", len(timestamp))
   286  	mac.Write([]byte(timestamp))
   287  	fmt.Fprintf(mac, "|%v|", len(nonce))
   288  	mac.Write([]byte(nonce))
   289  	fmt.Fprintf(mac, "|%v|", len(body))
   290  	mac.Write(body)
   291  
   292  	// optional parameters (httpVerb, httpResourceUri)
   293  	if signVerbAndURI {
   294  		fmt.Fprintf(mac, "|%v|", len(httpVerb))
   295  		mac.Write([]byte(httpVerb))
   296  		fmt.Fprintf(mac, "|%v|", len(httpResourceURI))
   297  		mac.Write([]byte(httpResourceURI))
   298  	}
   299  
   300  	// optional parameters (headers)
   301  	for _, headerValue := range headerValues {
   302  		fmt.Fprintf(mac, "|%v|", len(headerValue))
   303  		mac.Write([]byte(headerValue))
   304  	}
   305  
   306  	return mac.Sum(nil)
   307  }
   308  
   309  func checkMAC(secretKey []byte, signVerbAndURI bool, httpVerb string, httpResourceURI string,
   310  	timestamp string, nonce string, body []byte, headerValues []string, signature string) (bool, error) {
   311  
   312  	// the hmac we get is a hexdigest (string representation of hex values)
   313  	// which needs to be decoded before before we can use it
   314  	expectedMAC, err := hex.DecodeString(signature)
   315  	if err != nil {
   316  		return false, err
   317  	}
   318  
   319  	// compute the hmac
   320  	computedMAC := computeMAC(secretKey, signVerbAndURI, httpVerb, httpResourceURI, timestamp, nonce, body, headerValues)
   321  
   322  	// constant time compare
   323  	isEqual := hmac.Equal(expectedMAC, computedMAC)
   324  	if !isEqual {
   325  		return false, fmt.Errorf("signature header value %v does not match computed value", expectedMAC)
   326  	}
   327  
   328  	return true, nil
   329  }
   330  
   331  // readBody will read in the request body, return a byte slice, and also restore it
   332  // within the *http.Request so it can be read later. Tries to be smart and initialize
   333  // a buffer based off content-length.
   334  //
   335  // See for more details:
   336  // https://github.com/golang/go/blob/release-branch.go1.5/src/io/ioutil/ioutil.go#L16-L43
   337  func readBody(r *http.Request) (b []byte, err error) {
   338  	// if we have no body, like a GET request, set it to ""
   339  	if r.Body == nil {
   340  		return []byte(""), nil
   341  	}
   342  
   343  	// try and be smart and pre-allocate buffer
   344  	var n int64 = bytes.MinRead
   345  	if r.ContentLength > int64(n) {
   346  		n = r.ContentLength
   347  	}
   348  	buf := bytes.NewBuffer(make([]byte, 0, n))
   349  
   350  	// If the buffer overflows, we will get bytes.ErrTooLarge.
   351  	// Return that as an error. Any other panic remains.
   352  	defer func() {
   353  		e := recover()
   354  		if e == nil {
   355  			return
   356  		}
   357  		if panicErr, ok := e.(error); ok && panicErr == bytes.ErrTooLarge {
   358  			err = panicErr
   359  		} else {
   360  			panic(e)
   361  		}
   362  	}()
   363  	_, err = buf.ReadFrom(r.Body)
   364  
   365  	// restore the body back to the request
   366  	b = buf.Bytes()
   367  	r.Body = io.NopCloser(bytes.NewReader(b))
   368  
   369  	return b, err
   370  }
   371  
   372  func extractHeaderValues(r *http.Request, headerNames []string) ([]string, error) {
   373  	if len(headerNames) < 1 {
   374  		return nil, nil
   375  	}
   376  
   377  	headerValues := make([]string, len(headerNames))
   378  	for i, headerName := range headerNames {
   379  		_, ok := r.Header[headerName]
   380  		if !ok {
   381  			return nil, fmt.Errorf("header %s not found in request", headerName)
   382  		}
   383  		headerValues[i] = r.Header.Get(headerName)
   384  	}
   385  
   386  	return headerValues, nil
   387  }
   388  
   389  func readKeyFromDisk(keypath string) ([]byte, error) {
   390  	// load key from disk
   391  	keyBytes, err := os.ReadFile(keypath)
   392  	if err != nil {
   393  		return nil, err
   394  	}
   395  
   396  	// strip newline (\n or 0x0a) if it's at the end
   397  	keyBytes = bytes.TrimSuffix(keyBytes, []byte("\n"))
   398  
   399  	return keyBytes, nil
   400  }