Convert signature verification specs to request specs (#28443)
This commit is contained in:
		
							parent
							
								
									bb8077e784
								
							
						
					
					
						commit
						a2624ff739
					
				| @ -1,305 +0,0 @@ | |||||||
| # frozen_string_literal: true |  | ||||||
| 
 |  | ||||||
| require 'rails_helper' |  | ||||||
| 
 |  | ||||||
| describe SignatureVerification do |  | ||||||
|   let(:wrapped_actor_class) do |  | ||||||
|     Class.new do |  | ||||||
|       attr_reader :wrapped_account |  | ||||||
| 
 |  | ||||||
|       def initialize(wrapped_account) |  | ||||||
|         @wrapped_account = wrapped_account |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       delegate :uri, :keypair, to: :wrapped_account |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   controller(ApplicationController) do |  | ||||||
|     include SignatureVerification |  | ||||||
| 
 |  | ||||||
|     before_action :require_actor_signature!, only: [:signature_required] |  | ||||||
| 
 |  | ||||||
|     def success |  | ||||||
|       head 200 |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     def alternative_success |  | ||||||
|       head 200 |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     def signature_required |  | ||||||
|       head 200 |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   before do |  | ||||||
|     routes.draw do |  | ||||||
|       match :via => [:get, :post], 'success' => 'anonymous#success' |  | ||||||
|       match :via => [:get, :post], 'signature_required' => 'anonymous#signature_required' |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   context 'without signature header' do |  | ||||||
|     before do |  | ||||||
|       get :success |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     describe '#signed_request?' do |  | ||||||
|       it 'returns false' do |  | ||||||
|         expect(controller.signed_request?).to be false |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     describe '#signed_request_account' do |  | ||||||
|       it 'returns nil' do |  | ||||||
|         expect(controller.signed_request_account).to be_nil |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   context 'with signature header' do |  | ||||||
|     let!(:author) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } |  | ||||||
| 
 |  | ||||||
|     context 'without body' do |  | ||||||
|       before do |  | ||||||
|         get :success |  | ||||||
| 
 |  | ||||||
|         fake_request = Request.new(:get, request.url) |  | ||||||
|         fake_request.on_behalf_of(author) |  | ||||||
| 
 |  | ||||||
|         request.headers.merge!(fake_request.headers) |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request?' do |  | ||||||
|         it 'returns true' do |  | ||||||
|           expect(controller.signed_request?).to be true |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request_account' do |  | ||||||
|         it 'returns an account' do |  | ||||||
|           expect(controller.signed_request_account).to eq author |  | ||||||
|         end |  | ||||||
| 
 |  | ||||||
|         it 'returns nil when path does not match' do |  | ||||||
|           request.path = '/alternative-path' |  | ||||||
|           expect(controller.signed_request_account).to be_nil |  | ||||||
|         end |  | ||||||
| 
 |  | ||||||
|         it 'returns nil when method does not match' do |  | ||||||
|           post :success |  | ||||||
|           expect(controller.signed_request_account).to be_nil |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     context 'with a valid actor that is not an Account' do |  | ||||||
|       let(:actor) { wrapped_actor_class.new(author) } |  | ||||||
| 
 |  | ||||||
|       before do |  | ||||||
|         get :success |  | ||||||
| 
 |  | ||||||
|         fake_request = Request.new(:get, request.url) |  | ||||||
|         fake_request.on_behalf_of(author) |  | ||||||
| 
 |  | ||||||
|         request.headers.merge!(fake_request.headers) |  | ||||||
| 
 |  | ||||||
|         allow(ActivityPub::TagManager.instance).to receive(:uri_to_actor).with(anything) do |  | ||||||
|           actor |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request?' do |  | ||||||
|         it 'returns true' do |  | ||||||
|           expect(controller.signed_request?).to be true |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request_account' do |  | ||||||
|         it 'returns nil' do |  | ||||||
|           expect(controller.signed_request_account).to be_nil |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request_actor' do |  | ||||||
|         it 'returns the expected actor' do |  | ||||||
|           expect(controller.signed_request_actor).to eq actor |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     context 'with request with unparsable Date header' do |  | ||||||
|       before do |  | ||||||
|         get :success |  | ||||||
| 
 |  | ||||||
|         fake_request = Request.new(:get, request.url) |  | ||||||
|         fake_request.add_headers({ 'Date' => 'wrong date' }) |  | ||||||
|         fake_request.on_behalf_of(author) |  | ||||||
| 
 |  | ||||||
