diff --git a/api/gateway/mntx/internal/service/gateway/callback.go b/api/gateway/mntx/internal/service/gateway/callback.go index 692f328..6533aec 100644 --- a/api/gateway/mntx/internal/service/gateway/callback.go +++ b/api/gateway/mntx/internal/service/gateway/callback.go @@ -1,8 +1,10 @@ package gateway import ( + "bytes" "context" "crypto/hmac" + "encoding/json" "net/http" "strings" @@ -43,10 +45,11 @@ type callbackOperation struct { Currency string `json:"currency"` } `json:"sum_converted"` Provider struct { - ID int64 `json:"id"` - PaymentID string `json:"payment_id"` - Date string `json:"date"` - AuthCode string `json:"auth_code"` + ID int64 `json:"id"` + PaymentID string `json:"payment_id"` + AuthCode string `json:"auth_code"` + EndpointID int64 `json:"endpoint_id"` + Date string `json:"date"` } `json:"provider"` Code string `json:"code"` Message string `json:"message"` @@ -56,7 +59,11 @@ type monetixCallback struct { ProjectID int64 `json:"project_id"` Payment callbackPayment `json:"payment"` Account struct { - Number string `json:"number"` + Number string `json:"number"` + Type string `json:"type"` + CardHolder string `json:"card_holder"` + ExpiryMonth string `json:"expiry_month"` + ExpiryYear string `json:"expiry_year"` } `json:"account"` Customer struct { ID string `json:"id"` @@ -120,17 +127,48 @@ func fallbackProviderPaymentID(cb monetixCallback) string { return cb.Payment.ID } -func verifyCallbackSignature(cb monetixCallback, secret string) error { - expected := cb.Signature - cb.Signature = "" - calculated, err := monetix.SignPayload(cb, secret) +func verifyCallbackSignature(payload []byte, secret string) (string, error) { + root, err := decodeCallbackPayload(payload) if err != nil { - return err + return "", err } - if subtleConstantTimeCompare(expected, calculated) { - return nil + signature, ok := signatureFromPayload(root) + if !ok || strings.TrimSpace(signature) == "" { + return "", merrors.InvalidArgument("signature is missing") } - return merrors.DataConflict("signature mismatch") + calculated, err := monetix.SignPayload(root, secret) + if err != nil { + return signature, err + } + if subtleConstantTimeCompare(signature, calculated) { + return signature, nil + } + return signature, merrors.DataConflict("signature mismatch") +} + +func decodeCallbackPayload(payload []byte) (any, error) { + var root any + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.UseNumber() + if err := decoder.Decode(&root); err != nil { + return nil, err + } + return root, nil +} + +func signatureFromPayload(root any) (string, bool) { + payload, ok := root.(map[string]any) + if !ok { + return "", false + } + for key, value := range payload { + if !strings.EqualFold(key, "signature") { + continue + } + signature, ok := value.(string) + return signature, ok + } + return "", false } func subtleConstantTimeCompare(a, b string) bool { diff --git a/api/gateway/mntx/internal/service/gateway/callback_test.go b/api/gateway/mntx/internal/service/gateway/callback_test.go index 6290030..0fe6a7f 100644 --- a/api/gateway/mntx/internal/service/gateway/callback_test.go +++ b/api/gateway/mntx/internal/service/gateway/callback_test.go @@ -1,6 +1,7 @@ package gateway import ( + "encoding/json" "testing" "time" @@ -38,11 +39,11 @@ func TestMapCallbackToState_StatusMapping(t *testing.T) { cfg := monetix.DefaultConfig() cases := []struct { - name string - paymentStatus string + name string + paymentStatus string operationStatus string - code string - expectedStatus mntxv1.PayoutStatus + code string + expectedStatus mntxv1.PayoutStatus expectedOutcome string }{ { @@ -119,12 +120,20 @@ func TestVerifyCallbackSignature(t *testing.T) { t.Fatalf("failed to sign payload: %v", err) } cb.Signature = sig - if err := verifyCallbackSignature(cb, secret); err != nil { + payload, err := json.Marshal(cb) + if err != nil { + t.Fatalf("failed to marshal callback: %v", err) + } + if _, err := verifyCallbackSignature(payload, secret); err != nil { t.Fatalf("expected valid signature, got %v", err) } cb.Signature = "invalid" - if err := verifyCallbackSignature(cb, secret); err == nil { + payload, err = json.Marshal(cb) + if err != nil { + t.Fatalf("failed to marshal callback: %v", err) + } + if _, err := verifyCallbackSignature(payload, secret); err == nil { t.Fatalf("expected signature mismatch error") } } diff --git a/api/gateway/mntx/internal/service/gateway/card_processor.go b/api/gateway/mntx/internal/service/gateway/card_processor.go index a74a7c4..b66f0bb 100644 --- a/api/gateway/mntx/internal/service/gateway/card_processor.go +++ b/api/gateway/mntx/internal/service/gateway/card_processor.go @@ -3,6 +3,7 @@ package gateway import ( "context" "encoding/json" + "errors" "net/http" "strings" @@ -338,18 +339,19 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt return http.StatusBadRequest, err } - if strings.TrimSpace(cb.Signature) == "" { - p.logger.Warn("Monetix callback signature is missing", zap.String("payout_id", cb.Payment.ID)) - return http.StatusBadRequest, merrors.InvalidArgument("signature is missing") - } - if err := verifyCallbackSignature(cb, p.config.SecretKey); err != nil { + signature, err := verifyCallbackSignature(payload, p.config.SecretKey) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, merrors.ErrDataConflict) { + status = http.StatusForbidden + } p.logger.Warn("Monetix callback signature check failed", zap.String("payout_id", cb.Payment.ID), - zap.String("signature", cb.Signature), + zap.String("signature", signature), zap.String("payload", string(payload)), zap.Error(err), ) - return http.StatusForbidden, err + return status, err } state, statusLabel := mapCallbackToState(p.clock, p.config, cb) diff --git a/api/gateway/mntx/internal/service/monetix/signature_test.go b/api/gateway/mntx/internal/service/monetix/signature_test.go index aab44f6..217d05d 100644 --- a/api/gateway/mntx/internal/service/monetix/signature_test.go +++ b/api/gateway/mntx/internal/service/monetix/signature_test.go @@ -146,3 +146,66 @@ func TestSignPayload_EthEstimateGasExample(t *testing.T) { t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got) } } + +func TestSignPayload_MonetixCallbackExample(t *testing.T) { + payload := map[string]any{ + "customer": map[string]any{ + "id": "694ece88df756c2672dc6ce8", + }, + "account": map[string]any{ + "number": "220070******0161", + "type": "mir", + "card_holder": "STEPHAN", + "expiry_month": "03", + "expiry_year": "2030", + }, + "project_id": 157432, + "payment": map[string]any{ + "id": "6952d0b307d2916aba87d4e8", + "type": "payout", + "status": "success", + "date": "2025-12-29T19:04:24+0000", + "method": "card", + "sum": map[string]any{ + "amount": 10849, + "currency": "RUB", + }, + "description": "", + }, + "operation": map[string]any{ + "sum_initial": map[string]any{ + "amount": 10849, + "currency": "RUB", + }, + "sum_converted": map[string]any{ + "amount": 10849, + "currency": "RUB", + }, + "code": "0", + "message": "Success", + "provider": map[string]any{ + "id": 26226, + "payment_id": "a3761838-eabc-4c65-aa36-c854c47a226b", + "auth_code": "", + "endpoint_id": 26226, + "date": "2025-12-29T19:04:23+0000", + }, + "id": int64(5089807000008124), + "type": "payout", + "status": "success", + "date": "2025-12-29T19:04:24+0000", + "created_date": "2025-12-29T19:04:21+0000", + "request_id": "7c3032f00629c94ad78e399c87da936f1cdc30de-2559ba11d6958d558a9f8ab8c20474d33061c654-05089808", + }, + "signature": "IBgtwCoxhMUxF15q8DLc7orYOIJomeiaNpWs8JHHsdDYPKJsIKn4T+kYavPnKTO+yibhCLNKeL+hk2oWg9wPCQ==", + } + + got, err := SignPayload(payload, "1") + if err != nil { + t.Fatalf("failed to sign payload: %v", err) + } + expected := "IBgtwCoxhMUxF15q8DLc7orYOIJomeiaNpWs8JHHsdDYPKJsIKn4T+kYavPnKTO+yibhCLNKeL+hk2oWg9wPCQ==" + if got != expected { + t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got) + } +}