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

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

  timestamp = 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"
      timestamp = value.to_i if /\A\d+\z/.match?(value)
    elsif key == "v1"
      signatures << value
    end
  end

  if timestamp.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

  [timestamp, 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

Verify a delivery and return its parsed body as a Response.

A thin wrapper over verify that parses the JSON only after the signature checks out.



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)
  timestamp, signatures = parse_header(signature_header)

  if tolerance_seconds.positive?
    current = now || Time.now.to_i
    if current - timestamp > tolerance_seconds
      raise WebhookVerificationError.new(
        "Timestamp #{timestamp} is older than the #{tolerance_seconds}s tolerance.",
        :timestamp_out_of_tolerance
      )
    end
  end

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{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