Module: Axn::Webhooks::Signature

Defined in:
lib/axn/webhooks/signature.rb

Overview

The shared HMAC primitive. Pure functions over bytes — no Request, no Rack, no axn. Both inbound verify and outbound sign build on this. ALWAYS constant-time.

Defined Under Namespace

Classes: Check

Constant Summary collapse

DIGESTS =
{ sha256: "SHA256", sha1: "SHA1", md5: "MD5" }.freeze
ENCODINGS =

Named so a caller can validate an encoding: up front rather than discovering it inside encode mid-request (Outbound::Signer::HmacSigner does exactly that at declaration time).

%i[hex base64 base64_urlsafe].freeze
UNITS =
{ seconds: 1, ms: 1_000, milliseconds: 1_000, microseconds: 1_000_000 }.freeze
AUTO =

Infer the unit from the timestamp's magnitude, per-timestamp. The default, because a vendor can send more than one unit -- Lob delivers epoch-seconds via Svix and epoch-ms from its dashboard, and no static unit: is correct for both (PRO-3142).

:auto
AUTO_MS_FLOOR =

The bands :auto reads magnitude against. The three scales sit 1000x apart and their plausible-date ranges don't overlap, so every timestamp a vendor could legitimately send falls in exactly one band:

value >= 1e11 can't be seconds -- that's the year 5138; as ms it's 1973.
value >= 1e14 can't be ms      -- that's the year 5138; as microseconds it's 1973.

A misread is therefore impossible in the 1973..5138 range, and outside it a misread can only produce a ~56-year skew, which the tolerance window rejects. Inference never widens what is accepted: no wrong-scale reading of a stale timestamp lands inside a tolerance of any realistic size.

100_000_000_000
AUTO_US_FLOOR =
100_000_000_000_000
CANONICAL_UNITS =

The distinct scales, for diagnostics. Excludes the :milliseconds alias (same divisor as :ms) so a mismatch is never reported as the caller's own unit under a different name.

%i[seconds ms microseconds].freeze
REASONS =

The last two belong to verify :basic_auth, which rejects for reasons that have nothing to do with a signature. Keeping them out would stamp every Basic-auth rejection :signature_mismatch — the exact misdirection PRO-3141 added reason to end, on endpoints where no signature exists.

%i[
  replay_window replay_timestamp_invalid signature_missing signature_mismatch
  credentials_missing credentials_mismatch
].freeze
OK =
Check.new(ok: true, reason: nil, skew: nil, suggested_unit: nil).freeze
MISMATCH =

The verdict a bare falsey return from a custom verify block is read as — it rejected the signature without saying more, which is exactly :signature_mismatch.

Check.new(ok: false, reason: :signature_mismatch, skew: nil, suggested_unit: nil).freeze
SIGNATURE_MISSING =

"The request carried no signature at all", for a custom verify block to return in place of MISMATCH. Exported because that distinction is only available to a verifier that reads the header itself: a bare falsey return collapses to :signature_mismatch, which on a guessable public path buries the alertable case (a rotated secret, or a URL we rebuild wrong) under ordinary unsigned scanner traffic. :hmac reports it via hmac_check below.

Check.new(ok: false, reason: :signature_missing, skew: nil, suggested_unit: nil).freeze
CREDENTIALS_MISSING =

verify :basic_auth's two verdicts. CREDENTIALS_MISSING covers both "no Authorization at all" and "an Authorization that isn't Basic", but only the second reaches Verify over HTTP: the first is the bare handshake leg, which Endpoint answers with the challenge before verifying (PRO-3148, and see BasicAuth#challenge_required?).

Check.new(ok: false, reason: :credentials_missing, skew: nil, suggested_unit: nil).freeze
CREDENTIALS_MISMATCH =
Check.new(ok: false, reason: :credentials_mismatch, skew: nil, suggested_unit: nil).freeze
NO_TOLERANCE =

