0
0
instrumental/app/controllers/concerns/signature_verification.rb
Eugen Rochko 1618b68bfa HTTP signatures (#4146)
* Add Request class with HTTP signature generator

Spec: https://tools.ietf.org/html/draft-cavage-http-signatures-06

* Add HTTP signature verification concern

* Add test for SignatureVerification concern

* Add basic test for Request class

* Make PuSH subscribe/unsubscribe requests use new Request class

Accidentally fix lease_seconds not being set and sent properly, and
change the new minimum subscription duration to 1 day

* Make all PuSH workers use new Request class

* Make Salmon sender use new Request class

* Make FetchLinkService use new Request class

* Make FetchAtomService use the new Request class

* Make Remotable use the new Request class

* Make ResolveRemoteAccountService use the new Request class

* Add more tests

* Allow +-30 seconds window for signed request to remain valid

* Disable time window validation for signed requests, restore 7 days
as PuSH subscription duration (which was previous default due to a bug)
2017-07-14 20:41:49 +02:00

88 lines
2.4 KiB
Ruby

# frozen_string_literal: true
# Implemented according to HTTP signatures (Draft 6)
# <https://tools.ietf.org/html/draft-cavage-http-signatures-06>
module SignatureVerification
extend ActiveSupport::Concern
def signed_request?
request.headers['Signature'].present?
end
def signed_request_account
return @signed_request_account if defined?(@signed_request_account)
unless signed_request?
@signed_request_account = nil
return
end
raw_signature = request.headers['Signature']
signature_params = {}
raw_signature.split(',').each do |part|
parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
next if parsed_parts.nil? || parsed_parts.size != 3
signature_params[parsed_parts[1]] = parsed_parts[2]
end
if incompatible_signature?(signature_params)
@signed_request_account = nil
return
end
account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, ''))
if account.nil?
@signed_request_account = nil
return
end
signature = Base64.decode64(signature_params['signature'])
compare_signed_string = build_signed_string(signature_params['headers'])
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
@signed_request_account = account
@signed_request_account
else
@signed_request_account = nil
end
end
private
def build_signed_string(signed_headers)
signed_headers = 'date' if signed_headers.blank?
signed_headers.split(' ').map do |signed_header|
if signed_header == Request::REQUEST_TARGET
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
else
"#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
end
end.join("\n")
end
def matches_time_window?
begin
time_sent = DateTime.httpdate(request.headers['Date'])
rescue ArgumentError
return false
end
(Time.now.utc - time_sent).abs <= 30
end
def to_header_name(name)
name.split(/-/).map(&:capitalize).join('-')
end
def incompatible_signature?(signature_params)
signature_params['keyId'].blank? ||
signature_params['signature'].blank? ||
signature_params['algorithm'].blank? ||
signature_params['algorithm'] != 'rsa-sha256' ||
!signature_params['keyId'].start_with?('acct:')
end
end