Skip to content

Commit

Permalink
Merge pull request #23 from lacepool/webhook_signature_verification
Browse files Browse the repository at this point in the history
Add Webhook signature verification
  • Loading branch information
robinboening authored Mar 20, 2023
2 parents 3e89d56 + e067d63 commit 6ef8747
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 4 deletions.
76 changes: 73 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@

## Installation

You can download the latest release directly from rubygems.org:
Add the gem to your Gemfile

gem "blockfrost-ruby"

Or download the latest release directly from rubygems.org:

$ gem install blockfrost-ruby

Expand All @@ -34,7 +38,7 @@ That's it! You may use the gem in your projects.

## Usage

To use this SDK, you first need login into blockfrost.io and create your project to retrieve your API token.
To use this SDK, you first need to login into blockfrost.io and create your project to retrieve your API token.

And here are examples of how to use this SDK.

Expand Down Expand Up @@ -84,7 +88,6 @@ blockfrost_mainnet = Blockfrostruby::CardanoMainNet.new('your-API-key')

# Or if you want to access other networks:

blockfrost_testnet = Blockfrostruby::CardanoTestNet.new('your-API-key')
blockfrost_preview = Blockfrostruby::CardanoPreview.new('your-API-key')
blockfrost_preprod = Blockfrostruby::CardanoPreprod.new('your-API-key')
blockfrost_ipfs = Blockfrostruby::IPFS.new('your-API-key')
Expand Down Expand Up @@ -127,6 +130,73 @@ blockfrost.get_block_latest_transactions({ count: 20 })

blockfrost.get_list_of_next_blocks("hash_here", { count: 40, from_page: 11520, to_page: 11640, parallel_requests: 15 })

# 5. Webhooks

# Read about the available webhooks here: https://blockfrost.dev/docs/start-building/webhooks

# This SDK provides a module BlockfrostRuby::Webhooks you can use as a mixin in your classes. It provides you with a method for verifying the signature sent with the webhook.

# Example for Rails

# config/routes.rb
post "/webhook", to: "cardano_webhooks#create"

# app/controllers/cardano_webhooks_controller.rb
class CardanoWebhooksController < ApplicationController
include BlockfrostRuby::Webhooks

# You will find your webhook secret auth token in your webhook settings in the Blockfrost Dashboard
BLOCKFROST_SECRET_AUTH_TOKEN = "BLOCKFROST-SECRET-AUTH-TOKEN"

before_action :verify_request

def create
type = params.fetch("type")
payload = params.fetch('payload')

case type
when "transaction"
# payload is an array of Transaction events
payload.each do |tx|
puts "Transaction id: #{tx.dig 'tx', 'hash'}",
"block: #{tx.dig 'tx', 'block'} (#{tx.dig 'tx', 'block_height'})"
end
when "block"
# process Block event
puts "Received block hash #{payload['hash']}"
when "delegation"
# payload is an array of objects with fields: "tx" (an object) and "delegations" (an array)
payload.each do |tx|
tx['delegations'].each do |delegation|
puts "Delegation from an address #{delegation['address']} included in tx #{tx['tx']['hash']}"
end
end
when "epoch"
# process Epoch event
puts "Epoch switch from #{payload.dig 'previous_epoch', 'epoch'} to #{payload.dig 'current_epoch', 'epoch'}"
else
puts "Unexpected event type #{type}"
end

head 200
end

private

def verify_request
verify_webhook_signature(
request.raw_post,
request.headers['Blockfrost-Signature'],
BLOCKFROST_SECRET_AUTH_TOKEN
)

# In case of invalid signature SignatureVerificationError will be raised
rescue BlockfrostRuby::Webhooks::SignatureVerificationError => e
puts "Webhook signature is invalid. #{e.message}"
head 403 and return
end
end

# That's it! Enjoy