Raises on an unrecognized unit. Called eagerly by hmac (independent of whether replay protection is active or the request carries a signature), so a misconfigured unit: is a loud config error rather than a silent 401. Distinguishes "caller omitted tolerance:" (no replay check — the documented default, and what Signature.hmac(secret:, payload:, signature:) relies on) from "caller PASSED a blank tolerance". The latter is a value that came from somewhere — ENV["TOLERANCE"]&.to_i on an unset var is the shape — and silently turning replay protection OFF for it is the one place this gem failed open where everything else fails closed (security audit).

Same stance unit: already takes: default only on absence, never on an explicit blank.

Object.new.freeze

Class Method Summary collapse

Class Method Details

.compute(secret:, payload:, digest: :sha256, encoding: :hex) ⇒ Object

The encoded expected signature for payload. Reused by outbound's Signer::StandardWebhooksSigner.

Raises:

  • (ArgumentError)


125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/axn/webhooks/signature.rb', line 125

def compute(secret:, payload:, digest: :sha256, encoding: :hex)
  # A blank secret is a WEAK KEY, not a failure: "" is a legal HMAC key, so the digest it
  # produces is one any stranger can compute. Guarded at this chokepoint — the lowest layer
  # every signing and verification path funnels through — so the public primitive is safe on
  # its own, not merely when reached via a strategy that happens to check first. The README's
  # own example passes `ENV["WEBHOOK_SECRET"]` straight in, and a set-but-empty env var is
  # routine in k8s ConfigMaps and CI. Never interpolates the value.
  raise ArgumentError, "secret must be a non-empty String (got #{secret.is_a?(String) ? 'an empty String' : secret.class})" \
    unless secret.is_a?(String) && !secret.empty?

  raw = OpenSSL::HMAC.digest(openssl_digest(digest), secret, payload.to_s)
  encode(raw, encoding)
end

.hmac(secret:, payload:, signature:, digest: :sha256, encoding: :hex, prefix: nil, timestamp: nil, tolerance: NO_TOLERANCE, now: nil, unit: AUTO) ⇒ Object

Verify a candidate signature header against the HMAC of payload. signature may hold several whitespace/comma-separated candidates (key rotation); returns true if ANY matches. Never raises on hostile input. rubocop:disable Naming/PredicateMethod -- it IS a predicate, but hmac is the documented public entry point (README, every direct caller); renaming it to hmac? is a breaking change.



88
89
90
91
# File 'lib/axn/webhooks/signature.rb', line 88

def hmac(secret:, payload:, signature:, digest: :sha256, encoding: :hex, prefix: nil,
         timestamp: nil, tolerance: NO_TOLERANCE, now: nil, unit: AUTO)
  hmac_check(secret:, payload:, signature:, digest:, encoding:, prefix:, timestamp:, tolerance:, now:, unit:).ok?
end

.hmac_check(secret:, payload:, signature:, digest: :sha256, encoding: :hex, prefix: nil, timestamp: nil, tolerance: NO_TOLERANCE, now: nil, unit: AUTO) ⇒ Object

Same check as hmac, but returns a Check naming WHY a rejection happened rather than a bare false. hmac is this method's .ok?, so the replay window lives in exactly one place and every caller (both built-in verifiers, and Signature.hmac itself) agrees.



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/axn/webhooks/signature.rb', line 97

def hmac_check(secret:, payload:, signature:, digest: :sha256, encoding: :hex, prefix: nil,
               timestamp: nil, tolerance: NO_TOLERANCE, now: nil, unit: AUTO)
  # Validate unit: unconditionally — a misconfigured unit: is a config error independent of
  # whether replay protection is active or the request happens to carry a signature.
  validate_unit!(unit)
  tolerance = validate_tolerance!(tolerance)

  if tolerance
    now ||= Time.now
    drift = skew(timestamp:, now:, unit:)
    return rejected(:replay_timestamp_invalid) if drift.nil?

    if drift.abs > tolerance.to_i
      # Only asked on the rejection path — it re-runs the window against each other scale.
      return rejected(:replay_window, skew: drift,
                                      suggested_unit: mismatched_unit(timestamp:, tolerance:, now:, unit:))
    end
  end

  return SIGNATURE_MISSING if signature.nil? || signature.to_s.empty?

  expected = compute(secret:, payload:, digest:, encoding:)
  return OK if candidates(signature, prefix:).any? { |candidate| secure_compare(candidate, expected) }

  rejected(:signature_mismatch)