|         request.headers.merge!(fake_request.headers) |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request?' do |  | ||||||
|         it 'returns true' do |  | ||||||
|           expect(controller.signed_request?).to be true |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request_account' do |  | ||||||
|         it 'returns nil' do |  | ||||||
|           expect(controller.signed_request_account).to be_nil |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signature_verification_failure_reason' do |  | ||||||
|         it 'contains an error description' do |  | ||||||
|           controller.signed_request_account |  | ||||||
|           expect(controller.signature_verification_failure_reason[:error]).to eq 'Invalid Date header: not RFC 2616 compliant date: "wrong date"' |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     context 'with request older than a day' do |  | ||||||
|       before do |  | ||||||
|         get :success |  | ||||||
| 
 |  | ||||||
|         fake_request = Request.new(:get, request.url) |  | ||||||
|         fake_request.add_headers({ 'Date' => 2.days.ago.utc.httpdate }) |  | ||||||
|         fake_request.on_behalf_of(author) |  | ||||||
| 
 |  | ||||||
|         request.headers.merge!(fake_request.headers) |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request?' do |  | ||||||
|         it 'returns true' do |  | ||||||
|           expect(controller.signed_request?).to be true |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request_account' do |  | ||||||
|         it 'returns nil' do |  | ||||||
|           expect(controller.signed_request_account).to be_nil |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signature_verification_failure_reason' do |  | ||||||
|         it 'contains an error description' do |  | ||||||
|           controller.signed_request_account |  | ||||||
|           expect(controller.signature_verification_failure_reason[:error]).to eq 'Signed request date outside acceptable time window' |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     context 'with inaccessible key' do |  | ||||||
|       before do |  | ||||||
|         get :success |  | ||||||
| 
 |  | ||||||
|         author = Fabricate(:account, domain: 'localhost:5000', uri: 'http://localhost:5000/actor') |  | ||||||
|         fake_request = Request.new(:get, request.url) |  | ||||||
|         fake_request.on_behalf_of(author) |  | ||||||
|         author.destroy |  | ||||||
| 
 |  | ||||||
|         request.headers.merge!(fake_request.headers) |  | ||||||
| 
 |  | ||||||
|         stub_request(:get, 'http://localhost:5000/actor#main-key').to_raise(Mastodon::HostValidationError) |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request?' do |  | ||||||
|         it 'returns true' do |  | ||||||
|           expect(controller.signed_request?).to be true |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request_account' do |  | ||||||
|         it 'returns nil' do |  | ||||||
|           expect(controller.signed_request_account).to be_nil |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     context 'with body' do |  | ||||||
|       before do |  | ||||||
|         allow(controller).to receive(:actor_refresh_key!).and_return(author) |  | ||||||
|         post :success, body: 'Hello world' |  | ||||||
| 
 |  | ||||||
|         fake_request = Request.new(:post, request.url, body: 'Hello world') |  | ||||||
|         fake_request.on_behalf_of(author) |  | ||||||
| 
 |  | ||||||
|         request.headers.merge!(fake_request.headers) |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request?' do |  | ||||||
|         it 'returns true' do |  | ||||||
|           expect(controller.signed_request?).to be true |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       describe '#signed_request_account' do |  | ||||||
|         it 'returns an account' do |  | ||||||
|           expect(controller.signed_request_account).to eq author |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       context 'when path does not match' do |  | ||||||
|         before do |  | ||||||
|           request.path = '/alternative-path' |  | ||||||
|         end |  | ||||||
| 
 |  | ||||||
|         describe '#signed_request_account' do |  | ||||||
|           it 'returns nil' do |  | ||||||
|             expect(controller.signed_request_account).to be_nil |  | ||||||
|           end |  | ||||||
|         end |  | ||||||
| 
 |  | ||||||
|         describe '#signature_verification_failure_reason' do |  | ||||||
|           it 'contains an error description' do |  | ||||||
|             controller.signed_request_account |  | ||||||
|             expect(controller.signature_verification_failure_reason[:error]).to include('using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)') |  | ||||||
|             expect(controller.signature_verification_failure_reason[:signed_string]).to include("(request-target): post /alternative-path\n") |  | ||||||
|           end |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       context 'when method does not match' do |  | ||||||
|         before do |  | ||||||
|           get :success |  | ||||||
|         end |  | ||||||
| 
 |  | ||||||
|         describe '#signed_request_account' do |  | ||||||
|           it 'returns nil' do |  | ||||||
|             expect(controller.signed_request_account).to be_nil |  | ||||||
|           end |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       context 'when body has been tampered' do |  | ||||||
|         before do |  | ||||||
|           post :success, body: 'doo doo doo' |  | ||||||
|         end |  | ||||||
| 
 |  | ||||||
