4 Commits

Author SHA1 Message Date
202582626a Merge pull request 'fixed signature check' (#215) from signature-214 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #215
2025-12-29 23:23:27 +00:00
Stephan D
6a2efd3d22 fixed signature check 2025-12-30 00:22:49 +01:00
a6374d1136 Merge pull request 'increased payout timeout' (#213) from timeout-210 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
Reviewed-on: #213
2025-12-29 15:43:48 +00:00
Stephan D
7c864dc304 increased payout timeout 2025-12-29 16:43:03 +01:00
11 changed files with 218 additions and 59 deletions

View File

@@ -7,6 +7,7 @@ import (
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
) )
@@ -23,6 +24,7 @@ type gatewayClient struct {
conn *grpc.ClientConn conn *grpc.ClientConn
client mntxv1.MntxGatewayServiceClient client mntxv1.MntxGatewayServiceClient
cfg Config cfg Config
logger *zap.Logger
} }
// New dials the Monetix gateway. // New dials the Monetix gateway.
@@ -47,6 +49,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
conn: conn, conn: conn,
client: mntxv1.NewMntxGatewayServiceClient(conn), client: mntxv1.NewMntxGatewayServiceClient(conn),
cfg: cfg, cfg: cfg,
logger: cfg.Logger,
}, nil }, nil
} }
@@ -57,28 +60,39 @@ func (g *gatewayClient) Close() error {
return nil return nil
} }
func (g *gatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { func (g *gatewayClient) callContext(ctx context.Context, method string) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
timeout := g.cfg.CallTimeout timeout := g.cfg.CallTimeout
if timeout <= 0 { if timeout <= 0 {
timeout = 5 * time.Second timeout = 5 * time.Second
} }
fields := []zap.Field{
zap.String("method", method),
zap.Duration("timeout", timeout),
}
if deadline, ok := ctx.Deadline(); ok {
fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline)))
}
g.logger.Info("Mntx gateway client call timeout applied", fields...)
return context.WithTimeout(ctx, timeout) return context.WithTimeout(ctx, timeout)
} }
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
ctx, cancel := g.callContext(ctx) ctx, cancel := g.callContext(ctx, "CreateCardPayout")
defer cancel() defer cancel()
return g.client.CreateCardPayout(ctx, req) return g.client.CreateCardPayout(ctx, req)
} }
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
ctx, cancel := g.callContext(ctx) ctx, cancel := g.callContext(ctx, "CreateCardTokenPayout")
defer cancel() defer cancel()
return g.client.CreateCardTokenPayout(ctx, req) return g.client.CreateCardTokenPayout(ctx, req)
} }
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) { func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
ctx, cancel := g.callContext(ctx) ctx, cancel := g.callContext(ctx, "GetCardPayoutStatus")
defer cancel() defer cancel()
return g.client.GetCardPayoutStatus(ctx, req) return g.client.GetCardPayoutStatus(ctx, req)
} }

View File

