Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SHA256 signing for HTTP requests #2874

Merged
merged 1 commit into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: 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
Loading