diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index ffe612f468..b5c2ba0013 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -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) diff --git a/app/lib/signed_request.rb b/app/lib/signed_request.rb new file mode 100644 index 0000000000..066411c680 --- /dev/null +++ b/app/lib/signed_request.rb @@ -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 diff --git a/spec/requests/signature_verification_spec.rb b/spec/requests/signature_verification_spec.rb index 128e7c0787..9f516d154d 100644 --- a/spec/requests/signature_verification_spec.rb +++ b/spec/requests/signature_verification_spec.rb @@ -69,284 +69,558 @@ RSpec.describe 'signature verification concern' do end end - context 'with an HTTP Signature from 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 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 - let(:signature_header) do - 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="' # rubocop:disable Layout/LineLength + context 'with a valid signature on a GET request' do + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="' # rubocop:disable Layout/LineLength + end + + it 'successfuly verifies signature', :aggregate_failures do + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) + + get '/activitypub/success', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + '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 - it 'successfuly verifies signature', :aggregate_failures do - expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) + context 'with a valid signature on a GET request that has a query string' do + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # rubocop:disable Layout/LineLength + end + it 'successfuly verifies signature', :aggregate_failures do + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) + + get '/activitypub/success?foo=42', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + '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 query string is missing from the signature verification (compatibility quirk)' do + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="' # rubocop:disable Layout/LineLength + end + + it 'successfuly verifies signature', :aggregate_failures do + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) + + get '/activitypub/success?foo=42', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + '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_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # rubocop:disable Layout/LineLength + end + + it 'fails to verify signature', :aggregate_failures do + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) + + get '/activitypub/success?foo=43', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + '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 + it 'fails to verify signature', :aggregate_failures do + get '/activitypub/alternative-path', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength + } + + expect(response.parsed_body).to match( + signed_request: true, + signature_actor_id: nil, + error: anything + ) + end + end + + context 'with a mismatching method' do + it 'fails to verify signature', :aggregate_failures do + post '/activitypub/success', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength + } + + expect(response.parsed_body).to match( + signed_request: true, + signature_actor_id: nil, + error: anything + ) + end + end + + context 'with an unparsable date' do + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="d4B7nfx8RJcfdJDu1J//5WzPzK/hgtPkdzZx49lu5QhnE7qdV3lgyVimmhCFrO16bwvzIp9iRMyRLkNFxLiEeVaa1gqeKbldGSnU0B0OMjx7rFBa65vLuzWQOATDitVGiBEYqoK4v0DMuFCz2DtFaA/DIUZ3sty8bZ/Ea3U1nByLOO6MacARA3zhMSI0GNxGqsSmZmG0hPLavB3jIXoE3IDoQabMnC39jrlcO/a8h1iaxBm2WD8TejrImJullgqlJIFpKhIHI3ipQkvTGPlm9dx0y+beM06qBvWaWQcmT09eRIUefVsOAzIhUtS/7FVb/URhZvircIJDa7vtiFcmZQ=="' # rubocop:disable Layout/LineLength + end + + it 'fails to verify signature', :aggregate_failures do + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'wrong date', 'Host' => 'www.example.com' }) + + get '/activitypub/success', headers: { + 'Host' => 'www.example.com', + 'Date' => 'wrong date', + 'Signature' => signature_header, + } + + expect(response.parsed_body).to match( + signed_request: true, + signature_actor_id: nil, + error: 'Invalid Date header: not RFC 2616 compliant date: "wrong date"' + ) + end + end + + context 'with a request older than a day' do + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="G1NuJv4zgoZ3B/ZIjzDWZHK4RC+5pYee74q8/LJEMCWXhcnAomcb9YHaqk1QYfQvcBUIXw3UZ3Q9xO8F9y0i8G5mzJHfQ+OgHqCoJk8EmGwsUXJMh5s1S5YFCRt8TT12TmJZz0VMqLq85ubueSYBM7QtUE/FzFIVLvz4RysgXxaXQKzdnM6+gbUEEKdCURpXdQt2NXQhp4MAmZH3+0lQoR6VxdsK0hx0Ji2PNp1nuqFTlYqNWZazVdLBN+9rETLRmvGXknvg9jOxTTppBVWnkAIl26HtLS3wwFVvz4pJzi9OQDOvLziehVyLNbU61hky+oJ215e2HuKSe2hxHNl1MA=="' # rubocop:disable Layout/LineLength + end + + it 'fails to verify signature', :aggregate_failures do + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 18 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) + + get '/activitypub/success', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 18 Dec 2023 10:00:00 GMT', + '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_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="' # rubocop:disable Layout/LineLength + end + + it 'successfuly verifies signature', :aggregate_failures do + expect(digest_header).to eq digest_value('Hello world') + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Digest' => digest_header }) + + post '/activitypub/success', params: 'Hello world', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Digest' => digest_header, + '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_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date (request-target)",signature="CPD704CG8aCm8X8qIP8kkkiGp1qwFLk/wMVQHOGP0Txxan8c2DZtg/KK7eN8RG8tHx8br/yS2hJs51x4kXImYukGzNJd7ihE3T8lp+9RI1tCcdobTzr/VcVJHDFySdQkg266GCMijRQRZfNvqlJLiisr817PI+gNVBI5qV+vnVd1XhWCEZ+YSmMe8UqYARXAYNqMykTheojqGpTeTFGPUpTQA2Fmt2BipwIjcFDm2Hpihl2kB0MUS0x3zPmHDuadvzoBbN6m3usPDLgYrpALlh+wDs1dYMntcwdwawRKY1oE1XNtgOSum12wntDq3uYL4gya2iPdcw3c929b4koUzw=="' # rubocop:disable Layout/LineLength + end + + it 'fails to verify signature', :aggregate_failures do + expect(digest_header).to eq digest_value('Hello world') + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT' }) + + post '/activitypub/success', params: 'Hello world', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Digest' => digest_header, + 'Signature' => signature_header, + } + + expect(response.parsed_body).to match( + signed_request: true, + signature_actor_id: nil, + error: 'Mastodon requires the 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_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="' # rubocop:disable Layout/LineLength + end + + it 'fails to verify signature', :aggregate_failures do + expect(digest_header).to_not eq digest_value('Hello world!') + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Digest' => digest_header }) + + post '/activitypub/success', params: 'Hello world!', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Digest' => 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=', + '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 + it 'fails to verify signature', :aggregate_failures do + post '/activitypub/alternative-path', params: 'Hello world', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Digest' => 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=', + 'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="', # rubocop:disable Layout/LineLength + } + + 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 + 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', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', - '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_header) do - 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # rubocop:disable Layout/LineLength - end - - it 'successfuly verifies signature', :aggregate_failures do - expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) - - get '/activitypub/success?foo=42', headers: { - 'Host' => 'www.example.com', - 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', - '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 query string is missing from the signature verification (compatibility quirk)' do - let(:signature_header) do - 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="' # rubocop:disable Layout/LineLength - end - - it 'successfuly verifies signature', :aggregate_failures do - expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) - - get '/activitypub/success?foo=42', headers: { - 'Host' => 'www.example.com', - 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', - '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_header) do - 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # rubocop:disable Layout/LineLength - end - - it 'fails to verify signature', :aggregate_failures do - expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) - - get '/activitypub/success?foo=43', headers: { - 'Host' => 'www.example.com', - 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', - 'Signature' => signature_header, + 'Signature' => 'keyId="https://remote.domain/users/alice#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength } expect(response.parsed_body).to match( signed_request: true, signature_actor_id: nil, - error: anything - ) - end - end - - context 'with a mismatching path' do - it 'fails to verify signature', :aggregate_failures do - get '/activitypub/alternative-path', headers: { - 'Host' => 'www.example.com', - 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', - 'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength - } - - expect(response.parsed_body).to match( - signed_request: true, - signature_actor_id: nil, - error: anything - ) - end - end - - context 'with a mismatching method' do - it 'fails to verify signature', :aggregate_failures do - post '/activitypub/success', headers: { - 'Host' => 'www.example.com', - 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', - 'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength - } - - expect(response.parsed_body).to match( - signed_request: true, - signature_actor_id: nil, - error: anything - ) - end - end - - context 'with an unparsable date' do - let(:signature_header) do - 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="d4B7nfx8RJcfdJDu1J//5WzPzK/hgtPkdzZx49lu5QhnE7qdV3lgyVimmhCFrO16bwvzIp9iRMyRLkNFxLiEeVaa1gqeKbldGSnU0B0OMjx7rFBa65vLuzWQOATDitVGiBEYqoK4v0DMuFCz2DtFaA/DIUZ3sty8bZ/Ea3U1nByLOO6MacARA3zhMSI0GNxGqsSmZmG0hPLavB3jIXoE3IDoQabMnC39jrlcO/a8h1iaxBm2WD8TejrImJullgqlJIFpKhIHI3ipQkvTGPlm9dx0y+beM06qBvWaWQcmT09eRIUefVsOAzIhUtS/7FVb/URhZvircIJDa7vtiFcmZQ=="' # rubocop:disable Layout/LineLength - end - - it 'fails to verify signature', :aggregate_failures do - expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'wrong date', 'Host' => 'www.example.com' }) - - get '/activitypub/success', headers: { - 'Host' => 'www.example.com', - 'Date' => 'wrong date', - 'Signature' => signature_header, - } - - expect(response.parsed_body).to match( - signed_request: true, - signature_actor_id: nil, - error: 'Invalid Date header: not RFC 2616 compliant date: "wrong date"' - ) - end - end - - context 'with a request older than a day' do - let(:signature_header) do - 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="G1NuJv4zgoZ3B/ZIjzDWZHK4RC+5pYee74q8/LJEMCWXhcnAomcb9YHaqk1QYfQvcBUIXw3UZ3Q9xO8F9y0i8G5mzJHfQ+OgHqCoJk8EmGwsUXJMh5s1S5YFCRt8TT12TmJZz0VMqLq85ubueSYBM7QtUE/FzFIVLvz4RysgXxaXQKzdnM6+gbUEEKdCURpXdQt2NXQhp4MAmZH3+0lQoR6VxdsK0hx0Ji2PNp1nuqFTlYqNWZazVdLBN+9rETLRmvGXknvg9jOxTTppBVWnkAIl26HtLS3wwFVvz4pJzi9OQDOvLziehVyLNbU61hky+oJ215e2HuKSe2hxHNl1MA=="' # rubocop:disable Layout/LineLength - end - - it 'fails to verify signature', :aggregate_failures do - expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 18 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) - - get '/activitypub/success', headers: { - 'Host' => 'www.example.com', - 'Date' => 'Wed, 18 Dec 2023 10:00:00 GMT', - '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_header) do - 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="' # rubocop:disable Layout/LineLength - end - - it 'successfuly verifies signature', :aggregate_failures do - expect(digest_header).to eq digest_value('Hello world') - expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Digest' => digest_header }) - - post '/activitypub/success', params: 'Hello world', headers: { - 'Host' => 'www.example.com', - 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', - 'Digest' => digest_header, - '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_header) do - 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date (request-target)",signature="CPD704CG8aCm8X8qIP8kkkiGp1qwFLk/wMVQHOGP0Txxan8c2DZtg/KK7eN8RG8tHx8br/yS2hJs51x4kXImYukGzNJd7ihE3T8lp+9RI1tCcdobTzr/VcVJHDFySdQkg266GCMijRQRZfNvqlJLiisr817PI+gNVBI5qV+vnVd1XhWCEZ+YSmMe8UqYARXAYNqMykTheojqGpTeTFGPUpTQA2Fmt2BipwIjcFDm2Hpihl2kB0MUS0x3zPmHDuadvzoBbN6m3usPDLgYrpALlh+wDs1dYMntcwdwawRKY1oE1XNtgOSum12wntDq3uYL4gya2iPdcw3c929b4koUzw=="' # rubocop:disable Layout/LineLength - end - - it 'fails to verify signature', :aggregate_failures do - expect(digest_header).to eq digest_value('Hello world') - expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT' }) - - post '/activitypub/success', params: 'Hello world', headers: { - 'Host' => 'www.example.com', - 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', - 'Digest' => digest_header, - 'Signature' => signature_header, - } - - expect(response.parsed_body).to match( - signed_request: true, - signature_actor_id: nil, - error: 'Mastodon requires the 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_header) do - 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="' # rubocop:disable Layout/LineLength - end - - it 'fails to verify signature', :aggregate_failures do - expect(digest_header).to_not eq digest_value('Hello world!') - expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Digest' => digest_header }) - - post '/activitypub/success', params: 'Hello world!', headers: { - 'Host' => 'www.example.com', - 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', - 'Digest' => 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=', - '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 - it 'fails to verify signature', :aggregate_failures do - post '/activitypub/alternative-path', params: 'Hello world', headers: { - 'Host' => 'www.example.com', - 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', - 'Digest' => 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=', - 'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="', # rubocop:disable Layout/LineLength - } - - expect(response).to have_http_status(200) - expect(response.parsed_body).to match( - signed_request: true, - signature_actor_id: nil, - error: anything + error: 'Unable to fetch key JSON at https://remote.domain/users/alice#main-key' ) end end end - context 'with an inaccessible key' do - before do - stub_request(:get, 'https://remote.domain/users/alice#main-key').to_return(status: 404) + 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 - it 'fails to verify signature', :aggregate_failures do - get '/activitypub/success', headers: { - 'Host' => 'www.example.com', - 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', - 'Signature' => 'keyId="https://remote.domain/users/alice#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength - } + 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 - 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' - ) + 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