@@ -1,12 +1,17 @@
package client package client
import "time" import (
"time"
"go.uber.org/zap"
)
// Config holds Monetix gateway client settings. // Config holds Monetix gateway client settings.
type Config struct { type Config struct {
Address string Address string
DialTimeout time.Duration DialTimeout time.Duration
CallTimeout time.Duration CallTimeout time.Duration
Logger *zap.Logger
} }
func (c *Config) setDefaults() { func (c *Config) setDefaults() {
@@ -16,4 +21,7 @@ func (c *Config) setDefaults() {
if c.CallTimeout <= 0 { if c.CallTimeout <= 0 {
c.CallTimeout = 10 * time.Second c.CallTimeout = 10 * time.Second
} }
if c.Logger == nil {
c.Logger = zap.NewNop()
}
} }

View File

@@ -1,8 +1,10 @@
package gateway package gateway
import ( import (
"bytes"
"context" "context"
"crypto/hmac" "crypto/hmac"
"encoding/json"
"net/http" "net/http"
"strings" "strings"
@@ -45,8 +47,9 @@ type callbackOperation struct {
Provider struct { Provider struct {
ID int64 `json:"id"` ID int64 `json:"id"`
PaymentID string `json:"payment_id"` PaymentID string `json:"payment_id"`
Date string `json:"date"`
AuthCode string `json:"auth_code"` AuthCode string `json:"auth_code"`
EndpointID int64 `json:"endpoint_id"`
Date string `json:"date"`
} `json:"provider"` } `json:"provider"`
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
@@ -57,6 +60,10 @@ type monetixCallback struct {
Payment callbackPayment `json:"payment"` Payment callbackPayment `json:"payment"`
Account struct { 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"` } `json:"account"`
Customer struct { Customer struct {
ID string `json:"id"` ID string `json:"id"`
@@ -120,17 +127,48 @@ func fallbackProviderPaymentID(cb monetixCallback) string {
return cb.Payment.ID return cb.Payment.ID
} }
func verifyCallbackSignature(cb monetixCallback, secret string) error { func verifyCallbackSignature(payload []byte, secret string) (string, error) {
expected := cb.Signature root, err := decodeCallbackPayload(payload)
cb.Signature = ""
calculated, err := monetix.SignPayload(cb, secret)
if err != nil { if err != nil {
return err return "", err
} }
if subtleConstantTimeCompare(expected, calculated) { signature, ok := signatureFromPayload(root)
return nil 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 { func subtleConstantTimeCompare(a, b string) bool {

View File

@@ -1,6 +1,7 @@
package gateway package gateway
import ( import (
"encoding/json"
"testing" "testing"
"time" "time"
@@ -119,12 +120,20 @@ func TestVerifyCallbackSignature(t *testing.T) {
t.Fatalf("failed to sign payload: %v", err) t.Fatalf("failed to sign payload: %v", err)
} }
cb.Signature = sig 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) t.Fatalf("expected valid signature, got %v", err)
} }
cb.Signature = "invalid" 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") t.Fatalf("expected signature mismatch error")
} }
} }

View File

@@ -3,6 +3,7 @@ package gateway
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"strings" "strings"
@@ -338,13 +339,19 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
return http.StatusBadRequest, err return http.StatusBadRequest, err
} }
if strings.TrimSpace(cb.Signature) == "" { signature, err := verifyCallbackSignature(payload, p.config.SecretKey)
p.logger.Warn("Monetix callback signature is missing", zap.String("payout_id", cb.Payment.ID)) if err != nil {
return http.StatusBadRequest, merrors.InvalidArgument("signature is missing") status := http.StatusBadRequest
if errors.Is(err, merrors.ErrDataConflict) {
status = http.StatusForbidden
} }
if err := verifyCallbackSignature(cb, p.config.SecretKey); err != nil { p.logger.Warn("Monetix callback signature check failed",
p.logger.Warn("Monetix callback signature check failed", zap.Error(err)) zap.String("payout_id", cb.Payment.ID),
return http.StatusForbidden, err zap.String("signature", signature),
zap.String("payload", string(payload)),
zap.Error(err),
)
return status, err
} }
state, statusLabel := mapCallbackToState(p.clock, p.config, cb) state, statusLabel := mapCallbackToState(p.clock, p.config, cb)

View File

@@ -106,12 +106,23 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
zap.String("masked_pan", MaskPAN(req.Card.PAN)), zap.String("masked_pan", MaskPAN(req.Card.PAN)),
) )
logRequestDeadline(c.logger, ctx, url)
start := time.Now() start := time.Now()
resp, err := c.client.Do(httpReq) resp, err := c.client.Do(httpReq)
duration := time.Since(start) duration := time.Since(start)
if err != nil { if err != nil {
observeRequest(outcomeNetworkError, duration) observeRequest(outcomeNetworkError, duration)
c.logger.Warn("Monetix tokenization request failed", zap.Error(err)) fields := []zap.Field{
zap.String("url", url),
zap.Error(err),
}
if ctxErr := ctx.Err(); ctxErr != nil {
fields = append(fields, zap.NamedError("ctx_error", ctxErr))
}
if deadline, ok := ctx.Deadline(); ok {
fields = append(fields, zap.Time("deadline", deadline), zap.Duration("time_until_deadline", time.Until(deadline)))
}
c.logger.Warn("Monetix tokenization request failed", fields...)
return nil, merrors.Internal("monetix tokenization request failed: " + err.Error()) return nil, merrors.Internal("monetix tokenization request failed: " + err.Error())
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -221,11 +232,23 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun
dispatchLog() dispatchLog()
} }
logRequestDeadline(c.logger, ctx, url)
start := time.Now() start := time.Now()
resp, err := c.client.Do(httpReq) resp, err := c.client.Do(httpReq)
duration := time.Since(start) duration := time.Since(start)
if err != nil { if err != nil {
observeRequest(outcomeNetworkError, duration) observeRequest(outcomeNetworkError, duration)
fields := []zap.Field{
zap.String("url", url),
zap.Error(err),
}
if ctxErr := ctx.Err(); ctxErr != nil {
fields = append(fields, zap.NamedError("ctx_error", ctxErr))
}
if deadline, ok := ctx.Deadline(); ok {
fields = append(fields, zap.Time("deadline", deadline), zap.Duration("time_until_deadline", time.Until(deadline)))
}
c.logger.Warn("Monetix request failed", fields...)
return nil, merrors.Internal("monetix request failed: " + err.Error()) return nil, merrors.Internal("monetix request failed: " + err.Error())
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -288,3 +311,23 @@ func clearSignature(req any) (func(string), error) {
return nil, merrors.Internal("unsupported monetix payload type for signing") return nil, merrors.Internal("unsupported monetix payload type for signing")
} }
} }
func logRequestDeadline(logger *zap.Logger, ctx context.Context, url string) {
if logger == nil {
return
}
if ctx == nil {
logger.Info("Monetix request context is nil", zap.String("url", url))
return
}
deadline, ok := ctx.Deadline()
if !ok {
logger.Info("Monetix request context has no deadline", zap.String("url", url))
return
}
logger.Info("Monetix request context deadline",
zap.String("url", url),
zap.Time("deadline", deadline),
zap.Duration("time_until_deadline", time.Until(deadline)),
)
}

View File

@@ -146,3 +146,66 @@ func TestSignPayload_EthEstimateGasExample(t *testing.T) {
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got) 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)
}
}

