Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/fintoc.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require 'fintoc/version'
require 'fintoc/errors'
require 'fintoc/client'
require 'fintoc/webhook_signature'

require 'config/initializers/money'

Expand Down
3 changes: 3 additions & 0 deletions lib/fintoc/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ class InternalServerError < FintocError; end
class UnrecognizedRequestError < FintocError; end
class CoreResponseError < FintocError; end

# Webhook Errors
class WebhookSignatureError < FintocError; end

# Legacy Errors (keeping existing ones for backward compatibility and just in case)
class LinkError < FintocError; end
class InstitutionError < FintocError; end
Expand Down
73 changes: 73 additions & 0 deletions lib/fintoc/webhook_signature.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# frozen_string_literal: true

require 'openssl'
require 'time'
require 'fintoc/errors'

module Fintoc
class WebhookSignature
EXPECTED_SCHEME = 'v1'
DEFAULT_TOLERANCE = 300 # 5 minutes

class << self
def verify_header(payload, header, secret, tolerance = DEFAULT_TOLERANCE) # rubocop:disable Naming/PredicateMethod
timestamp, signatures = parse_header(header)

verify_timestamp(timestamp, tolerance) if tolerance

expected_signature = compute_signature(payload, timestamp, secret)
signature = signatures[EXPECTED_SCHEME]

if signature.nil? || signature.empty? # rubocop:disable Rails/Blank
raise Fintoc::Errors::WebhookSignatureError.new("No #{EXPECTED_SCHEME} signature found")
end

unless same_signatures?(signature, expected_signature)
raise Fintoc::Errors::WebhookSignatureError.new('Signature mismatch')
end

true
end

def compute_signature(payload, timestamp, secret)
signed_payload = "#{timestamp}.#{payload}"
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, signed_payload)
end

private

def parse_header(header)
elements = header.split(',').map(&:strip)
pairs = elements.map { |element| element.split('=', 2).map(&:strip) }
pairs = pairs.to_h

if pairs['t'].nil? || pairs['t'].empty? # rubocop:disable Rails/Blank
raise Fintoc::Errors::WebhookSignatureError.new('Missing timestamp in header')
end

timestamp = pairs['t'].to_i
signatures = pairs.except('t')

[timestamp, signatures]
rescue StandardError => e
raise Fintoc::Errors::WebhookSignatureError.new(
'Unable to extract timestamp and signatures from header'
), cause: e
end

def verify_timestamp(timestamp, tolerance)
now = Time.now.to_i

if timestamp < (now - tolerance)
raise Fintoc::Errors::WebhookSignatureError.new(
"Timestamp outside the tolerance zone (#{timestamp})"
)
end
end

def same_signatures?(signature, expected_signature)
OpenSSL.secure_compare(expected_signature, signature)
end
end
end
end
132 changes: 132 additions & 0 deletions spec/lib/fintoc/webhook_signature_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
require 'fintoc/webhook_signature'

RSpec.describe Fintoc::WebhookSignature do
let(:secret) { 'test_secret_key' }
let(:payload) { '{"test": "payload"}' }
let(:frozen_time) { Time.parse('2025-09-05 10:10:10 UTC') }
let(:timestamp) { frozen_time.to_i }
let(:signature) { OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, "#{timestamp}.#{payload}") }
let(:valid_header) { "t=#{timestamp},v1=#{signature}" }

before do
allow(Time).to receive_messages(current: frozen_time, now: frozen_time)
end

describe '.verify_header' do
context 'when signature is valid' do
it 'returns true' do
expect(described_class.verify_header(payload, valid_header, secret)).to be true
end

it 'returns true with custom tolerance' do
expect(described_class.verify_header(payload, valid_header, secret, 600)).to be true
end

it 'returns true when tolerance is nil (no timestamp verification)' do
old_timestamp = timestamp - 1000
old_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret,
"#{old_timestamp}.#{payload}")
old_header = "t=#{old_timestamp},v1=#{old_signature}"

expect(described_class.verify_header(payload, old_header, secret, nil)).to be true
end
end

context 'when header does not have a timestamp' do
let(:invalid_header) { "v1=#{signature}" }

it 'raises WebhookSignatureError' do
expect do
described_class.verify_header(payload, invalid_header, secret)
end.to raise_error(
Fintoc::Errors::WebhookSignatureError,
"\nUnable to extract timestamp and signatures from header\n " \
'Please check the docs at: https://docs.fintoc.com/reference/errors'
)
end
end

context 'when timestamp is too old' do
let(:old_timestamp) { timestamp - 400 }
let(:old_signature) do
OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new('sha256'),
secret,
"#{old_timestamp}.#{payload}"
)
end
let(:old_header) { "t=#{old_timestamp},v1=#{old_signature}" }

it 'raises WebhookSignatureError with default tolerance' do
expect do
described_class.verify_header(payload, old_header, secret)
end.to raise_error(
Fintoc::Errors::WebhookSignatureError,
"\nTimestamp outside the tolerance zone (#{old_timestamp})\n " \
'Please check the docs at: https://docs.fintoc.com/reference/errors'
)
end

it 'raises WebhookSignatureError with custom tolerance' do
expect do
described_class.verify_header(payload, old_header, secret, 100)
end.to raise_error(
Fintoc::Errors::WebhookSignatureError,
"\nTimestamp outside the tolerance zone (#{old_timestamp})\n " \
'Please check the docs at: https://docs.fintoc.com/reference/errors'
)
end
end

context 'when header does not contain signature scheme' do
let(:header_without_scheme) { "t=#{timestamp}" }

it 'raises WebhookSignatureError' do
expect do
described_class.verify_header(payload, header_without_scheme, secret)
end.to raise_error(
Fintoc::Errors::WebhookSignatureError,
"\nNo v1 signature found\n " \
'Please check the docs at: https://docs.fintoc.com/reference/errors'
)
end
end

context 'when signature and expected signature do not match' do
let(:wrong_signature) { 'wrong_signature_value' }
let(:invalid_header) { "t=#{timestamp},v1=#{wrong_signature}" }

it 'raises WebhookSignatureError' do
expect do
described_class.verify_header(payload, invalid_header, secret)
end.to raise_error(
Fintoc::Errors::WebhookSignatureError,
"\nSignature mismatch\n " \
'Please check the docs at: https://docs.fintoc.com/reference/errors'
)
end
end

context 'with different signature schemes' do
it 'ignores non-v1 signatures and uses v1' do
header_with_multiple = "t=#{timestamp},v0=wrong_signature,v1=#{signature},v2=another_wrong"

expect(described_class.verify_header(payload, header_with_multiple, secret)).to be true
end
end

context 'with malformed headers' do
it 'raises WebhookSignatureError' do
header_empty_timestamp = "t=,v1=#{signature}"

expect do
described_class.verify_header(payload, header_empty_timestamp, secret)
end.to raise_error(
Fintoc::Errors::WebhookSignatureError,
"\nUnable to extract timestamp and signatures from header\n " \
'Please check the docs at: https://docs.fintoc.com/reference/errors'
)
end
end
end
end