From 96dbaf5d1aa0294ef32458a0a637b63e7ed1cf45 Mon Sep 17 00:00:00 2001 From: Kachit Date: Sun, 4 Dec 2022 14:23:00 +0300 Subject: [PATCH 1/4] signature validator --- signature.go | 57 ++++++++++++++++++++++++++++++++++++++++ signature_test.go | 56 +++++++++++++++++++++++++++++++++++++++ stubs/rsa/public-key.pem | 9 +++++++ stubs_test.go | 6 +++++ webhooks.go | 17 ++++++++---- webhooks_test.go | 24 +++++++++++++++++ 6 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 signature.go create mode 100644 signature_test.go create mode 100644 stubs/rsa/public-key.pem diff --git a/signature.go b/signature.go new file mode 100644 index 0000000..cf0c1c7 --- /dev/null +++ b/signature.go @@ -0,0 +1,57 @@ +package dusupay + +import ( + "bytes" + "crypto" + "crypto/rsa" + "crypto/sha512" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" +) + +//IncomingWebhookInterface interface +type IncomingWebhookInterface interface { + BuildPayloadString(url string) string +} + +func NewSignatureValidator(publicKeyBytes []byte) (*SignatureValidator, error) { + block, _ := pem.Decode(publicKeyBytes) + publicKey, err := parsePublicKey(block.Bytes) + if err != nil { + return nil, err + } + return &SignatureValidator{publicKey}, nil +} + +type SignatureValidator struct { + publicKey *rsa.PublicKey +} + +func (sv *SignatureValidator) ValidateSignature(webhook IncomingWebhookInterface, webhookUrl string, signature string) error { + messageBytes := bytes.NewBufferString(webhook.BuildPayloadString(webhookUrl)) + hash := sha512.New() + hash.Write(messageBytes.Bytes()) + digest := hash.Sum(nil) + + data, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return err + } + return rsa.VerifyPKCS1v15(sv.publicKey, crypto.SHA512, digest, data) +} + +func parsePublicKey(rawBytes []byte) (*rsa.PublicKey, error) { + key, err := x509.ParsePKIXPublicKey(rawBytes) + if err != nil { + return nil, fmt.Errorf("parsePublicKey wrong parse PKIX: %v", err) + } + switch pk := key.(type) { + case *rsa.PublicKey: + return pk, nil + default: + return nil, errors.New("parsePublicKey: PublicKey must be of type rsa.PublicKey") + } +} diff --git a/signature_test.go b/signature_test.go new file mode 100644 index 0000000..c219114 --- /dev/null +++ b/signature_test.go @@ -0,0 +1,56 @@ +package dusupay + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "io/ioutil" + "testing" +) + +type SignatureTestSuite struct { + suite.Suite +} + +func (suite *SignatureTestSuite) TestValidateSignatureCollectionWebhookSuccess() { + rawBytes, _ := ioutil.ReadFile("stubs/rsa/public-key.pem") + webhook := &CollectionWebhook{ID: 226, InternalReference: "DUSUPAY405GZM1G5JXGA71IK", TransactionStatus: "COMPLETED"} + validator, _ := NewSignatureValidator(rawBytes) + err := validator.ValidateSignature(webhook, "https://www.sample-url.com/callback", stubSignature) + assert.NoError(suite.T(), err) +} + +func (suite *SignatureTestSuite) TestValidateSignatureCollectionWebhookFail() { + rawBytes, _ := ioutil.ReadFile("stubs/rsa/public-key.pem") + webhook := &CollectionWebhook{ID: 225, InternalReference: "DUSUPAY405GZM1G5JXGA71IK", TransactionStatus: "COMPLETED"} + validator, _ := NewSignatureValidator(rawBytes) + err := validator.ValidateSignature(webhook, "https://www.sample-url.com/callback", stubSignature) + assert.Error(suite.T(), err) + assert.Equal(suite.T(), "crypto/rsa: verification error", err.Error()) +} + +func (suite *SignatureTestSuite) TestValidateSignaturePayoutWebhookSuccess() { + rawBytes, _ := ioutil.ReadFile("stubs/rsa/public-key.pem") + webhook := &PayoutWebhook{ID: 226, InternalReference: "DUSUPAY405GZM1G5JXGA71IK", TransactionStatus: "COMPLETED"} + validator, _ := NewSignatureValidator(rawBytes) + err := validator.ValidateSignature(webhook, "https://www.sample-url.com/callback", stubSignature) + assert.NoError(suite.T(), err) +} + +func (suite *SignatureTestSuite) TestValidateSignatureRefundWebhookSuccess() { + rawBytes, _ := ioutil.ReadFile("stubs/rsa/public-key.pem") + webhook := &RefundWebhook{ID: 226, InternalReference: "DUSUPAY405GZM1G5JXGA71IK", TransactionStatus: "COMPLETED"} + validator, _ := NewSignatureValidator(rawBytes) + err := validator.ValidateSignature(webhook, "https://www.sample-url.com/callback", stubSignature) + assert.NoError(suite.T(), err) +} + +func (suite *SignatureTestSuite) TestParsePublicKeyError() { + pk, err := parsePublicKey([]byte(`foo`)) + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "parsePublicKey wrong parse PKIX: asn1: structure error: tags don't match") + assert.Nil(suite.T(), pk) +} + +func TestSignatureTestSuite(t *testing.T) { + suite.Run(t, new(SignatureTestSuite)) +} diff --git a/stubs/rsa/public-key.pem b/stubs/rsa/public-key.pem new file mode 100644 index 0000000..6c8fe19 --- /dev/null +++ b/stubs/rsa/public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmiVRA6VUyxaLCj1BC2Ij +ImHSyPxmS7Zvoyima3WJRcm1AtMdibLdWB4NJJFN7CyGjjZ18R7lB1CJjxWNbFIE +pwM/NahyBDYXfYwyzkkiqLIwndSLIBzpLTBUJj0EArLCVHDLAIooYP3DWkSz6yj2 +S3WT4NgYAptJP0yFv9rsnfzIMFXGiPUQeVEuoxiUXX/usTg1wWXLVZtL/BLJS98D +Sr7X65gwfEqgCaQOCJWRoUlsjfnNi3uEqSmQisRqrFk5eiOsfo4WaBohhOStu4nU +GKy/G/2JxOTJVaXvyRv8Ejj5mxxyMp0XcjdpSOa6OajxVgrOIRVGnJxwSWLQRpOu +FwIDAQAB +-----END PUBLIC KEY----- diff --git a/stubs_test.go b/stubs_test.go index 7b8779c..8f9a899 100644 --- a/stubs_test.go +++ b/stubs_test.go @@ -7,6 +7,12 @@ import ( "strings" ) +const stubSignature = `gYC3u1wUtk6UFpOVvCx+AyCnE3LXkS9Sg74fiRUQxRDDlllPu5vuRUrEbEqq/TEO90fYr76KGAWC6YSo +J9joYwk8RVftDQ1pNhROdfRkXL/yaQbrAvuT2gM2sO+HJhShCBLWbBcfXPOcjGcCedPCHSNFc5bq/Mk/ +DszqlFEoH0dUN8hmqXQr673zyFivaKT76CpJTcmn5nvJi8r6IGoOJXb5uN8CdMTXbT6J08OmsbILPlfX +qe8PS/IlvXz11oy5xUaLXt+whhZL8rBrwQUsi9aNVf8Gd5m93D2ls1z03zDSOjSlb26Rvnvk97+XSM13 +KuGbYjc3eJ6CUlQuIbTC1A==` + func BuildStubConfig() *Config { return &Config{ Uri: SandboxAPIUrl, diff --git a/webhooks.go b/webhooks.go index 765d570..acce981 100644 --- a/webhooks.go +++ b/webhooks.go @@ -7,11 +7,6 @@ import ( "net/http" ) -//WebhookInterface interface -type WebhookInterface interface { - GetPayloadString() string -} - //CollectionWebhook struct type CollectionWebhook struct { ID int64 `json:"id"` @@ -33,6 +28,10 @@ type CollectionWebhook struct { InstitutionName string `json:"institution_name"` } +func (cw *CollectionWebhook) BuildPayloadString(url string) string { + return fmt.Sprintf("%d:%s:%s:%s", cw.ID, cw.InternalReference, cw.TransactionStatus, url) +} + //PayoutWebhook struct type PayoutWebhook struct { ID int64 `json:"id"` @@ -53,6 +52,10 @@ type PayoutWebhook struct { InstitutionName string `json:"institution_name"` } +func (pw *PayoutWebhook) BuildPayloadString(url string) string { + return fmt.Sprintf("%d:%s:%s:%s", pw.ID, pw.InternalReference, pw.TransactionStatus, url) +} + //RefundWebhook struct type RefundWebhook struct { ID int64 `json:"id"` @@ -69,6 +72,10 @@ type RefundWebhook struct { Message string `json:"message"` } +func (rw *RefundWebhook) BuildPayloadString(url string) string { + return fmt.Sprintf("%d:%s:%s:%s", rw.ID, rw.InternalReference, rw.TransactionStatus, url) +} + //WebhookResponse struct type WebhookResponse struct { ResponseBody diff --git a/webhooks_test.go b/webhooks_test.go index 889248c..6eb5f83 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -39,6 +39,14 @@ func (suite *WebhooksTestSuite) TestCollectionWebhookUnmarshalSuccess() { assert.Equal(suite.T(), "MTN Mobile Money - Optional", webhook.InstitutionName) } +func (suite *WebhooksTestSuite) TestCollectionWebhookBuildPayloadString() { + var webhook CollectionWebhook + body, _ := LoadStubResponseData("stubs/webhooks/request/collection-success.json") + _ = json.Unmarshal(body, &webhook) + result := webhook.BuildPayloadString("https://www.sample-url.com/callback") + assert.Equal(suite.T(), "226:DUSUPAY405GZM1G5JXGA71IK:COMPLETED:https://www.sample-url.com/callback", result) +} + func (suite *WebhooksTestSuite) TestPayoutWebhookUnmarshalSuccess() { var webhook PayoutWebhook body, _ := LoadStubResponseData("stubs/webhooks/request/payout-success.json") @@ -62,6 +70,14 @@ func (suite *WebhooksTestSuite) TestPayoutWebhookUnmarshalSuccess() { assert.Equal(suite.T(), "MTN Mobile Money - Optional", webhook.InstitutionName) } +func (suite *WebhooksTestSuite) TestPayoutWebhookBuildPayloadString() { + var webhook PayoutWebhook + body, _ := LoadStubResponseData("stubs/webhooks/request/payout-success.json") + _ = json.Unmarshal(body, &webhook) + result := webhook.BuildPayloadString("https://www.sample-url.com/callback") + assert.Equal(suite.T(), "226:DUSUPAY405GZM1G5JXGA71IK:COMPLETED:https://www.sample-url.com/callback", result) +} + func (suite *WebhooksTestSuite) TestRefundWebhookUnmarshalSuccess() { var webhook RefundWebhook body, _ := LoadStubResponseData("stubs/webhooks/request/refund-success.json") @@ -81,6 +97,14 @@ func (suite *WebhooksTestSuite) TestRefundWebhookUnmarshalSuccess() { assert.Equal(suite.T(), "4860610032773134", webhook.AccountNumber) } +func (suite *WebhooksTestSuite) TestRefundWebhookBuildPayloadString() { + var webhook RefundWebhook + body, _ := LoadStubResponseData("stubs/webhooks/request/refund-success.json") + _ = json.Unmarshal(body, &webhook) + result := webhook.BuildPayloadString("https://www.sample-url.com/callback") + assert.Equal(suite.T(), "65205:RFD-DUSUPAYXYXYXYXYXYXYXYXYX-3486003:COMPLETED:https://www.sample-url.com/callback", result) +} + func TestWebhooksTestSuite(t *testing.T) { suite.Run(t, new(WebhooksTestSuite)) } From 4d6106a50277e5761820127ded675f13dd35a4ea Mon Sep 17 00:00:00 2001 From: Kachit Date: Sun, 4 Dec 2022 14:36:53 +0300 Subject: [PATCH 2/4] signature validator --- signature.go | 3 +++ signature_test.go | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/signature.go b/signature.go index cf0c1c7..96e8baa 100644 --- a/signature.go +++ b/signature.go @@ -19,6 +19,9 @@ type IncomingWebhookInterface interface { func NewSignatureValidator(publicKeyBytes []byte) (*SignatureValidator, error) { block, _ := pem.Decode(publicKeyBytes) + if block == nil { + return nil, errors.New("wrong public key data") + } publicKey, err := parsePublicKey(block.Bytes) if err != nil { return nil, err diff --git a/signature_test.go b/signature_test.go index c219114..e6c9d51 100644 --- a/signature_test.go +++ b/signature_test.go @@ -19,7 +19,7 @@ func (suite *SignatureTestSuite) TestValidateSignatureCollectionWebhookSuccess() assert.NoError(suite.T(), err) } -func (suite *SignatureTestSuite) TestValidateSignatureCollectionWebhookFail() { +func (suite *SignatureTestSuite) TestValidateSignatureCollectionWebhookWrongPayload() { rawBytes, _ := ioutil.ReadFile("stubs/rsa/public-key.pem") webhook := &CollectionWebhook{ID: 225, InternalReference: "DUSUPAY405GZM1G5JXGA71IK", TransactionStatus: "COMPLETED"} validator, _ := NewSignatureValidator(rawBytes) @@ -28,6 +28,15 @@ func (suite *SignatureTestSuite) TestValidateSignatureCollectionWebhookFail() { assert.Equal(suite.T(), "crypto/rsa: verification error", err.Error()) } +func (suite *SignatureTestSuite) TestValidateSignatureCollectionWebhookNonBase64Signature() { + rawBytes, _ := ioutil.ReadFile("stubs/rsa/public-key.pem") + webhook := &CollectionWebhook{ID: 225, InternalReference: "DUSUPAY405GZM1G5JXGA71IK", TransactionStatus: "COMPLETED"} + validator, _ := NewSignatureValidator(rawBytes) + err := validator.ValidateSignature(webhook, "https://www.sample-url.com/callback", "qwerty") + assert.Error(suite.T(), err) + assert.Equal(suite.T(), "illegal base64 data at input byte 4", err.Error()) +} + func (suite *SignatureTestSuite) TestValidateSignaturePayoutWebhookSuccess() { rawBytes, _ := ioutil.ReadFile("stubs/rsa/public-key.pem") webhook := &PayoutWebhook{ID: 226, InternalReference: "DUSUPAY405GZM1G5JXGA71IK", TransactionStatus: "COMPLETED"} @@ -51,6 +60,13 @@ func (suite *SignatureTestSuite) TestParsePublicKeyError() { assert.Nil(suite.T(), pk) } +func (suite *SignatureTestSuite) TestNewSignatureValidatorError() { + validator, err := NewSignatureValidator([]byte(`foo`)) + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "wrong public key data") + assert.Nil(suite.T(), validator) +} + func TestSignatureTestSuite(t *testing.T) { suite.Run(t, new(SignatureTestSuite)) } From d14987f9e31454b95c92ae56d34ad912bfc25d5f Mon Sep 17 00:00:00 2001 From: Kachit Date: Sun, 4 Dec 2022 14:49:25 +0300 Subject: [PATCH 3/4] signature validator --- signature.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/signature.go b/signature.go index 96e8baa..d0d46e9 100644 --- a/signature.go +++ b/signature.go @@ -17,6 +17,7 @@ type IncomingWebhookInterface interface { BuildPayloadString(url string) string } +//NewSignatureValidator method func NewSignatureValidator(publicKeyBytes []byte) (*SignatureValidator, error) { block, _ := pem.Decode(publicKeyBytes) if block == nil { @@ -29,10 +30,12 @@ func NewSignatureValidator(publicKeyBytes []byte) (*SignatureValidator, error) { return &SignatureValidator{publicKey}, nil } +//SignatureValidator struct type SignatureValidator struct { publicKey *rsa.PublicKey } +//ValidateSignature method (see https://docs.dusupay.com/webhooks-and-redirects/webhooks/signature-verification) func (sv *SignatureValidator) ValidateSignature(webhook IncomingWebhookInterface, webhookUrl string, signature string) error { messageBytes := bytes.NewBufferString(webhook.BuildPayloadString(webhookUrl)) hash := sha512.New() @@ -46,6 +49,7 @@ func (sv *SignatureValidator) ValidateSignature(webhook IncomingWebhookInterface return rsa.VerifyPKCS1v15(sv.publicKey, crypto.SHA512, digest, data) } +//parsePublicKey method func parsePublicKey(rawBytes []byte) (*rsa.PublicKey, error) { key, err := x509.ParsePKIXPublicKey(rawBytes) if err != nil { From 41bb2ea9a2f8c2900c6cfb49b01c332cc6807c21 Mon Sep 17 00:00:00 2001 From: Kachit Date: Tue, 6 Dec 2022 22:21:07 +0300 Subject: [PATCH 4/4] signature validator --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 31ea828..66e462b 100644 --- a/README.md +++ b/README.md @@ -212,3 +212,35 @@ fmt.Println(result.Code) fmt.Println(result.Message) fmt.Println((*result.Data).ID) ``` + +### Verify webhook signature +```go +requestPayload := ` +{ + "id": 226, + "request_amount": 10, + "request_currency": "USD", + "account_amount": 737.9934, + "account_currency": "UGX", + "transaction_fee": 21.4018, + "total_credit": 716.5916, + "customer_charged": false, + "provider_id": "mtn_ug", + "merchant_reference": "76859aae-f148-48c5-9901-2e474cf19b71", + "internal_reference": "DUSUPAY405GZM1G5JXGA71IK", + "transaction_status": "COMPLETED", + "transaction_type": "collection", + "message": "Transaction Completed Successfully" +} +` +requestUri := "https://www.sample-url.com/callback" +signature := "value from 'dusupay-signature' http header" + +var webhook dusupay.CollectionWebhook +_ = json.Unmarshal(requestPayload, &webhook) + +rawBytes, _ := ioutil.ReadFile("path/to/dusupay-public-key.pem") + +validator, _ := dusupay.NewSignatureValidator(rawBytes) +err := validator.ValidateSignature(webhook, requestUri, signature) +```