Module: Coffrify::Webhook

Defined in:
lib/coffrify/webhook.rb

Overview

Webhook signature verification.

Two formats are auto-detected by ‘verify_from_headers`:

* **Standard Webhooks** (https://www.standardwebhooks.com/) — preferred.
    headers: webhook-id / webhook-timestamp / webhook-signature
    signed payload: "<id>.<ts>.<raw_body>"
    signature : "v1,<base64>" (space-separated for rotation).

* **Coffrify legacy** — single header `X-Coffrify-Signature: t=<ts>,v1=<hex>`.
    signed payload : "<ts>.<raw_body>"

‘secret` accepts a String OR an Array of Strings — pass multiple values to support rotation grace windows. Returns

{ valid:, event:, reason:, matched_secret_index: }

Constant Summary collapse

DEFAULT_TOLERANCE_SECONDS =

5 minutes

300
SIGNATURE_VERSION =
"v1".freeze

Class Method Summary collapse

Class Method Details

.normalise_secrets(secret) ⇒ Object



36
37
38
39
# File 'lib/coffrify/webhook.rb', line 36

def self.normalise_secrets(secret)
  arr = secret.is_a?(Array) ? secret : [secret]
  arr.compact.reject { |s| !s.is_a?(String) || s.empty? }
end

.resolve_key(secret) ⇒ Object

Resolve a Coffrify secret to its raw key bytes. “whsec_<hex>” → bytes via hex decode (Standard Webhooks convention). Anything else returned as-is.



28
29
30
31
32
33
34
# File 'lib/coffrify/webhook.rb', line 28

def self.resolve_key(secret)
  if secret.is_a?(String) && secret.start_with?("whsec_") && secret.length > 8
    hex = secret[6..]
    return [hex].pack("H*") if hex =~ /\A[0-9a-f]+\z/i && hex.length.even?
  end
  secret
end

.result(valid, event: nil, reason: nil, matched_secret_index: nil) ⇒ Object



117
118
119
# File 'lib/coffrify/webhook.rb', line 117

def self.result(valid, event: nil, reason: nil, matched_secret_index: nil)
  { valid: valid, event: event, reason: reason, matched_secret_index: matched_secret_index }
end

.secure_compare(a, b) ⇒ Object

Constant-time comparison to thwart timing attacks



122
123
124
125
126
127
128
# File 'lib/coffrify/webhook.rb', line 122

def self.secure_compare(a, b)
  return false if a.bytesize != b.bytesize
  l = a.unpack("C*")
  res = 0
  b.each_byte { |byte| res |= byte ^ l.shift }
  res == 0
end

.verify(raw_body, signature_header, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS) ⇒ Object

Legacy Coffrify header verification.



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/coffrify/webhook.rb', line 42

def self.verify(raw_body, signature_header, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS)
  secrets = normalise_secrets(secret)
  return result(false, reason: "missing signature") if signature_header.nil? || signature_header.empty?
  return result(false, reason: "missing secret") if secrets.empty?

  parts = signature_header.split(",").map { |p| p.strip.split("=", 2) }.to_h
  timestamp = parts["t"]
  sig_hex   = parts[SIGNATURE_VERSION]
  return result(false, reason: "malformed signature header") if timestamp.nil? || sig_hex.nil?

  ts = timestamp.to_i
  return result(false, reason: "timestamp out of tolerance") if (Time.now.to_i - ts).abs > tolerance_seconds

  signed = "#{timestamp}.#{raw_body}"
  provided = sig_hex.downcase
  secrets.each_with_index do |s, i|
    expected = OpenSSL::HMAC.hexdigest("SHA256", resolve_key(s), signed)
    if secure_compare(expected, provided)
      event = JSON.parse(raw_body) rescue nil
      return result(true, event: event, matched_secret_index: i)
    end
  end
  result(false, reason: "signature mismatch")
end

.verify_from_headers(raw_body, headers, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS) ⇒ Object

Auto-detect verification from a request headers hash (case-insensitive). Standard Webhooks is preferred when its three headers are present.



69
70
71
72
73
74
75
76
77
78
79
80
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
# File 'lib/coffrify/webhook.rb', line 69

def self.verify_from_headers(raw_body, headers, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS)
  secrets = normalise_secrets(secret)
  return result(false, reason: "missing secret") if secrets.empty?

  lookup = lambda do |name|
    target = name.downcase
    pair = headers.find { |k, _| k.to_s.downcase == target }
    pair && pair[1].is_a?(Array) ? pair[1].first : (pair && pair[1])
  end

  std_id = lookup.call("webhook-id")
  std_ts = lookup.call("webhook-timestamp")
  std_sig = lookup.call("webhook-signature")

  if std_id && std_ts && std_sig
    return result(false, reason: "malformed timestamp") unless std_ts.to_s =~ /\A\d+\z/
    ts = std_ts.to_i
    return result(false, reason: "timestamp out of tolerance") if (Time.now.to_i - ts).abs > tolerance_seconds

    candidates = std_sig.to_s.split(/\s+/).filter_map do |piece|
      next nil unless piece.start_with?("#{SIGNATURE_VERSION},")
      begin
        Base64.strict_decode64(piece[(SIGNATURE_VERSION.length + 1)..])
      rescue ArgumentError
        nil
      end
    end
    return result(false, reason: "malformed signature") if candidates.empty?

    message = "#{std_id}.#{ts}.#{raw_body}"
    secrets.each_with_index do |s, i|
      expected = OpenSSL::HMAC.digest("SHA256", resolve_key(s), message)
      candidates.each do |c|
        if secure_compare(expected, c)
          event = JSON.parse(raw_body) rescue nil
          return result(true, event: event, matched_secret_index: i)
        end
      end
    end
    return result(false, reason: "signature mismatch")
  end

  # Fall back to legacy.
  legacy = lookup.call("x-coffrify-signature")
  return verify(raw_body, legacy, secrets, tolerance_seconds: tolerance_seconds) if legacy
  result(false, reason: "malformed")
end