Basic FASP support (#34031)

This commit is contained in:
David Roetzel 2025-03-28 13:16:40 +01:00 committed by GitHub
parent e5fd61a84e
commit 97b9994743
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1423 additions and 1 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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M352-120H200q-33 0-56.5-23.5T120-200v-152q48 0 84-30.5t36-77.5q0-47-36-77.5T120-568v-152q0-33 23.5-56.5T200-800h160q0-42 29-71t71-29q42 0 71 29t29 71h160q33 0 56.5 23.5T800-720v160q42 0 71 29t29 71q0 42-29 71t-71 29v160q0 33-23.5 56.5T720-120H568q0-50-31.5-85T460-240q-45 0-76.5 35T352-120Z"/></svg>

After

Width:  |  Height:  |  Size: 396 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M352-120H200q-33 0-56.5-23.5T120-200v-152q48 0 84-30.5t36-77.5q0-47-36-77.5T120-568v-152q0-33 23.5-56.5T200-800h160q0-42 29-71t71-29q42 0 71 29t29 71h160q33 0 56.5 23.5T800-720v160q42 0 71 29t29 71q0 42-29 71t-71 29v160q0 33-23.5 56.5T720-120H568q0-50-31.5-85T460-240q-45 0-76.5 35T352-120Zm-152-80h85q24-66 77-93t98-27q45 0 98 27t77 93h85v-240h80q8 0 14-6t6-14q0-8-6-14t-14-6h-80v-240H480v-80q0-8-6-14t-14-6q-8 0-14 6t-6 14v80H200v88q54 20 87 67t33 105q0 57-33 104t-87 68v88Zm260-260Z"/></svg>

After

Width:  |  Height:  |  Size: 591 B

76
app/lib/fasp/request.rb Normal file
View file

@ -0,0 +1,76 @@
# frozen_string_literal: true
class Fasp::Request
def initialize(provider)
@provider = provider
end
def get(path)
perform_request(:get, path)
end
def post(path, body: nil)
perform_request(:post, path, body:)
end
def delete(path, body: nil)
perform_request(:delete, path, body:)
end
private
def perform_request(verb, path, body: nil)
url = @provider.url(path)
body = body.present? ? body.to_json : ''
headers = request_headers(verb, url, body)
response = HTTP.headers(headers).send(verb, url, body:)
validate!(response)
response.parse if response.body.present?
end
def request_headers(verb, url, body = '')
result = {
'accept' => '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 SignatureVerification::SignatureVerificationError, 'content-digest missing' if content_digest_header.blank?
raise SignatureVerification::SignatureVerificationError, 'content-digest does not match' if content_digest_header != content_digest(response.body)
signature_input = response.headers['signature-input']&.encode('UTF-8')
raise SignatureVerification::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)
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
module Fasp::Provider::DebugConcern
extend ActiveSupport::Concern
def perform_debug_call
Fasp::Request.new(self)
.post('/debug/v0/callback/logs', body: { hello: 'world' })
end
end

7
app/models/fasp.rb Normal file
View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
module Fasp
def self.table_name_prefix
'fasp_'
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
class Fasp::Capability
include ActiveModel::Model
include ActiveModel::Attributes
attribute :id, :string
attribute :version, :string
attribute :enabled, :boolean, default: false
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: fasp_debug_callbacks
#
# id :bigint(8) not null, primary key
# ip :string not null
# request_body :text not null
# created_at :datetime not null
# updated_at :datetime not null
# fasp_provider_id :bigint(8) not null
#
class Fasp::DebugCallback < ApplicationRecord
belongs_to :fasp_provider, class_name: 'Fasp::Provider'
end

141
app/models/fasp/provider.rb Normal file
View file

@ -0,0 +1,141 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: fasp_providers
#
# id :bigint(8) not null, primary key
# base_url :string not null
# capabilities :jsonb not null
# confirmed :boolean default(FALSE), not null
# contact_email :string
# fediverse_account :string
# name :string not null
# privacy_policy :jsonb
# provider_public_key_pem :string not null
# remote_identifier :string not null
# server_private_key_pem :string not null
# sign_in_url :string
# created_at :datetime not null
# updated_at :datetime not null
#
class Fasp::Provider < ApplicationRecord
include DebugConcern
has_many :fasp_debug_callbacks, inverse_of: :fasp_provider, class_name: 'Fasp::DebugCallback', dependent: :delete_all
validates :name, presence: true
validates :base_url, presence: true, url: true
validates :provider_public_key_pem, presence: true
validates :remote_identifier, presence: true
before_create :create_keypair
after_commit :update_remote_capabilities
def capabilities
read_attribute(:capabilities).map do |attributes|
Fasp::Capability.new(attributes)
end
end
def capabilities_attributes=(attributes)
capability_objects = attributes.values.map { |a| Fasp::Capability.new(a) }
self[:capabilities] = capability_objects.map(&:attributes)
end
def enabled_capabilities
capabilities.select(&:enabled).map(&:id)
end
def capability?(capability_name)
return false unless confirmed?
capabilities.present? && capabilities.any? do |capability|
capability.id == capability_name
end
end
def capability_enabled?(capability_name)
return false unless confirmed?
capabilities.present? && capabilities.any? do |capability|
capability.id == capability_name && capability.enabled
end
end
def server_private_key
@server_private_key ||= OpenSSL::PKey.read(server_private_key_pem)
end
def server_public_key_base64
Base64.strict_encode64(server_private_key.raw_public_key)
end
def provider_public_key_base64=(string)
return if string.blank?
self.provider_public_key_pem =
OpenSSL::PKey.new_raw_public_key(
'ed25519',
Base64.strict_decode64(string)
).public_to_pem
end
def provider_public_key
@provider_public_key ||= OpenSSL::PKey.read(provider_public_key_pem)
end
def provider_public_key_raw
provider_public_key.raw_public_key
end
def provider_public_key_fingerprint
OpenSSL::Digest.base64digest('sha256', provider_public_key_raw)
end
def url(path)
base = base_url
base = base.chomp('/') if path.start_with?('/')
"#{base}#{path}"
end
def update_info!(confirm: false)
self.confirmed = true if confirm
provider_info = Fasp::Request.new(self).get('/provider_info')
assign_attributes(
privacy_policy: provider_info['privacyPolicy'],
capabilities: provider_info['capabilities'],
sign_in_url: provider_info['signInUrl'],
contact_email: provider_info['contactEmail'],
fediverse_account: provider_info['fediverseAccount']
)
save!
end
private
def create_keypair
self.server_private_key_pem ||=
OpenSSL::PKey.generate_key('ed25519').private_to_pem
end
def update_remote_capabilities
return unless saved_change_to_attribute?(:capabilities)
old, current = saved_change_to_attribute(:capabilities)
old ||= []
current.each do |capability|
update_remote_capability(capability) if capability.key?('enabled') && !old.include?(capability)
end
end
def update_remote_capability(capability)
version, = capability['version'].split('.')
path = "/capabilities/#{capability['id']}/#{version}/activation"
if capability['enabled']
Fasp::Request.new(self).post(path)
else
Fasp::Request.new(self).delete(path)
end
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Admin::Fasp::ProviderPolicy < ApplicationPolicy
def index?
role.can?(:manage_federation)
end
def show?
role.can?(:manage_federation)
end
def create?
role.can?(:manage_federation)
end
def update?
role.can?(:manage_federation)
end
def destroy?
role.can?(:manage_federation)
end
end

View file

@ -0,0 +1,10 @@
%tr
%td= callback.fasp_provider.name
%td= callback.fasp_provider.base_url
%td= callback.ip
%td
%time.relative-formatted{ datetime: callback.created_at.iso8601 }
%td
%code= callback.request_body
%td
= table_link_to 'close', t('admin.fasp.debug.callbacks.delete'), admin_fasp_debug_callback_path(callback), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }

View file

@ -0,0 +1,22 @@
- content_for :page_title do
= t('admin.fasp.debug.callbacks.title')
- content_for :heading do
%h2= t('admin.fasp.debug.callbacks.title')
= render 'admin/fasp/shared/links'
- unless @callbacks.empty?
%hr.spacer
.table-wrapper
%table.table
%thead
%tr
%th= t('admin.fasp.providers.name')
%th= t('admin.fasp.providers.base_url')
%th= t('admin.fasp.debug.callbacks.ip')
%th= t('admin.fasp.debug.callbacks.created_at')
%th= t('admin.fasp.debug.callbacks.request_body')
%th
%tbody
= render partial: 'callback', collection: @callbacks

View file

@ -0,0 +1,19 @@
%tr
%td= provider.name
%td= provider.base_url
%td
- if provider.confirmed?
= t('admin.fasp.providers.active')
- else
= t('admin.fasp.providers.registration_requested')
%td
- if provider.confirmed?
= table_link_to 'edit', t('admin.fasp.providers.edit'), edit_admin_fasp_provider_path(provider)
- else
= table_link_to 'check', t('admin.fasp.providers.finish_registration'), new_admin_fasp_provider_registration_path(provider)
- if provider.sign_in_url.present?
= table_link_to 'open_in_new', t('admin.fasp.providers.sign_in'), provider.sign_in_url, target: '_blank'
- if provider.capability_enabled?('callback')
= table_link_to 'repeat', t('admin.fasp.providers.callback'), admin_fasp_provider_debug_calls_path(provider), data: { method: :post }
= table_link_to 'close', t('admin.fasp.providers.delete'), admin_fasp_provider_path(provider), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }

