Module: ScalarRubyTest::Webhooks

Defined in:
lib/amritk-scalar-test/webhooks.rb

Defined Under Namespace

Classes: VerificationError, WebhookEvent

Constant Summary collapse

EVENT_NAMES =
[].freeze

Class Method Summary collapse

Class Method Details

.decode_signature(candidate) ⇒ Object



76
77
78
79
80
81
# File 'lib/amritk-scalar-test/webhooks.rb', line 76

def self.decode_signature(candidate)
  return [candidate].pack("H*") if candidate.match?(/\A[0-9a-f]+\z/i) && candidate.length.even?
  Base64.strict_decode64(candidate)
rescue ArgumentError
  nil
end

.header_value(headers, name) ⇒ Object



107
108
109
# File 'lib/amritk-scalar-test/webhooks.rb', line 107

def self.header_value(headers, name)
  headers.find { |key, _| key.to_s.downcase == name.downcase }&.last
end

.parse_event(payload, headers: {}, secret: nil, signature_header: "webhook-signature") ⇒ Object

Raises:



59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/amritk-scalar-test/webhooks.rb', line 59

def self.parse_event(payload, headers: {}, secret: nil, signature_header: "webhook-signature")
  if secret
    signature = header_value(headers, signature_header)
    raise VerificationError, "missing webhook signature header" unless signature
    raise VerificationError, "webhook signature verification failed" unless verify_signature(payload, secret, signature)
  end
  data = JSON.parse(payload)
  type = data.is_a?(Hash) ? data["type"] : nil
  raise VerificationError, "webhook payload is missing discriminator: type" unless type.is_a?(String)
  raise VerificationError, "unknown webhook event type: #{type}" if EVENT_NAMES.any? && !EVENT_NAMES.include?(type)
  WebhookEvent.new(type: type, payload: data)
end

.secure_compare(left, right) ⇒ Object



102
103
104
105
# File 'lib/amritk-scalar-test/webhooks.rb', line 102

def self.secure_compare(left, right)
  return false unless left.bytesize == right.bytesize
  OpenSSL.fixed_length_secure_compare(left, right)
end

.signature_candidates(header) ⇒ Object



72
73
74
# File 'lib/amritk-scalar-test/webhooks.rb', line 72

def self.signature_candidates(header)
  header.to_s.split(/[,\s]+/).map { |part| part.sub(/\A(sha256|v0|v1|sig|signature)=/i, "").strip }.reject { |part| part.empty? || part.match?(/\A(t|ts|timestamp)=/i) }
end

.verify_asymmetric(payload, signature_header, public_key, algorithm, verifier) ⇒ Object

Raises:



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/amritk-scalar-test/webhooks.rb', line 83

def self.verify_asymmetric(payload, signature_header, public_key, algorithm, verifier)
  raise VerificationError, "webhook public key is required" if public_key.to_s.empty?
  signature_candidates(signature_header).any? do |candidate|
    decoded = decode_signature(candidate)
    next false unless decoded
    return true if verifier&.call(payload, decoded, public_key, algorithm)
    key = OpenSSL::PKey.read(public_key)
    digest = OpenSSL::Digest.new("SHA256")
    case algorithm
    when "rsa-sha256", "ecdsa-sha256"
      key.verify(digest, decoded, payload)
    when "ed25519"
      raise VerificationError, "Ed25519 verification requires a verifier callback"
    else
      raise VerificationError, "unsupported webhook signature algorithm: #{algorithm}"
    end
  end
end

.verify_ecdsa_sha256(payload, public_key, signature_header) ⇒ Object



51
52
53
# File 'lib/amritk-scalar-test/webhooks.rb', line 51

def self.verify_ecdsa_sha256(payload, public_key, signature_header)
  verify_asymmetric(payload, signature_header, public_key, "ecdsa-sha256", nil)
end

.verify_ed25519(payload, public_key, signature_header, verifier) ⇒ Object



55
56
57
# File 'lib/amritk-scalar-test/webhooks.rb', line 55

def self.verify_ed25519(payload, public_key, signature_header, verifier)
  verify_asymmetric(payload, signature_header, public_key, "ed25519", verifier)
end

.verify_rsa_sha256(payload, public_key, signature_header) ⇒ Object



47
48
49
# File 'lib/amritk-scalar-test/webhooks.rb', line 47

def self.verify_rsa_sha256(payload, public_key, signature_header)
  verify_asymmetric(payload, signature_header, public_key, "rsa-sha256", nil)
end

.verify_signature(payload, secret, signature_header, algorithm: "hmac-sha256", public_key: nil, timestamp: nil, tolerance: nil, replay_store: nil, replay_id: nil, asymmetric_verifier: nil) ⇒ Object

Raises:



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/amritk-scalar-test/webhooks.rb', line 24

def self.verify_signature(
  payload, secret, signature_header,
  algorithm: "hmac-sha256", public_key: nil,
  timestamp: nil, tolerance: nil,
  replay_store: nil, replay_id: nil, asymmetric_verifier: nil
)
  return verify_asymmetric(payload, signature_header, public_key || secret.to_s, algorithm, asymmetric_verifier) unless algorithm == "hmac-sha256"
  secrets = secret.is_a?(Array) ? secret : [secret]
  raise VerificationError, "webhook secret is required" if secrets.empty? || secrets.first.to_s.empty?
  if timestamp && tolerance && (Time.now.to_i - timestamp.to_i).abs > tolerance.to_i
    raise VerificationError, "webhook timestamp is outside the allowed tolerance"
  end
  if replay_store
    id = replay_id || signature_header
    raise VerificationError, "webhook replay detected" if replay_store.include?(id)
    replay_store << id if replay_store.respond_to?(:<<)
  end
  signature_candidates(signature_header).any? do |candidate|
    decoded = decode_signature(candidate)
    decoded && secrets.any? { |item| secure_compare(decoded, OpenSSL::HMAC.digest("SHA256", item.to_s, payload)) }
  end
end