diff --git a/.gitignore b/.gitignore index fbe9aa0..54d1ea3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.out .vscode/ .idea/ +.vs/ diff --git a/README.md b/README.md index 2d35825..fb4c273 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,17 @@ The logger package provides logic related to logging. It offers the Facade Desig | [`Request Logger Configuration`](logger/requestLoggerConfiguration.go) | Provides request logging configurations for the Sdk Logger. | | [`Sdk Logger`](logger/sdkLogger.go) | Provides default and null implementation of [` Sdk Logger Interface`](logger/sdkLogger.go) to log API requests and responses. | +### Security +The security package provides logic related to request authentication and verification. It includes digest encoding/decoding, signature verification, and HMAC-based request validation. + +| File Name | Description | +|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [`DigestCodec`](security/digest_codec.go) | Provides digest encoding/decoding interfaces and implementations for Hex, Base64, and Base64URL formats. | +| [`SignatureVerifier`](security/hmac_signature_verifier.go) | Provides interfaces for verifying signatures for HTTP requests. | +| [`HmacSignatureVerifier`](security/hmac_signature_verifier.go) | Provides an implementation of [`SignatureVerifier`](security/hmac_signature_verifier.go) for HMAC-based signature verification for HTTP requests, supporting configurable algorithms and templates. | +| [`VerificationResult`](security/verification_result.go) | Defines the structure for representing signature verification outcomes with success/failure states and error details. | + + ### Test Helper Package testHelper provides helper functions for testing purposes. diff --git a/https/httpClient_test.go b/https/httpClient_test.go index a217851..aafe05b 100644 --- a/https/httpClient_test.go +++ b/https/httpClient_test.go @@ -1,8 +1,11 @@ package https import ( + "bytes" + "io" "net/http" "reflect" + "strconv" "testing" ) @@ -19,7 +22,7 @@ func TestNewHttpClient(t *testing.T) { func TestHttpClientExecute(t *testing.T) { client := NewHttpClient(NewHttpConfiguration()) - request, _ := http.NewRequest(http.MethodGet, "https://apimatic-goo.free.beeceptor.com", nil) + request, _ := http.NewRequest(http.MethodGet, "https://apimatic-go.free.beeceptor.com", nil) response, _ := client.Execute(request) if response == nil || response.StatusCode != 200 { @@ -35,3 +38,59 @@ func TestHttpClientExecuteError(t *testing.T) { t.Errorf("Failed: Response not okay!\n %v", response) } } + +func TestHttpClientInterfaceExecuteFailure(t *testing.T) { + req, err := FromFastHttpRequest( + []byte("INVALID_METHOD"), + "://invalid-url", + nil, + nil, + []byte{}, + nil, + ) + + if err == nil || req != nil { + t.Errorf("Failed: Response not okay!\n %v", req) + } +} + +func TestHttpClientInterfaceExecute(t *testing.T) { + fastReq, _ := http.NewRequest( + http.MethodGet, + "https://apimatic-go.free.beeceptor.com", + io.NopCloser(bytes.NewBuffer([]byte("This is data")))) + bodyBytes, _ := ReadRequestBody(fastReq) + + fastReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + fastReq.Header.Set("Content-Length", strconv.Itoa(len(bodyBytes))) + + fastReqVisitAll := func(setFunc func(k, v []byte)) { + for k, v := range fastReq.Header { + for _, hv := range v { + setFunc([]byte(k), []byte(hv)) + } + } + } + + fastReq.AddCookie(&http.Cookie{Name: API_KEY, Value: "12345"}) + fastReq.AddCookie(&http.Cookie{Name: API_TOKEN, Value: "token-123456"}) + + fastReqVisitAllCookie := func(setFunc func(key, value []byte)) { + for _, cookie := range fastReq.Cookies() { + setFunc([]byte(cookie.Name), []byte(cookie.Value)) + } + } + + req, err := FromFastHttpRequest( + []byte(fastReq.Method), + fastReq.URL.String(), + bodyBytes, + fastReqVisitAll, + []byte(fastReq.RequestURI), + fastReqVisitAllCookie, + ) + + if err != nil || req == nil { + t.Errorf("Failed: Response not okay!\n %v", req) + } +} diff --git a/https/httpContext.go b/https/httpContext.go index af1e22f..de0d48a 100644 --- a/https/httpContext.go +++ b/https/httpContext.go @@ -2,6 +2,7 @@ package https import ( "bytes" + "fmt" "io" "net/http" ) @@ -23,3 +24,36 @@ func (ctx *HttpContext) GetResponseBody() ([]byte, error) { ctx.Response.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) return bodyBytes, err } + +func ReadRequestBody(req *http.Request) ([]byte, error) { + bodyBytes, err := io.ReadAll(req.Body) + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + return bodyBytes, err +} + +func FromFastHttpRequest( + methodBytes []byte, + url string, + bodyBytes []byte, + headerFunc func(setFunc func(k, v []byte)), + requestURIBytes []byte, + visitAllCookieFunc func(func(key, value []byte)), +) (*http.Request, error) { + req, err := http.NewRequest(string(methodBytes), url, io.NopCloser(bytes.NewReader(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("failed to create http.Request: %w", err) + } + // Copy headers + if headerFunc != nil { + headerFunc(func(k, v []byte) { req.Header.Add(string(k), string(v)) }) + } + // Set RequestURI (useful if mimicking server request) + req.RequestURI = string(requestURIBytes) + // Handle cookies explicitly + if visitAllCookieFunc != nil { + visitAllCookieFunc(func(k, v []byte) { + req.AddCookie(&http.Cookie{Name: string(k), Value: string(v)}) + }) + } + return req, nil +} diff --git a/https/httpContext_test.go b/https/httpContext_test.go index 7b4e34f..d64b0b1 100644 --- a/https/httpContext_test.go +++ b/https/httpContext_test.go @@ -36,3 +36,21 @@ func TestGetResponseBody(t *testing.T) { t.Errorf("Failed:\nExpected: %v\nGot: %v", bodyBytes, newBodyBytes) } } + +func TestReadRequestBody(t *testing.T) { + bodyBytes := []byte(`{"invalidJson"}`) + respBody := io.NopCloser(bytes.NewReader(bodyBytes)) + ctx := HttpContext{ + Request: &http.Request{ + Body: respBody, + }, + } + + newBodyBytes, err := ReadRequestBody(ctx.Request) + if err != nil { + t.Error(err) + } + if !reflect.DeepEqual(newBodyBytes, bodyBytes) { + t.Errorf("Failed:\nExpected: %v\nGot: %v", bodyBytes, newBodyBytes) + } +} diff --git a/https/interceptors_test.go b/https/interceptors_test.go index 59ae202..b56b87f 100644 --- a/https/interceptors_test.go +++ b/https/interceptors_test.go @@ -40,7 +40,7 @@ func TestPassThroughInterceptorNilNext(t *testing.T) { func TestCallHttpInterceptors(t *testing.T) { client := NewHttpClient(NewHttpConfiguration()) callBuilder := CreateCallBuilderFactory( - func(server string) string { return "https://apimatic-goo.free.beeceptor.com" }, nil, client, NewRetryConfiguration(), Indexed) + func(server string) string { return "https://apimatic-go.free.beeceptor.com" }, nil, client, NewRetryConfiguration(), Indexed) req := callBuilder(context.Background(), "POST", "/interceptors") interceptor1 := func(request *http.Request, next HttpCallExecutor) HttpContext { diff --git a/security/digest_codec.go b/security/digest_codec.go new file mode 100644 index 0000000..b33797d --- /dev/null +++ b/security/digest_codec.go @@ -0,0 +1,63 @@ +package security + +import ( + "encoding/base64" + "encoding/hex" +) + +// DigestCodec is an interface for encoding and decoding digest values. +type DigestCodec interface { + // Encode encodes a byte slice digest into a string representation. + Encode(bytes []byte) string + + // Decode decodes a string representation back into a byte slice. + Decode(encoded string) ([]byte, error) +} + +// NewHexDigestCodec returns a DigestCodec for Hex encoding/decoding. +func NewHexDigestCodec() DigestCodec { + return hexDigestCodec{} +} + +// NewBase64DigestCodec returns a DigestCodec for Base64 encoding/decoding. +func NewBase64DigestCodec() DigestCodec { + return base64DigestCodec{} +} + +// NewBase64URLDigestCodec returns a DigestCodec for Base64 URL-safe encoding/decoding. +func NewBase64URLDigestCodec() DigestCodec { + return base64URLDigestCodec{} +} + +// hexDigestCodec implements DigestCodec for hexadecimal encoding. +type hexDigestCodec struct{} + +func (hexDigestCodec) Encode(bytes []byte) string { + return hex.EncodeToString(bytes) +} + +func (hexDigestCodec) Decode(encoded string) ([]byte, error) { + return hex.DecodeString(encoded) +} + +// base64DigestCodec implements DigestCodec for Base64 encoding. +type base64DigestCodec struct{} + +func (base64DigestCodec) Encode(bytes []byte) string { + return base64.StdEncoding.EncodeToString(bytes) +} + +func (base64DigestCodec) Decode(encoded string) ([]byte, error) { + return base64.StdEncoding.DecodeString(encoded) +} + +// base64URLDigestCodec implements DigestCodec for Base64 URL-safe encoding. +type base64URLDigestCodec struct{} + +func (base64URLDigestCodec) Encode(bytes []byte) string { + return base64.RawURLEncoding.EncodeToString(bytes) +} + +func (base64URLDigestCodec) Decode(encoded string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(encoded) +} diff --git a/security/digest_codec_factory_test.go b/security/digest_codec_factory_test.go new file mode 100644 index 0000000..8ae6a4a --- /dev/null +++ b/security/digest_codec_factory_test.go @@ -0,0 +1,135 @@ +package security + +import ( + "testing" +) + +// Assumptions (adjust/remove if your actual API differs): +// - type DigestCodec interface { Encode([]byte) string; Decode(string) ([]byte, error) } +// - func Hex() DigestCodec +// - func Base64() DigestCodec +// - func Base64URL() DigestCodec + +func TestHexEncodeDecode(t *testing.T) { + codec := NewHexDigestCodec() + input := []byte{0x0A, 0x1B, 0xFF} + encoded := codec.Encode(input) + if encoded != "0a1bff" { + t.Fatalf("expected 0a1bff, got %s", encoded) + } + decoded, err := codec.Decode(encoded) + if err != nil { + t.Fatalf("decode error: %v", err) + } + if string(decoded) != string(input) { + t.Fatalf("decoded mismatch: %v != %v", decoded, input) + } +} + +func TestHexEncodeEmpty(t *testing.T) { + codec := NewHexDigestCodec() + var input []byte + encoded := codec.Encode(input) + if encoded != "" { + t.Fatalf("expected empty string, got %q", encoded) + } + decoded, err := codec.Decode(encoded) + if err != nil { + t.Fatalf("decode error: %v", err) + } + if len(decoded) != 0 { + t.Fatalf("expected empty slice, got %v", decoded) + } +} + +func TestHexDecodeInvalidLength(t *testing.T) { + codec := NewHexDigestCodec() + if _, err := codec.Decode("abc"); err == nil { + t.Fatalf("expected error for odd-length hex input") + } +} + +func TestHexDecodeInvalidCharacter(t *testing.T) { + codec := NewHexDigestCodec() + if _, err := codec.Decode("zzzzz"); err == nil { + t.Fatalf("expected error for invalid hex characters") + } +} + +func TestBase64EncodeDecode(t *testing.T) { + codec := NewBase64DigestCodec() + input := []byte{1, 2, 3, 4, 5} + encoded := codec.Encode(input) + if encoded != "AQIDBAU=" { + t.Fatalf("expected AQIDBAU=, got %s", encoded) + } + decoded, err := codec.Decode(encoded) + if err != nil { + t.Fatalf("decode error: %v", err) + } + if string(decoded) != string(input) { + t.Fatalf("decoded mismatch: %v != %v", decoded, input) + } +} + +func TestBase64EncodeEmpty(t *testing.T) { + codec := NewBase64DigestCodec() + input := make([]byte, 0) + encoded := codec.Encode(input) + if encoded != "" { + t.Fatalf("expected empty string, got %q", encoded) + } + decoded, err := codec.Decode(encoded) + if err != nil { + t.Fatalf("decode error: %v", err) + } + if len(decoded) != 0 { + t.Fatalf("expected empty slice, got %v", decoded) + } +} + +func TestBase64DecodeInvalid(t *testing.T) { + codec := NewBase64DigestCodec() + if _, err := codec.Decode("!@#$"); err == nil { + t.Fatalf("expected error for invalid base64 input") + } +} + +func TestBase64URLEncodeDecode(t *testing.T) { + codec := NewBase64URLDigestCodec() + input := []byte{10, 20, 30, 40, 50} + encoded := codec.Encode(input) + if encoded != "ChQeKDI" { // raw URL (no padding) + t.Fatalf("expected ChQeKDI, got %s", encoded) + } + decoded, err := codec.Decode(encoded) + if err != nil { + t.Fatalf("decode error: %v", err) + } + if string(decoded) != string(input) { + t.Fatalf("decoded mismatch: %v != %v", decoded, input) + } +} + +func TestBase64URLEncodeEmpty(t *testing.T) { + codec := NewBase64URLDigestCodec() + var input []byte + encoded := codec.Encode(input) + if encoded != "" { + t.Fatalf("expected empty string, got %q", encoded) + } + decoded, err := codec.Decode(encoded) + if err != nil { + t.Fatalf("decode error: %v", err) + } + if len(decoded) != 0 { + t.Fatalf("expected empty slice, got %v", decoded) + } +} + +func TestBase64URLDecodeInvalid(t *testing.T) { + codec := NewBase64URLDigestCodec() + if _, err := codec.Decode("!@#$"); err == nil { + t.Fatalf("expected error for invalid base64url input") + } +} diff --git a/security/hmac_signature_verifier.go b/security/hmac_signature_verifier.go new file mode 100644 index 0000000..b64a60a --- /dev/null +++ b/security/hmac_signature_verifier.go @@ -0,0 +1,181 @@ +package security + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "crypto/subtle" + "errors" + "fmt" + "net/http" + "strings" +) + +// SignatureVerifier represents an interface for verifying the signature of an HTTP request. +type SignatureVerifier interface { + // Verify checks the signature of the given HTTP request and returns + // a VerificationResult indicating the outcome. + Verify(req *http.Request) VerificationResult +} + +// HmacSignatureVerifier verifies HMAC-based HTTP request signatures. +type HmacSignatureVerifier struct { + // Name of the header carrying the provided signature (lookup is case-insensitive). + signatureHeaderName string + // HMAC algorithm used for signature generation (default: HmacSHA256). + algorithm string + // Initialized key spec; used to create a new Mac per verification call. + secretKey []byte + // Template containing "{digest}". + signatureValueTemplate string + // Resolves the bytes to sign from the request. + requestBytesResolver func(*http.Request) ([]byte, error) + // Codec used to decode (and possibly encode) digest text ↔ bytes (e.g., hex/base64). + digestCodec DigestCodec +} + +const signatureValuePlaceholder = "{digest}" + +// NewHmacSignatureVerifier initializes a new HmacSignatureVerifier instance. +func NewHmacSignatureVerifier( + secretKey string, + signatureHeaderName string, + digestCodec DigestCodec, + requestBytesResolver func(*http.Request) ([]byte, error), + algorithm string, + signatureValueTemplate string, +) (*HmacSignatureVerifier, error) { + + if strings.TrimSpace(secretKey) == "" { + return nil, errors.New("secret key cannot be null or empty") + } + if strings.TrimSpace(signatureHeaderName) == "" { + return nil, errors.New("signature header name cannot be null or empty") + } + if strings.TrimSpace(signatureValueTemplate) == "" { + return nil, errors.New("signature value template cannot be null or empty") + } + if requestBytesResolver == nil { + return nil, errors.New("requestBytesResolver cannot be null") + } + if digestCodec == nil { + return nil, errors.New("digestCodec cannot be null") + } + if strings.TrimSpace(algorithm) == "" { + return nil, errors.New("algorithm cannot be null or empty") + } + + return &HmacSignatureVerifier{ + signatureHeaderName: signatureHeaderName, + algorithm: algorithm, + secretKey: []byte(secretKey), + signatureValueTemplate: signatureValueTemplate, + requestBytesResolver: requestBytesResolver, + digestCodec: digestCodec, + }, nil +} + +// Verify verifies the HMAC signature for the given request. +func (v *HmacSignatureVerifier) Verify(request *http.Request) VerificationResult { + headerValue := make([]string, 0) + for k, val := range request.Header { + if strings.EqualFold(k, v.signatureHeaderName) { + headerValue = val + break + } + } + + if len(headerValue) < 1 { + return NewFailure(fmt.Sprintf("Signature header '%s' is missing.", v.signatureHeaderName)) + } + + provided := v.extractSignature(headerValue[0]) + if len(provided) == 0 { + return NewFailure(fmt.Sprintf("Malformed signature header '%s'.", v.signatureHeaderName)) + } + + message, err := v.requestBytesResolver(request) + if err != nil { + return NewFailure(fmt.Sprintf("Error resolving Request Bytes: %v", err)) + } + computed, err := v.computeHMAC(message) + if err != nil { + return NewFailure(fmt.Sprintf("Error computing HMAC: %v", err)) + } + + if subtle.ConstantTimeCompare(provided, computed) == 1 { + return NewSuccess() + } + return NewFailure("Signature verification failed.") +} + +// computeHMAC computes the HMAC for the given message using the configured algorithm. +func (v *HmacSignatureVerifier) computeHMAC(message []byte) ([]byte, error) { + switch strings.ToUpper(v.algorithm) { + case "HMACSHA256", "HMAC-SHA256", "SHA256": + mac := hmac.New(sha256.New, v.secretKey) + mac.Write(message) + return mac.Sum(nil), nil + case "HMACSHA512", "HMAC-SHA512", "SHA512": + mac := hmac.New(sha512.New, v.secretKey) + mac.Write(message) + return mac.Sum(nil), nil + default: + return nil, fmt.Errorf("unsupported algorithm: %s", v.algorithm) + } +} + +// extractSignature extracts and decodes the digest from the header value. +func (v *HmacSignatureVerifier) extractSignature(headerValue string) []byte { + index := strings.Index(v.signatureValueTemplate, signatureValuePlaceholder) + if index < 0 { + return []byte{} + } + + prefix := v.signatureValueTemplate[:index] + suffix := v.signatureValueTemplate[index+len(signatureValuePlaceholder):] + + prefixAt := indexOfIgnoreCase(headerValue, prefix, 0) + if prefixAt < 0 { + return []byte{} + } + + digestStart := prefixAt + len(prefix) + digestEnd := len(headerValue) + + if suffix != "" { + suffixAt := indexOfIgnoreCase(headerValue, suffix, digestStart) + if suffixAt < 0 { + return []byte{} + } + digestEnd = suffixAt + } + + if digestEnd < digestStart { + return []byte{} + } + + digest := strings.TrimSpace(headerValue[digestStart:digestEnd]) + + // Strip optional quotes + if len(digest) >= 2 && digest[0] == '"' && digest[len(digest)-1] == '"' { + digest = digest[1 : len(digest)-1] + } + + decoded, _ := v.digestCodec.Decode(digest) + return decoded +} + +// indexOfIgnoreCase finds the first occurrence of needle in haystack (case-insensitive). +func indexOfIgnoreCase(haystack, needle string, fromIndex int) int { + if needle == "" { + return fromIndex + } + haystackLower := strings.ToLower(haystack) + needleLower := strings.ToLower(needle) + idx := strings.Index(haystackLower[fromIndex:], needleLower) + if idx == -1 { + return -1 + } + return fromIndex + idx +} diff --git a/security/hmac_signature_verifier_test.go b/security/hmac_signature_verifier_test.go new file mode 100644 index 0000000..95ab11a --- /dev/null +++ b/security/hmac_signature_verifier_test.go @@ -0,0 +1,355 @@ +package security + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "errors" + "hash" + "io" + "net/http" + "strings" + "testing" + + "github.com/apimatic/go-core-runtime/https" +) + +const ( + SECRET_KEY = "testSecret" + SIGNATURE_HEADER = "X-Signature" + BODY = "payload" + DEFAULT_ALGORITHM = "HmacSHA256" + SIGNATURE_VALUE_TEMPLATE = "{digest}" +) + +var DEFAULT_DIGEST_CODEC = NewHexDigestCodec() + +// Helper to compute expected signature (HmacSHA256 only for these tests). +func computeHmacHex(key, data string, algorithm func() hash.Hash, codec DigestCodec) string { + m := hmac.New(algorithm, []byte(key)) + m.Write([]byte(data)) + return codec.Encode(m.Sum(nil)) +} + +// --- Constructor validation tests --- +func TestHmacVerifierConstructorValidation(t *testing.T) { + tests := []struct { + name string + secret string + header string + codec DigestCodec + res func(*http.Request) ([]byte, error) + algo string + tmpl string + wantErr bool + }{ + {"NilSecret", "", SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, https.ReadRequestBody, DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE, true}, + {"BlankSecret", " ", SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, https.ReadRequestBody, DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE, true}, + {"NilHeader", "k", "", DEFAULT_DIGEST_CODEC, https.ReadRequestBody, DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE, true}, + {"BlankHeader", "k", " ", DEFAULT_DIGEST_CODEC, https.ReadRequestBody, DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE, true}, + {"NilCodec", "k", "X", nil, https.ReadRequestBody, DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE, true}, + {"Nilhttps.ReadRequestBody", "k", "X", DEFAULT_DIGEST_CODEC, nil, DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE, true}, + {"NilAlgo", "k", "X", DEFAULT_DIGEST_CODEC, https.ReadRequestBody, "", SIGNATURE_VALUE_TEMPLATE, true}, + {"BlankAlgo", "k", "X", DEFAULT_DIGEST_CODEC, https.ReadRequestBody, " ", SIGNATURE_VALUE_TEMPLATE, true}, + {"NilTemplate", "k", "X", DEFAULT_DIGEST_CODEC, https.ReadRequestBody, DEFAULT_ALGORITHM, "", true}, + {"BlankTemplate", "k", "X", DEFAULT_DIGEST_CODEC, https.ReadRequestBody, DEFAULT_ALGORITHM, " ", true}, + {"Valid", "k", "X", DEFAULT_DIGEST_CODEC, https.ReadRequestBody, DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := NewHmacSignatureVerifier(tc.secret, tc.header, tc.codec, tc.res, tc.algo, tc.tmpl) + if tc.wantErr && err == nil { + t.Fatalf("expected error") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func newRequest(method string, headers map[string][]string, body string) *http.Request { + return &http.Request{ + Method: method, + Header: headers, + Body: io.NopCloser(bytes.NewBuffer([]byte(body))), + ContentLength: int64(len(body)), + } +} + +// --- Behaviour tests --- +func TestHmacVerifySuccess(t *testing.T) { + signature := computeHmacHex(SECRET_KEY, BODY, sha256.New, DEFAULT_DIGEST_CODEC) + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {signature}}, BODY) + verifier, _ := NewHmacSignatureVerifier( + SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, https.ReadRequestBody, + DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE) + + result := verifier.Verify(req) + if !result.Success() { + t.Fatalf("expected success, got errors: %v", result.Errors()) + } +} + +func TestHmacVerifySuccessSHA512(t *testing.T) { + signature := computeHmacHex(SECRET_KEY, BODY, sha512.New, DEFAULT_DIGEST_CODEC) + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {signature}}, BODY) + verifier, _ := NewHmacSignatureVerifier( + SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, https.ReadRequestBody, + "HmacSHA512", SIGNATURE_VALUE_TEMPLATE) + + result := verifier.Verify(req) + if !result.Success() { + t.Fatalf("expected success, got errors: %v", result.Errors()) + } +} + +func TestHmacVerifySuccessSHA520(t *testing.T) { + signature := computeHmacHex(SECRET_KEY, BODY, sha512.New, DEFAULT_DIGEST_CODEC) + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {signature}}, BODY) + verifier, _ := NewHmacSignatureVerifier( + SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, https.ReadRequestBody, + "HmacSHA520", SIGNATURE_VALUE_TEMPLATE) + + result := verifier.Verify(req) + if result.Success() { + t.Fatalf("expected failure") + } +} + +func TestHmacVerifyMissingHeader(t *testing.T) { + req := newRequest("POST", map[string][]string{}, BODY) + verifier, _ := NewHmacSignatureVerifier( + SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, + https.ReadRequestBody, DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE) + + result := verifier.Verify(req) + if result.Success() { + t.Fatalf("expected failure") + } + if len(result.Errors()) == 0 || !strings.Contains(result.Errors()[0], "missing") { + t.Fatalf("unexpected error: %v", result.Errors()) + } +} + +func TestHmacVerifyMalformedSignature(t *testing.T) { + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {"not-a-hex-signature"}}, BODY) + verifier, _ := NewHmacSignatureVerifier(SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, + https.ReadRequestBody, DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE) + + result := verifier.Verify(req) + if result.Success() { + t.Fatalf("expected failure") + } + if len(result.Errors()) == 0 || !strings.Contains(strings.ToLower(result.Errors()[0]), "malformed") { + t.Fatalf("unexpected error: %v", result.Errors()) + } +} + +func TestHmacVerifyWrongSignature(t *testing.T) { + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {"deadbeef"}}, BODY) + verifier, _ := NewHmacSignatureVerifier(SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, + https.ReadRequestBody, + DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE) + + result := verifier.Verify(req) + if result.Success() { + t.Fatalf("expected failure") + } +} + +func TestHmacVerifyResolverException(t *testing.T) { + sig := computeHmacHex(SECRET_KEY, BODY, sha256.New, DEFAULT_DIGEST_CODEC) + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {sig}}, BODY) + verifier, _ := NewHmacSignatureVerifier(SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, + func(r *http.Request) ([]byte, error) { return nil, errors.New("resolver error") }, + DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE) + + result := verifier.Verify(req) + if result.Success() { + t.Fatalf("expected failure") + } + if len(result.Errors()) == 0 || !strings.Contains(result.Errors()[0], "resolver error") { + t.Fatalf("unexpected errors: %v", result.Errors()) + } +} + +// --- Template variation tests --- + +func TestHmacVerifyTemplatePrefixSHA512(t *testing.T) { + digest := computeHmacHex(SECRET_KEY, BODY, sha512.New, DEFAULT_DIGEST_CODEC) + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {"sha256=" + digest}}, BODY) + + verifier, _ := NewHmacSignatureVerifier(SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, + https.ReadRequestBody, + "HmacSHA512", "sha256={digest}") + + if sv := verifier.Verify(req); !sv.Success() { + t.Fatalf("expected success") + } +} + +func TestHmacVerifyTemplatePrefix(t *testing.T) { + digest := computeHmacHex(SECRET_KEY, BODY, sha256.New, DEFAULT_DIGEST_CODEC) + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {"sha256=" + digest}}, BODY) + + verifier, _ := NewHmacSignatureVerifier(SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, + https.ReadRequestBody, + DEFAULT_ALGORITHM, "sha256={digest}") + + if sv := verifier.Verify(req); !sv.Success() { + t.Fatalf("expected success") + } +} + +func TestHmacVerifyTemplatePrefixWrong(t *testing.T) { + digest := computeHmacHex(SECRET_KEY, BODY, sha256.New, DEFAULT_DIGEST_CODEC) + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {digest}}, BODY) + + verifier, _ := NewHmacSignatureVerifier(SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, + https.ReadRequestBody, + DEFAULT_ALGORITHM, "sha256={digest}") + + sv := verifier.Verify(req) + if sv.Success() { + t.Fatalf("expected failure") + } + + if len(sv.Errors()) == 0 || sv.Errors()[0] != "Malformed signature header '"+SIGNATURE_HEADER+"'." { + t.Fatalf("unexpected errors: %v", sv.Errors()) + } +} + +func TestHmacVerifyTemplateSuffix(t *testing.T) { + digest := computeHmacHex(SECRET_KEY, BODY, sha256.New, DEFAULT_DIGEST_CODEC) + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {digest + "complex"}}, BODY) + + verifier, _ := NewHmacSignatureVerifier(SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, + https.ReadRequestBody, + DEFAULT_ALGORITHM, "{digest}complex") + + if sv := verifier.Verify(req); !sv.Success() { + t.Fatalf("expected success") + } +} + +func TestHmacVerifyTemplateSuffixWrong(t *testing.T) { + digest := computeHmacHex(SECRET_KEY, BODY, sha256.New, DEFAULT_DIGEST_CODEC) + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {digest}}, BODY) + + verifier, _ := NewHmacSignatureVerifier(SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, + https.ReadRequestBody, + DEFAULT_ALGORITHM, "{digest}complex") + + sv := verifier.Verify(req) + if sv.Success() { + t.Fatalf("expected failure") + } + + if len(sv.Errors()) == 0 || sv.Errors()[0] != "Malformed signature header '"+SIGNATURE_HEADER+"'." { + t.Fatalf("unexpected errors: %v", sv.Errors()) + } +} + +func TestHmacVerifyTemplateMismatchPlaceholder(t *testing.T) { + d := computeHmacHex(SECRET_KEY, BODY, sha256.New, DEFAULT_DIGEST_CODEC) + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {d}}, BODY) + + verifier, _ := NewHmacSignatureVerifier(SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, + https.ReadRequestBody, + DEFAULT_ALGORITHM, "{diet}") // wrong placeholder + + if sv := verifier.Verify(req); sv.Success() { + t.Fatalf("expected failure due to malformed template") + } +} + +func TestHmacVerifyTemplateQuotedWrong(t *testing.T) { + req := newRequest("POST", map[string][]string{SIGNATURE_HEADER: {"v0=\"a\"complex"}}, BODY) + + verifier, _ := NewHmacSignatureVerifier(SECRET_KEY, SIGNATURE_HEADER, DEFAULT_DIGEST_CODEC, + https.ReadRequestBody, + DEFAULT_ALGORITHM, "v0={digest}complex") + + if sv := verifier.Verify(req); sv.Success() { + t.Fatalf("expected failure") + } +} + +// --- Complex resolver tests (base64 codec variation) --- + +func TestHmacVerifyComplexResolverBody(t *testing.T) { + codec := NewBase64DigestCodec() + body := `{"id":123,"type":"payment","amount":100.5}` + // Compose data string replicating Java logic (extracting "payment" as /type) + data := "session=abc123:2025-09-17T12:34:56Z:POST:payment" + + ":{\"id\":123,\"type\":\"payment\",\"amount\":100.5}" + + signature := computeHmacHex(SECRET_KEY, data, sha256.New, codec) + headers := map[string][]string{ + SIGNATURE_HEADER: {signature}, + "Cookie": {"session=abc123"}, + "X-Timestamp": {"2025-09-17T12:34:56Z"}, + } + req := newRequest("POST", headers, body) + + resolver := func(r *http.Request) ([]byte, error) { + cookie := r.Header.Get("Cookie") + timestamp := r.Header.Get("X-Timestamp") + // naive extraction of "type" value from JSON (test-only) + var extracted string + reqBody, _ := https.ReadRequestBody(r) + if idx := strings.Index(string(reqBody), `"type":"`); idx >= 0 { + sub := reqBody[idx+len(`"type":"`):] + if j := bytes.IndexByte(sub, '"'); j >= 0 { + extracted = string(sub[:j]) + } + } + payload := strings.Join([]string{ + cookie, timestamp, r.Method, extracted, string(reqBody), + }, ":") + return []byte(payload), nil + } + + verifier, _ := NewHmacSignatureVerifier(SECRET_KEY, SIGNATURE_HEADER, codec, resolver, DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE) + if sv := verifier.Verify(req); !sv.Success() { + t.Fatalf("expected success") + } +} + +func TestHmacVerifyComplexResolverHeaders(t *testing.T) { + const secret = SECRET_KEY + header := SIGNATURE_HEADER + codec := NewBase64DigestCodec() + body := `{"id":123,"type":"payment","amount":100.5}` + + data := "session=abc123:2025-09-17T12:34:56Z:POST:x-signature-header-value" + + ":{\"id\":123,\"type\":\"payment\",\"amount\":100.5}" + + signature := computeHmacHex(secret, data, sha256.New, codec) + headers := map[string][]string{ + header: {signature}, + "Cookie": {"session=abc123"}, + "X-Timestamp": {"2025-09-17T12:34:56Z"}, + SIGNATURE_HEADER: {"x-signature-header-value"}, + } + req := newRequest("POST", headers, body) + + resolver := func(r *http.Request) ([]byte, error) { + reqBody, _ := https.ReadRequestBody(r) + payload := strings.Join([]string{ + r.Header.Get("Cookie"), + r.Header.Get("X-Timestamp"), + r.Method, + string(reqBody), + }, ":") + return []byte(payload), nil + } + + verifier, _ := NewHmacSignatureVerifier(secret, header, codec, resolver, DEFAULT_ALGORITHM, SIGNATURE_VALUE_TEMPLATE) + if sv := verifier.Verify(req); sv.Success() { + t.Fatalf("expected failure due to wrong signature (header value not included)") + } +} diff --git a/security/verification_result.go b/security/verification_result.go new file mode 100644 index 0000000..3cae6a9 --- /dev/null +++ b/security/verification_result.go @@ -0,0 +1,33 @@ +package security + +// VerificationResult represents the outcome of a signature verification process. +type VerificationResult struct { + success bool + errors []string +} + +// Success returns true if the verification was successful. +func (v *VerificationResult) Success() bool { + return v.success +} + +// Errors returns the list of errors (if any) encountered during verification. +func (v *VerificationResult) Errors() []string { + return v.errors +} + +// NewSuccess returns a VerificationResult representing a successful verification. +func NewSuccess() VerificationResult { + return VerificationResult{ + success: true, + errors: []string{}, + } +} + +// NewFailure returns a VerificationResult representing a failed verification. +func NewFailure(errors ...string) VerificationResult { + return VerificationResult{ + success: false, + errors: errors, + } +}