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
-
.secure_compare(expected, actual) ⇒ Object
Constant-time comparison.
-
.sign(payload, timestamp, secret) ⇒ Object
Produces the digest MisarMail sends.
-
.verify(payload:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE_SECONDS) ⇒ Object
Returns true when the signature is authentic and the timestamp is fresh.
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, , secret) OpenSSL::HMAC.hexdigest("SHA256", secret, "#{}.#{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, , secret].any? { |v| v.nil? || v.to_s.empty? } sent_at = Float() 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, , secret), signature.strip) end |