From a8cc001866a659449cfc75ef0ed3e98040dda4f2 Mon Sep 17 00:00:00 2001 From: Danny Fuentes Date: Fri, 5 Sep 2025 15:40:13 -0400 Subject: [PATCH 1/3] feat: add webhook signature module --- lib/fintoc/webhook_signature.rb | 73 ++++++++++++ spec/lib/fintoc/webhook_signature_spec.rb | 132 ++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 lib/fintoc/webhook_signature.rb create mode 100644 spec/lib/fintoc/webhook_signature_spec.rb diff --git a/lib/fintoc/webhook_signature.rb b/lib/fintoc/webhook_signature.rb new file mode 100644 index 0000000..f0bcd49 --- /dev/null +++ b/lib/fintoc/webhook_signature.rb @@ -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 diff --git a/spec/lib/fintoc/webhook_signature_spec.rb b/spec/lib/fintoc/webhook_signature_spec.rb new file mode 100644 index 0000000..7d2cb22 --- /dev/null +++ b/spec/lib/fintoc/webhook_signature_spec.rb @@ -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 From f96e7bd6b02eb2017ee203710e08a994f2db2ed4 Mon Sep 17 00:00:00 2001 From: Danny Fuentes Date: Fri, 5 Sep 2025 15:40:49 -0400 Subject: [PATCH 2/3] feat: add webhook signature error --- lib/fintoc/errors.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/fintoc/errors.rb b/lib/fintoc/errors.rb index 465bf45..ab65162 100644 --- a/lib/fintoc/errors.rb +++ b/lib/fintoc/errors.rb @@ -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 From 95ef7d08416746b233cfd03a2e8623768eb2034e Mon Sep 17 00:00:00 2001 From: Danny Fuentes Date: Fri, 5 Sep 2025 15:41:06 -0400 Subject: [PATCH 3/3] feat: include webhook signature module to fintoc --- lib/fintoc.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/fintoc.rb b/lib/fintoc.rb index 1224934..4743214 100644 --- a/lib/fintoc.rb +++ b/lib/fintoc.rb @@ -1,6 +1,7 @@ require 'fintoc/version' require 'fintoc/errors' require 'fintoc/client' +require 'fintoc/webhook_signature' require 'config/initializers/money'