Accept HTTP Message Signatures (RFC9421) (#34814)
This commit is contained in:
parent
eb42425427
commit
9c80b16401
3 changed files with 822 additions and 411 deletions
|
@ -22,6 +22,18 @@ module SignatureVerification
|
|||
request.headers['Signature'].present?
|
||||
end
|
||||
|
||||
def signature_key_id
|
||||
signed_request.key_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def signed_request
|
||||
@signed_request ||= SignedRequest.new(request) if signed_request?
|
||||
rescue SignatureVerificationError
|
||||
nil
|
||||
end
|
||||
|
||||
def signature_verification_failure_reason
|
||||
@signature_verification_failure_reason
|
||||
end
|
||||
|
@ -30,12 +42,6 @@ module SignatureVerification
|
|||
@signature_verification_failure_code || 401
|
||||
end
|
||||
|
||||
def signature_key_id
|
||||
signature_params['keyId']
|
||||
rescue Mastodon::SignatureVerificationError
|
||||
nil
|
||||
end
|
||||
|
||||
def signed_request_account
|
||||
signed_request_actor.is_a?(Account) ? signed_request_actor : nil
|
||||
end
|
||||
|
@ -44,38 +50,20 @@ module SignatureVerification
|
|||
return @signed_request_actor if defined?(@signed_request_actor)
|
||||
|
||||
raise Mastodon::SignatureVerificationError, 'Request not signed' unless signed_request?
|
||||
raise Mastodon::SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
|
||||
raise Mastodon::SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
|
||||
raise Mastodon::SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
|
||||
|
||||
verify_signature_strength!
|
||||
verify_body_digest!
|
||||
actor = actor_from_key_id
|
||||
|
||||
actor = actor_from_key_id(signature_params['keyId'])
|
||||
raise Mastodon::SignatureVerificationError, "Public key not found for key #{signature_key_id}" if actor.nil?
|
||||
|
||||
raise Mastodon::SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
|
||||
|
||||
signature = Base64.decode64(signature_params['signature'])
|
||||
compare_signed_string = build_signed_string(include_query_string: true)
|
||||
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
|
||||
# Compatibility quirk with older Mastodon versions
|
||||
compare_signed_string = build_signed_string(include_query_string: false)
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
||||
|
||||
actor = stoplight_wrapper.run { actor_refresh_key!(actor) }
|
||||
|
||||
raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
|
||||
raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_key_id}" if actor.nil?
|
||||
|
||||
compare_signed_string = build_signed_string(include_query_string: true)
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
||||
|
||||
# Compatibility quirk with older Mastodon versions
|
||||
compare_signed_string = build_signed_string(include_query_string: false)
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
|
||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
|
||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}"
|
||||
rescue Mastodon::SignatureVerificationError => e
|
||||
fail_with! e.message
|
||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
|
@ -86,12 +74,6 @@ module SignatureVerification
|
|||
fail_with! 'Fetching attempt skipped because of recent connection failure'
|
||||
end
|
||||
|
||||
def request_body
|
||||
@request_body ||= request.raw_post
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fail_with!(message, **options)
|
||||
Rails.logger.debug { "Signature verification failed: #{message}" }
|
||||
|
||||
|
@ -99,123 +81,8 @@ module SignatureVerification
|
|||
@signed_request_actor = nil
|
||||
end
|
||||
|
||||
def signature_params
|
||||
@signature_params ||= SignatureParser.parse(request.headers['Signature'])
|
||||
rescue SignatureParser::ParsingError
|
||||
raise Mastodon::SignatureVerificationError, 'Error parsing signature parameters'
|
||||
end
|
||||
|
||||
def signature_algorithm
|
||||
signature_params.fetch('algorithm', 'hs2019')
|
||||
end
|
||||
|
||||
def signed_headers
|
||||
signature_params.fetch('headers', signature_algorithm == 'hs2019' ? '(created)' : 'date').downcase.split
|
||||
end
|
||||
|
||||
def verify_signature_strength!
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest')
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host')
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
|
||||
end
|
||||
|
||||
def verify_body_digest!
|
||||
return unless signed_headers.include?('digest')
|
||||
raise Mastodon::SignatureVerificationError, 'Digest header missing' unless request.headers.key?('Digest')
|
||||
|
||||
digests = request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] }
|
||||
sha256 = digests.assoc('sha-256')
|
||||
raise Mastodon::SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil?
|
||||
|
||||
return if body_digest == sha256[1]
|
||||
|
||||
digest_size = begin
|
||||
Base64.strict_decode64(sha256[1].strip).length
|
||||
rescue ArgumentError
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}"
|
||||
end
|
||||
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32
|
||||
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}"
|
||||
end
|
||||
|
||||
def verify_signature(actor, signature, compare_signed_string)
|
||||
if actor.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), signature, compare_signed_string)
|
||||
@signed_request_actor = actor
|
||||
@signed_request_actor
|
||||
end
|
||||
rescue OpenSSL::PKey::RSAError
|
||||
nil
|
||||
end
|
||||
|
||||
def build_signed_string(include_query_string: true)
|
||||
signed_headers.map do |signed_header|
|
||||
case signed_header
|
||||
when HttpSignatureDraft::REQUEST_TARGET
|
||||
if include_query_string
|
||||
"#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}"
|
||||
else
|
||||
# Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header.
|
||||
# Therefore, temporarily support such incorrect signatures for compatibility.
|
||||
# TODO: remove eventually some time after release of the fixed version
|
||||
"#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
||||
end
|
||||
when '(created)'
|
||||
raise Mastodon::SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||
raise Mastodon::SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
||||
|
||||
"(created): #{signature_params['created']}"
|
||||
when '(expires)'
|
||||
raise Mastodon::SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||
raise Mastodon::SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
|
||||
|
||||
"(expires): #{signature_params['expires']}"
|
||||
else
|
||||
"#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
|
||||
end
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def matches_time_window?
|
||||
created_time = nil
|
||||
expires_time = nil
|
||||
|
||||
begin
|
||||
if signature_algorithm == 'hs2019' && signature_params['created'].present?
|
||||
created_time = Time.at(signature_params['created'].to_i).utc
|
||||
elsif request.headers['Date'].present?
|
||||
created_time = Time.httpdate(request.headers['Date']).utc
|
||||
end
|
||||
|
||||
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
|
||||
rescue ArgumentError => e
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Date header: #{e.message}"
|
||||
end
|
||||
|
||||
expires_time ||= created_time + 5.minutes unless created_time.nil?
|
||||
expires_time = [expires_time, created_time + EXPIRATION_WINDOW_LIMIT].min unless created_time.nil?
|
||||
|
||||
return false if created_time.present? && created_time > Time.now.utc + CLOCK_SKEW_MARGIN
|
||||
return false if expires_time.present? && Time.now.utc > expires_time + CLOCK_SKEW_MARGIN
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def body_digest
|
||||
@body_digest ||= Digest::SHA256.base64digest(request_body)
|
||||
end
|
||||
|
||||
def to_header_name(name)
|
||||
name.split('-').map(&:capitalize).join('-')
|
||||
end
|
||||
|
||||
def missing_required_signature_parameters?
|
||||
signature_params['keyId'].blank? || signature_params['signature'].blank?
|
||||
end
|
||||
|
||||
def actor_from_key_id(key_id)
|
||||
def actor_from_key_id
|
||||
key_id = signature_key_id
|
||||
domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id
|
||||
|
||||
if domain_not_allowed?(domain)
|
||||
|
|
270
app/lib/signed_request.rb
Normal file
270
app/lib/signed_request.rb
Normal file
|
@ -0,0 +1,270 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class SignedRequest
|
||||
include DomainControlHelper
|
||||
|
||||
EXPIRATION_WINDOW_LIMIT = 12.hours
|
||||
CLOCK_SKEW_MARGIN = 1.hour
|
||||
|
||||
class HttpSignature
|
||||
REQUIRED_PARAMETERS = %w(keyId signature).freeze
|
||||
|
||||
def initialize(request)
|
||||
@request = request
|
||||
end
|
||||
|
||||
def key_id
|
||||
signature_params['keyId']
|
||||
end
|
||||
|
||||
def missing_signature_parameters
|
||||
REQUIRED_PARAMETERS if REQUIRED_PARAMETERS.any? { |p| signature_params[p].blank? }
|
||||
end
|
||||
|
||||
def algorithm_supported?
|
||||
%w(rsa-sha256 hs2019).include?(signature_algorithm)
|
||||
end
|
||||
|
||||
def verified?(actor)
|
||||
signature = Base64.decode64(signature_params['signature'])
|
||||
compare_signed_string = build_signed_string(include_query_string: true)
|
||||
|
||||
return true unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
|
||||
compare_signed_string = build_signed_string(include_query_string: false)
|
||||
return true unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def created_time
|
||||
if signature_algorithm == 'hs2019' && signature_params['created'].present?
|
||||
Time.at(signature_params['created'].to_i).utc
|
||||
elsif @request.headers['Date'].present?
|
||||
Time.httpdate(@request.headers['Date']).utc
|
||||
end
|
||||
rescue ArgumentError => e
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Date header: #{e.message}"
|
||||
end
|
||||
|
||||
def expires_time
|
||||
Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
|
||||
rescue ArgumentError => e
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Date header: #{e.message}"
|
||||
end
|
||||
|
||||
def verify_signature_strength!
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest')
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if @request.get? && !signed_headers.include?('host')
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if @request.post? && !signed_headers.include?('digest')
|
||||
end
|
||||
|
||||
def verify_body_digest!
|
||||
return unless signed_headers.include?('digest')
|
||||
raise Mastodon::SignatureVerificationError, 'Digest header missing' unless @request.headers.key?('Digest')
|
||||
|
||||
digests = @request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] }
|
||||
sha256 = digests.assoc('sha-256')
|
||||
raise Mastodon::SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil?
|
||||
|
||||
return if body_digest == sha256[1]
|
||||
|
||||
digest_size = begin
|
||||
Base64.strict_decode64(sha256[1].strip).length
|
||||
rescue ArgumentError
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}"
|
||||
end
|
||||
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32
|
||||
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_body
|
||||
@request_body ||= @request.raw_post
|
||||
end
|
||||
|
||||
def signature_params
|
||||
@signature_params ||= SignatureParser.parse(@request.headers['Signature'])
|
||||
rescue SignatureParser::ParsingError
|
||||
raise Mastodon::SignatureVerificationError, 'Error parsing signature parameters'
|
||||
end
|
||||
|
||||
def signature_algorithm
|
||||
signature_params.fetch('algorithm', 'hs2019')
|
||||
end
|
||||
|
||||
def signed_headers
|
||||
signature_params.fetch('headers', signature_algorithm == 'hs2019' ? '(created)' : 'date').downcase.split
|
||||
end
|
||||
|
||||
def verify_signature(actor, signature, compare_signed_string)
|
||||
true if actor.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), signature, compare_signed_string)
|
||||
rescue OpenSSL::PKey::RSAError
|
||||
nil
|
||||
end
|
||||
|
||||
def build_signed_string(include_query_string: true)
|
||||
signed_headers.map do |signed_header|
|
||||
case signed_header
|
||||
when HttpSignatureDraft::REQUEST_TARGET
|
||||
if include_query_string
|
||||
"#{HttpSignatureDraft::REQUEST_TARGET}: #{@request.method.downcase} #{@request.original_fullpath}"
|
||||
else
|
||||
# Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header.
|
||||
# Therefore, temporarily support such incorrect signatures for compatibility.
|
||||
# TODO: remove eventually some time after release of the fixed version
|
||||
"#{HttpSignatureDraft::REQUEST_TARGET}: #{@request.method.downcase} #{@request.path}"
|
||||
end
|
||||
when '(created)'
|
||||
raise Mastodon::SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||
raise Mastodon::SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
||||
|
||||
"(created): #{signature_params['created']}"
|
||||
when '(expires)'
|
||||
raise Mastodon::SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||
raise Mastodon::SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
|
||||
|
||||
"(expires): #{signature_params['expires']}"
|
||||
else
|
||||
"#{signed_header}: #{@request.headers[to_header_name(signed_header)]}"
|
||||
end
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def body_digest
|
||||
@body_digest ||= Digest::SHA256.base64digest(request_body)
|
||||
end
|
||||
|
||||
def to_header_name(name)
|
||||
name.split('-').map(&:capitalize).join('-')
|
||||
end
|
||||
end
|
||||
|
||||
class HttpMessageSignature
|
||||
REQUIRED_PARAMETERS = %w(keyid created).freeze
|
||||
|
||||
def initialize(request)
|
||||
@request = request
|
||||
@signature = Linzer::Signature.build({
|
||||
'signature-input' => @request.headers['signature-input'],
|
||||
'signature' => @request.headers['signature'],
|
||||
})
|
||||
end
|
||||
|
||||
def key_id
|
||||
@signature.parameters['keyid']
|
||||
end
|
||||
|
||||
def missing_signature_parameters
|
||||
REQUIRED_PARAMETERS if REQUIRED_PARAMETERS.any? { |p| @signature.parameters[p].blank? }
|
||||
end
|
||||
|
||||
# This method can lie as we only support one specific algorith for now.
|
||||
# But HTTP Message Signatures do not need to specify an algorithm (as
|
||||
# this can be inferred from the key used). Using an unsupported
|
||||
# algorithm will fail anyway further down the line.
|
||||
def algorithm_supported?
|
||||
true
|
||||
end
|
||||
|
||||
def verified?(actor)
|
||||
key = Linzer.new_rsa_v1_5_sha256_public_key(actor.public_key)
|
||||
|
||||
Linzer.verify!(@request.rack_request, key:)
|
||||
rescue Linzer::VerifyError
|
||||
false
|
||||
end
|
||||
|
||||
def verify_signature_strength!
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the (created) parameter to be signed' if @signature.parameters['created'].blank?
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the @method and @target-uri derived components to be signed' unless @signature.components.include?('@method') && @signature.components.include?('@target-uri')
|
||||
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Content-Digest header to be signed when doing a POST request' if @request.post? && !signed_headers.include?('content-digest')
|
||||
end
|
||||
|
||||
def verify_body_digest!
|
||||
return unless signed_headers.include?('content-digest')
|
||||
raise Mastodon::SignatureVerificationError, 'Content-Digest header missing' unless @request.headers.key?('content-digest')
|
||||
|
||||
digests = Starry.parse_dictionary(@request.headers['content-digest'])
|
||||
raise Mastodon::SignatureVerificationError, "Mastodon only supports SHA-256 in Content-Digest header. Offered algorithms: #{digests.keys.join(', ')}" unless digests.key?('sha-256')
|
||||
|
||||
received_digest = Base64.strict_encode64(digests['sha-256'].value)
|
||||
return if body_digest == received_digest
|
||||
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{received_digest}"
|
||||
end
|
||||
|
||||
def created_time
|
||||
Time.at(@signature.parameters['created'].to_i).utc
|
||||
rescue ArgumentError => e
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Date header: #{e.message}"
|
||||
end
|
||||
|
||||
def expires_time
|
||||
Time.at(@signature.parameters['expires'].to_i).utc if @signature.parameters['expires'].present?
|
||||
rescue ArgumentError => e
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Date header: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_body
|
||||
@request_body ||= @request.raw_post
|
||||
end
|
||||
|
||||
def signed_headers
|
||||
@signed_headers ||= @signature.components.reject { |c| c.start_with?('@') }
|
||||
end
|
||||
|
||||
def body_digest
|
||||
@body_digest ||= Digest::SHA256.base64digest(request_body)
|
||||
end
|
||||
|
||||
def missing_required_signature_parameters?
|
||||
@signature.parameters['keyid'].blank?
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :signature
|
||||
|
||||
delegate :key_id, to: :signature
|
||||
|
||||
def initialize(request)
|
||||
@signature =
|
||||
if Mastodon::Feature.http_message_signatures_enabled? && request.headers['signature-input'].present?
|
||||
HttpMessageSignature.new(request)
|
||||
else
|
||||
HttpSignature.new(request)
|
||||
end
|
||||
end
|
||||
|
||||
def verified?(actor)
|
||||
missing_signature_parameters = @signature.missing_signature_parameters
|
||||
raise Mastodon::SignatureVerificationError, "Incompatible request signature. #{missing_signature_parameters.to_sentence} are required" if missing_signature_parameters
|
||||
raise Mastodon::SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless @signature.algorithm_supported?
|
||||
raise Mastodon::SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
|
||||
|
||||
@signature.verify_signature_strength!
|
||||
@signature.verify_body_digest!
|
||||
@signature.verified?(actor)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def matches_time_window?
|
||||
created_time = @signature.created_time
|
||||
expires_time = @signature.expires_time
|
||||
|
||||
expires_time ||= created_time + 5.minutes unless created_time.nil?
|
||||
expires_time = [expires_time, created_time + EXPIRATION_WINDOW_LIMIT].min unless created_time.nil?
|
||||
|
||||
return false if created_time.present? && created_time > Time.now.utc + CLOCK_SKEW_MARGIN
|
||||
return false if expires_time.present? && Time.now.utc > expires_time + CLOCK_SKEW_MARGIN
|
||||
|
||||
true
|
||||
end
|
||||
end
|
|
@ -69,7 +69,8 @@ RSpec.describe 'signature verification concern' do
|
|||
end
|
||||
end
|
||||
|
||||
context 'with an HTTP Signature from a known account' do
|
||||
context 'with an HTTP Signature (draft version)' do
|
||||
context 'with a known account' do
|
||||
let!(:actor) { Fabricate(:account, domain: 'remote.domain', uri: 'https://remote.domain/users/bob', private_key: nil, public_key: actor_keypair.public_key.to_pem) }
|
||||
|
||||
context 'with a valid signature on a GET request' do
|
||||
|
@ -349,6 +350,279 @@ RSpec.describe 'signature verification concern' do
|
|||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an HTTP Message Signature (final RFC version)', feature: :http_message_signatures do
|
||||
context 'with a known account' do
|
||||
let!(:actor) { Fabricate(:account, domain: 'remote.domain', uri: 'https://remote.domain/users/bob', private_key: nil, public_key: actor_keypair.public_key.to_pem) }
|
||||
|
||||
context 'with a valid signature on a GET request' do
|
||||
let(:signature_input) do
|
||||
'sig1=("@method" "@target-uri");created=1703066400;keyid="https://remote.domain/users/bob#main-key"'
|
||||
end
|
||||
let(:signature_header) do
|
||||
'sig1=:WfM6q/qBqhUyqPUDt9metjadJGtLLpmMTBzk/t+R3byKe4/TGAXC6vBB/M6NsD5qv8GCmQGtisCMQxJQO0IGODGzi+Jv+eqDJ50agMVXNV6nUOzY44c4/XTPoI98qyx1oEMa4Hefy3vSYKq96iDVAc+RDLCMTeGP3wn9wizjD1SNmU0RZI1bTB+eCkywMP9mM5zXzUOYF+Qkuf+WdEpPR1XUGPlnqfdvPalcKVfaI/VThBjI91D/lmUGoa69x4EBEHM+aJmW6086e7/dVh+FndKkdGfXslZXFZKi2flTGQZgEWLn948SqAaJQROkJg8B14Sb1NONS1qZBhK3Mum8Pg==:' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'successfully verifies signature', :aggregate_failures do
|
||||
get '/activitypub/success', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Signature-Input' => signature_input,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(response.parsed_body).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: actor.id.to_s
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a valid signature on a GET request that has a query string' do
|
||||
let(:signature_input) do
|
||||
'sig1=("@method" "@target-uri");created=1703066400;keyid="https://remote.domain/users/bob#main-key"'
|
||||
end
|
||||
let(:signature_header) do
|
||||
'sig1=:JbC0oqoruKkT5p3bTASZZbHQP+EwUmT6+vBjEBw/KSkLPn+tKjEj0HHIMLA2Rw3bshZyzmsVD8+2UkPcwZYnE3gzuX0r0/gC8v4dSBfwGe7EBwpekB2xU8yHW4jawxiof2LmErvEocqcnI2uiA4IlJ09uz2Os/ARmf60lj+0Qf1qqzFeM7KoXJ331BUGMJ4cQ7iS4aO9RG4P8EJ+upe7Ik1LB/q9CZmk/6MFaB2lIemV0pcg2MwctpzMw9GWN1wL10hGxx+BPT2WCXdlPQmetVSoJ89WVV8S/4lQaCA1IucYUVDvBEFgMM//VJBuw7kg8wSTeAg9oKzbR2otLqv8Lg==:' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'successfuly verifies signature', :aggregate_failures do
|
||||
get '/activitypub/success?foo=42', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Signature-Input' => signature_input,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(response.parsed_body).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: actor.id.to_s
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mismatching query string' do
|
||||
let(:signature_input) do
|
||||
'sig1=("@method" "@target-uri");created=1703066400;keyid="https://remote.domain/users/bob#main-key"'
|
||||
end
|
||||
let(:signature_header) do
|
||||
'sig1=:JbC0oqoruKkT5p3bTASZZbHQP+EwUmT6+vBjEBw/KSkLPn+tKjEj0HHIMLA2Rw3bshZyzmsVD8+2UkPcwZYnE3gzuX0r0/gC8v4dSBfwGe7EBwpekB2xU8yHW4jawxiof2LmErvEocqcnI2uiA4IlJ09uz2Os/ARmf60lj+0Qf1qqzFeM7KoXJ331BUGMJ4cQ7iS4aO9RG4P8EJ+upe7Ik1LB/q9CZmk/6MFaB2lIemV0pcg2MwctpzMw9GWN1wL10hGxx+BPT2WCXdlPQmetVSoJ89WVV8S/4lQaCA1IucYUVDvBEFgMM//VJBuw7kg8wSTeAg9oKzbR2otLqv8Lg==:' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
get '/activitypub/success?foo=43', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Signature-Input' => signature_input,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response.parsed_body).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: anything
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a mismatching path' do
|
||||
let(:signature_input) do
|
||||
'sig1=("@method" "@target-uri");created=1703066400;keyid="https://remote.domain/users/bob#main-key"'
|
||||
end
|
||||
let(:signature_header) do
|
||||
'sig1=:WfM6q/qBqhUyqPUDt9metjadJGtLLpmMTBzk/t+R3byKe4/TGAXC6vBB/M6NsD5qv8GCmQGtisCMQxJQO0IGODGzi+Jv+eqDJ50agMVXNV6nUOzY44c4/XTPoI98qyx1oEMa4Hefy3vSYKq96iDVAc+RDLCMTeGP3wn9wizjD1SNmU0RZI1bTB+eCkywMP9mM5zXzUOYF+Qkuf+WdEpPR1XUGPlnqfdvPalcKVfaI/VThBjI91D/lmUGoa69x4EBEHM+aJmW6086e7/dVh+FndKkdGfXslZXFZKi2flTGQZgEWLn948SqAaJQROkJg8B14Sb1NONS1qZBhK3Mum8Pg==:' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
get '/activitypub/alternative-path', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Signature-Input' => signature_input,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response.parsed_body).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: anything
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a mismatching method' do
|
||||
let(:signature_input) do
|
||||
'sig1=("@method" "@target-uri");created=1703066400;keyid="https://remote.domain/users/bob#main-key"'
|
||||
end
|
||||
let(:signature_header) do
|
||||
'sig1=:WfM6q/qBqhUyqPUDt9metjadJGtLLpmMTBzk/t+R3byKe4/TGAXC6vBB/M6NsD5qv8GCmQGtisCMQxJQO0IGODGzi+Jv+eqDJ50agMVXNV6nUOzY44c4/XTPoI98qyx1oEMa4Hefy3vSYKq96iDVAc+RDLCMTeGP3wn9wizjD1SNmU0RZI1bTB+eCkywMP9mM5zXzUOYF+Qkuf+WdEpPR1XUGPlnqfdvPalcKVfaI/VThBjI91D/lmUGoa69x4EBEHM+aJmW6086e7/dVh+FndKkdGfXslZXFZKi2flTGQZgEWLn948SqAaJQROkJg8B14Sb1NONS1qZBhK3Mum8Pg==:' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
post '/activitypub/success', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Signature-Input' => signature_input,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response.parsed_body).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: anything
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a request older than a day' do
|
||||
let(:signature_input) do
|
||||
'sig1=("@method" "@target-uri");created=1702893600;keyid="https://remote.domain/users/bob#main-key"'
|
||||
end
|
||||
let(:signature_header) do
|
||||
'sig1=:LtvwxwAAyiP7fGzsgKDRUHaZNNclAq6ScmH7RY+KERgaJcrjHFuqaYraQ3d9JVNsDJzZhdtJs+7UDPfIUjYNwWj/5KRQscB2sMQ9+EYR2tBDen+K5TILv/SXoWUdvVU/3vbGMiVIACgynaXokySNrE8AGFWdrzT5NbxE+/pJ0tkB3uWO7LfFpm0ipzo0NN07CGC2AUVl6WxsiTGWtFRqVrrHFmYmRcVYn7NxkKytx8eDg95cyIsB4xAHz8i++NqZHiXaooh79OdhOy10kMWHFDbuy/AijjI3aGtGriAbXdxb8O3nwoSCvfEJ5f7qQ+iMJl/fLvFOUZElZgRo3mk2lA==:' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
get '/activitypub/success', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Signature-Input' => signature_input,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response.parsed_body).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: 'Signed request date outside acceptable time window'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a valid signature on a POST request' do
|
||||
let(:digest_header) { 'sha-256=:ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=:' }
|
||||
let(:signature_input) do
|
||||
'sig1=("@method" "@target-uri" "content-digest");created=1703066400;keyid="https://remote.domain/users/bob#main-key"'
|
||||
end
|
||||
let(:signature_header) do
|
||||
'sig1=:c4jGY/PnOV4CwyvNnAmY6NLX0sf6EtbKu7kYseNARRZaq128PrP0GNQ4cd3XsX9cbMfJMw1ntI4zuEC81ncW8g+90OHP02bX0LkT57RweUtN4CSA01hRqSVe/MW32tjGixCiItvWqjNHoIZnZApu1bd+M3zMR+VCEue4/8a0D2eRrvfQxJUUBXZR1ZTRFlf1LNFDW3U7cuTbAKYr2zWVr7on+h2vA+vzEND9WE8z1SHd6SIFFgP0QRqrCXYx+vsTs3aLusTsamRWissoycJGexb64mI9iqiD8SD+uN1xk6iRU3nkUmhUquugjlOFyjxbbLo5ZnYjsECMt/BW+Catxw==:' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'successfuly verifies signature', :aggregate_failures do
|
||||
post '/activitypub/success', params: 'Hello world', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Content-Digest' => digest_header,
|
||||
'Signature-Input' => signature_input,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(response.parsed_body).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: actor.id.to_s
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the Digest of a POST request is not signed' do
|
||||
let(:digest_header) { 'sha-256=:ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=:' }
|
||||
let(:signature_input) do
|
||||
'sig1=("@method" "@target-uri");created=1703066400;keyid="https://remote.domain/users/bob#main-key"'
|
||||
end
|
||||
let(:signature_header) do
|
||||
'sig1=:Rhc5CzxdUYCzwo3V7y5wjEIN4o2XD90Bhf7lTDg2TIlp33ygl6ufwZpQ156fLJ0aUCkJ4+9KQsHBIkxF4PZJn8d/ZIfz3dpHJAVyMErAToSw+36V61mbnnnJxIPZPvmTT3zYCL7HPv+3GItOA4SqBhjJZRRJwOIW6NmmyrmSpc8xF9klnkeyGbYYRusaG7w6BDzM7ECCttxk120v1rHkGyqVON9fQADqs2LNqPa9WM9kWKiC5LhnZSYgoojhPmhniiA4NpgprncEBo4dOIC8CJihafWVSf+CZp3eogb/hn3Yd0//Pz0ta/lVtLGdb7t9f0rQFiqZfwIcCCz51nDMKw==:' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
post '/activitypub/success', params: 'Hello world', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Content-Digest' => digest_header,
|
||||
'Signature-Input' => signature_input,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response.parsed_body).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: 'Mastodon requires the Content-Digest header to be signed when doing a POST request'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a tampered body on a POST request' do
|
||||
let(:digest_header) { 'sha-256=:ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=:' }
|
||||
let(:signature_input) do
|
||||
'sig1=("@method" "@target-uri" "content-digest");created=1703066400;keyid="https://remote.domain/users/bob#main-key"'
|
||||
end
|
||||
let(:signature_header) do
|
||||
'sig1=:c4jGY/PnOV4CwyvNnAmY6NLX0sf6EtbKu7kYseNARRZaq128PrP0GNQ4cd3XsX9cbMfJMw1ntI4zuEC81ncW8g+90OHP02bX0LkT57RweUtN4CSA01hRqSVe/MW32tjGixCiItvWqjNHoIZnZApu1bd+M3zMR+VCEue4/8a0D2eRrvfQxJUUBXZR1ZTRFlf1LNFDW3U7cuTbAKYr2zWVr7on+h2vA+vzEND9WE8z1SHd6SIFFgP0QRqrCXYx+vsTs3aLusTsamRWissoycJGexb64mI9iqiD8SD+uN1xk6iRU3nkUmhUquugjlOFyjxbbLo5ZnYjsECMt/BW+Catxw==:' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
post '/activitypub/success', params: 'Hello world!', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Content-Digest' => digest_header,
|
||||
'Signature-Input' => signature_input,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response.parsed_body).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: 'Invalid Digest value. Computed SHA-256 digest: wFNeS+K3n/2TKRMFQ2v4iTFOSj+uwF7P/Lt98xrZ5Ro=; given: ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw='
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a tampered path in a POST request' do
|
||||
let(:digest_header) { 'sha-256=:ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=:' }
|
||||
let(:signature_input) do
|
||||
'sig1=("@method" "@target-uri" "content-digest");created=1703066400;keyid="https://remote.domain/users/bob#main-key"'
|
||||
end
|
||||
let(:signature_header) do
|
||||
'sig1=:aJmJgCOuAJD1su2QeeD7Y9wfda8dqReyuau1EBWAz1DYwKWx5kVosONGgJb+XZFugh6CQ15XDFz0vRJRYdt6GyquloOFYLzPYWp3mYRlMhvehR64ALeGIwJbb460/tOeX2PwaFNVBrqLBAHf8PZDAPCxE8Q9cPWhewwQQirBZTm0xhOy8nRkSEfMish87JEQLkEzH+pZQDYIpv+oE+Tz6gow6bllCmjUd8vgLABpc7sZJTz5qklfOMqFczW6HvVxvQK/9G7V509u2z5I2PC/q+XdEs+jC0uzuer5baTJgL2q37gvnKzmz7pB+kbtBz5tmGNEMVLtPYQIEYjVI4Y34Q==:' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
post '/activitypub/alternative-path', params: 'Hello world', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Content-Digest' => digest_header,
|
||||
'Signature-Input' => signature_input,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(response.parsed_body).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: anything
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an inaccessible key' do
|
||||
let(:signature_input) do
|
||||
'sig1=("@method" "@target-uri");created=1703066400;keyid="https://remote.domain/users/alice#main-key"'
|
||||
end
|
||||
let(:signature_header) do
|
||||
'sig1=:WfM6q/qBqhUyqPUDt9metjadJGtLLpmMTBzk/t+R3byKe4/TGAXC6vBB/M6NsD5qv8GCmQGtisCMQxJQO0IGODGzi+Jv+eqDJ50agMVXNV6nUOzY44c4/XTPoI98qyx1oEMa4Hefy3vSYKq96iDVAc+RDLCMTeGP3wn9wizjD1SNmU0RZI1bTB+eCkywMP9mM5zXzUOYF+Qkuf+WdEpPR1XUGPlnqfdvPalcKVfaI/VThBjI91D/lmUGoa69x4EBEHM+aJmW6086e7/dVh+FndKkdGfXslZXFZKi2flTGQZgEWLn948SqAaJQROkJg8B14Sb1NONS1qZBhK3Mum8Pg==:' # rubocop:disable Layout/LineLength
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://remote.domain/users/alice#main-key').to_return(status: 404)
|
||||
end
|
||||
|
||||
it 'fails to verify signature', :aggregate_failures do
|
||||
get '/activitypub/success', headers: {
|
||||
'Host' => 'www.example.com',
|
||||
'Signature-Input' => signature_input,
|
||||
'Signature' => signature_header,
|
||||
}
|
||||
|
||||
expect(response.parsed_body).to match(
|
||||
signed_request: true,
|
||||
signature_actor_id: nil,
|
||||
error: 'Unable to fetch key JSON at https://remote.domain/users/alice#main-key'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue