Skip to content

Commit

Permalink
feat: add VerifyWebhookSignature
Browse files Browse the repository at this point in the history
  • Loading branch information
slowbackspace committed Dec 4, 2023
1 parent ecac71c commit b7df134
Show file tree
Hide file tree
Showing 7 changed files with 333 additions and 30 deletions.
9 changes: 8 additions & 1 deletion api_assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ func testIntUtil(t *testing.T, fp string, got interface{}, want interface{}, opt
}

if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v got %v", want, got)
gotJSON, err := json.Marshal(got)
wantJSON, err := json.Marshal(want)
if err != nil {
t.Fatalf("\nexpected %v \ngot %v", want, got)
}

t.Fatalf("\nexpected %s \ngot %s", string(wantJSON), string(gotJSON))

}
}
2 changes: 1 addition & 1 deletion api_block.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ type Block struct {
PreviousBlock string `json:"previous_block"`

// Hash of the next block
NextBlock string `json:"next_block"`
NextBlock *string `json:"next_block"`

// Number of block confirmations
Confirmations int `json:"confirmations"`
Expand Down
4 changes: 2 additions & 2 deletions api_script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package blockfrost_test
import (
"context"
"encoding/json"
"io/ioutil"
"os"
"path/filepath"
"reflect"
"testing"
Expand All @@ -25,7 +25,7 @@ func TestScriptUnmarshal(t *testing.T) {
}

func testStructGotWant(t *testing.T, fp string, got interface{}, want interface{}) {
bytes, err := ioutil.ReadFile(fp)
bytes, err := os.ReadFile(fp)
if err != nil {
t.Fatalf("failed to open json test file %s with err %v", fp, err)
}
Expand Down
4 changes: 2 additions & 2 deletions api_transaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,11 @@ func TestTransactionEvaluateUTXOsIntegration(t *testing.T) {

additionalUtxoSet := blockfrost.AdditionalUtxoSet{
{
TxIn: blockfrost.TxIn{
TxIn: blockfrost.AdditionalUtxoSetTxIn{
TxID: "ec6eb047f74e5412c116a819cdd43f1c27a29f2871241453019637b850461b43",
Index: 0,
},
TxOut: blockfrost.TxOut{
TxOut: blockfrost.AdditionalUtxoSetTxOut{
Address: "addr1qxvduldkktan65x4dg5gkfaaehc798pjg755yckuk5tjcedre5df3pzwwmyq946axfcejy5n4x0y99wqpgtp2gd0k09qgcyhcc",
Value: blockfrost.Value{
Coins: "1300000000",
Expand Down
51 changes: 27 additions & 24 deletions api_transactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,28 +105,31 @@ type TxAmount struct {
Unit string `json:"unit"`
}

type TransactionInput struct {
Address string `json:"address"`
Amount []TxAmount `json:"amount"`
OutputIndex float32 `json:"output_index"`
TxHash string `json:"tx_hash"`
DataHash string `json:"data_hash"`
Collateral bool `json:"collateral"`
InlineDatum string `json:"inline_datum"`
ReferenceScriptHash string `json:"reference_script_hash"`
Reference bool `json:"reference"`
}

type TransactionOutput struct {
Address string `json:"address"`
Amount []TxAmount `json:"amount"`
OutputIndex int `json:"output_index"`
DataHash string `json:"data_hash"`
InlineDatum string `json:"inline_datum"`
ReferenceScriptHash string `json:"reference_script_hash"`
}
type TransactionUTXOs struct {
// Transaction hash
Hash string `json:"hash"`
Inputs []struct {
Address string `json:"address"`
Amount []TxAmount `json:"amount"`
OutputIndex float32 `json:"output_index"`
TxHash string `json:"tx_hash"`
DataHash string `json:"data_hash"`
Collateral bool `json:"collateral"`
InlineDatum string `json:"inline_datum"`
ReferenceScriptHash string `json:"reference_script_hash"`
Reference bool `json:"reference"`
} `json:"inputs"`
Outputs []struct {
Address string `json:"address"`
Amount []TxAmount `json:"amount"`
OutputIndex int `json:"output_index"`
DataHash string `json:"data_hash"`
InlineDatum string `json:"inline_datum"`
ReferenceScriptHash string `json:"reference_script_hash"`
} `json:"outputs"`
Hash string `json:"hash"`
Inputs []TransactionInput `json:"inputs"`
Outputs []TransactionOutput `json:"outputs"`
}

type TransactionStakeAddressCert struct {
Expand Down Expand Up @@ -285,12 +288,12 @@ type Value struct {

type TxOutScript interface{} // This is an interface, actual implementation depends on usage

type TxIn struct {
type AdditionalUtxoSetTxIn struct {
TxID string `json:"txId"`
Index int `json:"index"`
}

type TxOut struct {
type AdditionalUtxoSetTxOut struct {
Address string `json:"address"`
Value Value `json:"value"`
DatumHash *string `json:"datumHash,omitempty"` // Pointer to handle null
Expand All @@ -300,8 +303,8 @@ type TxOut struct {

// AdditionalUtxoSet represents a slice of tuples (TxIn, TxOut)
type AdditionalUtxoSet []struct {
TxIn TxIn `json:"txIn"`
TxOut TxOut `json:"txOut"`
TxIn AdditionalUtxoSetTxIn `json:"txIn"`
TxOut AdditionalUtxoSetTxOut `json:"txOut"`
}

type OgmiosResponse struct {
Expand Down
218 changes: 218 additions & 0 deletions webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package blockfrost

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
)

type WebhookEventType string

const (
WebhookEventTypeBlock WebhookEventType = "block"
WebhookEventTypeDelegation WebhookEventType = "delegation"
WebhookEventTypeEpoch WebhookEventType = "epoch"
WebhookEventTypeTransaction WebhookEventType = "transaction"
)

type TransactionPayload struct {
Tx Transaction `json:"tx"`
Inputs []TransactionInput `json:"inputs"`
Outputs []TransactionOutput `json:"outputs"`
}

type StakeDelegationPayload struct {
Tx Transaction `json:"tx"`
Delegations []struct {
TransactionDelegation
Pool Pool `json:"pool"`
}
}

type EpochPayload struct {
PreviousEpoch Epoch `json:"previous_epoch"`
CurrentEpoch struct {
Epoch int `json:"epoch"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
} `json:"current_epoch"`
}

type WebhookEventCommon struct {
ID string `json:"id"`
WebhookID string `json:"webhook_id"`
Created int `json:"created"`
APIVersion int `json:"api_version,omitempty"` // omitempty because test fixtures do not include it
}

type WebhookEventBlock struct {
WebhookEventCommon
Type string `json:"type"` // "block"
Payload Block `json:"payload"`
}

type WebhookEventTransaction struct {
WebhookEventCommon
Type string `json:"type"` // "transaction"
Payload []TransactionPayload `json:"payload"`
}

type WebhookEventEpoch struct {
WebhookEventCommon
Type string `json:"type"` // "epoch"
Payload EpochPayload `json:"payload"`
}

type WebhookEventDelegation struct {
WebhookEventCommon
Type string `json:"type"` // "delegation"
Payload []StakeDelegationPayload `json:"payload"`
}

type WebhookEvent interface{}

const (
// Signatures older than this will be rejected by ConstructEvent
DefaultTolerance time.Duration = 600 * time.Second
signingVersion string = "v1"
)

var (
ErrNotSigned error = errors.New("Missing blockfrost-signature header")
ErrInvalidHeader error = errors.New("Invalid blockfrost-signature header format")
ErrTooOld error = errors.New("Signature's timestamp is not within time tolerance")
ErrNoValidSignature error = errors.New("No valid signature")
)

func computeSignature(t time.Time, payload []byte, secret string) []byte {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(fmt.Sprintf("%d", t.Unix())))
mac.Write([]byte("."))
mac.Write(payload)
return mac.Sum(nil)
}

type signedHeader struct {
timestamp time.Time
signatures [][]byte
}

func parseSignatureHeader(header string) (*signedHeader, error) {
sh := &signedHeader{}

if header == "" {
return sh, ErrNotSigned
}

// Signed header looks like "t=1495999758,v1=ABC,v1=DEF,v0=GHI"
pairs := strings.Split(header, ",")
for _, pair := range pairs {
parts := strings.Split(pair, "=")
if len(parts) != 2 {
return sh, ErrInvalidHeader
}

switch parts[0] {
case "t":
timestamp, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return sh, ErrInvalidHeader
}
sh.timestamp = time.Unix(timestamp, 0)

case signingVersion:
sig, err := hex.DecodeString(parts[1])
if err != nil {
continue // Ignore invalid signatures
}

sh.signatures = append(sh.signatures, sig)

default:
fmt.Printf("WARNING: Cannot parse part of the signature header, key \"%s\" is not supported by this version of Blockfrost SDK.\n", parts[0])
continue // Ignore unknown parts of the header
}
}

if len(sh.signatures) == 0 {
return sh, ErrNoValidSignature
}

if sh.timestamp == (time.Time{}) {
return sh, ErrInvalidHeader

}

return sh, nil
}

func VerifyWebhookSignature(payload []byte, header string, secret string) (WebhookEvent, error) {
return VerifyWebhookSignatureWithTolerance(payload, header, secret, DefaultTolerance)
}

func VerifyWebhookSignatureWithTolerance(payload []byte, header string, secret string, tolerance time.Duration) (WebhookEvent, error) {
return verifyWebhookSignature(payload, header, secret, tolerance, true)
}

func VerifyWebhookSignatureIgnoringTolerance(payload []byte, header string, secret string) (WebhookEvent, error) {
return verifyWebhookSignature(payload, header, secret, 0*time.Second, false)
}

func verifyWebhookSignature(payload []byte, sigHeader string, secret string, tolerance time.Duration, enforceTolerance bool) (WebhookEvent, error) {
// First unmarshal into a generic map to inspect the type
var genericEvent map[string]interface{}
if err := json.Unmarshal(payload, &genericEvent); err != nil {
return nil, fmt.Errorf("failed to parse webhook body json: %s", err)
}

// Determine the specific event type
eventType, ok := genericEvent["type"].(string)
if !ok {
return nil, errors.New("event type not found")
}

var event WebhookEvent

// Unmarshal into the specific event type based on the eventType
switch eventType {
case string(WebhookEventTypeBlock):
event = new(WebhookEventBlock)
case string(WebhookEventTypeTransaction):
event = new(WebhookEventTransaction)
case string(WebhookEventTypeEpoch):
event = new(WebhookEventEpoch)
case string(WebhookEventTypeDelegation):
event = new(WebhookEventDelegation)
default:
return nil, fmt.Errorf("unknown event type: %s", eventType)
}

if err := json.Unmarshal(payload, &event); err != nil {
return nil, fmt.Errorf("failed to parse specific webhook event json: %s", err)
}

header, err := parseSignatureHeader(sigHeader)
if err != nil {
return event, err
}

expectedSignature := computeSignature(header.timestamp, payload, secret)
expiredTimestamp := time.Since(header.timestamp) > tolerance
if enforceTolerance && expiredTimestamp {
return event, ErrTooOld
}

for _, sig := range header.signatures {
if hmac.Equal(expectedSignature, sig) {
return event, nil
}
}

return event, ErrNoValidSignature
}
Loading

0 comments on commit b7df134

Please sign in to comment.