Module: Dinie::Webhooks
- Defined in:
- lib/dinie/runtime/webhooks.rb,
sig/dinie/runtime/webhooks.rbs
Overview
Dinie::Webhooks.extract — webhook verification + per-type deserialization, a module-function
(RB11, architecture §8, §18.2). Implements the Standard Webhooks v1 contract Dinie inherited:
signed_payload = "{webhook-id}.{webhook-timestamp}.{body}"
expected = base64(HMAC-SHA256(decode_secret(secret), signed_payload))
header = "v1,<sig1> v1,<sig2>" # space-separated, rotation-capable
extract does verification AND deserialization in one call: it reads the three webhook-*
headers (case-insensitive, symbol/string), enforces a bidirectional timestamp window (replay
guard), decodes the secret (whsec_ → base64), HMAC-SHA256s the signed payload, and compares —
in CONSTANT time via OpenSSL.secure_compare — against every v1,<sig> in the header. Any
match → Events::DESERIALIZERS[type] hydrates the snake-native body into the typed
Events member; an unknown type → UnknownWebhookEventError; no match →
WebhookSignatureError. It NEVER returns an unverified event (security by behavior — the
name extract returns the event, not a boolean).
Both secret and the signature header accept multiple values, so secret rotation works from
either side. Verification needs no OAuth credentials, hence a module-function (not a client
method). Mirrors sdk-js src/runtime/webhooks.ts (@ 19c9bca), in Ruby (openssl stdlib;
base64 via String#unpack1("m") / Array#pack("m0") — no extra dependency).
── runtime ↔ generated boundary (controlled inverse import — openapi-SoT, §4) ──
The general rule is "runtime/ never imports generated/". webhooks.rb is one of two declared
exceptions (the other is http.rb → ERROR_REGISTRY). The webhook event catalog's SoT is
openapi.yaml (webhooks:), so the typed members AND the Events::DESERIALIZERS table
live in generated/events/. This module references the table at call time; the barrel
(lib/dinie.rb) loads generated/events before any call. The reference is the forcing-function:
an event not in openapi is not in generated/events, not in the table, so extract raises
UnknownWebhookEventError — forcing the contract conversation.
── Header normalization (deferred decision §21.5 — RESOLVED: case-insensitive + symbol/string) ──
Standard Webhooks headers are lowercase dashed (webhook-id). Inbound frameworks vary in
capitalization, so the lookup is case-insensitive and accepts both String and Symbol keys. The
partner passes a Hash keyed by the webhook-* names; framework env normalization (Rack's
HTTP_WEBHOOK_ID) is the partner's responsibility (a framework adapter is out of scope, §1):
# Rails: Dinie::Webhooks.extract(headers: request.headers.to_h, body: request.raw_post, secret:)
# Sinatra: Dinie::Webhooks.extract(headers: { "webhook-id" => request.env["HTTP_WEBHOOK_ID"],
# "webhook-timestamp" => request.env["HTTP_WEBHOOK_TIMESTAMP"],
# "webhook-signature" => request.env["HTTP_WEBHOOK_SIGNATURE"] }, body: request.body.read, secret:)
# Rack: headers = env.select { |k, _| k.start_with?("HTTP_") }
# .transform_keys { |k| k.delete_prefix("HTTP_").downcase.tr("_", "-") }
Constant Summary collapse
- DEFAULT_TOLERANCE_SECONDS =
Default replay tolerance, in seconds (5 minutes), applied in both directions.
300- WHSEC_PREFIX =
Secrets carrying this prefix are base64-encoded; the remainder is decoded before HMAC.
"whsec_"- SIGNATURE_VERSION =
Only
v1signature tokens are honored. "v1"
Class Method Summary collapse
-
.decode_secret(secret) ⇒ Object
private
whsec_-prefixed secrets are base64; everything else is treated as raw bytes. -
.deserialize_verified_event(body) ⇒ Object
private
Deserialize a VERIFIED body per type (reached only after a signature matched, so the bytes are authentic).
-
.extract(headers:, body:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS) ⇒ Dinie::Events::WebhookEventBase
Module-function aliases (callable as
Dinie::Webhooks.extract). - .fetch_header(headers, name) ⇒ Object private
-
.lookup_header(headers, name) ⇒ Object
private
Case-insensitive, symbol/string single-value header lookup (§21.5).
- .normalize_secrets(secret) ⇒ Object private
-
.parse_signature_header(header) ⇒ Object
private
Split the
webhook-signatureheader into decodedv1signatures. -
.required_header(headers, name, error_class) ⇒ Object
private
Fetch a required header or raise
error_class. -
.signatures_match?(payload, secrets, provided_signatures) ⇒ Boolean
private
True when any (secret, signature) pair matches.
-
.signed_payload(webhook_id, timestamp, body) ⇒ Object
private
Build the byte-faithful signed payload
"{id}.{timestamp}.{body}"(binary-concatenated so a non-UTF-8 body never raises an encoding error). -
.verify_signature!(payload, secret, signature_header) ⇒ Object
private
Raise unless any (secret, signature) pair matches (constant-time, multi-sig rotation).
-
.verify_timestamp!(timestamp, tolerance_seconds) ⇒ Object
private
Enforce the bidirectional replay window: reject a malformed, too-old, or too-far-future timestamp.
Instance Method Summary collapse
-
#decode_secret(secret) ⇒ Object
private
whsec_-prefixed secrets are base64; everything else is treated as raw bytes. -
#deserialize_verified_event(body) ⇒ Dinie::Events::WebhookEventBase
private
Deserialize a VERIFIED body per type (reached only after a signature matched, so the bytes are authentic).
- #fetch_header(headers, name) ⇒ Object private
-
#lookup_header(headers, name) ⇒ Object
private
Case-insensitive, symbol/string single-value header lookup (§21.5).
- #normalize_secrets(secret) ⇒ Array[untyped] private
-
#parse_signature_header(header) ⇒ Array[untyped]
private
Split the
webhook-signatureheader into decodedv1signatures. -
#required_header(headers, name, error_class) ⇒ Object
private
Fetch a required header or raise
error_class. -
#signatures_match?(payload, secrets, provided_signatures) ⇒ Boolean
private
True when any (secret, signature) pair matches.
-
#signed_payload(webhook_id, timestamp, body) ⇒ String
private
Build the byte-faithful signed payload
"{id}.{timestamp}.{body}"(binary-concatenated so a non-UTF-8 body never raises an encoding error). -
#verify_signature!(payload, secret, signature_header) ⇒ void
private
Raise unless any (secret, signature) pair matches (constant-time, multi-sig rotation).
-
#verify_timestamp!(timestamp, tolerance_seconds) ⇒ void
private
Enforce the bidirectional replay window: reject a malformed, too-old, or too-far-future timestamp.
Class Method Details
.decode_secret(secret) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
whsec_-prefixed secrets are base64; everything else is treated as raw bytes.
175 176 177 178 179 |
# File 'lib/dinie/runtime/webhooks.rb', line 175 def decode_secret(secret) return secret.delete_prefix(WHSEC_PREFIX).unpack1("m") if secret.start_with?(WHSEC_PREFIX) secret.b end |
.deserialize_verified_event(body) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Deserialize a VERIFIED body per type (reached only after a signature matched, so the bytes are
authentic). Dispatch is table-driven through Events::DESERIALIZERS; a type absent
from the catalog raises UnknownWebhookEventError (forces the contract conversation).
185 186 187 188 189 190 191 192 |
# File 'lib/dinie/runtime/webhooks.rb', line 185 def deserialize_verified_event(body) raw = JSON.parse(body, symbolize_names: true) event_type = raw[:type] deserializer = event_type.is_a?(String) ? Dinie::Events::DESERIALIZERS[event_type] : nil raise Dinie::UnknownWebhookEventError, event_type.to_s if deserializer.nil? deserializer.call(raw) end |
.extract(headers:, body:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS) ⇒ Dinie::Events::WebhookEventBase
Module-function aliases (callable as Dinie::Webhooks.extract).
77 78 79 80 81 82 83 84 85 |
# File 'lib/dinie/runtime/webhooks.rb', line 77 def extract(headers:, body:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS) webhook_id = required_header(headers, "webhook-id", Dinie::WebhookSignatureError) = required_header(headers, "webhook-timestamp", Dinie::WebhookTimestampError) signature = required_header(headers, "webhook-signature", Dinie::WebhookSignatureError) (, tolerance_seconds) verify_signature!(signed_payload(webhook_id, , body), secret, signature) deserialize_verified_event(body) end |
.fetch_header(headers, name) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
112 113 114 115 116 117 118 119 |
# File 'lib/dinie/runtime/webhooks.rb', line 112 def fetch_header(headers, name) return headers[name] if headers.key?(name) return headers[name.to_sym] if headers.key?(name.to_sym) target = name.downcase headers.each { |key, value| return value if key.to_s.downcase == target } nil end |
.lookup_header(headers, name) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Case-insensitive, symbol/string single-value header lookup (§21.5). A list value (rare) yields its first element.
106 107 108 109 |
# File 'lib/dinie/runtime/webhooks.rb', line 106 def lookup_header(headers, name) value = fetch_header(headers, name) value.is_a?(Array) ? value.first : value end |
.normalize_secrets(secret) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
147 148 149 |
# File 'lib/dinie/runtime/webhooks.rb', line 147 def normalize_secrets(secret) Array(secret).select { |candidate| candidate.is_a?(String) && !candidate.empty? } end |
.parse_signature_header(header) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Split the webhook-signature header into decoded v1 signatures. Space-separated
v1,<base64> tokens; non-v1 and malformed tokens are dropped.
154 155 156 157 158 159 160 161 |
# File 'lib/dinie/runtime/webhooks.rb', line 154 def parse_signature_header(header) header.split.filter_map do |token| version, separator, value = token.partition(",") next if separator.empty? || value.empty? || version != SIGNATURE_VERSION value.unpack1("m") end end |
.required_header(headers, name, error_class) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Fetch a required header or raise error_class. Missing and empty both fail.
96 97 98 99 100 101 |
# File 'lib/dinie/runtime/webhooks.rb', line 96 def required_header(headers, name, error_class) value = lookup_header(headers, name) return value unless value.nil? || value == "" raise error_class, "Missing required webhook header: #{name}." end |
.signatures_match?(payload, secrets, provided_signatures) ⇒ Boolean
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
True when any (secret, signature) pair matches. OpenSSL.secure_compare is constant-time and safe on unequal-length inputs (it digests both first), so no length guard is needed.
166 167 168 169 170 171 |
# File 'lib/dinie/runtime/webhooks.rb', line 166 def signatures_match?(payload, secrets, provided_signatures) secrets.any? do |secret| expected = OpenSSL::HMAC.digest("SHA256", decode_secret(secret), payload) provided_signatures.any? { |candidate| OpenSSL.secure_compare(candidate, expected) } end end |
.signed_payload(webhook_id, timestamp, body) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Build the byte-faithful signed payload "{id}.{timestamp}.{body}" (binary-concatenated so a
non-UTF-8 body never raises an encoding error).
90 91 92 |
# File 'lib/dinie/runtime/webhooks.rb', line 90 def signed_payload(webhook_id, , body) "#{webhook_id}.#{}.".b + body.to_s.b end |
.verify_signature!(payload, secret, signature_header) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Raise unless any (secret, signature) pair matches (constant-time, multi-sig rotation).
137 138 139 140 141 142 143 144 |
# File 'lib/dinie/runtime/webhooks.rb', line 137 def verify_signature!(payload, secret, signature_header) secrets = normalize_secrets(secret) raise Dinie::WebhookSignatureError, "No webhook secret provided." if secrets.empty? return if signatures_match?(payload, secrets, parse_signature_header(signature_header)) raise Dinie::WebhookSignatureError, "No matching webhook signature found; the payload may have been tampered with." end |
.verify_timestamp!(timestamp, tolerance_seconds) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Enforce the bidirectional replay window: reject a malformed, too-old, or too-far-future timestamp.
124 125 126 127 128 129 130 131 132 133 |
# File 'lib/dinie/runtime/webhooks.rb', line 124 def (, tolerance_seconds) seconds = Integer(.to_s.strip, 10, exception: false) raise Dinie::WebhookTimestampError, "Invalid webhook timestamp: #{.inspect}." if seconds.nil? skew = Time.now.to_i - seconds raise Dinie::WebhookTimestampError, "Webhook timestamp is too old." if skew > tolerance_seconds return unless skew < -tolerance_seconds raise Dinie::WebhookTimestampError, "Webhook timestamp is too far in the future." end |
Instance Method Details
#decode_secret(secret) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
whsec_-prefixed secrets are base64; everything else is treated as raw bytes.
175 176 177 178 179 |
# File 'lib/dinie/runtime/webhooks.rb', line 175 def decode_secret(secret) return secret.delete_prefix(WHSEC_PREFIX).unpack1("m") if secret.start_with?(WHSEC_PREFIX) secret.b end |
#deserialize_verified_event(body) ⇒ Dinie::Events::WebhookEventBase
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Deserialize a VERIFIED body per type (reached only after a signature matched, so the bytes are
authentic). Dispatch is table-driven through Events::DESERIALIZERS; a type absent
from the catalog raises UnknownWebhookEventError (forces the contract conversation).
185 186 187 188 189 190 191 192 |
# File 'lib/dinie/runtime/webhooks.rb', line 185 def deserialize_verified_event(body) raw = JSON.parse(body, symbolize_names: true) event_type = raw[:type] deserializer = event_type.is_a?(String) ? Dinie::Events::DESERIALIZERS[event_type] : nil raise Dinie::UnknownWebhookEventError, event_type.to_s if deserializer.nil? deserializer.call(raw) end |
#fetch_header(headers, name) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
112 113 114 115 116 117 118 119 |
# File 'lib/dinie/runtime/webhooks.rb', line 112 def fetch_header(headers, name) return headers[name] if headers.key?(name) return headers[name.to_sym] if headers.key?(name.to_sym) target = name.downcase headers.each { |key, value| return value if key.to_s.downcase == target } nil end |
#lookup_header(headers, name) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Case-insensitive, symbol/string single-value header lookup (§21.5). A list value (rare) yields its first element.
106 107 108 109 |
# File 'lib/dinie/runtime/webhooks.rb', line 106 def lookup_header(headers, name) value = fetch_header(headers, name) value.is_a?(Array) ? value.first : value end |
#normalize_secrets(secret) ⇒ Array[untyped]
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
147 148 149 |
# File 'lib/dinie/runtime/webhooks.rb', line 147 def normalize_secrets(secret) Array(secret).select { |candidate| candidate.is_a?(String) && !candidate.empty? } end |
#parse_signature_header(header) ⇒ Array[untyped]
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Split the webhook-signature header into decoded v1 signatures. Space-separated
v1,<base64> tokens; non-v1 and malformed tokens are dropped.
154 155 156 157 158 159 160 161 |
# File 'lib/dinie/runtime/webhooks.rb', line 154 def parse_signature_header(header) header.split.filter_map do |token| version, separator, value = token.partition(",") next if separator.empty? || value.empty? || version != SIGNATURE_VERSION value.unpack1("m") end end |
#required_header(headers, name, error_class) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Fetch a required header or raise error_class. Missing and empty both fail.
96 97 98 99 100 101 |
# File 'lib/dinie/runtime/webhooks.rb', line 96 def required_header(headers, name, error_class) value = lookup_header(headers, name) return value unless value.nil? || value == "" raise error_class, "Missing required webhook header: #{name}." end |
#signatures_match?(payload, secrets, provided_signatures) ⇒ Boolean
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
True when any (secret, signature) pair matches. OpenSSL.secure_compare is constant-time and safe on unequal-length inputs (it digests both first), so no length guard is needed.
166 167 168 169 170 171 |
# File 'lib/dinie/runtime/webhooks.rb', line 166 def signatures_match?(payload, secrets, provided_signatures) secrets.any? do |secret| expected = OpenSSL::HMAC.digest("SHA256", decode_secret(secret), payload) provided_signatures.any? { |candidate| OpenSSL.secure_compare(candidate, expected) } end end |
#signed_payload(webhook_id, timestamp, body) ⇒ String
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Build the byte-faithful signed payload "{id}.{timestamp}.{body}" (binary-concatenated so a
non-UTF-8 body never raises an encoding error).
90 91 92 |
# File 'lib/dinie/runtime/webhooks.rb', line 90 def signed_payload(webhook_id, , body) "#{webhook_id}.#{}.".b + body.to_s.b end |
#verify_signature!(payload, secret, signature_header) ⇒ void
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
This method returns an undefined value.
Raise unless any (secret, signature) pair matches (constant-time, multi-sig rotation).
137 138 139 140 141 142 143 144 |
# File 'lib/dinie/runtime/webhooks.rb', line 137 def verify_signature!(payload, secret, signature_header) secrets = normalize_secrets(secret) raise Dinie::WebhookSignatureError, "No webhook secret provided." if secrets.empty? return if signatures_match?(payload, secrets, parse_signature_header(signature_header)) raise Dinie::WebhookSignatureError, "No matching webhook signature found; the payload may have been tampered with." end |
#verify_timestamp!(timestamp, tolerance_seconds) ⇒ void
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
This method returns an undefined value.
Enforce the bidirectional replay window: reject a malformed, too-old, or too-far-future timestamp.
124 125 126 127 128 129 130 131 132 133 |
# File 'lib/dinie/runtime/webhooks.rb', line 124 def (, tolerance_seconds) seconds = Integer(.to_s.strip, 10, exception: false) raise Dinie::WebhookTimestampError, "Invalid webhook timestamp: #{.inspect}." if seconds.nil? skew = Time.now.to_i - seconds raise Dinie::WebhookTimestampError, "Webhook timestamp is too old." if skew > tolerance_seconds return unless skew < -tolerance_seconds raise Dinie::WebhookTimestampError, "Webhook timestamp is too far in the future." end |