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
- .decode_signature(candidate) ⇒ Object
- .header_value(headers, name) ⇒ Object
- .parse_event(payload, headers: {}, secret: nil, signature_header: "webhook-signature") ⇒ Object
- .secure_compare(left, right) ⇒ Object
- .signature_candidates(header) ⇒ Object
- .verify_asymmetric(payload, signature_header, public_key, algorithm, verifier) ⇒ Object
- .verify_ecdsa_sha256(payload, public_key, signature_header) ⇒ Object
- .verify_ed25519(payload, public_key, signature_header, verifier) ⇒ Object
- .verify_rsa_sha256(payload, public_key, signature_header) ⇒ Object
- .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
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
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
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
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 && tolerance && (Time.now.to_i - .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 |