# frozen_string_literal: true require 'openssl' require 'active_support/security_utils' module StrikeMe module API # Verifies the X-Webhook-Signature header Strike attaches to every # webhook POST — HMAC-SHA256 of the raw request body, keyed by the # secret set at webhook-subscription creation time. See # https://docs.strike.me/webhooks/signature-verification — this # mirrors Strike's own documented reference implementation exactly # (hex digest, timing-safe compare), just in Ruby. # # `raw_body` must be the exact bytes Strike sent, before any # JSON-parsing round-trip — re-serializing a parsed body can reorder # keys or change whitespace and silently break verification. module Webhook SIGNATURE_HEADER = 'X-Webhook-Signature' def self.valid_signature?(raw_body:, signature:, secret:) return false if signature.blank? || raw_body.blank? expected = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body) # Strike sends X-Webhook-Signature in UPPERCASE hex — confirmed # live against a real webhook delivery (payment.updated, a real # executed Lightning send) 2026-09-22. OpenSSL::HMAC.hexdigest # always returns lowercase. Without downcasing both sides here, # secure_compare's case-sensitive byte comparison rejected every # genuine Strike webhook as an invalid signature — silently: a # rejected webhook looks identical to an actual forgery attempt # from the outside, so this would have broken settlement in # production with no visible error at all. Their own docs' # reference implementation (docs.strike.me/webhooks/ # signature-verification) doesn't mention this case difference. secure_compare(expected.downcase, signature.downcase) end # ActiveSupport::SecurityUtils.secure_compare raises on a length # mismatch rather than returning false — a garbled/truncated header # from a malformed request should fail closed, not raise. def self.secure_compare(expected, actual) return false unless expected.bytesize == actual.bytesize ActiveSupport::SecurityUtils.secure_compare(expected, actual) end end end end