Module: Silas::Webhook
- Defined in:
- lib/silas/webhook.rb
Overview
Inbound webhook signature verification, shared by every channel.
Vendors differ only in what they sign and how they label it: Slack signs "v0:#timestamp:#body" and prefixes "v0=", GitHub signs the raw body and prefixes "sha256=", Shopify signs the raw body and Base64-encodes it. The dangerous parts are identical everywhere — constant-time comparison, a replay window, and failing closed on a missing secret — so they live here and get tested once, while the caller supplies the vendor's shape.
Constant Summary collapse
- REPLAY_WINDOW =
seconds
300
Class Method Summary collapse
- .hmac(algorithm, secret, payload, digest) ⇒ Object
-
.verify_hmac(secret:, signature:, payload:, timestamp: nil, window: REPLAY_WINDOW, prefix: "", algorithm: "SHA256", digest: :hex, now: Time.current.to_i) ⇒ Object
Returns true ONLY for a genuine, fresh request.
Class Method Details
.hmac(algorithm, secret, payload, digest) ⇒ Object
39 40 41 42 43 44 45 |
# File 'lib/silas/webhook.rb', line 39 def hmac(algorithm, secret, payload, digest) case digest when :hex then OpenSSL::HMAC.hexdigest(algorithm, secret, payload) when :base64 then Base64.strict_encode64(OpenSSL::HMAC.digest(algorithm, secret, payload)) else raise ArgumentError, "digest must be :hex or :base64, got #{digest.inspect}" end end |
.verify_hmac(secret:, signature:, payload:, timestamp: nil, window: REPLAY_WINDOW, prefix: "", algorithm: "SHA256", digest: :hex, now: Time.current.to_i) ⇒ Object
Returns true ONLY for a genuine, fresh request. Every failure path returns false rather than raising: a webhook endpoint answers 401, it does not 500.
secret the shared signing secret. Blank => false (a channel with no
secret configured must reject, never accept).
signature the header value, exactly as sent.
payload the bytes the vendor signed — usually request.raw_post,
sometimes a basestring built from it. NEVER the parsed
params: re-serializing changes the bytes and the HMAC.
timestamp the vendor's request timestamp, if it sends one. Present =>
anything outside +/- window is refused as a replay.
prefix whatever the vendor puts before the digest ("v0=", "sha256=").
digest "hex" (nearly everyone) or "base64" (Shopify, Twilio).
30 31 32 33 34 35 36 37 |
# File 'lib/silas/webhook.rb', line 30 def verify_hmac(secret:, signature:, payload:, timestamp: nil, window: REPLAY_WINDOW, prefix: "", algorithm: "SHA256", digest: :hex, now: Time.current.to_i) return false if secret.blank? || signature.blank? return false if .present? && window && (now - .to_i).abs > window expected = prefix + hmac(algorithm, secret, payload, digest) ActiveSupport::SecurityUtils.secure_compare(expected, signature) end |