Strike sends X-Webhook-Signature in UPPERCASE hex. OpenSSL::HMAC. hexdigest always returns lowercase, and valid_signature? compared them case-sensitively via secure_compare — every genuine Strike webhook was being silently rejected as an invalid signature. Confirmed live: executed a real Lightning payment (payment-quotes/lightning + PATCH .../execute), received a real payment.updated webhook, and recomputing the HMAC over the captured payload/secret matched case-insensitively but not case-sensitively — proving the algorithm/secret/body were already correct and this was purely a case-comparison bug. Their own reference docs (docs.strike.me/webhooks/signature-verification) don't mention the case difference. Regression test added using the real captured signature, upcased. Also, both found via that same live test: - Invoices::Invoice didn't declare correlationId, raising ActiveModel::UnknownAttributeError the moment a real one (which CreateInvoice always sends) round-tripped through a live response — no existing spec fixture's stubbed body had included it. - BtcAttributeType was only registered with ActiveModel::Type, not ActiveRecord::Type (a separate registry) — an `attribute :x, :strike_btc` declaration on an ActiveRecord model raised "Unknown type" without this. Guarded behind defined?(ActiveRecord::Type) since this gem only depends on active_model. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
50 lines
2.2 KiB
Ruby
50 lines
2.2 KiB
Ruby
# 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
|