diff --git a/Gemfile b/Gemfile index 126d1bebbd..6a5f0c5343 100644 --- a/Gemfile +++ b/Gemfile @@ -62,7 +62,7 @@ gem 'inline_svg' gem 'irb', '~> 1.8' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' -gem 'linzer', '~> 0.6.1' +gem 'linzer', '~> 0.7.2' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar' gem 'mutex_m' diff --git a/Gemfile.lock b/Gemfile.lock index 2139c01c97..7ae8d412b1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -148,6 +148,7 @@ GEM case_transform (0.2) activesupport cbor (0.5.9.8) + cgi (0.4.2) charlock_holmes (0.7.9) chewy (7.6.0) activesupport (>= 5.2) @@ -262,6 +263,7 @@ GEM fog-core (~> 2.1) fog-json (>= 1.0) formatador (1.1.0) + forwardable (1.3.3) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -396,7 +398,11 @@ GEM rexml link_header (0.0.8) lint_roller (1.1.0) - linzer (0.6.5) + linzer (0.7.2) + cgi (~> 0.4.2) + forwardable (~> 1.3, >= 1.3.3) + logger (~> 1.7, >= 1.7.0) + net-http (~> 0.6.0) openssl (~> 3.0, >= 3.0.0) rack (>= 2.2, < 4.0) starry (~> 0.2) @@ -994,7 +1000,7 @@ DEPENDENCIES letter_opener (~> 1.8) letter_opener_web (~> 3.0) link_header (~> 0.0) - linzer (~> 0.6.1) + linzer (~> 0.7.2) lograge (~> 0.12) mail (~> 2.8) mario-redis-lock (~> 1.2) diff --git a/app/controllers/api/fasp/base_controller.rb b/app/controllers/api/fasp/base_controller.rb index 690f7e419a..f786ea1767 100644 --- a/app/controllers/api/fasp/base_controller.rb +++ b/app/controllers/api/fasp/base_controller.rb @@ -42,37 +42,22 @@ class Api::Fasp::BaseController < ApplicationController end def validate_signature! - signature_input = request.headers['signature-input']&.encode('UTF-8') - raise Error, 'signature-input is missing' if signature_input.blank? + raise Error, 'signature-input is missing' if request.headers['signature-input'].blank? + + provider = nil + + Linzer.verify!(request.rack_request, no_older_than: 5.minutes) do |keyid| + provider = Fasp::Provider.find(keyid) + Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid) + end - keyid = signature_input.match(KEYID_PATTERN)[1] - provider = Fasp::Provider.find(keyid) - linzer_request = Linzer.new_request( - request.method, - request.original_url, - {}, - { - 'content-digest' => request.headers['content-digest'], - 'signature-input' => signature_input, - 'signature' => request.headers['signature'], - } - ) - message = Linzer::Message.new(linzer_request) - key = Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid) - signature = Linzer::Signature.build(message.headers) - Linzer.verify(key, message, signature) @current_provider = provider end def sign_response response.headers['content-digest'] = "sha-256=:#{OpenSSL::Digest.base64digest('sha256', response.body || '')}:" - - linzer_response = Linzer.new_response(response.body, response.status, { 'content-digest' => response.headers['content-digest'] }) - message = Linzer::Message.new(linzer_response) key = Linzer.new_ed25519_key(current_provider.server_private_key_pem) - signature = Linzer.sign(key, message, %w(@status content-digest)) - - response.headers.merge!(signature.to_h) + Linzer.sign!(response, key:, components: %w(@status content-digest)) end def check_fasp_enabled diff --git a/app/lib/fasp/request.rb b/app/lib/fasp/request.rb index 7d8c05d406..6ea837b89c 100644 --- a/app/lib/fasp/request.rb +++ b/app/lib/fasp/request.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Fasp::Request + COVERED_COMPONENTS = %w(@method @target-uri content-digest).freeze + def initialize(provider) @provider = provider end @@ -23,55 +25,36 @@ class Fasp::Request url = @provider.url(path) body = body.present? ? body.to_json : '' headers = request_headers(verb, url, body) - response = HTTP.headers(headers).send(verb, url, body:) + key = Linzer.new_ed25519_key(@provider.server_private_key_pem, @provider.remote_identifier) + response = HTTP + .headers(headers) + .use(http_signature: { key:, covered_components: COVERED_COMPONENTS }) + .send(verb, url, body:) + validate!(response) response.parse if response.body.present? end - def request_headers(verb, url, body = '') - result = { + def request_headers(_verb, _url, body = '') + { 'accept' => 'application/json', 'content-type' => 'application/json', 'content-digest' => content_digest(body), } - result.merge(signature_headers(verb, url, result)) end def content_digest(body) "sha-256=:#{OpenSSL::Digest.base64digest('sha256', body || '')}:" end - def signature_headers(verb, url, headers) - linzer_request = Linzer.new_request(verb, url, {}, headers) - message = Linzer::Message.new(linzer_request) - key = Linzer.new_ed25519_key(@provider.server_private_key_pem, @provider.remote_identifier) - signature = Linzer.sign(key, message, %w(@method @target-uri content-digest)) - Linzer::Signer.send(:populate_parameters, key, {}) - - signature.to_h - end - def validate!(response) content_digest_header = response.headers['content-digest'] raise Mastodon::SignatureVerificationError, 'content-digest missing' if content_digest_header.blank? raise Mastodon::SignatureVerificationError, 'content-digest does not match' if content_digest_header != content_digest(response.body) + raise Mastodon::SignatureVerificationError, 'signature-input is missing' if response.headers['signature-input'].blank? - signature_input = response.headers['signature-input']&.encode('UTF-8') - raise Mastodon::SignatureVerificationError, 'signature-input is missing' if signature_input.blank? - - linzer_response = Linzer.new_response( - response.body, - response.status, - { - 'content-digest' => content_digest_header, - 'signature-input' => signature_input, - 'signature' => response.headers['signature'], - } - ) - message = Linzer::Message.new(linzer_response) key = Linzer.new_ed25519_public_key(@provider.provider_public_key_pem) - signature = Linzer::Signature.build(message.headers) - Linzer.verify(key, message, signature) + Linzer.verify!(response, key:) end end diff --git a/config/initializers/linzer.rb b/config/initializers/linzer.rb new file mode 100644 index 0000000000..119f65af17 --- /dev/null +++ b/config/initializers/linzer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'linzer/http/signature_feature' +require 'linzer/message/adapter/http_gem/response' + +module Linzer::Message::Adapter + module ActionDispatch + class Response < Linzer::Message::Adapter::Abstract + def initialize(operation, **_options) # rubocop:disable Lint/MissingSuper + @operation = operation + end + + def header(name) + @operation.headers[name] + end + + def attach!(signature) + signature.to_h.each { |h, v| @operation.headers[h] = v } + end + + # Incomplete, but sufficient for FASP + def [](field_name) + return @operation.status if field_name == '@status' + + @operation.headers[field_name] + end + end + end +end + +Linzer::Message.register_adapter(HTTP::Response, Linzer::Message::Adapter::HTTPGem::Response) +Linzer::Message.register_adapter(ActionDispatch::Response, Linzer::Message::Adapter::ActionDispatch::Response) diff --git a/spec/support/fasp/provider_request_helper.rb b/spec/support/fasp/provider_request_helper.rb index c5d8ae4919..bd48086c62 100644 --- a/spec/support/fasp/provider_request_helper.rb +++ b/spec/support/fasp/provider_request_helper.rb @@ -23,19 +23,27 @@ module ProviderRequestHelper body = encode_body(body) headers = {} headers['content-digest'] = content_digest(body) - request = Linzer.new_request(method, url, {}, headers) + request = "Net::HTTP::#{method.to_s.classify}".constantize.new(URI(url), headers) key = private_key_for(provider) - signature = sign(request, key, %w(@method @target-uri content-digest)) - headers.merge(signature.to_h) + Linzer.sign!(request, key:, components: %w(@method @target-uri content-digest)) + signature_headers(request) end def response_authentication_headers(provider, status, body) - headers = {} - headers['content-digest'] = content_digest(body) - response = Linzer.new_response(body, status, headers) + response = Net::HTTPResponse::CODE_TO_OBJ[status.to_s].new('1.1', status, Rack::Utils::HTTP_STATUS_CODES[status]) + response.body = body + response['content-digest'] = content_digest(body) key = private_key_for(provider) - signature = sign(response, key, %w(@status content-digest)) - headers.merge(signature.to_h) + Linzer.sign!(response, key:, components: %w(@status content-digest)) + signature_headers(response) + end + + def signature_headers(operation) + { + 'content-digest' => operation['content-digest'], + 'signature-input' => operation['signature-input'], + 'signature' => operation['signature'], + } end def private_key_for(provider) @@ -47,16 +55,7 @@ module ProviderRequestHelper key end - { - id: provider.id.to_s, - private_key: @cached_provider_keys[provider].private_to_pem, - } - end - - def sign(request_or_response, key, components) - message = Linzer::Message.new(request_or_response) - linzer_key = Linzer.new_ed25519_key(key[:private_key], key[:id]) - Linzer.sign(linzer_key, message, components) + Linzer.new_ed25519_key(@cached_provider_keys[provider].private_to_pem, provider.id.to_s) end def encode_body(body)