Skip to content

Commit

Permalink
feat: adds sha256 signatures to outgoing http requests
Browse files Browse the repository at this point in the history
  • Loading branch information
adamcooke committed Mar 12, 2024
1 parent 9982bb8 commit 0a5e0e7
Show file tree
Hide file tree
Showing 12 changed files with 193 additions and 10 deletions.
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -410,14 +411,14 @@ DEPENDENCIES
domain_name
dotenv
dynamic_form
encrypto_signo
execjs (~> 2.7, < 2.8)
factory_bot_rails
gelf
haml
hashie
highline
jquery-rails
jwt
kaminari
klogger-logger
konfig-config (~> 3.0)
Expand Down
15 changes: 15 additions & 0 deletions app/controllers/well_known_controller.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion app/lib/dkim_header.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions app/lib/signer.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 6 additions & 3 deletions lib/postal/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions lib/postal/config_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions lib/postal/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
12 changes: 12 additions & 0 deletions spec/lib/postal_spec.rb
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions spec/lib/signer_spec.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion spec/services/webhook_delivery_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 0a5e0e7

Please sign in to comment.