|         describe '#signed_request_account' do |  | ||||||
|           it 'returns nil when body has been tampered' do |  | ||||||
|             expect(controller.signed_request_account).to be_nil |  | ||||||
|           end |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   context 'when a signature is required' do |  | ||||||
|     before do |  | ||||||
|       get :signature_required |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     context 'without signature header' do |  | ||||||
|       it 'returns HTTP 401' do |  | ||||||
|         expect(response).to have_http_status(401) |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       it 'returns an error' do |  | ||||||
|         expect(Oj.load(response.body)['error']).to eq 'Request not signed' |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| end |  | ||||||
							
								
								
									
										332
									
								
								spec/requests/signature_verification_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										332
									
								
								spec/requests/signature_verification_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,332 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | require 'rails_helper' | ||||||
|  | 
 | ||||||
|  | describe 'signature verification concern' do | ||||||
|  |   before do | ||||||
|  |     stub_tests_controller | ||||||
|  | 
 | ||||||
|  |     # Signature checking is time-dependent, so travel to a fixed date | ||||||
|  |     travel_to '2023-12-20T10:00:00Z' | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   after { Rails.application.reload_routes! } | ||||||
|  | 
 | ||||||
|  |   # Include the private key so the tests can be easily adjusted and reviewed | ||||||
|  |   let(:actor_keypair) do | ||||||
|  |     OpenSSL::PKey.read(<<~PEM_TEXT) | ||||||
|  |       -----BEGIN RSA PRIVATE KEY----- | ||||||
|  |       MIIEowIBAAKCAQEAqIAYvNFGbZ5g4iiK6feSdXD4bDStFM58A7tHycYXaYtzZQpI | ||||||
|  |       eHXAmaXuZzXIwtrP4N0gIk8JNwZvXj2UPS+S07t0V9wNK94he01LV5EMz/GN4eNn | ||||||
|  |       FmDL64HIEuKLvV8TvgjbUPRD6Y5X0UpKi2ZIFLSb96Q5w0Z/k7ntpVKV52y8kz5F | ||||||
|  |       jr/O/0JuHryZe0yItzJh8kzFfeMf0EXzfSnaKvT7P9jhgC6uTre+jXyvVZjiHDrn | ||||||
|  |       qvvucdI3I7DRfXo1OqARBrLjy+TdseUAjNYJ+OuPRI1URIWQI01DCHqcohVu9+Ar | ||||||
|  |       +BiCjFp3ua+XMuJvrvbD61d1Fvig/9nbBRR+8QIDAQABAoIBAAgySHnFWI6gItR3 | ||||||
|  |       fkfiqIm80cHCN3Xk1C6iiVu+3oBOZbHpW9R7vl9e/WOA/9O+LPjiSsQOegtWnVvd | ||||||
|  |       RRjrl7Hj20VDlZKv5Mssm6zOGAxksrcVbqwdj+fUJaNJCL0AyyseH0x/IE9T8rDC | ||||||
|  |       I1GH+3tB3JkhkIN/qjipdX5ab8MswEPu8IC4ViTpdBgWYY/xBcAHPw4xuL0tcwzh | ||||||
|  |       FBlf4DqoEVQo8GdK5GAJ2Ny0S4xbXHUURzx/R4y4CCts7niAiLGqd9jmLU1kUTMk | ||||||
|  |       QcXfQYK6l+unLc7wDYAz7sFEHh04M48VjWwiIZJnlCqmQbLda7uhhu8zkF1DqZTu | ||||||
|  |       ulWDGQECgYEA0TIAc8BQBVab979DHEEmMdgqBwxLY3OIAk0b+r50h7VBGWCDPRsC | ||||||
|  |       STD73fQY3lNet/7/jgSGwwAlAJ5PpMXxXiZAE3bUwPmHzgF7pvIOOLhA8O07tHSO | ||||||
|  |       L2mvQe6NPzjZ+6iAO2U9PkClxcvGvPx2OBvisfHqZLmxC9PIVxzruQECgYEAzjM6 | ||||||
|  |       BTUXa6T/qHvLFbN699BXsUOGmHBGaLRapFDBfVvgZrwqYQcZpBBhesLdGTGSqwE7 | ||||||
|  |       gWsITPIJ+Ldo+38oGYyVys+w/V67q6ud7hgSDTW3hSvm+GboCjk6gzxlt9hQ0t9X | ||||||
|  |       8vfDOYhEXvVUJNv3mYO60ENqQhILO4bQ0zi+VfECgYBb/nUccfG+pzunU0Cb6Dp3 | ||||||
|  |       qOuydcGhVmj1OhuXxLFSDG84Tazo7juvHA9mp7VX76mzmDuhpHPuxN2AzB2SBEoE | ||||||
|  |       cSW0aYld413JRfWukLuYTc6hJHIhBTCRwRQFFnae2s1hUdQySm8INT2xIc+fxBXo | ||||||
|  |       zrp+Ljg5Wz90SAnN5TX0AQKBgDaatDOq0o/r+tPYLHiLtfWoE4Dau+rkWJDjqdk3 | ||||||
|  |       lXWn/e3WyHY3Vh/vQpEqxzgju45TXjmwaVtPATr+/usSykCxzP0PMPR3wMT+Rm1F | ||||||
|  |       rIoY/odij+CaB7qlWwxj0x/zRbwB7x1lZSp4HnrzBpxYL+JUUwVRxPLIKndSBTza | ||||||
|  |       GvVRAoGBAIVBcNcRQYF4fvZjDKAb4fdBsEuHmycqtRCsnkGOz6ebbEQznSaZ0tZE | ||||||
|  |       +JuouZaGjyp8uPjNGD5D7mIGbyoZ3KyG4mTXNxDAGBso1hrNDKGBOrGaPhZx8LgO | ||||||
|  |       4VXJ+ybXrATf4jr8ccZYsZdFpOphPzz+j55Mqg5vac5P1XjmsGTb | ||||||
|  |       -----END RSA PRIVATE KEY----- | ||||||
|  |     PEM_TEXT | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   context 'without a Signature header' do | ||||||
|  |     it 'does not treat the request as signed' do | ||||||
|  |       get '/activitypub/success' | ||||||
|  | 
 | ||||||
|  |       expect(response).to have_http_status(200) | ||||||
|  |       expect(body_as_json).to match( | ||||||
|  |         signed_request: false, | ||||||
|  |         signature_actor_id: nil, | ||||||
|  |         error: 'Request not signed' | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'when a signature is required' do | ||||||
|  |       it 'returns http unauthorized with appropriate error' do | ||||||
|  |         get '/activitypub/signature_required' | ||||||
|  | 
 | ||||||
|  |         expect(response).to have_http_status(401) | ||||||
|  |         expect(body_as_json).to match( | ||||||
|  |           error: 'Request not signed' | ||||||
|  |         ) | ||||||
|  |       end | ||||||
|  |     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 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(body_as_json).to match( | ||||||
|  |           signed_request: true, | ||||||
|  |           signature_actor_id: actor.id.to_s | ||||||
|  |         ) | ||||||
|  |       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(body_as_json).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(body_as_json).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(body_as_json).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(body_as_json).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(body_as_json).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(body_as_json).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(body_as_json).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(body_as_json).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' => '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(body_as_json).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 | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def stub_tests_controller | ||||||
|  |     stub_const('ActivityPub::TestsController', activitypub_tests_controller) | ||||||
|  | 
 | ||||||
|  |     Rails.application.routes.draw do | ||||||
|  |       # NOTE: RouteSet#draw removes all routes, so we need to re-insert one | ||||||
|  |       resource :instance_actor, path: 'actor', only: [:show] | ||||||
|  | 
 | ||||||
|  |       match :via => [:get, :post], '/activitypub/success' => 'activitypub/tests#success' | ||||||
|  |       match :via => [:get, :post], '/activitypub/alternative-path' => 'activitypub/tests#alternative_success' | ||||||
|  |       match :via => [:get, :post], '/activitypub/signature_required' => 'activitypub/tests#signature_required' | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def activitypub_tests_controller | ||||||
|  |     Class.new(ApplicationController) do | ||||||
|  |       include SignatureVerification | ||||||
|  | 
 | ||||||
|  |       before_action :require_actor_signature!, only: [:signature_required] | ||||||
|  | 
 | ||||||
|  |       def success | ||||||
|  |         render json: { | ||||||
|  |           signed_request: signed_request?, | ||||||
|  |           signature_actor_id: signed_request_actor&.id&.to_s, | ||||||
|  |         }.merge(signature_verification_failure_reason || {}) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       alias_method :alternative_success, :success | ||||||
|  |       alias_method :signature_required, :success | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def digest_value(body) | ||||||
|  |     "SHA-256=#{Digest::SHA256.base64digest(body)}" | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def build_signature_string(keypair, key_id, request_target, headers) | ||||||
|  |     algorithm = 'rsa-sha256' | ||||||
|  |     signed_headers = headers.merge({ '(request-target)' => request_target }) | ||||||
|  |     signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n") | ||||||
|  |     signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string)) | ||||||
|  | 
 | ||||||
|  |     "keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\"" | ||||||
|  |   end | ||||||
|  | end | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user