```
Expand Down
2 changes: 1 addition & 1 deletion blockfrost-ruby.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Gem::Specification.new do |spec|
spec.require_paths = ['lib']

# Uncomment to register a new dependency of your gem
# spec.add_dependency "example-gem", "~> 1.0"
spec.add_development_dependency "timecop"

# For more information and examples about making a new gem, checkout our
# guide at: https://bundler.io/guides/creating_gem.html
Expand Down
2 changes: 2 additions & 0 deletions lib/blockfrost-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
require_relative 'blockfrostruby/endpoints/ipfs/ipfs_endpoints'
require_relative 'blockfrostruby/endpoints/custom_endpoints'

require_relative 'blockfrostruby/webhooks'

module Blockfrostruby
class Net
include Configuration
Expand Down
82 changes: 82 additions & 0 deletions lib/blockfrostruby/webhooks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
module Blockfrostruby
module Webhooks
def verify_webhook_signature(request_body, signature_header, secret, timestamp_tolerance_seconds: 600)
tokens = signature_header.split(",")
signatures = []
timestamp = nil

tokens.each do |token|
key, value = token.split("=")

case key
when "t"
timestamp = value.to_i
when "v1"
signatures << value
else
puts "Cannot parse part of the Blockfrost-Signature header, key #{key} is not supported by this version of Blockfrost SDK. Please upgrade."
end
end

if timestamp.nil? || tokens.length < 2
# timestamp and at least one signature must be present
raise SignatureVerificationError, "Invalid signature header format."
end

if signatures.length < 1
# There are no signatures that this version of SDK supports
raise SignatureVerificationError, "No signatures with supported version scheme."
end

# Recreate signature by concatenating the timestamp with the payload,
# then compute HMAC using sha256 and provided secret (webhook auth token)
signature_payload = "#{timestamp}.#{request_body}"
local_signature = OpenSSL::HMAC.hexdigest("sha256", secret, signature_payload)

has_valid_signature = false

signatures.each do |signature|
if secure_compare(signature, local_signature)
has_valid_signature = true

break
end
end

unless has_valid_signature
raise SignatureVerificationError, "No signature matches the expected signature for the payload."
end

if Time.now.utc.to_i - timestamp > timestamp_tolerance_seconds
# Event is older than timestamp_tolerance_seconds
raise SignatureVerificationError, "Signature's timestamp is outside of the time tolerance."
else
true
end
end

# borrowed from ActiveSupport::SecurityUtils
# https://github.com/rails/rails/blob/main/activesupport/lib/active_support/security_utils.rb
if defined?(OpenSSL.fixed_length_secure_compare)
def fixed_length_secure_compare(a, b)
OpenSSL.fixed_length_secure_compare(a, b)
end
else
def fixed_length_secure_compare(a, b)
raise ArgumentError, "string length mismatch." unless a.bytesize == b.bytesize

l = a.unpack "C#{a.bytesize}"

res = 0
b.each_byte { |byte| res |= byte ^ l.shift }
res == 0
end
end

def secure_compare(a, b)
a.bytesize == b.bytesize && fixed_length_secure_compare(a, b)
end

class SignatureVerificationError < StandardError; end
end
end
80 changes: 80 additions & 0 deletions spec/webhook_signature_verification_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
require 'spec_helper'
require 'timecop'

RSpec.describe Blockfrostruby::Webhooks do
describe "#verify_webhook_signature" do
let(:test_class) { Class.new { include Blockfrostruby::Webhooks }.send(:new) }
let(:signed_at) { Time.at(1650013856) }
let(:requested_at) { signed_at + 1 }
let(:secret) { "59a1eb46-96f4-4f0b-8a03-b4d26e70593a" }
let(:request_body) do
'{"id":"47668401-c3a4-42d4-bac1-ad46515924a3","webhook_id":"cf68eb9c-635f-415e-a5a8-6233638f28d7","created":1650013856,"type":"block","payload":{"time":1650013853,"height":7126256,"hash":"f49521b67b440e5030adf124aee8f88881b7682ba07acf06c2781405b0f806a4","slot":58447562,"epoch":332,"epoch_slot":386762,"slot_leader":"pool1njjr0zn7uvydjy8067nprgwlyxqnznp9wgllfnag24nycgkda25","size":34617,"tx_count":13,"output":"13403118309871","fees":"4986390","block_vrf":"vrf_vk197w95j9alkwt8l4g7xkccknhn4pqwx65c5saxnn5ej3cpmps72msgpw69d","previous_block":"9e3f5bfc9f0be44cf6e14db9ed5f1efb6b637baff0ea1740bb6711786c724915","next_block":null,"confirmations":0}}'
end

subject do
test_class.verify_webhook_signature(request_body, signature_header, secret)
end

before do
Timecop.travel(requested_at)
end

context "with a valid signature" do
let(:signature_header) do
"t=1650013856,v1=f4c3bb2a8b0c8e21fa7d5fdada2ee87c9c6f6b0b159cc22e483146917e195c3e"
end

it "returns true" do
expect(subject).to eq(true)
end

context "and timestamp out of tolerance zone" do
let(:requested_at) { signed_at + 7200 }

it "throws verification error" do
expect { subject }.to raise_error(Blockfrostruby::Webhooks::SignatureVerificationError)
end
end
end

context "with two signatures, one valid and one invalid" do
let(:signature_header) do
"t=1650013856,v1=abc,v1=f4c3bb2a8b0c8e21fa7d5fdada2ee87c9c6f6b0b159cc22e483146917e195c3e"
end

it "returns true" do
expect(subject).to eq(true)
end
end

context "with missing timestamp in signature" do
let(:signature_header) do
"v1=f4c3bb2a8b0c8e21fa7d5fdada2ee87c9c6f6b0b159cc22e483146917e195c3e"
end

it "throws verification error" do
expect { subject }.to raise_error(Blockfrostruby::Webhooks::SignatureVerificationError)
end
end

context "with unsupported signature version" do
let(:signature_header) do
"t=1650013856,v42=abc"
end

it "throws verification error" do
expect { subject }.to raise_error(Blockfrostruby::Webhooks::SignatureVerificationError)
end
end

context "with no signature matching" do
let(:signature_header) do
"t=1650013856,v1=abc"
end

it "throws verification error" do
expect { subject }.to raise_error(Blockfrostruby::Webhooks::SignatureVerificationError)
end
end
end
end

0 comments on commit 6ef8747

Please sign in to comment.