diff --git a/README.md b/README.md index 98e82860..9821fe90 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,13 @@ SSL forcing can be disabled by setting - `DISABLE_SSL_FORCE` +You can enable webhook payload encryption by setting + +- `WEBHOOK_ENCRYPTION_KEY` + +It expects to be a base64-encoded RSA public key in PEM format (see below). + + you can store these in a local .env file for development. It's done via environment variables. You can utilize a `.env` file while @@ -72,6 +79,46 @@ generate one, you can use the following command: ruby -rsecurerandom -e 'puts SecureRandom.hex(32)' ``` +### Generate a Webhook encryption key + + +If OpenSSL is not installed, please refer to the OpenSSL documentation for installation instructions specific to your operating system. + +#### Step 1: Generate the Private-Public Keypair + +To generate the private-public keypair, follow these steps: + +1. Open your terminal or command prompt. + +2. Run the following command to generate a private key file named `private_key.pem`: +```bash + openssl genpkey -algorithm RSA -out private_key.pem +``` + +3. You will be prompted to set a passphrase for the private key. Choose a strong passphrase and remember it for future use. + +4. Run the following command to generate the corresponding public key file named `public_key.pem`: + +```bash + openssl rsa -pubout -in private_key.pem -out public_key.pem +``` + +5. Remember to keep the private key (`private_key.pem`) secure and do not share it with anyone. + +#### Step 2: Encode the Public Key in Base64 + +To encode the public key in Base64, follow these steps: + +1. Use the following command to encode the public key in Base64: + +```bash +openssl base64 -in public_key.pem -out public_key_base64.txt +``` + +2. The public key is now encoded in Base64 and saved as `public_key_base64.txt`. The file contains the Base64-encoded public key. + +Congratulations! You have successfully generated a private-public keypair, converted the public key to a `.pem` file, and encoded it in Base64. You can now use it for `WEBHOOK_ENCRYPTION_KEY` (see above). + ## Usage see [docs.ebicsbox.apiary.io](http://docs.ebicsbox.apiary.io) diff --git a/box/models/webhook_delivery.rb b/box/models/webhook_delivery.rb index 9c6ea433..f6a74b3b 100644 --- a/box/models/webhook_delivery.rb +++ b/box/models/webhook_delivery.rb @@ -7,6 +7,7 @@ require_relative './event' require_relative '../middleware/signer' +require_relative '../../config/configuration' module Box class WebhookDelivery < Sequel::Model @@ -43,7 +44,8 @@ def execute_request response = conn.post do |req| req.url URI(event.callback_url).path req.headers['Content-Type'] = 'application/json' - req.body = event.to_webhook_payload.to_json + payload = event.to_webhook_payload.to_json + req.body = Box.configuration.encrypt_webhooks? ? encrypt(payload) : payload end end rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::Error => ex @@ -52,6 +54,24 @@ def execute_request end [response, execution_time] end + + def encrypt(payload) + aes_key = OpenSSL::Cipher.new('AES-256-CBC').random_key + cipher = OpenSSL::Cipher.new('AES-256-CBC') + cipher.encrypt + cipher.key = aes_key + encrypted_payload_base64 = Base64.encode64(cipher.update(payload) + cipher.final) + public_key = OpenSSL::PKey::RSA.new(Base64.decode64(Box.configuration.webhook_encryption_key)) + encrypted_aes_key_base64 = Base64.encode64(public_key.public_encrypt(aes_key)) + transformToJsonString(encrypted_aes_key_base64, encrypted_payload_base64) + end + + def transformToJsonString (encrypted_aes_key_base64, encrypted_payload_base64) + { + 'encrypted_aes_key_base64' => encrypted_aes_key_base64, + 'encrypted_payload_base64' => encrypted_payload_base64 + }.to_json + end class FailedResponse def initialize(message) diff --git a/config/configuration.rb b/config/configuration.rb index 4acff8e2..43f60f30 100644 --- a/config/configuration.rb +++ b/config/configuration.rb @@ -77,5 +77,14 @@ def valid? jwt_secret unless static_auth? end + + def webhook_encryption_key + ENV['WEBHOOK_ENCRYPTION_KEY'] + end + + def encrypt_webhooks? + webhook_encryption_key != nil + end + end end diff --git a/spec/models/webhook_delivery_spec.rb b/spec/models/webhook_delivery_spec.rb index 25ad303d..eb77db84 100644 --- a/spec/models/webhook_delivery_spec.rb +++ b/spec/models/webhook_delivery_spec.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true require 'faraday' +require 'openssl' +require 'base64' +require 'json' module Box RSpec.describe WebhookDelivery do @@ -41,6 +44,61 @@ module Box it 'returns a response' do expect(subject.execute_request.size).to eq(2) end + + it 'does encrypt the payload if encrypt_webhooks is true' do + # Set up Event to return consistent payload + expected_payload = {"foo": "bar"} + allow_any_instance_of(Box::Event).to receive(:to_webhook_payload).and_return(expected_payload) + + # Set up public encryption key + webhook_encryption_public_key_pem = File.read('./spec/webhook_encryption_public_key_RSPEC_ONLY.pem') + webhook_encryption_public_key_base_64 = Base64.encode64(webhook_encryption_public_key_pem) + webhook_encryption_public_key = OpenSSL::PKey::RSA.new(webhook_encryption_public_key_pem) + + # Set up private encryption key + webhook_encryption_private_key_pem = File.read('./spec/webhook_encryption_private_key_RSPEC_ONLY.pem') + webhook_encryption_private_key = OpenSSL::PKey::RSA.new(webhook_encryption_private_key_pem) + aes_key = OpenSSL::Cipher.new('AES-256-CBC') + + # Set up configuration + allow_any_instance_of(Box::Configuration).to receive(:encrypt_webhooks?).and_return(true) + allow_any_instance_of(Box::Configuration).to receive(:webhook_encryption_key).and_return(webhook_encryption_public_key_base_64) + allow(OpenSSL::Cipher).to receive(:new).and_return(aes_key) + + expected_payload_encrypted = Base64.encode64(webhook_encryption_public_key.public_encrypt(expected_payload.to_json)) + + allow_any_instance_of(Faraday::Request).to receive(:body=) do |instance, payload| + payload_as_json = JSON.parse(payload) + + decrypted_aes_key = webhook_encryption_private_key.private_decrypt(Base64.decode64(payload_as_json['encrypted_aes_key_base64'])) + encrypted_payload = Base64.decode64(payload_as_json['encrypted_payload_base64']) + + cipher = OpenSSL::Cipher.new('AES-256-CBC') + cipher.decrypt + cipher.key = decrypted_aes_key + decrypted_payload = cipher.update(encrypted_payload) + cipher.final + + expect(decrypted_payload).to eq(expected_payload.to_json) + end + + subject.execute_request + end + + it 'does not encrypt payload if encrypt_webhooks is false' do + # Set up Event to return consistent payload + expected_payload = {"foo": "bar"} + allow_any_instance_of(Box::Event).to receive(:to_webhook_payload).and_return(expected_payload) + + # Set up configuration + allow_any_instance_of(Box::Configuration).to receive(:encrypt_webhooks?).and_return(false) + + allow_any_instance_of(Faraday::Request).to receive(:body=) do |instance, payload| + expect(payload).to eq(expected_payload.to_json) + end + + subject.execute_request + end + end context 'without auth defined' do diff --git a/spec/webhook_encryption_private_key_RSPEC_ONLY.pem b/spec/webhook_encryption_private_key_RSPEC_ONLY.pem new file mode 100644 index 00000000..a44ee980 --- /dev/null +++ b/spec/webhook_encryption_private_key_RSPEC_ONLY.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- diff --git a/spec/webhook_encryption_public_key_RSPEC_ONLY.pem b/spec/webhook_encryption_public_key_RSPEC_ONLY.pem new file mode 100644 index 00000000..f74127ae --- /dev/null +++ b/spec/webhook_encryption_public_key_RSPEC_ONLY.pem @@ -0,0 +1,13 @@ +-----BEGIN RSA PUBLIC KEY----- +MIICCgKCAgEAx+ee0Nm05q9zvGgUihLw78XPJvc359rrP6VUwdd/AUHLeMMP6BsJ +Mtc+jwum6Ia2AomzffsJDAypdS77b2Pmt+KzN0GIsI30w3xKzEvgP1Qv+Rb07SjY +TJJ7i5zKMJAa1LfX2ThO89oBqwhP8mylZFowSBmIkTHTkfNvcCa40EMPk4hOSd7M +qK0ZtmSlWO+zzkm5IgbVUlurKX4Fu34Llx6EqXJE3WlRljwFvPy+S7SJCh4kpIL+ +OQ7VRShn+RtmvfyhxZt5FlypZ5OenoZOmWPoXHfQwdjxozJsAodYdnwricuXdSGI +/mqw9e5NFORQZUEtkgCjKDTkBc/7a4XQefAOhBhqVOafbMnsvb1DlPcCv1dQnFYn +jr7BI9ahA+AecFP3YIHtf2VKA0K14ogf1H4Q7VbJYFeJvlSt+yxWKlNRZz7dOT2r +MeQ2PpVM4B7B5ufBi50mo/+YgCixk2YbjQIxahxxk6uojW3nhbnWKaFU9j7hs1Z/ +97T4H6NrekP6Rde5Y6iHM6apmiPby+Y1xmcWHK5yna2m7EUn5w3gGx7jmZOM477c +l7rvb/+LyIudtySr0t6bOHuhWMCRTwqio6UcPU4z4DJtkxgd9PglfIEKLyqvouNQ +Q5M5k8g1kHKUpuT3vVckvMz/ruPy3IvER5IhVzKtmyjfUhE+ixczNlcCAwEAAQ== +-----END RSA PUBLIC KEY-----