Module: Axn::Webhooks::Verifiers::StandardWebhooks

Defined in:
lib/axn/webhooks/verifiers/standard_webhooks.rb

Overview

Standard Webhooks (Svix) scheme. Secret is whsec_<base64>; the signed string is id.timestamp.body; the signature header holds space-separated v1,<base64sig> candidates; a ±tolerance replay window applies.

Class Method Summary collapse

Class Method Details

.decode_secret(secret) ⇒ Object

NOT secret.to_s (Codex round-4 finding): coercing here is what turned a nil secret — an unset ENV var, or a header(...) resolver on an absent header — into "", which is valid Base64 and decodes to an EMPTY HMAC key. That is an authentication bypass, since anyone who knows the credential is missing can sign with the empty key. Callers must hand this a String; secret_key guards the type, and require_secret_key! is the safe entry point for anything resolved at request time.

Raises:

  • (ArgumentError)


20
21
22
23
24
# File 'lib/axn/webhooks/verifiers/standard_webhooks.rb', line 20

def decode_secret(secret)
  raise ArgumentError, "secret must be a String (got #{secret.class})" unless secret.is_a?(String)

  Base64.strict_decode64(secret.delete_prefix("whsec_"))
end

.describe_secret(secret) ⇒ Object

Describes a rejected secret's SHAPE for an error message, never its bytes: this can be raised per delivery attempt on the outbound side, and would otherwise flow the live signing credential into whatever Axn.config.on_exception is wired to.



54
55
56
57
58
59
# File 'lib/axn/webhooks/verifiers/standard_webhooks.rb', line 54

def describe_secret(secret)
  return secret.class.name unless secret.is_a?(String)
  return "a #{secret.length}-char String not prefixed with whsec_" unless secret.start_with?("whsec_")

  "a whsec_-prefixed String that failed to decode"
end

.extract_v1(header) ⇒ Object

Keep only v1,<sig> candidates, stripped to the bare base64 signature. Done here (not via Signature's generic splitter) because that splitter treats the comma as a separator and would break v1,<sig> into two tokens.



68
69
70
# File 'lib/axn/webhooks/verifiers/standard_webhooks.rb', line 68

def extract_v1(header)
  header.to_s.split(/\s+/).select { |t| t.start_with?("v1,") }.map { |t| t.delete_prefix("v1,") }
end

.invalid_secret_message(declaration, secret) ⇒ Object



61
62
63
# File 'lib/axn/webhooks/verifiers/standard_webhooks.rb', line 61

def invalid_secret_message(declaration, secret)
  "#{declaration} secret must be a whsec_<base64> value (got #{describe_secret(secret)})"
end

.require_secret_key!(secret) ⇒ Object

The raw key for a secret resolved at REQUEST time, raising if it isn't usable. Loud on purpose: a secret that has gone missing is a misconfiguration worth paging on, and must never degrade into a quiet :signature_mismatch that reads like a rotated key.



29
30
31
# File 'lib/axn/webhooks/verifiers/standard_webhooks.rb', line 29

def require_secret_key!(secret)
  secret_key(secret) || raise(Axn::Webhooks::Error, invalid_secret_message("verify :standard_webhooks", secret))
end

.secret_key(secret) ⇒ Object

The raw HMAC key behind a whsec_<base64> secret, or nil if the value isn't one. The single source of truth for "is this a usable Standard Webhooks secret", shared by inbound's declaration-time check and outbound's (Outbound::Signer), so the two can't drift.

The whsec_ prefix check is what carries this: an unprefixed secret is very often still VALID Base64 — a 32-char hex secret is, and that's a common shape — so it would decode silently to the wrong key rather than raising. The rescue is scoped to the decode alone; a caller that RESOLVES a secret (from a callable or a secret store) must do so outside this method, or its own ArgumentError would be swallowed and rewritten.



42
43
44
45
46
47
48
49
# File 'lib/axn/webhooks/verifiers/standard_webhooks.rb', line 42

def secret_key(secret)
  return nil unless secret.is_a?(String) && secret.start_with?("whsec_")

  key = decode_secret(secret)
  key.empty? ? nil : key
rescue ArgumentError
  nil
end