end

.mismatched_unit(timestamp:, tolerance:, now: nil, unit: AUTO) ⇒ Object

Diagnostic: the unit that WOULD have put timestamp inside the window, when unit didn't. nil when unit already fits, when the timestamp is missing/unparseable, or when no scale rescues it -- i.e. nil for a genuine replay, a symbol for a misconfigured unit:. Pure; logging and failure-reason classification belong to the caller.



174
175
176
177
178
179
180
181
# File 'lib/axn/webhooks/signature.rb', line 174

def mismatched_unit(timestamp:, tolerance:, now: nil, unit: AUTO)
  validate_unit!(unit)
  return nil if within_tolerance?(timestamp:, tolerance:, now:, unit:)

  CANONICAL_UNITS.find do |candidate|
    candidate != unit && within_tolerance?(timestamp:, tolerance:, now:, unit: candidate)
  end
end

.secure_compare(candidate, expected) ⇒ Object

Constant-time comparison. False (never raises) on nil or length mismatch.



140
141
142
143
144
145
# File 'lib/axn/webhooks/signature.rb', line 140

def secure_compare(candidate, expected)
  return false if candidate.nil? || expected.nil?
  return false unless candidate.bytesize == expected.bytesize

  OpenSSL.fixed_length_secure_compare(candidate, expected)
end

.skew(timestamp:, now: nil, unit: AUTO) ⇒ Object

Seconds between now and timestamp, signed (positive = timestamp is in the past). nil when the timestamp is absent or unparseable — a distinct condition from "far away", which is why the two get separate rejection reasons. Goes through coerce_epoch, so unit: (including AUTO's per-timestamp inference) applies here exactly as it does to the window.



163
164
165
166
167
168
# File 'lib/axn/webhooks/signature.rb', line 163

def skew(timestamp:, now: nil, unit: AUTO)
  epoch = coerce_epoch(timestamp, unit)
  return nil if epoch.nil?

  (now || Time.now).to_i - epoch
end

.validate_tolerance!(tolerance) ⇒ Object

Raises:

  • (ArgumentError)


251
252
253
254
255
256
257
258
# File 'lib/axn/webhooks/signature.rb', line 251

def validate_tolerance!(tolerance)
  return nil if tolerance.equal?(NO_TOLERANCE)
  return tolerance if tolerance.is_a?(Numeric) && tolerance.positive?

  raise ArgumentError,
        "tolerance must be a positive number of seconds, or omitted entirely to skip the " \
        "replay window (got #{tolerance.inspect})"
end

.within_tolerance?(timestamp:, tolerance:, now: nil, unit: AUTO) ⇒ Boolean

True when timestamp is present, parseable, and within ±tolerance seconds of now.

Returns:

  • (Boolean)


148
149
150
151
152
153
154
155
156
157
# File 'lib/axn/webhooks/signature.rb', line 148

def within_tolerance?(timestamp:, tolerance:, now: nil, unit: AUTO)
  # `tolerance:` is required here, so there is no "omitted" case to honor — any blank value is
  # an explicit one, and `nil.to_i` silently collapsing the window to 0 is the same
  # coerce-instead-of-reject shape the audit flagged elsewhere. Fails closed today (0 rejects
  # nearly everything) rather than open, but it should say so rather than pretend.
  validate_tolerance!(tolerance)

  drift = skew(timestamp:, now:, unit:)
  !drift.nil? && drift.abs <= tolerance
end