Module: Blockchain0x::Webhooks

Defined in:
lib/blockchain0x/webhooks.rb

Defined Under Namespace

Classes: Result

Constant Summary collapse

SIG_HEADER =
'X-Blockchain0x-Signature'
TS_HEADER =
'X-Blockchain0x-Timestamp'
TYPE_HEADER =
'X-Blockchain0x-Event-Type'
EVENT_ID_HEADER =
'X-Blockchain0x-Event-Id'
DELIVERY_ID_HEADER =
'X-Blockchain0x-Delivery-Id'
DEFAULT_TOLERANCE_SECONDS =
300
SIGNATURE_MISSING =

Failure-code constants mirror @blockchain0x/node + Python + Go.

'webhook.signature_missing'
SIGNATURE_MALFORMED =
'webhook.signature_malformed'
TIMESTAMP_OUTSIDE_WINDOW =
'webhook.timestamp_outside_window'
SIGNATURE_MISMATCH =
'webhook.signature_mismatch'
SECRET_MISSING =
'webhook.secret_missing'
TIMESTAMP_MISSING =
'webhook.timestamp_missing'
TIMESTAMP_INVALID =
'webhook.timestamp_invalid'

Class Method Summary collapse

Class Method Details

.verify(headers:, raw_body:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil, raise_on_fail: false) ⇒ Result

Returns discriminated-union result (default path).

Parameters:

  • headers (Hash)

    case-insensitive header bag (a plain Hash works, ActionController headers work)

  • raw_body (String)

    request body EXACTLY as it arrived; do not pre-parse JSON

  • secret (String)

    webhook signing secret

  • tolerance_seconds (Integer) (defaults to: DEFAULT_TOLERANCE_SECONDS)

    override the 5-minute default

  • now (Integer) (defaults to: nil)

    override ‘Time.now.to_i` for tests

  • raise_on_fail (Boolean) (defaults to: false)

    when true, raise WebhookSignatureError on any failure

Returns:

  • (Result)

    discriminated-union result (default path)

Raises:



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/blockchain0x/webhooks.rb', line 81

def verify(headers:, raw_body:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil, raise_on_fail: false)
  if secret.nil? || secret.empty?
    return fail_result(SECRET_MISSING, 'missing webhook secret', raise_on_fail)
  end

  raw_sig = pick_header(headers, SIG_HEADER)
  if raw_sig.nil?
    return fail_result(SIGNATURE_MISSING, 'missing X-Blockchain0x-Signature header', raise_on_fail)
  end

  parsed = parse_signature(raw_sig)
  return fail_result(SIGNATURE_MALFORMED, 'malformed signature header', raise_on_fail) if parsed.nil?

  ts_from_header, sig_hex = parsed
  ts = ts_from_header
  if ts.nil?
    raw_ts = pick_header(headers, TS_HEADER)
    return fail_result(TIMESTAMP_MISSING, 'missing X-Blockchain0x-Timestamp header', raise_on_fail) if raw_ts.nil?

    begin
      ts = Integer(raw_ts)
    rescue ArgumentError, TypeError
      return fail_result(TIMESTAMP_INVALID, 'invalid X-Blockchain0x-Timestamp value', raise_on_fail)
    end
  end

  current = now || Time.now.to_i
  if (current - ts).abs > tolerance_seconds
    return fail_result(TIMESTAMP_OUTSIDE_WINDOW, 'timestamp outside tolerance window', raise_on_fail)
  end

  want = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{raw_body}")
  unless secure_compare(want, sig_hex.downcase)
    return fail_result(SIGNATURE_MISMATCH, 'signature mismatch', raise_on_fail)
  end

  Result.new(
    ok: true,
    code: nil,
    event_type: pick_header(headers, TYPE_HEADER),
    event_id: pick_header(headers, EVENT_ID_HEADER),
    delivery_id: pick_header(headers, DELIVERY_ID_HEADER),
  )
end