Class: Philiprehberger::WebhookSignature::Verifier

Inherits:
Object
  • Object
show all
Defined in:
lib/philiprehberger/webhook_signature/verifier.rb

Overview

Verifies HMAC webhook signatures with replay prevention.

Constant Summary collapse

DEFAULT_TOLERANCE =

5 minutes

300
SUPPORTED_ALGORITHMS =
{
  sha256: 'SHA256',
  sha512: 'SHA512'
}.freeze

Instance Method Summary collapse

Constructor Details

#initialize(secret = nil, algorithm: :sha256, secrets: nil) ⇒ Verifier

Returns a new instance of Verifier.

Parameters:

  • secret (String, nil) (defaults to: nil)

    the shared secret key (single-secret form)

  • algorithm (Symbol) (defaults to: :sha256)

    HMAC digest algorithm (:sha256 or :sha512)

  • secrets (Array<String>, nil) (defaults to: nil)

    an Array of shared secrets to support key rotation



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/philiprehberger/webhook_signature/verifier.rb', line 19

def initialize(secret = nil, algorithm: :sha256, secrets: nil)
  if !secret.nil? && !secrets.nil?
    raise ArgumentError, 'Provide either secret: or secrets:, not both'
  end

  resolved = if secrets.nil?
               raise ArgumentError, 'Secret must be a non-empty string' if secret.nil? || secret.empty?

               [secret]
             else
               unless secrets.is_a?(Array) && !secrets.empty?
                 raise ArgumentError, 'secrets must be a non-empty Array of strings'
               end
               if secrets.any? { |s| s.nil? || s.empty? }
                 raise ArgumentError, 'Each secret must be a non-empty string'
               end

               secrets
             end

  unless SUPPORTED_ALGORITHMS.key?(algorithm)
    raise ArgumentError,
          "Unsupported algorithm: #{algorithm.inspect}. Supported algorithms: #{SUPPORTED_ALGORITHMS.keys.map(&:inspect).join(', ')}"
  end

  @secrets = resolved
  @algorithm = algorithm
  @digest_name = SUPPORTED_ALGORITHMS.fetch(algorithm)
end

Instance Method Details

#valid?(payload, timestamp:, signature:, tolerance: DEFAULT_TOLERANCE) ⇒ Boolean

Boolean wrapper around verify!.

Parameters:

  • payload (String)

    the raw payload body

  • timestamp (Integer)

    the timestamp from the signature

  • signature (String)

    the hex-encoded HMAC signature

  • tolerance (Integer, nil) (defaults to: DEFAULT_TOLERANCE)

    max age in seconds (nil to skip replay check)

Returns:

  • (Boolean)


110
111
112
113
114
# File 'lib/philiprehberger/webhook_signature/verifier.rb', line 110

def valid?(payload, timestamp:, signature:, tolerance: DEFAULT_TOLERANCE)
  verify!(payload, timestamp: timestamp, signature: signature, tolerance: tolerance)
rescue VerificationError
  false
end

#valid_header?(payload, header:, tolerance: DEFAULT_TOLERANCE) ⇒ Boolean

Boolean wrapper around verify_header!.

Parameters:

  • payload (String)

    the raw payload body

  • header (String)

    the header value in “t=TIMESTAMP,v1=SIGNATURE” format

  • tolerance (Integer, nil) (defaults to: DEFAULT_TOLERANCE)

    max age in seconds

Returns:

  • (Boolean)


122
123
124
125
126
# File 'lib/philiprehberger/webhook_signature/verifier.rb', line 122

def valid_header?(payload, header:, tolerance: DEFAULT_TOLERANCE)
  verify_header!(payload, header: header, tolerance: tolerance)
rescue VerificationError
  false
end

#verify(payload, timestamp:, signature:, tolerance: DEFAULT_TOLERANCE) ⇒ Boolean

Verify a payload against a signature.

Parameters:

  • payload (String)

    the raw payload body

  • timestamp (Integer)

    the timestamp from the signature

  • signature (String)

    the hex-encoded HMAC signature

  • tolerance (Integer, nil) (defaults to: DEFAULT_TOLERANCE)

    max age in seconds (nil to skip replay check)

Returns:

  • (Boolean)

    true if valid



56
57
58
59
60
# File 'lib/philiprehberger/webhook_signature/verifier.rb', line 56

def verify(payload, timestamp:, signature:, tolerance: DEFAULT_TOLERANCE)
  return false if tolerance && stale?(timestamp, tolerance)

  @secrets.any? { |s| signature_matches?(payload, timestamp, signature, s) }
end

#verify!(payload, timestamp:, signature:, tolerance: DEFAULT_TOLERANCE) ⇒ true

Verify and raise on failure.

Parameters:

  • payload (String)

    the raw payload body

  • timestamp (Integer)

    the timestamp from the signature

  • signature (String)

    the hex-encoded HMAC signature

  • tolerance (Integer, nil) (defaults to: DEFAULT_TOLERANCE)

    max age in seconds (nil to skip replay check)

Returns:

  • (true)

Raises:



80
81
82
83
84
85
86
87
88
89
90
# File 'lib/philiprehberger/webhook_signature/verifier.rb', line 80

def verify!(payload, timestamp:, signature:, tolerance: DEFAULT_TOLERANCE)
  if tolerance && stale?(timestamp, tolerance)
    raise VerificationError, "Signature timestamp is too old (tolerance: #{tolerance}s)"
  end

  unless @secrets.any? { |s| signature_matches?(payload, timestamp, signature, s) }
    raise VerificationError, 'Signature mismatch'
  end

  true
end

#verify_header(payload, header:, tolerance: DEFAULT_TOLERANCE) ⇒ Boolean

Verify a signature header string.

Parameters:

  • payload (String)

    the raw payload body

  • header (String)

    the header value in “t=TIMESTAMP,v1=SIGNATURE” format

  • tolerance (Integer, nil) (defaults to: DEFAULT_TOLERANCE)

    max age in seconds

Returns:

  • (Boolean)

    true if valid



68
69
70
71
72
73
# File 'lib/philiprehberger/webhook_signature/verifier.rb', line 68

def verify_header(payload, header:, tolerance: DEFAULT_TOLERANCE)
  parsed = parse_header(header)
  return false unless parsed

  verify(payload, timestamp: parsed[:timestamp], signature: parsed[:signature], tolerance: tolerance)
end

#verify_header!(payload, header:, tolerance: DEFAULT_TOLERANCE) ⇒ true

Verify a header string or raise on failure.

Parameters:

  • payload (String)

    the raw payload body

  • header (String)

    the header value in “t=TIMESTAMP,v1=SIGNATURE” format

  • tolerance (Integer, nil) (defaults to: DEFAULT_TOLERANCE)

    max age in seconds

Returns:

  • (true)

Raises:



99
100
101
102
103
104
# File 'lib/philiprehberger/webhook_signature/verifier.rb', line 99

def verify_header!(payload, header:, tolerance: DEFAULT_TOLERANCE)
  parsed = parse_header(header)
  raise VerificationError, 'Invalid header format' unless parsed

  verify!(payload, timestamp: parsed[:timestamp], signature: parsed[:signature], tolerance: tolerance)
end