From 0a5e0e7d0c3037f1a0522618aa8495392428eb22 Mon Sep 17 00:00:00 2001 From: Adam Cooke Date: Tue, 12 Mar 2024 22:16:05 +0000 Subject: [PATCH] feat: adds sha256 signatures to outgoing http requests --- Gemfile | 2 +- Gemfile.lock | 5 +- app/controllers/well_known_controller.rb | 15 ++++ app/lib/dkim_header.rb | 2 +- app/lib/signer.rb | 66 ++++++++++++++++ config/routes.rb | 2 + lib/postal/config.rb | 9 ++- lib/postal/config_schema.rb | 5 ++ lib/postal/http.rb | 5 +- spec/lib/postal_spec.rb | 12 +++ spec/lib/signer_spec.rb | 76 +++++++++++++++++++ .../services/webhook_delivery_service_spec.rb | 4 +- 12 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 app/controllers/well_known_controller.rb create mode 100644 app/lib/signer.rb create mode 100644 spec/lib/postal_spec.rb create mode 100644 spec/lib/signer_spec.rb diff --git a/Gemfile b/Gemfile index 231f2001..95f82040 100644 --- a/Gemfile +++ b/Gemfile @@ -8,12 +8,12 @@ gem "chronic" gem "domain_name" gem "dotenv" gem "dynamic_form" -gem "encrypto_signo" gem "execjs", "~> 2.7", "< 2.8" gem "gelf" gem "haml" gem "hashie" gem "highline", require: false +gem "jwt" gem "kaminari" gem "klogger-logger" gem "konfig-config", "~> 3.0" diff --git a/Gemfile.lock b/Gemfile.lock index 0f4fe96b..b9cfedf1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -112,7 +112,6 @@ GEM activemodel (> 5.2.0) email_validator (2.2.4) activemodel - encrypto_signo (1.0.0) erubi (1.12.0) execjs (2.7.0) factory_bot (6.4.6) @@ -152,6 +151,8 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects + jwt (2.8.1) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -410,7 +411,6 @@ DEPENDENCIES domain_name dotenv dynamic_form - encrypto_signo execjs (~> 2.7, < 2.8) factory_bot_rails gelf @@ -418,6 +418,7 @@ DEPENDENCIES hashie highline jquery-rails + jwt kaminari klogger-logger konfig-config (~> 3.0) diff --git a/app/controllers/well_known_controller.rb b/app/controllers/well_known_controller.rb new file mode 100644 index 00000000..0b542aa9 --- /dev/null +++ b/app/controllers/well_known_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class WellKnownController < ApplicationController + + layout false + + skip_before_action :set_browser_id + skip_before_action :login_required + skip_before_action :set_timezone + + def jwks + render json: JWT::JWK::Set.new(Postal.signer.jwk).export.to_json + end + +end diff --git a/app/lib/dkim_header.rb b/app/lib/dkim_header.rb index 3b6216ea..85972614 100644 --- a/app/lib/dkim_header.rb +++ b/app/lib/dkim_header.rb @@ -9,7 +9,7 @@ def initialize(domain, message) @dkim_identifier = domain.dkim_identifier else @domain_name = Postal::Config.dns.return_path_domain - @dkim_key = Postal.signing_key + @dkim_key = Postal.signer.private_key @dkim_identifier = Postal::Config.dns.dkim_identifier end @domain = domain diff --git a/app/lib/signer.rb b/app/lib/signer.rb new file mode 100644 index 00000000..4234a653 --- /dev/null +++ b/app/lib/signer.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "base64" + +class Signer + + # Create a new Signer + # + # @param [OpenSSL::PKey::RSA] private_key The private key to use for signing + # @return [Signer] + def initialize(private_key) + @private_key = private_key + end + + # Return the private key + # + # @return [OpenSSL::PKey::RSA] + attr_reader :private_key + + # Return the public key for the private key + # + # @return [OpenSSL::PKey::RSA] + def public_key + @private_key.public_key + end + + # Sign the given data + # + # @param [String] data The data to sign + # @return [String] The signature + def sign(data) + private_key.sign(OpenSSL::Digest.new("SHA256"), data) + end + + # Sign the given data and return a Base64-encoded signature + # + # @param [String] data The data to sign + # @return [String] The Base64-encoded signature + def sign64(data) + Base64.strict_encode64(sign(data)) + end + + # Return a JWK for the private key + # + # @return [JWT::JWK] The JWK + def jwk + @jwk ||= JWT::JWK.new(private_key, { use: "sig", alg: "RS256" }) + end + + # Sign the given data using SHA1 (for legacy use) + # + # @param [String] data The data to sign + # @return [String] The signature + def sha1_sign(data) + private_key.sign(OpenSSL::Digest.new("SHA1"), data) + end + + # Sign the given data using SHA1 (for legacy use) and return a Base64-encoded string + # + # @param [String] data The data to sign + # @return [String] The signature + def sha1_sign64(data) + Base64.strict_encode64(sha1_sign(data)) + end + +end diff --git a/config/routes.rb b/config/routes.rb index 281b0d5a..aa6e20cc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,6 +89,8 @@ get "auth/oidc/callback", to: "sessions#create_from_oidc" end + get ".well-known/jwks.json" => "well_known#jwks" + get "ip" => "sessions#ip" root "organizations#index" diff --git a/lib/postal/config.rb b/lib/postal/config.rb index 8462c745..40b6b20e 100644 --- a/lib/postal/config.rb +++ b/lib/postal/config.rb @@ -98,12 +98,15 @@ def locker_name_with_suffix(suffix) "#{locker_name} #{suffix}" end - def signing_key - @signing_key ||= OpenSSL::PKey::RSA.new(File.read(Config.postal.signing_key_path)) + def signer + @signer ||= begin + key = OpenSSL::PKey::RSA.new(File.read(Config.postal.signing_key_path)) + Signer.new(key) + end end def rp_dkim_dns_record - public_key = signing_key.public_key.to_s.gsub(/-+[A-Z ]+-+\n/, "").gsub(/\n/, "") + public_key = signer.private_key.public_key.to_s.gsub(/-+[A-Z ]+-+\n/, "").gsub(/\n/, "") "v=DKIM1; t=s; h=sha256; p=#{public_key};" end diff --git a/lib/postal/config_schema.rb b/lib/postal/config_schema.rb index 99d39a3b..3bce95d5 100644 --- a/lib/postal/config_schema.rb +++ b/lib/postal/config_schema.rb @@ -72,6 +72,11 @@ module Postal transform { |v| Postal.substitute_config_file_root(v) } end + string :signing_key_id do + description "The ID (KID) for the key used for signing" + default "postal" + end + string :smtp_relays do array description "An array of SMTP relays in the format of smtp://host:port" diff --git a/lib/postal/http.rb b/lib/postal/http.rb index 9f1dbb8f..8a32d9b5 100644 --- a/lib/postal/http.rb +++ b/lib/postal/http.rb @@ -40,8 +40,9 @@ def self.request(method, url, options = {}) end if options[:sign] - signature = EncryptoSigno.sign(Postal.signing_key, request.body.to_s).gsub("\n", "") - request.add_field "X-Postal-Signature", signature + request.add_field "X-Postal-Signature-KID", Postal.signer.jwk.kid + request.add_field "X-Postal-Signature", Postal.signer.sha1_sign64(request.body.to_s) + request.add_field "X-Postal-Signature-256", Postal.signer.sign64(request.body.to_s) end request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}" diff --git a/spec/lib/postal_spec.rb b/spec/lib/postal_spec.rb new file mode 100644 index 00000000..692fde06 --- /dev/null +++ b/spec/lib/postal_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Postal do + describe "#signer" do + it "returns a signer with the installation's signing key" do + expect(Postal.signer).to be_a(Signer) + expect(Postal.signer.private_key.to_pem).to eq OpenSSL::PKey::RSA.new(File.read(Postal::Config.postal.signing_key_path)).to_pem + end + end +end diff --git a/spec/lib/signer_spec.rb b/spec/lib/signer_spec.rb new file mode 100644 index 00000000..948b3c35 --- /dev/null +++ b/spec/lib/signer_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Signer do + STATIC_PRIVATE_KEY = OpenSSL::PKey::RSA.new(2048) # rubocop:disable Lint/ConstantDefinitionInBlock + + subject(:signer) { described_class.new(STATIC_PRIVATE_KEY) } + + describe "#private_key" do + it "returns the private key" do + expect(signer.private_key).to eq(STATIC_PRIVATE_KEY) + end + end + + describe "#public_key" do + it "returns the public key" do + expect(signer.public_key.to_s).to eq(STATIC_PRIVATE_KEY.public_key.to_s) + end + end + + describe "#sign" do + it "returns a valid signature" do + data = "hello world!" + signature = signer.sign(data) + expect(signature).to be_a(String) + verification = STATIC_PRIVATE_KEY.public_key.verify(OpenSSL::Digest.new("SHA256"), + signature, + data) + expect(verification).to be true + end + end + + describe "#sign64" do + it "returns a valid Base64-encoded signature" do + data = "hello world!" + signature = signer.sign64(data) + expect(signature).to be_a(String) + verification = STATIC_PRIVATE_KEY.public_key.verify(OpenSSL::Digest.new("SHA256"), + Base64.strict_decode64(signature), + data) + expect(verification).to be true + end + end + + describe "#jwk" do + it "returns a valid JWK" do + jwk = signer.jwk + expect(jwk).to be_a(JWT::JWK::RSA) + end + end + + describe "#sha1_sign" do + it "returns a valid signature" do + data = "hello world!" + signature = signer.sha1_sign(data) + expect(signature).to be_a(String) + verification = STATIC_PRIVATE_KEY.public_key.verify(OpenSSL::Digest.new("SHA1"), + signature, + data) + expect(verification).to be true + end + end + + describe "#sha1_sign64" do + it "returns a valid Base64-encoded signature" do + data = "hello world!" + signature = signer.sha1_sign64(data) + expect(signature).to be_a(String) + verification = STATIC_PRIVATE_KEY.public_key.verify(OpenSSL::Digest.new("SHA1"), + Base64.strict_decode64(signature), + data) + expect(verification).to be true + end + end +end diff --git a/spec/services/webhook_delivery_service_spec.rb b/spec/services/webhook_delivery_service_spec.rb index a6f1bf31..4386c0fc 100644 --- a/spec/services/webhook_delivery_service_spec.rb +++ b/spec/services/webhook_delivery_service_spec.rb @@ -28,7 +28,9 @@ }.to_json, headers: { "Content-Type" => "application/json", - "X-Postal-Signature" => /\A[a-z0-9\/+]+=*\z/i + "X-Postal-Signature" => /\A[a-z0-9\/+]+=*\z/i, + "X-Postal-Signature-256" => /\A[a-z0-9\/+]+=*\z/i, + "X-Postal-Signature-KID" => /\A[a-f0-9\/+]{64}\z/i } }) end