Merge remote-tracking branch 'parent/main' into upstream-20250403

This commit is contained in:
KMY 2025-04-03 08:36:36 +09:00
commit 32f5604499
265 changed files with 6227 additions and 3383 deletions

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
class Admin::Fasp::Debug::CallbacksController < Admin::BaseController
def index
authorize [:admin, :fasp, :provider], :update?
@callbacks = Fasp::DebugCallback
.includes(:fasp_provider)
.order(created_at: :desc)
end
def destroy
authorize [:admin, :fasp, :provider], :update?
callback = Fasp::DebugCallback.find(params[:id])
callback.destroy
redirect_to admin_fasp_debug_callbacks_path
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class Admin::Fasp::DebugCallsController < Admin::BaseController
before_action :set_provider
def create
authorize [:admin, @provider], :update?
@provider.perform_debug_call
redirect_to admin_fasp_providers_path
end
private
def set_provider
@provider = Fasp::Provider.find(params[:provider_id])
end
end

View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
class Admin::Fasp::ProvidersController < Admin::BaseController
before_action :set_provider, only: [:show, :edit, :update, :destroy]
def index
authorize [:admin, :fasp, :provider], :index?
@providers = Fasp::Provider.order(confirmed: :asc, created_at: :desc)
end
def show
authorize [:admin, @provider], :show?
end
def edit
authorize [:admin, @provider], :update?
end
def update
authorize [:admin, @provider], :update?
if @provider.update(provider_params)
redirect_to admin_fasp_providers_path
else
render :edit
end
end
def destroy
authorize [:admin, @provider], :destroy?
@provider.destroy
redirect_to admin_fasp_providers_path
end
private
def provider_params
params.expect(fasp_provider: [capabilities_attributes: {}])
end
def set_provider
@provider = Fasp::Provider.find(params[:id])
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Admin::Fasp::RegistrationsController < Admin::BaseController
before_action :set_provider
def new
authorize [:admin, @provider], :create?
end
def create
authorize [:admin, @provider], :create?
@provider.update_info!(confirm: true)
redirect_to edit_admin_fasp_provider_path(@provider)
end
private
def set_provider
@provider = Fasp::Provider.find(params[:provider_id])
end
end

View file

@ -0,0 +1,81 @@
# frozen_string_literal: true
class Api::Fasp::BaseController < ApplicationController
class Error < ::StandardError; end
DIGEST_PATTERN = /sha-256=:(.*?):/
KEYID_PATTERN = /keyid="(.*?)"/
attr_reader :current_provider
skip_forgery_protection
before_action :check_fasp_enabled
before_action :require_authentication
after_action :sign_response
private
def require_authentication
validate_content_digest!
validate_signature!
rescue Error, Linzer::Error, ActiveRecord::RecordNotFound => e
logger.debug("FASP Authentication error: #{e}")
authentication_error
end
def authentication_error
respond_to do |format|
format.json { head 401 }
end
end
def validate_content_digest!
content_digest_header = request.headers['content-digest']
raise Error, 'content-digest missing' if content_digest_header.blank?
digest_received = content_digest_header.match(DIGEST_PATTERN)[1]
digest_computed = OpenSSL::Digest.base64digest('sha256', request.body&.string || '')
raise Error, 'content-digest does not match' if digest_received != digest_computed
end
def validate_signature!
signature_input = request.headers['signature-input']&.encode('UTF-8')
raise Error, 'signature-input is missing' if signature_input.blank?
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)
end
def check_fasp_enabled
raise ActionController::RoutingError unless Mastodon::Feature.fasp_enabled?
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class Api::Fasp::Debug::V0::Callback::ResponsesController < Api::Fasp::BaseController
def create
Fasp::DebugCallback.create(
fasp_provider: current_provider,
ip: request.remote_ip,
request_body: request.raw_post
)
respond_to do |format|
format.json { head 201 }
end
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
class Api::Fasp::RegistrationsController < Api::Fasp::BaseController
skip_before_action :require_authentication
def create
@current_provider = Fasp::Provider.create!(
name: params[:name],
base_url: params[:baseUrl],
remote_identifier: params[:serverId],
provider_public_key_base64: params[:publicKey]
)
render json: registration_confirmation
end
private
def registration_confirmation
{
faspId: current_provider.id.to_s,
publicKey: current_provider.server_public_key_base64,
registrationCompletionUri: new_admin_fasp_provider_registration_url(current_provider),
}
end
end

View file

@ -10,8 +10,6 @@ module SignatureVerification
EXPIRATION_WINDOW_LIMIT = 12.hours
CLOCK_SKEW_MARGIN = 1.hour
class SignatureVerificationError < StandardError; end
def require_account_signature!
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
end
@ -34,7 +32,7 @@ module SignatureVerification
def signature_key_id
signature_params['keyId']
rescue SignatureVerificationError
rescue Mastodon::SignatureVerificationError
nil
end
@ -45,17 +43,17 @@ module SignatureVerification
def signed_request_actor
return @signed_request_actor if defined?(@signed_request_actor)
raise SignatureVerificationError, 'Request not signed' unless signed_request?
raise SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
raise SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
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(signature_params['keyId'])
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" 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)
@ -68,7 +66,7 @@ module SignatureVerification
actor = stoplight_wrapper.run { actor_refresh_key!(actor) }
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
compare_signed_string = build_signed_string(include_query_string: true)
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
@ -78,7 +76,7 @@ module SignatureVerification
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']
rescue SignatureVerificationError => e
rescue Mastodon::SignatureVerificationError => e
fail_with! e.message
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
fail_with! "Failed to fetch remote data: #{e.message}"
@ -104,7 +102,7 @@ module SignatureVerification
def signature_params
@signature_params ||= SignatureParser.parse(request.headers['Signature'])
rescue SignatureParser::ParsingError
raise SignatureVerificationError, 'Error parsing signature parameters'
raise Mastodon::SignatureVerificationError, 'Error parsing signature parameters'
end
def signature_algorithm
@ -116,31 +114,31 @@ module SignatureVerification
end
def verify_signature_strength!
raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
raise 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 SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host')
raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
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 SignatureVerificationError, 'Digest header missing' unless request.headers.key?('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 SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil?
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 SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}"
raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}"
end
raise 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. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32
raise SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}"
raise Mastodon::SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}"
end
def verify_signature(actor, signature, compare_signed_string)
@ -165,13 +163,13 @@ module SignatureVerification
"#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
end
when '(created)'
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
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 SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
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
@ -193,7 +191,7 @@ module SignatureVerification
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
rescue ArgumentError => e
raise SignatureVerificationError, "Invalid Date header: #{e.message}"
raise Mastodon::SignatureVerificationError, "Invalid Date header: #{e.message}"
end
expires_time ||= created_time + 5.minutes unless created_time.nil?
@ -233,9 +231,9 @@ module SignatureVerification
account
end
rescue Mastodon::PrivateNetworkAddressError => e
raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, ActivityPub::FetchRemoteKeyService::Error, Webfinger::Error => e
raise SignatureVerificationError, e.message
raise Mastodon::SignatureVerificationError, e.message
end
def stoplight_wrapper
@ -251,8 +249,8 @@ module SignatureVerification
ActivityPub::FetchRemoteActorService.new.call(actor.uri, only_key: true, suppress_errors: false)
rescue Mastodon::PrivateNetworkAddressError => e
raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, Webfinger::Error => e
raise SignatureVerificationError, e.message
raise Mastodon::SignatureVerificationError, e.message
end
end