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 insideencodemid-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 addedreasonto 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
verifyblock 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
verifyblock 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.:hmacreports 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 misconfiguredunit:is a loud config error rather than a silent 401. Distinguishes "caller omitted tolerance:" (no replay check — the documented default, and whatSignature.hmac(secret:, payload:, signature:)relies on) from "caller PASSED a blank tolerance". The latter is a value that came from somewhere —ENV["TOLERANCE"]&.to_ion 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
-
.compute(secret:, payload:, digest: :sha256, encoding: :hex) ⇒ Object
The encoded expected signature for
payload. -
.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. -
.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. -
.mismatched_unit(timestamp:, tolerance:, now: nil, unit: AUTO) ⇒ Object
Diagnostic: the unit that WOULD have put
timestampinside the window, whenunitdidn't. -
.secure_compare(candidate, expected) ⇒ Object
Constant-time comparison.
-
.skew(timestamp:, now: nil, unit: AUTO) ⇒ Object
Seconds between
nowandtimestamp, signed (positive =timestampis in the past). - .validate_tolerance!(tolerance) ⇒ Object
-
.within_tolerance?(timestamp:, tolerance:, now: nil, unit: AUTO) ⇒ Boolean
True when
timestampis present, parseable, and within ±tolerance seconds ofnow.
Class Method Details
.compute(secret:, payload:, digest: :sha256, encoding: :hex) ⇒ Object
The encoded expected signature for payload. Reused by outbound's Signer::StandardWebhooksSigner.
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(, unit) return nil if epoch.nil? (now || Time.now).to_i - epoch end |
.validate_tolerance!(tolerance) ⇒ Object
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.
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 |