From 78d8eedc40ecda0fca964f64d5f6fd34ddb15023 Mon Sep 17 00:00:00 2001 From: superchilled Date: Thu, 26 Oct 2023 17:02:52 +0100 Subject: [PATCH 1/3] Implementing webhook token verification functionality --- lib/vonage/jwt.rb | 4 ++++ lib/vonage/messaging.rb | 4 ++++ lib/vonage/voice.rb | 4 ++++ test/vonage/jwt_test.rb | 14 +++++++++++++- test/vonage/messaging_test.rb | 25 ++++++++++++++++++++++++- test/vonage/test.rb | 12 ++++++++++++ test/vonage/voice_test.rb | 23 +++++++++++++++++++++++ vonage.gemspec | 2 +- 8 files changed, 85 insertions(+), 3 deletions(-) diff --git a/lib/vonage/jwt.rb b/lib/vonage/jwt.rb index f8c0ffc2..176c97e4 100644 --- a/lib/vonage/jwt.rb +++ b/lib/vonage/jwt.rb @@ -39,5 +39,9 @@ def self.generate(payload, private_key = nil) payload[:private_key] = private_key if private_key && !payload[:private_key] @token = Vonage::JWTBuilder.new(payload).jwt.generate end + + def self.verify_hs256_signature(token:, signature_secret:) + verify_signature(token, signature_secret, 'HS256') + end end end diff --git a/lib/vonage/messaging.rb b/lib/vonage/messaging.rb index 93520ee5..ee95f69e 100644 --- a/lib/vonage/messaging.rb +++ b/lib/vonage/messaging.rb @@ -25,5 +25,9 @@ class Messaging < Namespace def send(params) request('/v1/messages', params: params, type: Post) end + + def verify_webhook_token(token:, signature_secret: @config.signature_secret) + JWT.verify_hs256_signature(token: token, signature_secret: signature_secret) + end end end diff --git a/lib/vonage/voice.rb b/lib/vonage/voice.rb index 9bb111c3..ddb69caf 100644 --- a/lib/vonage/voice.rb +++ b/lib/vonage/voice.rb @@ -279,5 +279,9 @@ def talk def dtmf @dtmf ||= DTMF.new(@config) end + + def verify_webhook_token(token:, signature_secret: @config.signature_secret) + JWT.verify_hs256_signature(token: token, signature_secret: signature_secret) + end end end diff --git a/test/vonage/jwt_test.rb b/test/vonage/jwt_test.rb index 667df5b8..e823e51d 100644 --- a/test/vonage/jwt_test.rb +++ b/test/vonage/jwt_test.rb @@ -1,7 +1,7 @@ # typed: false require_relative './test' -class Vonage::JWTTest < Minitest::Test +class Vonage::JWTTest < Vonage::Test def private_key @private_key ||= File.read('test/private_key.txt') end @@ -111,4 +111,16 @@ def test_no_exception_with_private_key_in_second_argument assert token end + + def test_verify_hs256_signature_with_valid_secret + verification = Vonage::JWT.verify_hs256_signature(token: sample_webhook_token, signature_secret: sample_valid_signature_secret) + + assert_equal(true, verification) + end + + def test_verify_hs256_signature_with_invalid_secret + verification = Vonage::JWT.verify_hs256_signature(token: sample_webhook_token, signature_secret: sample_invalid_signature_secret) + + assert_equal(false, verification) + end end diff --git a/test/vonage/messaging_test.rb b/test/vonage/messaging_test.rb index 6bb17c35..bbdffdb6 100644 --- a/test/vonage/messaging_test.rb +++ b/test/vonage/messaging_test.rb @@ -22,7 +22,30 @@ def test_send_method stub_request(:post, messaging_uri).with(request(body: params)).to_return(response) message = Vonage::Messaging::Message.sms(message: "Hello world!") - + assert_kind_of Vonage::Response, messaging.send(to: "447700900000", from: "447700900001", **message) end + + def test_verify_webhook_token_method_with_valid_secret_passed_in + verification = messaging.verify_webhook_token(token: sample_webhook_token, signature_secret: sample_valid_signature_secret) + + assert_equal(true, verification) + end + + def test_verify_webhook_token_method_with_valid_secret_in_config + config.signature_secret = sample_valid_signature_secret + verification = messaging.verify_webhook_token(token: sample_webhook_token) + + assert_equal(true, verification) + end + + def test_verify_webhook_token_method_with_invalid_secret + verification = messaging.verify_webhook_token(token: sample_webhook_token, signature_secret: sample_invalid_signature_secret) + + assert_equal(false, verification) + end + + def test_verify_webhook_token_method_with_no_token + assert_raises(ArgumentError) { messaging.verify_webhook_token } + end end diff --git a/test/vonage/test.rb b/test/vonage/test.rb index 132b7b38..67607101 100644 --- a/test/vonage/test.rb +++ b/test/vonage/test.rb @@ -59,6 +59,18 @@ def private_key ) end + def sample_webhook_token + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1ODc0OTQ5NjIsImp0aSI6ImM1YmE4ZjI0LTFhMTQtNGMxMC1iZmRmLTNmYmU4Y2U1MTFiNSIsImlzcyI6IlZvbmFnZSIsInBheWxvYWRfaGFzaCI6ImQ2YzBlNzRiNTg1N2RmMjBlM2I3ZTUxYjMwYzBjMmE0MGVjNzNhNzc4NzliNmYwNzRkZGM3YTIzMTdkZDAzMWIiLCJhcGlfa2V5IjoiYTFiMmMzZCIsImFwcGxpY2F0aW9uX2lkIjoiYWFhYWFhYWEtYmJiYi1jY2NjLWRkZGQtMDEyMzQ1Njc4OWFiIn0.JQRKi1d0SQitmjPINfTWMpt3XZkGsLbD7EjCdXoNSbk" + end + + def sample_valid_signature_secret + "ZYtdTtGV3BCFN7tWmOWr1md66XsquMggr4W2cTtXtcPgfnI0Xw" + end + + def sample_invalid_signature_secret + "ZYtdTtGV3BCFN7tWmOWr1md66XsquMggr4W2cTtXtcPgf55555" + end + def config @config ||= Vonage::Config.new.merge( diff --git a/test/vonage/voice_test.rb b/test/vonage/voice_test.rb index bf11d9c9..368a16d1 100644 --- a/test/vonage/voice_test.rb +++ b/test/vonage/voice_test.rb @@ -144,4 +144,27 @@ def test_talk_method def test_dtmf_method assert_kind_of Vonage::Voice::DTMF, calls.dtmf end + + def test_verify_webhook_token_method_with_valid_secret_passed_in + verification = calls.verify_webhook_token(token: sample_webhook_token, signature_secret: sample_valid_signature_secret) + + assert_equal(true, verification) + end + + def test_verify_webhook_token_method_with_valid_secret_in_config + config.signature_secret = sample_valid_signature_secret + verification = calls.verify_webhook_token(token: sample_webhook_token) + + assert_equal(true, verification) + end + + def test_verify_webhook_token_method_with_invalid_secret + verification = calls.verify_webhook_token(token: sample_webhook_token, signature_secret: sample_invalid_signature_secret) + + assert_equal(false, verification) + end + + def test_verify_webhook_token_method_with_no_token + assert_raises(ArgumentError) { calls.verify_webhook_token } + end end diff --git a/vonage.gemspec b/vonage.gemspec index 1013d3db..87538019 100644 --- a/vonage.gemspec +++ b/vonage.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |s| s.summary = 'This is the Ruby Server SDK for Vonage APIs. To use it you\'ll need a Vonage account. Sign up for free at https://www.vonage.com' s.files = Dir.glob('lib/**/*.rb') + %w(LICENSE.txt README.md vonage.gemspec) s.required_ruby_version = '>= 2.5.0' - s.add_dependency('vonage-jwt', '~> 0.1.3') + s.add_dependency('vonage-jwt', '~> 0.2.0') s.add_dependency('zeitwerk', '~> 2', '>= 2.2') s.add_dependency('sorbet-runtime', '~> 0.5') s.add_dependency('multipart-post', '~> 2.0') From a4ba1571829efdd695028dd8324435d7dbf7d61a Mon Sep 17 00:00:00 2001 From: superchilled Date: Fri, 27 Oct 2023 15:30:21 +0100 Subject: [PATCH 2/3] Implementing SMS#verify_webhook_sig method --- lib/vonage/signature.rb | 10 +++++----- lib/vonage/sms.rb | 11 +++++++++++ test/vonage/sms_test.rb | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/lib/vonage/signature.rb b/lib/vonage/signature.rb index a9621d50..e1623097 100644 --- a/lib/vonage/signature.rb +++ b/lib/vonage/signature.rb @@ -27,24 +27,24 @@ def initialize(config) # # @see https://developer.nexmo.com/concepts/guides/signing-messages # - def check(params, signature_method: @config.signature_method) + def check(params, signature_secret: @config.signature_secret, signature_method: @config.signature_method) params = params.dup signature = params.delete('sig') - ::JWT::Algos::Hmac::SecurityUtils.secure_compare(signature, digest(params, signature_method)) + ::JWT::Algos::Hmac::SecurityUtils.secure_compare(signature, digest(params, signature_secret, signature_method)) end private - def digest(params, signature_method) + def digest(params, signature_secret, signature_method) digest_string = params.sort.map { |k, v| "&#{k}=#{v.tr('&=', '_')}" }.join case signature_method when 'md5', 'sha1', 'sha256', 'sha512' - OpenSSL::HMAC.hexdigest(signature_method, @config.signature_secret, digest_string).upcase + OpenSSL::HMAC.hexdigest(signature_method, signature_secret, digest_string).upcase when 'md5hash' - Digest::MD5.hexdigest("#{digest_string}#{@config.signature_secret}") + Digest::MD5.hexdigest("#{digest_string}#{signature_secret}") else raise ArgumentError, "Unknown signature algorithm: #{signature_method}. Expected: md5hash, md5, sha1, sha256, or sha512." end diff --git a/lib/vonage/sms.rb b/lib/vonage/sms.rb index c4fa2a29..7e7f8f2b 100644 --- a/lib/vonage/sms.rb +++ b/lib/vonage/sms.rb @@ -91,11 +91,22 @@ def send(params) response end + def verify_webhook_sig(webhook_params:, signature_secret: @config.signature_secret, signature_method: @config.signature_method) + signature.check(webhook_params, signature_secret: signature_secret, signature_method: signature_method) + end + private sig { params(text: String).returns(T::Boolean) } def unicode?(text) !Vonage::GSM7.encoded?(text) end + + # @return [Signature] + # + sig { returns(T.nilable(Vonage::Signature)) } + def signature + @signature ||= T.let(Signature.new(@config), T.nilable(Vonage::Signature)) + end end end diff --git a/test/vonage/sms_test.rb b/test/vonage/sms_test.rb index 3b95fa81..88902736 100644 --- a/test/vonage/sms_test.rb +++ b/test/vonage/sms_test.rb @@ -24,6 +24,19 @@ def error_response } end + def signed_webhook_params + { + 'message-timestamp' => '2013-11-21 15:27:30', + 'messageId' => '020000001B0FE827', + 'msisdn' => '14843472194', + 'text' => 'Test again', + 'timestamp' => '1385047698', + 'to' => '13239877404', + 'type' => 'text', + 'sig' => 'd2e7b1dc968737c5998ad624e02f90b7' + } + end + def test_send_method params = {from: 'Ruby', to: msisdn, text: 'Hello from Ruby!'} @@ -60,4 +73,14 @@ def test_warn_when_sending_unicode_without_type assert_includes io.string, 'WARN -- : Sending unicode text SMS without setting the type parameter' end + + def test_verify_webhook_sig_method + check = sms.verify_webhook_sig( + webhook_params: signed_webhook_params, + signature_secret: 'my_secret_key_for_testing', + signature_method: 'md5hash' + ) + + assert_equal(true, check) + end end From 34e8f9e972ae19cf995f0093f702c5d178b908f7 Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 31 Oct 2023 10:54:33 +0000 Subject: [PATCH 3/3] Updating docs comments and README --- README.md | 110 ++++++++++++++++++++++++++++++++++++---- lib/vonage/jwt.rb | 13 +++++ lib/vonage/messaging.rb | 7 +++ lib/vonage/sms.rb | 9 ++++ lib/vonage/voice.rb | 7 +++ 5 files changed, 137 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4dadaf8f..a70fea72 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ claims = { token = Vonage::JWT.generate(claims) client = Vonage::Client.new(token: token) -```` +``` Documentation for the Vonage Ruby JWT generator gem can be found at [https://www.rubydoc.info/github/nexmo/nexmo-jwt-ruby](https://www.rubydoc.info/github/nexmo/nexmo-jwt-ruby). @@ -176,23 +176,115 @@ The documentation outlines all the possible parameters you can use to customize ## Webhook signatures -To check webhook signatures you'll also need to specify the `signature_secret` option. For example: +Certain Vonage APIs provide signed [webhooks](https://developer.vonage.com/en/getting-started/concepts/webhooks) as a means of verifying the origin of the webhooks. The exact signing mechanism varies depending on the API. + +### Signature in Request Body + +The [SMS API](https://developer.vonage.com/en/messaging/sms/overview) signs the webhook request using a hash digest. This is assigned to a `sig` parameter in the request body. + +You can verify the webhook request using the `Vonage::SMS#verify_webhook_sig` method. As well as the **request params** from the received webhook, the method also needs access to the signature secret associated with the Vonage account (available from the [Vonage Dashboard](https://dashboard.nexmo.com/settings)), and the signature method used for signing (e.g. `sha512`), again this is based on thes setting in the Dashboard. + +There are a few different ways of providing these values to the method: + +1. Pass all values to the method invocation. + +```ruby +client = Vonage::Client.new + +client.sms.verify_webhook_sig( + webhook_params: params, + signature_secret: 'secret', + signature_method: 'sha512' +) # => returns true if the signature is valid, false otherwise +``` + +2. Set `signature_secret` and `signature_method` at `Client` instantiation. + +```ruby +client = Vonage::Client.new( + signature_secret: 'secret', + signature_method: 'sha512' +) + +client.sms.verify_webhook_sig(webhook_params: params) # => returns true if the signature is valid, false otherwise +``` + +3. Set `signature_secret` and `signature_method` on the `Config` object. ```ruby client = Vonage::Client.new client.config.signature_secret = 'secret' client.config.signature_method = 'sha512' -if client.signature.check(request.GET) - # valid signature -else - # invalid signature -end +client.sms.verify_webhook_sig(webhook_params: params) # => returns true if the signature is valid, false otherwise +``` + +4. Set `signature_secret` and `signature_method` as environment variables named `VONAGE_SIGNATURE_SECRET` and `VONAGE_SIGNATURE_METHOD` + +```ruby +client = Vonage::Client.new + +client.sms.verify_webhook_sig(webhook_params: params) # => returns true if the signature is valid, false otherwise +``` + +**Note:** Webhook signing for the SMS API is not switched on by default. You'll need to contact support@vonage.com to enable message signing on your account. + +### Signed JWT in Header + +The [Voice API](https://developer.vonage.com/en/voice/voice-api/overview) and [Messages API](https://developer.vonage.com/en/messages/overview) both include an `Authorization` header in their webhook requests. The value of this header includes a JSON Web Token (JWT) signed using the Signature Secret associated with your Vonage account. + +The `Vonage::Voice` and `Vonage::Messaging` classes both define a `verify_webhook_token` method which can be used to verify the JWT received in the webhook `Authorization` header. + +To verify the JWT, you'll first need to extract it from the `Authorization` header. The header value will look something like the following: + +```ruby +"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1OTUyN" # remainder of token omitted for brevity +``` + +Note: we are only interested in the token itself, which comes *after* the word `Bearer` and the space. + +Once you have extrated the token, you can pass it to the `verify_webhook_token` method in order to verify it. + +The method also needs access to the the method also needs access to the signature secret associated with the Vonage account (available from the [Vonage Dashboard](https://dashboard.nexmo.com/settings)). There are a few different ways of providing this value to the method: + +1. Pass all values to the method invocation. + +```ruby +client = Vonage::Client.new + +client.voice.verify_webhook_token( + token: extracted_token, + signature_secret: 'secret' +) # => returns true if the token is valid, false otherwise ``` -Alternatively you can set the `VONAGE_SIGNATURE_SECRET` environment variable. +2. Set `signature_secret` at `Client` instantiation. + +```ruby +client = Vonage::Client.new( + signature_secret: 'secret' +) -Note: you'll need to contact support@nexmo.com to enable message signing on your account. +client.voice.verify_webhook_token(token: extracted_token) # => returns true if the token is valid, false otherwise +``` + +3. Set `signature_secret` on the `Config` object. + +```ruby +client = Vonage::Client.new +client.config.signature_secret = 'secret' +client.config.signature_method = 'sha512' + +client.voice.verify_webhook_token(token: extracted_token) # => returns true if the token is valid, false otherwise +``` + +4. Set `signature_secret` as an environment variable named `VONAGE_SIGNATURE_SECRET` + +```ruby +client = Vonage::Client.new + +client.voice.verify_webhook_token(token: extracted_token) # => returns true if the token is valid, false otherwise +``` ## Pagination diff --git a/lib/vonage/jwt.rb b/lib/vonage/jwt.rb index 176c97e4..d6babe61 100644 --- a/lib/vonage/jwt.rb +++ b/lib/vonage/jwt.rb @@ -40,6 +40,19 @@ def self.generate(payload, private_key = nil) @token = Vonage::JWTBuilder.new(payload).jwt.generate end + # Validate a JSON Web Token from a Vonage Webhook. + # + # Certain Vonage APIs include a JWT signed with a user's account signature secret in + # the Authorization header of Webhook requests. This method can be used to verify that those requests originate + # from the Vonage API. + # + # @param [String, required] :token The JWT from the Webhook's Authorization header + # @param [String, required] :signature_secret The account signature secret + # + # @return [Boolean] true, if the JWT is verified, false otherwise + # + # @see https://developer.vonage.com/en/getting-started/concepts/webhooks#decoding-signed-webhooks + # def self.verify_hs256_signature(token:, signature_secret:) verify_signature(token, signature_secret, 'HS256') end diff --git a/lib/vonage/messaging.rb b/lib/vonage/messaging.rb index ee95f69e..29fdc89d 100644 --- a/lib/vonage/messaging.rb +++ b/lib/vonage/messaging.rb @@ -26,6 +26,13 @@ def send(params) request('/v1/messages', params: params, type: Post) end + # Validate a JSON Web Token from a Messages API Webhook. + # + # @param [String, required] :token The JWT from the Webhook's Authorization header + # @param [String, optional] :signature_secret The account signature secret. Required, unless `signature_secret` + # is set in `Config` + # + # @return [Boolean] true, if the JWT is verified, false otherwise def verify_webhook_token(token:, signature_secret: @config.signature_secret) JWT.verify_hs256_signature(token: token, signature_secret: signature_secret) end diff --git a/lib/vonage/sms.rb b/lib/vonage/sms.rb index 7e7f8f2b..35e959a5 100644 --- a/lib/vonage/sms.rb +++ b/lib/vonage/sms.rb @@ -91,6 +91,15 @@ def send(params) response end + # Validate a Signature from an SMS API Webhook. + # + # @param [Hash, required] :webhook_params The parameters from the webhook request body + # @param [String, optional] :signature_secret The account signature secret. Required, unless `signature_secret` + # is set in `Config` + # @param [String, optional] :signature_method The account signature method. Required, unless `signature_method` + # is set in `Config` + # + # @return [Boolean] true, if the JWT is verified, false otherwise def verify_webhook_sig(webhook_params:, signature_secret: @config.signature_secret, signature_method: @config.signature_method) signature.check(webhook_params, signature_secret: signature_secret, signature_method: signature_method) end diff --git a/lib/vonage/voice.rb b/lib/vonage/voice.rb index ddb69caf..b1924f0c 100644 --- a/lib/vonage/voice.rb +++ b/lib/vonage/voice.rb @@ -280,6 +280,13 @@ def dtmf @dtmf ||= DTMF.new(@config) end + # Validate a JSON Web Token from a Voice API Webhook. + # + # @param [String, required] :token The JWT from the Webhook's Authorization header + # @param [String, optional] :signature_secret The account signature secret. Required, unless `signature_secret` + # is set in `Config` + # + # @return [Boolean] true, if the JWT is verified, false otherwise def verify_webhook_token(token:, signature_secret: @config.signature_secret) JWT.verify_hs256_signature(token: token, signature_secret: signature_secret) end