View file

@ -0,0 +1,16 @@
- content_for :page_title do
= t('admin.fasp.providers.edit')
= simple_form_for [:admin, @provider] do |f|
= render 'shared/error_messages', object: @provider
%h4= t('admin.fasp.providers.select_capabilities')
.fields_group
= f.fields_for :capabilities do |cf|
= cf.input :id, as: :hidden
= cf.input :version, as: :hidden
= cf.input :enabled, as: :boolean, label: cf.object.id, wrapper: :with_label
.actions
= f.button :button, t('admin.fasp.providers.save'), type: :submit

View file

@ -0,0 +1,20 @@
- content_for :page_title do
= t('admin.fasp.providers.title')
- content_for :heading do
%h2= t('admin.fasp.providers.title')
= render 'admin/fasp/shared/links'
- unless @providers.empty?
%hr.spacer
.table-wrapper
%table.table#providers
%thead
%tr
%th= t('admin.fasp.providers.name')
%th= t('admin.fasp.providers.base_url')
%th= t('admin.fasp.providers.status')
%th
%tbody
= render partial: 'provider', collection: @providers

View file

@ -0,0 +1,19 @@
- content_for :page_title do
= t('admin.fasp.providers.registrations.title')
%p= t('admin.fasp.providers.registrations.description')
%table.table.inline-table
%tbody
%tr
%th= t('admin.fasp.providers.name')
%td= @provider.name
%tr
%th= t('admin.fasp.providers.public_key_fingerprint')
%td
%code= @provider.provider_public_key_fingerprint
= form_with url: admin_fasp_provider_registration_path(@provider), class: :simple_form do |f|
.actions
= link_to t('admin.fasp.providers.registrations.reject'), admin_fasp_provider_path(@provider), data: { method: :delete }, class: 'btn negative'
= f.button t('admin.fasp.providers.registrations.confirm'), type: :submit, class: 'btn'

View file

@ -0,0 +1,5 @@
.content__heading__tabs
= render_navigation renderer: :links do |primary|
:ruby
primary.item :providers, safe_join([material_symbol('database'), t('admin.fasp.providers.providers')]), admin_fasp_providers_path
primary.item :debug_callbacks, safe_join([material_symbol('repeat'), t('admin.fasp.debug.callbacks.title')]), admin_fasp_debug_callbacks_path