From ed9269bbc1d5a755ac1814d7ac6669b202695b9b Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Sat, 8 Feb 2025 11:07:59 +0100 Subject: [PATCH] http_config: Add HMAC SHA256 request signing support Signed-off-by: Alexander Akhmetov --- config/http_config.go | 122 ++++++++++ config/http_config_test.go | 226 ++++++++++++++++++ .../http.conf.hmac_signature.bad.yaml | 3 + .../http.conf.hmac_signature.empty.yml | 0 .../http.conf.hmac_signature.full.good.yml | 4 + .../http.conf.hmac_signature.good.yml | 2 + 6 files changed, 357 insertions(+) create mode 100644 config/testdata/http.conf.hmac_signature.bad.yaml create mode 100644 config/testdata/http.conf.hmac_signature.empty.yml create mode 100644 config/testdata/http.conf.hmac_signature.full.good.yml create mode 100644 config/testdata/http.conf.hmac_signature.good.yml diff --git a/config/http_config.go b/config/http_config.go index 63809083..35761379 100644 --- a/config/http_config.go +++ b/config/http_config.go @@ -16,17 +16,21 @@ package config import ( "bytes" "context" + "crypto/hmac" "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/hex" "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/url" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -302,6 +306,8 @@ type HTTPClientConfig struct { BasicAuth *BasicAuth `yaml:"basic_auth,omitempty" json:"basic_auth,omitempty"` // The HTTP authorization credentials for the targets. Authorization *Authorization `yaml:"authorization,omitempty" json:"authorization,omitempty"` + // The HMAC signature configuration. + HMACSignature *HMACSignature `yaml:"hmac_signature,omitempty" json:"hmac_signature,omitempty"` // The OAuth2 client credentials used to fetch a token for the targets. OAuth2 *OAuth2 `yaml:"oauth2,omitempty" json:"oauth2,omitempty"` // The bearer token for the targets. Deprecated in favour of @@ -420,6 +426,11 @@ func (c *HTTPClientConfig) Validate() error { return err } } + if c.HMACSignature != nil { + if err := c.HMACSignature.Validate(); err != nil { + return err + } + } return nil } @@ -669,6 +680,14 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon rt = NewOAuth2RoundTripper(clientSecret, cfg.OAuth2, rt, &opts) } + if cfg.HMACSignature != nil { + secret, err := toSecret(opts.secretManager, cfg.HMACSignature.Secret, cfg.HMACSignature.SecretFile, cfg.HMACSignature.SecretRef) + if err != nil { + return nil, fmt.Errorf("unable to use HMAC secret: %w", err) + } + rt = NewHMACSignatureRoundTripper(secret, cfg.HMACSignature.Header, cfg.HMACSignature.TimestampHeader, rt) + } + if cfg.HTTPHeaders != nil { rt = NewHeadersRoundTripper(cfg.HTTPHeaders, rt) } @@ -702,6 +721,109 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon return NewTLSRoundTripperWithContext(ctx, tlsConfig, tlsSettings, newRT) } +// HMACSignature contains configuration for HMAC SHA256 signing. +// +// The HMAC signature is calculated over the request body and added to the +// request headers. +// +// If the timestamp header is set, the timestamp is included in the HMAC +// by concatenating the timestamp header value with the request body using +// a colon character as separator. +type HMACSignature struct { + // The secret key used for HMAC signing. + Secret Secret `yaml:"secret,omitempty" json:"secret,omitempty"` + // The secret key file for HMAC signing. + SecretFile string `yaml:"secret_file,omitempty" json:"secret_file,omitempty"` + // SecretRef is the name of the secret within the secret manager to use as the HMAC key + SecretRef string `yaml:"secret_ref,omitempty" json:"secret_ref,omitempty"` + // Header is the name of the header containing the HMAC signature + Header string `yaml:"header,omitempty" json:"header,omitempty"` + // TimestampHeader is the name of the header containing the timestamp + // used to generate the HMAC signature. If empty, time is not included. + TimestampHeader string `yaml:"timestamp_header,omitempty" json:"timestamp_header,omitempty"` +} + +// SetDirectory joins any relative file paths with dir. +func (h *HMACSignature) SetDirectory(dir string) { + if h == nil { + return + } + h.SecretFile = JoinDir(dir, h.SecretFile) +} + +// Validate checks that the HMAC signature config is valid. +func (h *HMACSignature) Validate() error { + if h == nil { + return nil + } + if nonZeroCount(len(h.Secret) > 0, len(h.SecretFile) > 0, len(h.SecretRef) > 0) > 1 { + return errors.New("at most one of secret, secret_file & secret_ref must be configured") + } + if h.Header == "" { + h.Header = "X-HMAC-SHA256" + } + return nil +} + +// hmacRoundTripper adds HMAC signatures to HTTP requests. +type hmacRoundTripper struct { + secret SecretReader + header string + timestampHeader string + rt http.RoundTripper +} + +// NewHMACSignatureRoundTripper creates a new round tripper that creates HMAC SHA256 +// signature and adds it to a header in the request. +func NewHMACSignatureRoundTripper(secret SecretReader, header, timestampHeader string, rt http.RoundTripper) http.RoundTripper { + return &hmacRoundTripper{secret: secret, header: header, timestampHeader: timestampHeader, rt: rt} +} + +func (rt *hmacRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if rt.secret == nil { + return rt.rt.RoundTrip(req) + } + + secret, err := rt.secret.Fetch(req.Context()) + if err != nil { + return nil, fmt.Errorf("unable to read HMAC secret: %w", err) + } + + var body []byte + if req.Body != nil { + body, err = io.ReadAll(req.Body) + if err != nil { + return nil, fmt.Errorf("error reading request body: %w", err) + } + req.Body = io.NopCloser(bytes.NewBuffer(body)) + } + req = cloneRequest(req) + + mac := hmac.New(sha256.New, []byte(secret)) + + // If the timestamp header is set, include the timestamp in the HMAC + // using colon as separator between the timestamp and the request body. + if rt.timestampHeader != "" { + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + req.Header.Set(rt.timestampHeader, timestamp) + mac.Write([]byte(timestamp)) + mac.Write([]byte(":")) + } + + mac.Write([]byte(body)) + signature := hex.EncodeToString(mac.Sum(nil)) + + req.Header.Set(rt.header, signature) + + return rt.rt.RoundTrip(req) +} + +func (rt *hmacRoundTripper) CloseIdleConnections() { + if ci, ok := rt.rt.(closeIdler); ok { + ci.CloseIdleConnections() + } +} + // SecretManager manages secret data mapped to names known as "references" or "refs". type SecretManager interface { // Fetch returns the secret data given a secret name indicated by `secretRef`. diff --git a/config/http_config_test.go b/config/http_config_test.go index 58d13b0d..6d8bfc6e 100644 --- a/config/http_config_test.go +++ b/config/http_config_test.go @@ -14,9 +14,13 @@ package config import ( + "bytes" "context" + "crypto/hmac" + "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -136,6 +140,10 @@ var invalidHTTPClientConfigs = []struct { httpClientConfigFile: "testdata/http.conf.headers-reserved.bad.yaml", errMsg: `setting header "User-Agent" is not allowed`, }, + { + httpClientConfigFile: "testdata/http.conf.hmac_signature.bad.yaml", + errMsg: `at most one of secret, secret_file & secret_ref must be configured`, + }, } func newTestServer(handler func(w http.ResponseWriter, r *http.Request)) (*httptest.Server, error) { @@ -2136,6 +2144,224 @@ func readFile(t *testing.T, filename string) string { return string(content) } +func TestHMACSignatureValidate(t *testing.T) { + t.Run("nil HMACSignature", func(t *testing.T) { + var hs *HMACSignature = nil + require.NoErrorf(t, hs.Validate(), "nil HMACSignature should return no error") + }) + + t.Run("sets default values", func(t *testing.T) { + hs := &HMACSignature{ + Secret: "secret", + } + require.NoError(t, hs.Validate()) + require.Equalf(t, "X-HMAC-SHA256", hs.Header, "default header should be set") + require.Emptyf(t, hs.TimestampHeader, "default timestamp header should be empty") + }) + + t.Run("sets the specified HMAC header", func(t *testing.T) { + hs := &HMACSignature{ + Secret: "secret", + Header: "X-Custom-HMAC-Header", + } + require.NoErrorf(t, hs.Validate(), "custom header with single secret should be valid") + require.Equalf(t, "X-Custom-HMAC-Header", hs.Header, "custom header should be preserved") + }) + + t.Run("fails when multiple secrets are configured: secret and file", func(t *testing.T) { + hs := &HMACSignature{ + Secret: "secret1", + SecretFile: "file-secret", + } + err := hs.Validate() + require.Errorf(t, err, "multiple secret sources should cause validation error") + require.Containsf(t, err.Error(), "at most one", "error message should indicate multiple secrets configured") + }) + + t.Run("fails when multiple secrets are configured: file and ref", func(t *testing.T) { + hs := &HMACSignature{ + SecretFile: "file-secret", + SecretRef: "ref-secret", + } + err := hs.Validate() + require.Errorf(t, err, "multiple secret sources should cause validation error") + require.Containsf(t, err.Error(), "at most one", "error message should indicate multiple secrets configured") + }) +} + +func TestHMACSignature(t *testing.T) { + var capturedRequest *http.Request + handler := func(w http.ResponseWriter, r *http.Request) { + capturedRequest = r + } + + testServer, err := newTestServer(handler) + require.NoError(t, err) + defer testServer.Close() + + // Create a temp file for the HMAC secret + secretFile, err := os.CreateTemp("", "hmac_secret") + require.NoError(t, err) + defer os.Remove(secretFile.Name()) + secretFileHMACsecret := "file-secret" + _, err = secretFile.Write([]byte(secretFileHMACsecret)) + require.NoError(t, err) + + tlsConfig := TLSConfig{ + CAFile: TLSCAChainPath, + CertFile: ClientCertificatePath, + KeyFile: ClientKeyNoPassPath, + InsecureSkipVerify: true, + } + + secretManagerHMACsecret := "ref-secret" + secretManager := &secretManager{ + data: map[string]string{ + "hmac-secret": secretManagerHMACsecret, + }, + } + + computeHMAC := func(secret, body, timestamp string) string { + mac := hmac.New(sha256.New, []byte(secret)) + + if timestamp != "" { + mac.Write([]byte(timestamp)) + mac.Write([]byte(":")) + } + + mac.Write([]byte(body)) + + return hex.EncodeToString(mac.Sum(nil)) + } + + tests := []struct { + name string + config HTTPClientConfig + requestBody string + verify func(t *testing.T, r *http.Request) + expectError string + }{ + { + name: "adds signature with inline secret", + config: HTTPClientConfig{ + HMACSignature: &HMACSignature{ + Secret: "mysecret", + Header: "X-HMAC-Signature", + }, + TLSConfig: tlsConfig, + }, + requestBody: "test request body", + verify: func(t *testing.T, r *http.Request) { + expected := computeHMAC("mysecret", "test request body", "") + header := r.Header.Get("X-HMAC-Signature") + require.Equalf(t, expected, header, "HMAC header mismatch") + }, + }, + { + name: "adds signature with secret file", + config: HTTPClientConfig{ + HMACSignature: &HMACSignature{ + SecretFile: secretFile.Name(), + Header: "X-HMAC-Signature", + }, + TLSConfig: tlsConfig, + }, + requestBody: "another test body", + verify: func(t *testing.T, r *http.Request) { + expected := computeHMAC(secretFileHMACsecret, "another test body", "") + header := r.Header.Get("X-HMAC-Signature") + require.Equalf(t, expected, header, "HMAC header mismatch") + }, + }, + { + name: "adds signature with secret ref", + config: HTTPClientConfig{ + HMACSignature: &HMACSignature{ + SecretRef: "hmac-secret", + Header: "X-HMAC-Signature", + }, + TLSConfig: tlsConfig, + }, + requestBody: "body with ref", + verify: func(t *testing.T, r *http.Request) { + expected := computeHMAC(secretManagerHMACsecret, "body with ref", "") + header := r.Header.Get("X-HMAC-Signature") + require.Equalf(t, expected, header, "HMAC header mismatch") + }, + }, + { + name: "adds signature with a timestamp header", + config: HTTPClientConfig{ + HMACSignature: &HMACSignature{ + SecretRef: "hmac-secret", + Header: "X-HMAC-Signature", + TimestampHeader: "X-HMAC-Timestamp", + }, + TLSConfig: tlsConfig, + }, + requestBody: "body with ref", + verify: func(t *testing.T, r *http.Request) { + timestampHeader := r.Header.Get("X-HMAC-Timestamp") + expected := computeHMAC(secretManagerHMACsecret, "body with ref", timestampHeader) + header := r.Header.Get("X-HMAC-Signature") + require.Equalf(t, expected, header, "HMAC header mismatch") + + // check that the unix time timestamp header is recent + timestamp, err := strconv.ParseInt(timestampHeader, 10, 64) + require.NoError(t, err) + require.Less(t, time.Now().Unix()-timestamp, int64(5)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClientFromConfig(tt.config, "test", WithSecretManager(secretManager)) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodGet, testServer.URL, bytes.NewBufferString(tt.requestBody)) + require.NoError(t, err) + + resp, err := client.Do(req) + if tt.expectError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectError) + return + } + + require.NoError(t, err) + defer resp.Body.Close() + + tt.verify(t, capturedRequest) + }) + } +} + +func TestHMACSignatureConfig(t *testing.T) { + t.Run("empty config", func(t *testing.T) { + cfg, _, err := LoadHTTPConfigFile("testdata/http.conf.hmac_signature.empty.yml") + require.NoError(t, err) + require.Nil(t, cfg.HMACSignature) + }) + + t.Run("simple config", func(t *testing.T) { + cfg, _, err := LoadHTTPConfigFile("testdata/http.conf.hmac_signature.good.yml") + require.NoError(t, err) + require.Equal(t, &HMACSignature{Secret: "123", Header: "X-HMAC-SHA256"}, cfg.HMACSignature) + }) + + t.Run("full config", func(t *testing.T) { + cfg, _, err := LoadHTTPConfigFile("testdata/http.conf.hmac_signature.full.good.yml") + require.NoError(t, err) + expConfig := &HMACSignature{ + Secret: "123", + Header: "X-HMAC-Custom-Header", + TimestampHeader: "X-HMAC-Custom-Timestamp-Header", + } + require.Equal(t, expConfig, cfg.HMACSignature) + }) +} + func TestHeaders(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { for k, v := range map[string]string{ diff --git a/config/testdata/http.conf.hmac_signature.bad.yaml b/config/testdata/http.conf.hmac_signature.bad.yaml new file mode 100644 index 00000000..7b324a60 --- /dev/null +++ b/config/testdata/http.conf.hmac_signature.bad.yaml @@ -0,0 +1,3 @@ +hmac_signature: + secret: "123" + secret_ref: "456" diff --git a/config/testdata/http.conf.hmac_signature.empty.yml b/config/testdata/http.conf.hmac_signature.empty.yml new file mode 100644 index 00000000..e69de29b diff --git a/config/testdata/http.conf.hmac_signature.full.good.yml b/config/testdata/http.conf.hmac_signature.full.good.yml new file mode 100644 index 00000000..0a6897f7 --- /dev/null +++ b/config/testdata/http.conf.hmac_signature.full.good.yml @@ -0,0 +1,4 @@ +hmac_signature: + secret: "123" + header: "X-HMAC-Custom-Header" + timestamp_header: "X-HMAC-Custom-Timestamp-Header" diff --git a/config/testdata/http.conf.hmac_signature.good.yml b/config/testdata/http.conf.hmac_signature.good.yml new file mode 100644 index 00000000..7bd3bd7a --- /dev/null +++ b/config/testdata/http.conf.hmac_signature.good.yml @@ -0,0 +1,2 @@ +hmac_signature: + secret: "123"