View File

@@ -54,7 +54,7 @@ gateway:
mntx: mntx:
address: "sendico_mntx_gateway:50075" address: "sendico_mntx_gateway:50075"
dial_timeout_seconds: 5 dial_timeout_seconds: 5
call_timeout_seconds: 3 call_timeout_seconds: 15
insecure: true insecure: true
oracle: oracle:

View File

@@ -273,6 +273,7 @@ func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client {
Address: addr, Address: addr,
DialTimeout: cfg.dialTimeout(), DialTimeout: cfg.dialTimeout(),
CallTimeout: cfg.callTimeout(), CallTimeout: cfg.callTimeout(),
Logger: i.logger.Named("client.mntx"),
}) })
if err != nil { if err != nil {
i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err)) i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err))

View File

@@ -39,7 +39,7 @@
@monetixSuccess { @monetixSuccess {
path /gateway/m/success* path /gateway/m/success*
method POST method POST
# remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244 remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244
} }
handle @monetixSuccess { handle @monetixSuccess {
rewrite * /monetix/callback rewrite * /monetix/callback
@@ -50,7 +50,7 @@
@monetixFail { @monetixFail {
path /gateway/m/fail* path /gateway/m/fail*
method POST method POST
# remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244 remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244
} }
handle @monetixFail { handle @monetixFail {
rewrite * /monetix/callback rewrite * /monetix/callback

View File

@@ -1,24 +0,0 @@
import 'package:flutter/material.dart';
class MockPaymentProvider with ChangeNotifier {
double _amount = 10.0;
bool _payerCoversFee = true;
double get amount => _amount;
bool get payerCoversFee => _payerCoversFee;
double get fee => _amount * 0.05;
double get total => payerCoversFee ? (_amount + fee) : _amount;
double get recipientGets => payerCoversFee ? _amount : (_amount - fee);
void setAmount(double value) {
_amount = value;
notifyListeners();
}
void setPayerCoversFee(bool value) {
_payerCoversFee = value;
notifyListeners();
}
}