Module: MisarMail::Webhooks

Defined in:
lib/misar_mail/core/webhooks.rb

Overview

Inbound webhook signature verification.

MisarMail signs each webhook as HMAC-SHA256(timestamp + "." + raw_body) with the endpoint's signing secret, sending the digest in X-Misar-Signature and the Unix timestamp in X-Misar-Timestamp.

Verify against the RAW body, not a re-serialized hash: key order and whitespace both change the digest. The comparison is constant-time so a timing oracle cannot recover the digest byte by byte.

Constant Summary collapse

DEFAULT_TOLERANCE_SECONDS =
300

Class Method Summary collapse

Class Method Details

.secure_compare(expected, actual) ⇒ Object

Constant-time comparison. OpenSSL.fixed_length_secure_compare exists only on newer Rubies and raises on length mismatch (which itself leaks length), so compare lengths first and then XOR every byte regardless of where the first difference is.



38
39
40
41
42
43
44
# File 'lib/misar_mail/core/webhooks.rb', line 38

def secure_compare(expected, actual)
  return false unless expected.bytesize == actual.bytesize

  difference = 0
  expected.bytes.zip(actual.bytes) { |a, b| difference |= a ^ b }
  difference.zero?
end

.sign(payload, timestamp, secret) ⇒ Object

Produces the digest MisarMail sends. Exported because verification is only half the job: testing a webhook consumer needs a valid signature, and the exact framing is where that usually goes wrong.



49
50
51
# File 'lib/misar_mail/core/webhooks.rb', line 49

def sign(payload, timestamp, secret)
  OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
end

.verify(payload:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE_SECONDS) ⇒ Object

Returns true when the signature is authentic and the timestamp is fresh. Never raises on malformed input — a bad signature is false, not an error.



22
23
24
25
26
27
28
29
30
31
32
# File 'lib/misar_mail/core/webhooks.rb', line 22

def verify(payload:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE_SECONDS)
  return false if [payload, signature, timestamp, secret].any? { |v| v.nil? || v.to_s.empty? }

  sent_at = Float(timestamp) rescue (return false)

  # Rejecting stale timestamps is what stops a captured request from being
  # replayed forever, so this is a real check rather than a formality.
  return false if (Time.now.to_f - sent_at).abs > tolerance

  secure_compare(sign(payload, timestamp, secret), signature.strip)
end