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
- .normalise_secrets(secret) ⇒ Object
-
.resolve_key(secret) ⇒ Object
Resolve a Coffrify secret to its raw key bytes.
- .result(valid, event: nil, reason: nil, matched_secret_index: nil) ⇒ Object
-
.secure_compare(a, b) ⇒ Object
Constant-time comparison to thwart timing attacks.
-
.verify(raw_body, signature_header, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS) ⇒ Object
Legacy Coffrify header verification.
-
.verify_from_headers(raw_body, headers, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS) ⇒ Object
Auto-detect verification from a request headers hash (case-insensitive).
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 = parts["t"] sig_hex = parts[SIGNATURE_VERSION] return result(false, reason: "malformed signature header") if .nil? || sig_hex.nil? ts = .to_i return result(false, reason: "timestamp out of tolerance") if (Time.now.to_i - ts).abs > tolerance_seconds signed = "#{}.#{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? = "#{std_id}.#{ts}.#{raw_body}" secrets.each_with_index do |s, i| expected = OpenSSL::HMAC.digest("SHA256", resolve_key(s), ) 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 |