Module: Anypost::WebhookSignature
- Defined in:
- lib/anypost/webhook_signature.rb
Overview
Verify the signature on an Anypost webhook delivery.
Constant Summary collapse
- DEFAULT_TOLERANCE_SECONDS =
300
Class Method Summary collapse
- .parse_header(header) ⇒ Object
- .secure_compare(left, right) ⇒ Object
-
.unwrap(payload, signature_header, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil) ⇒ Object
Verify a delivery and return its parsed body as a Response.
-
.verify(payload, signature_header, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil) ⇒ Object
Verify an Anypost webhook signature.
Class Method Details
.parse_header(header) ⇒ Object
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 |
# File 'lib/anypost/webhook_signature.rb', line 77 def parse_header(header) if header.nil? || header.empty? raise WebhookVerificationError.new("The Anypost-Signature header is empty.", :malformed_header) end = nil signatures = [] header.split(",").each do |part| key, separator, value = part.partition("=") next if separator.empty? key = key.strip value = value.strip if key == "t" = value.to_i if /\A\d+\z/.match?(value) elsif key == "v1" signatures << value end end if .nil? raise WebhookVerificationError.new("The Anypost-Signature header has no timestamp (t=).", :no_timestamp) end if signatures.empty? raise WebhookVerificationError.new("The Anypost-Signature header has no v1= signature.", :no_signatures) end [, signatures] end |
.secure_compare(left, right) ⇒ Object
108 109 110 111 112 |
# File 'lib/anypost/webhook_signature.rb', line 108 def secure_compare(left, right) return false unless left.bytesize == right.bytesize OpenSSL.fixed_length_secure_compare(left, right) end |
.unwrap(payload, signature_header, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil) ⇒ Object
71 72 73 74 75 |
# File 'lib/anypost/webhook_signature.rb', line 71 def unwrap(payload, signature_header, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil) verify(payload, signature_header, secret, tolerance_seconds: tolerance_seconds, now: now) decoded = JSON.parse(payload) Response.new(decoded.is_a?(Hash) ? decoded : {}) end |
.verify(payload, signature_header, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil) ⇒ Object
Verify an Anypost webhook signature.
Pass the raw request body (the exact bytes received, before JSON parsing), the ‘Anypost-Signature` header value, and the webhook’s signing secret. Returns nil on success; raises Anypost::WebhookVerificationError otherwise.
The header may carry more than one ‘v1=` component during a secret rotation; a match on any one passes, so deliveries keep verifying across a rotation. Set `tolerance_seconds:` to 0 to disable the freshness check.
38 39 40 41 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/anypost/webhook_signature.rb', line 38 def verify(payload, signature_header, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil) , signatures = parse_header(signature_header) if tolerance_seconds.positive? current = now || Time.now.to_i if current - > tolerance_seconds raise WebhookVerificationError.new( "Timestamp #{} is older than the #{tolerance_seconds}s tolerance.", :timestamp_out_of_tolerance ) end end expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{}.#{payload}") # Constant-time over every candidate: accumulate without early exit. matched = false signatures.each { |candidate| matched = true if secure_compare(candidate, expected) } unless matched raise WebhookVerificationError.new( "No signature in the header matched the computed signature.", :no_match ) end nil end |