# frozen_string_literal: truerequire"openssl"require"base64"require"time"moduleAttiomoduleUtil# Verifies webhook signatures from Attio to ensure authenticityclassWebhookSignature# HTTP header containing the webhook signatureSIGNATURE_HEADER="x-attio-signature"# HTTP header containing the request timestampTIMESTAMP_HEADER="x-attio-timestamp"TOLERANCE_SECONDS=300# 5 minutesclass<<self# Verify webhook signature (raises exception on failure)defverify!(payload:,signature:,timestamp:,secret:,tolerance: TOLERANCE_SECONDS)validate_inputs!(payload,signature,timestamp,secret)# Check timestamp to prevent replay attacksverify_timestamp!(timestamp,tolerance)# Calculate expected signatureexpected_signature=calculate_signature(payload,timestamp,secret)# Constant-time comparison to prevent timing attacksraiseSignatureVerificationError,"Invalid signature"unlesssecure_compare(signature,expected_signature)rescue=>eraiseSignatureVerificationError,"Webhook signature verification failed: #{e.message}"end# Verify webhook signature (returns boolean)defverify(payload:,signature:,timestamp:,secret:,tolerance: TOLERANCE_SECONDS)verify!(payload: payload,signature: signature,timestamp: timestamp,secret: secret,tolerance: tolerance)truerescueSignatureVerificationErrorfalseend# Calculate signature for a payloaddefcalculate_signature(payload,timestamp,secret)# Ensure payload is a stringpayload_string=payload.is_a?(String)?payload:JSON.generate(payload)# Create the signed payloadsigned_payload="#{timestamp}.#{payload_string}"# Calculate HMAChmac=OpenSSL::HMAC.hexdigest("SHA256",secret,signed_payload)# Return in the format Attio uses"v1=#{hmac}"end# Extract signature from headersdefextract_from_headers(headers)signature=headers[SIGNATURE_HEADER]||headers[SIGNATURE_HEADER.upcase]||headers[SIGNATURE_HEADER.tr("-","_").upcase]timestamp=headers[TIMESTAMP_HEADER]||headers[TIMESTAMP_HEADER.upcase]||headers[TIMESTAMP_HEADER.tr("-","_").upcase]raiseSignatureVerificationError,"Missing signature header: #{SIGNATURE_HEADER}"unlesssignatureraiseSignatureVerificationError,"Missing timestamp header: #{TIMESTAMP_HEADER}"unlesstimestamp{signature: signature,timestamp: timestamp}endprivatedefvalidate_inputs!(payload,signature,timestamp,secret)raiseArgumentError,"Payload cannot be nil"ifpayload.nil?raiseArgumentError,"Signature cannot be nil or empty"ifsignature.nil?||signature.empty?raiseArgumentError,"Timestamp cannot be nil or empty"iftimestamp.nil?||timestamp.to_s.empty?raiseArgumentError,"Secret cannot be nil or empty"ifsecret.nil?||secret.empty?enddefverify_timestamp!(timestamp,tolerance)timestamp_int=timestamp.to_icurrent_time=Time.now.to_iiftimestamp_int<(current_time-tolerance)raiseSignatureVerificationError,"Timestamp too old"endiftimestamp_int>(current_time+tolerance)raiseSignatureVerificationError,"Timestamp too far in the future"endenddefsecure_compare(a,b)returnfalseunlessa.bytesize==b.bytesize# Use constant-time comparisonres=0a.bytes.zip(b.bytes){|x,y|res|=x^y}res==0endend# Helper class for webhook handlersclassHandlerattr_reader:secretdefinitialize(secret)@secret=secretvalidate_secret!end# Verify a requestdefverify_request(request)headers=extract_headers(request)body=extract_body(request)signature_data=WebhookSignature.extract_from_headers(headers)WebhookSignature.verify!(payload: body,signature: signature_data[:signature],timestamp: signature_data[:timestamp],secret: secret)end# Parse and verify a requestdefparse_and_verify(request)verify_request(request)body=extract_body(request)JSON.parse(body,symbolize_names: true)rescueJSON::ParserError=>eraiseSignatureVerificationError,"Invalid JSON payload: #{e.message}"endprivatedefvalidate_secret!raiseArgumentError,"Webhook secret is required"ifsecret.nil?||secret.empty?enddefextract_headers(request)caserequestwhenHashrequest[:headers]||request["headers"]||{}whendefined?(Rack::Request)&&Rack::Requestrequest.env.select{|k,_|k.start_with?("HTTP_")}.transform_keys{|k|k.sub(/^HTTP_/,"").downcase}whendefined?(ActionDispatch::Request)&&ActionDispatch::Requestrequest.headers.to_helseraiseArgumentError,"Unsupported request type: #{request.class}"endenddefextract_body(request)caserequestwhenHashrequest[:body]||request["body"]||""whendefined?(Rack::Request)&&Rack::Requestrequest.body.rewindrequest.body.readwhendefined?(ActionDispatch::Request)&&ActionDispatch::Requestrequest.raw_postelseraiseArgumentError,"Unsupported request type: #{request.class}"endendend# Raised when webhook signature verification failsclassSignatureVerificationError<StandardError;endendendend