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

@ -62,6 +62,7 @@ gem 'inline_svg'
gem 'irb', '~> 1.8'
gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
gem 'linzer', '~> 0.6.1'
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'mime-types', '~> 3.6.0', require: 'mime/types/columnar'
gem 'mutex_m'

View File

@ -395,6 +395,12 @@ GEM
rexml
link_header (0.0.8)
lint_roller (1.1.0)
linzer (0.6.2)
openssl (~> 3.0, >= 3.0.0)
rack (>= 2.2, < 4.0)
starry (~> 0.2)
stringio (~> 3.1, >= 3.1.2)
uri (~> 1.0, >= 1.0.2)
llhttp-ffi (0.5.1)
ffi-compiler (~> 1.0)
rake (~> 13.0)
@ -829,6 +835,8 @@ GEM
simplecov-lcov (0.8.0)
simplecov_json_formatter (0.1.4)
stackprof (0.2.27)
starry (0.2.0)
base64
stoplight (4.1.1)
redlock (~> 1.0)
stringio (3.1.5)
@ -980,6 +988,7 @@ DEPENDENCIES
letter_opener (~> 1.8)
letter_opener_web (~> 3.0)
link_header (~> 0.0)
linzer (~> 0.6.1)
lograge (~> 0.12)
mail (~> 2.8)
mario-redis-lock (~> 1.2)

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

View File

@ -36,8 +36,8 @@ require_relative '../lib/paperclip/response_with_limit_adapter'
require_relative '../lib/terrapin/multi_pipe_extensions'
require_relative '../lib/mastodon/middleware/public_file_server'
require_relative '../lib/mastodon/middleware/socket_cleanup'
require_relative '../lib/mastodon/snowflake'
require_relative '../lib/mastodon/feature'
require_relative '../lib/mastodon/snowflake'
require_relative '../lib/mastodon/version'
require_relative '../lib/devise/strategies/two_factor_ldap_authenticatable'
require_relative '../lib/devise/strategies/two_factor_pam_authenticatable'

View File

@ -479,6 +479,36 @@ en:
new:
title: Import domain blocks
no_file: No file selected
fasp:
debug:
callbacks:
created_at: Created at
delete: Delete
ip: IP address
request_body: Request body
title: Debug Callbacks
providers:
active: Active
base_url: Base URL
callback: Callback
delete: Delete
edit: Edit Provider
finish_registration: Finish registration
name: Name
providers: Providers
public_key_fingerprint: Public key fingerprint
registration_requested: Registration requested
registrations:
confirm: Confirm
description: You received a registration from a FASP. Reject it if you did not initiate this. If you initiated this, carefully compare name and key fingerprint before confirming the registration.
reject: Reject
title: Confirm FASP Registration
save: Save
select_capabilities: Select Capabilities
sign_in: Sign In
status: Status
title: Fediverse Auxiliary Service Providers
title: FASP
follow_recommendations:
description_html: "<strong>Follow recommendations help new users quickly find interesting content</strong>. When a user has not interacted with others enough to form personalized follow recommendations, these accounts are recommended instead. They are re-calculated on a daily basis from a mix of accounts with the highest recent engagements and highest local follower counts for a given language."
language: For language

View File

@ -73,6 +73,7 @@ SimpleNavigation::Configuration.run do |navigation|
s.item :announcements, safe_join([material_symbol('campaign'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}, if: -> { current_user.can?(:manage_announcements) }
s.item :custom_emojis, safe_join([material_symbol('mood'), t('admin.custom_emojis.title')]), admin_custom_emojis_path, highlights_on: %r{/admin/custom_emojis}, if: -> { current_user.can?(:manage_custom_emojis) }
s.item :webhooks, safe_join([material_symbol('inbox'), t('admin.webhooks.title')]), admin_webhooks_path, highlights_on: %r{/admin/webhooks}, if: -> { current_user.can?(:manage_webhooks) }
s.item :fasp, safe_join([material_symbol('extension'), t('admin.fasp.title')]), admin_fasp_providers_path, highlights_on: %r{/admin/fasp}, if: -> { current_user.can?(:manage_federation) } if Mastodon::Feature.fasp_enabled?
s.item :relays, safe_join([material_symbol('captive_portal'), t('admin.relays.title')]), admin_relays_path, highlights_on: %r{/admin/relays}, if: -> { !limited_federation_mode? && current_user.can?(:manage_federation) }
end

View File

@ -196,6 +196,8 @@ Rails.application.routes.draw do
draw(:api)
draw(:fasp)
draw(:web_app)
get '/web/(*any)', to: redirect('/%{any}', status: 302), as: :web, defaults: { any: '' }, format: false

29
config/routes/fasp.rb Normal file
View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
namespace :api, format: false do
namespace :fasp do
namespace :debug do
namespace :v0 do
namespace :callback do
resources :responses, only: [:create]
end
end
end
resource :registration, only: [:create]
end
end
namespace :admin do
namespace :fasp do
namespace :debug do
resources :callbacks, only: [:index, :destroy]
end
resources :providers, only: [:index, :show, :edit, :update, :destroy] do
resources :debug_calls, only: [:create]
resource :registration, only: [:new, :create]
end
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class CreateFaspProviders < ActiveRecord::Migration[7.2]
def change
create_table :fasp_providers do |t|
t.boolean :confirmed, null: false, default: false
t.string :name, null: false
t.string :base_url, null: false, index: { unique: true }
t.string :sign_in_url
t.string :remote_identifier, null: false
t.string :provider_public_key_pem, null: false
t.string :server_private_key_pem, null: false
t.jsonb :capabilities, null: false, default: []
t.jsonb :privacy_policy
t.string :contact_email
t.string :fediverse_account
t.timestamps
end
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class CreateFaspDebugCallbacks < ActiveRecord::Migration[7.2]
def change
create_table :fasp_debug_callbacks do |t|
t.references :fasp_provider, null: false, foreign_key: true
t.string :ip, null: false
t.text :request_body, null: false
t.timestamps
end
end
end

View File

@ -445,6 +445,32 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true
end
create_table "fasp_debug_callbacks", force: :cascade do |t|
t.bigint "fasp_provider_id", null: false
t.string "ip", null: false
t.text "request_body", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["fasp_provider_id"], name: "index_fasp_debug_callbacks_on_fasp_provider_id"
end
create_table "fasp_providers", force: :cascade do |t|
t.boolean "confirmed", default: false, null: false
t.string "name", null: false
t.string "base_url", null: false
t.string "sign_in_url"
t.string "remote_identifier", null: false
t.string "provider_public_key_pem", null: false
t.string "server_private_key_pem", null: false
t.jsonb "capabilities", default: [], null: false
t.jsonb "privacy_policy"
t.string "contact_email"
t.string "fediverse_account"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["base_url"], name: "index_fasp_providers_on_base_url", unique: true
end
create_table "favourites", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
@ -1289,6 +1315,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
add_foreign_key "custom_filter_statuses", "statuses", on_delete: :cascade
add_foreign_key "custom_filters", "accounts", on_delete: :cascade
add_foreign_key "email_domain_blocks", "email_domain_blocks", column: "parent_id", on_delete: :cascade
add_foreign_key "fasp_debug_callbacks", "fasp_providers"
add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
add_foreign_key "featured_tags", "accounts", on_delete: :cascade

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
Fabricator(:fasp_debug_callback, from: 'Fasp::DebugCallback') do
fasp_provider
ip '127.0.0.234'
request_body 'MyText'
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
Fabricator(:fasp_provider, from: 'Fasp::Provider') do
name { Faker::App.name }
base_url { Faker::Internet.unique.url }
sign_in_url { Faker::Internet.url }
remote_identifier 'MyString'
provider_public_key_pem "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAh2ldXsaej2MXj0DHdCx7XibSo66uKlrLfJ5J6hte1Gk=\n-----END PUBLIC KEY-----\n"
server_private_key_pem "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEICDjlajhVb8XfzyTchQWKraMKwtQW+r4opoAg7V3kw1Q\n-----END PRIVATE KEY-----\n"
capabilities []
end
Fabricator(:confirmed_fasp, from: :fasp_provider) do
confirmed true
capabilities [
{ id: 'callback', version: '0.1' },
{ id: 'data_sharing', version: '0.1' },
]
end
Fabricator(:debug_fasp, from: :fasp_provider) do
confirmed true
capabilities [
{ id: 'callback', version: '0.1', enabled: true },
]
after_build do |fasp|
# Prevent fabrication from attempting an HTTP call to the provider
def fasp.update_remote_capabilities = true
end
end

View File

@ -0,0 +1,57 @@
# frozen_string_literal: true
require 'rails_helper'
require 'securerandom'
RSpec.describe Fasp::Request do
include ProviderRequestHelper
subject { described_class.new(provider) }
let(:provider) do
Fabricate(:fasp_provider, base_url: 'https://reqprov.example.com/fasp')
end
shared_examples 'a provider request' do |method|
context 'when the response is signed by the provider' do
before do
stub_provider_request(provider, method:, path: '/test_path')
end
it "performs a signed #{method.to_s.upcase} request relative to the base_path of the fasp" do
subject.send(method, '/test_path')
expect(WebMock).to have_requested(method, 'https://reqprov.example.com/fasp/test_path')
.with(headers: {
'Signature' => /.+/,
'Signature-Input' => /.+/,
})
end
end
context 'when the response is not signed' do
before do
stub_request(method, 'https://reqprov.example.com/fasp/test_path')
.to_return(status: 200)
end
it 'raises an error' do
expect do
subject.send(method, '/test_path')
end.to raise_error(SignatureVerification::SignatureVerificationError)
end
end
end
describe '#get' do
include_examples 'a provider request', :get
end
describe '#post' do
include_examples 'a provider request', :post
end
describe '#delete' do
include_examples 'a provider request', :delete
end
end

View File

@ -0,0 +1,209 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Fasp::Provider do
include ProviderRequestHelper
describe '#capabilities' do
subject { described_class.new(confirmed: true, capabilities:) }
let(:capabilities) do
[
{ 'id' => 'one', 'enabled' => false },
{ 'id' => 'two' },
]
end
it 'returns an array of `Fasp::Capability` objects' do
expect(subject.capabilities).to all(be_a(Fasp::Capability))
end
end
describe '#capabilities_attributes=' do
subject { described_class.new(confirmed: true) }
let(:capabilities_params) do
{
'0' => { 'id' => 'one', 'enabled' => '1' },
'1' => { 'id' => 'two', 'enabled' => '0' },
'2' => { 'id' => 'three' },
}
end
it 'sets capabilities from nested form style hash' do
subject.capabilities_attributes = capabilities_params
expect(subject).to be_capability('one')
expect(subject).to be_capability('two')
expect(subject).to be_capability('three')
expect(subject).to be_capability_enabled('one')
expect(subject).to_not be_capability_enabled('two')
expect(subject).to_not be_capability_enabled('three')
end
end
describe '#capability?' do
subject { described_class.new(confirmed:, capabilities:) }
let(:capabilities) do
[
{ 'id' => 'one', 'enabled' => false },
{ 'id' => 'two', 'enabled' => true },
]
end
context 'when the provider is not confirmed' do
let(:confirmed) { false }
it 'always returns false' do
expect(subject.capability?('one')).to be false
expect(subject.capability?('two')).to be false
end
end
context 'when the provider is confirmed' do
let(:confirmed) { true }
it 'returns true for available and false for missing capabilities' do
expect(subject.capability?('one')).to be true
expect(subject.capability?('two')).to be true
expect(subject.capability?('three')).to be false
end
end
end
describe '#capability_enabled?' do
subject { described_class.new(confirmed:, capabilities:) }
let(:capabilities) do
[
{ 'id' => 'one', 'enabled' => false },
{ 'id' => 'two', 'enabled' => true },
]
end
context 'when the provider is not confirmed' do
let(:confirmed) { false }
it 'always returns false' do
expect(subject).to_not be_capability_enabled('one')
expect(subject).to_not be_capability_enabled('two')
end
end
context 'when the provider is confirmed' do
let(:confirmed) { true }
it 'returns true for enabled and false for disabled or missing capabilities' do
expect(subject).to_not be_capability_enabled('one')
expect(subject).to be_capability_enabled('two')
expect(subject).to_not be_capability_enabled('three')
end
end
end
describe '#server_private_key' do
subject { Fabricate(:fasp_provider) }
it 'returns an OpenSSL::PKey::PKey' do
expect(subject.server_private_key).to be_a OpenSSL::PKey::PKey
end
end
describe '#server_public_key_base64' do
subject { Fabricate(:fasp_provider) }
it 'returns the server public key base64 encoded' do
expect(subject.server_public_key_base64).to eq 'T2RHkakkqAOWEMRYv9OY7LGsuIcAdmBlxuXOKax6sjw='
end
end
describe '#provider_public_key_base64=' do
subject { Fabricate(:fasp_provider) }
it 'allows setting the provider public key from a base64 encoded raw key' do
subject.provider_public_key_base64 = '9qgjOfWRhozWc9dwx5JmbshizZ7TyPBhYk9+b5tE3e4='
expect(subject.provider_public_key_pem).to eq "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA9qgjOfWRhozWc9dwx5JmbshizZ7TyPBhYk9+b5tE3e4=\n-----END PUBLIC KEY-----\n"
end
end
describe '#provider_public_key' do
subject { Fabricate(:fasp_provider) }
it 'returns an OpenSSL::PKey::PKey' do
expect(subject.provider_public_key).to be_a OpenSSL::PKey::PKey
end
end
describe '#provider_public_key_raw' do
subject { Fabricate(:fasp_provider) }
it 'returns a string comprised of raw bytes' do
expect(subject.provider_public_key_raw).to be_a String
expect(subject.provider_public_key_raw.encoding).to eq Encoding::BINARY
end
end
describe '#provider_public_key_fingerprint' do
subject { Fabricate(:fasp_provider) }
it 'returns a base64 encoded sha256 hash of the public key' do
expect(subject.provider_public_key_fingerprint).to eq '/AmW9EMlVq4o+Qcu9lNfTE8Ss/v9+evMPtyj2R437qE='
end
end
describe '#url' do
subject { Fabricate(:fasp_provider, base_url: 'https://myprovider.example.com/fasp_base/') }
it 'returns a full URL for a given path' do
url = subject.url('/test_path')
expect(url).to eq 'https://myprovider.example.com/fasp_base/test_path'
end
end
describe '#update_info!' do
subject { Fabricate(:fasp_provider, base_url: 'https://myprov.example.com/fasp/') }
before do
stub_provider_request(subject,
path: '/provider_info',
response_body: {
capabilities: [
{ id: 'debug', version: '0.1' },
],
contactEmail: 'newcontact@example.com',
fediverseAccount: '@newfedi@social.example.com',
privacyPolicy: 'https::///example.com/privacy',
signInUrl: 'https://myprov.example.com/sign_in',
})
end
context 'when setting confirm to `true`' do
it 'updates the provider and marks it as `confirmed`' do
subject.update_info!(confirm: true)
expect(subject.contact_email).to eq 'newcontact@example.com'
expect(subject.fediverse_account).to eq '@newfedi@social.example.com'
expect(subject.privacy_policy).to eq 'https::///example.com/privacy'
expect(subject.sign_in_url).to eq 'https://myprov.example.com/sign_in'
expect(subject).to be_confirmed
expect(subject).to be_persisted
end
end
context 'when setting confirm to `false`' do
it 'updates the provider but does not mark it as `confirmed`' do
subject.update_info!
expect(subject.contact_email).to eq 'newcontact@example.com'
expect(subject.fediverse_account).to eq '@newfedi@social.example.com'
expect(subject.privacy_policy).to eq 'https::///example.com/privacy'
expect(subject.sign_in_url).to eq 'https://myprov.example.com/sign_in'
expect(subject).to_not be_confirmed
expect(subject).to be_persisted
end
end
end
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Admin::Fasp::ProviderPolicy, type: :policy do
subject { described_class }
let(:admin) { Fabricate(:admin_user).account }
let(:user) { Fabricate(:account) }
shared_examples 'admin only' do |target|
let(:provider) { target.is_a?(Symbol) ? Fabricate(target) : target }
context 'with an admin' do
it 'permits' do
expect(subject).to permit(admin, provider)
end
end
context 'with a non-admin' do
it 'denies' do
expect(subject).to_not permit(user, provider)
end
end
end
permissions :index?, :create? do
include_examples 'admin only', Fasp::Provider
end
permissions :show?, :create?, :update?, :destroy? do
include_examples 'admin only', :fasp_provider
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Api::Fasp::Debug::V0::Callback::Responses', feature: :fasp do
include ProviderRequestHelper
describe 'POST /api/fasp/debug/v0/callback/responses' do
let(:provider) { Fabricate(:debug_fasp) }
it 'create a record of the callback' do
payload = { test: 'call' }
headers = request_authentication_headers(provider,
url: api_fasp_debug_v0_callback_responses_url,
method: :post,
body: payload)
expect do
post api_fasp_debug_v0_callback_responses_path, headers:, params: payload, as: :json
end.to change(Fasp::DebugCallback, :count).by(1)
expect(response).to have_http_status(201)
debug_callback = Fasp::DebugCallback.last
expect(debug_callback.fasp_provider).to eq provider
expect(debug_callback.request_body).to eq '{"test":"call"}'
end
end
end

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Api::Fasp::Registrations', feature: :fasp do
describe 'POST /api/fasp/registration' do
subject do
post api_fasp_registration_path, params:
end
context 'when given valid data' do
let(:params) do
{
name: 'Test Provider',
baseUrl: 'https://newprovider.example.com/fasp',
serverId: '123',
publicKey: '9qgjOfWRhozWc9dwx5JmbshizZ7TyPBhYk9+b5tE3e4=',
}
end
it 'creates a new provider' do
expect { subject }.to change(Fasp::Provider, :count).by(1)
expect(response).to have_http_status 200
end
end
context 'when given invalid data' do
let(:params) do
{
name: 'incomplete',
}
end
it 'does not create a provider and returns an error code' do
expect { subject }.to_not change(Fasp::Provider, :count)
expect(response).to have_http_status 422
end
end
end
end

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
module ProviderRequestHelper
private
def stub_provider_request(provider, path: '/', method: :get, response_status: 200, response_body: '')
response_body = encode_body(response_body)
response_headers = {
'content-type' => 'application/json',
}.merge(response_authentication_headers(provider, response_status, response_body))
stub_request(method, provider.url(path))
.to_return do |_request|
{
status: response_status,
body: response_body,
headers: response_headers,
}
end
end
def request_authentication_headers(provider, url: root_url, method: :get, body: '')
body = encode_body(body)
headers = {}
headers['content-digest'] = content_digest(body)
request = Linzer.new_request(method, url, {}, headers)
key = private_key_for(provider)
signature = sign(request, key, %w(@method @target-uri content-digest))
headers.merge(signature.to_h)
end
def response_authentication_headers(provider, status, body)
headers = {}
headers['content-digest'] = content_digest(body)
response = Linzer.new_response(body, status, headers)
key = private_key_for(provider)
signature = sign(response, key, %w(@status content-digest))
headers.merge(signature.to_h)
end
def private_key_for(provider)
@cached_provider_keys ||= {}
@cached_provider_keys[provider] ||=
begin
key = OpenSSL::PKey.generate_key('ed25519')
provider.update!(provider_public_key_pem: key.public_to_pem)
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)
end
def encode_body(body)
return body if body.nil? || body.is_a?(String)
encoder = ActionDispatch::RequestEncoder.encoder(:json)
encoder.encode_params(body)
end
def content_digest(content)
"sha-256=:#{OpenSSL::Digest.base64digest('sha256', content)}:"
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Debug FASP Callback Management', feature: :fasp do
before { sign_in Fabricate(:admin_user) }
describe 'Viewing and deleting callbacks' do
let(:provider) { Fabricate(:fasp_provider, name: 'debug prov') }
before do
Fabricate(:fasp_debug_callback, fasp_provider: provider, request_body: 'called back')
end
it 'displays callbacks and allows to delete them' do
visit admin_fasp_debug_callbacks_path
expect(page).to have_css('h2', text: I18n.t('admin.fasp.debug.callbacks.title'))
expect(page).to have_css('td', text: 'debug prov')
expect(page).to have_css('code', text: 'called back')
expect do
click_on I18n.t('admin.fasp.debug.callbacks.delete')
expect(page).to have_css('h2', text: I18n.t('admin.fasp.debug.callbacks.title'))
end.to change(Fasp::DebugCallback, :count).by(-1)
end
end
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'FASP Debug Calls', feature: :fasp do
include ProviderRequestHelper
before { sign_in Fabricate(:admin_user) }
describe 'Triggering a FASP debug call' do
let!(:provider) { Fabricate(:debug_fasp) }
let!(:debug_call) do
stub_provider_request(provider,
method: :post,
path: '/debug/v0/callback/logs',
response_status: 201)
end
it 'makes a debug call to the provider' do
visit admin_fasp_providers_path
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
expect(page).to have_css('td', text: provider.name)
within 'table#providers' do
click_on I18n.t('admin.fasp.providers.callback')
end
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
expect(debug_call).to have_been_requested
end
end
end

View File

@ -0,0 +1,81 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'FASP Management', feature: :fasp do
include ProviderRequestHelper
before { sign_in Fabricate(:admin_user) }
describe 'Managing capabilities' do
let!(:provider) { Fabricate(:confirmed_fasp) }
let!(:enable_call) do
stub_provider_request(provider,
method: :post,
path: '/capabilities/callback/0/activation')
end
let!(:disable_call) do
stub_provider_request(provider,
method: :delete,
path: '/capabilities/callback/0/activation')
end
before do
# We currently err on the side of caution and prefer to send
# a "disable capability" call too often over risking to miss
# one. So the following call _can_ happen here, and if it does
# that is fine, but it has no bearing on the behavior that is
# being tested.
stub_provider_request(provider,
method: :delete,
path: '/capabilities/data_sharing/0/activation')
end
it 'allows enabling and disabling of capabilities' do
visit admin_fasp_providers_path
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
expect(page).to have_css('td', text: provider.name)
click_on I18n.t('admin.fasp.providers.edit')
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.edit'))
check 'callback'
click_on I18n.t('admin.fasp.providers.save')
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
expect(provider.reload).to be_capability_enabled('callback')
expect(enable_call).to have_been_requested
click_on I18n.t('admin.fasp.providers.edit')
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.edit'))
uncheck 'callback'
click_on I18n.t('admin.fasp.providers.save')
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
expect(provider.reload).to_not be_capability_enabled('callback')
expect(disable_call).to have_been_requested
end
end
describe 'Removing a provider' do
let!(:provider) { Fabricate(:fasp_provider) }
it 'allows to completely remove a provider' do
visit admin_fasp_providers_path
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
expect(page).to have_css('td', text: provider.name)
click_on I18n.t('admin.fasp.providers.delete')
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
expect(page).to have_no_css('td', text: provider.name)
end
end
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'FASP registration', feature: :fasp do
include ProviderRequestHelper
before { sign_in Fabricate(:admin_user) }
describe 'Confirming an unconfirmed FASP' do
let(:provider) { Fabricate(:fasp_provider, confirmed: false) }
before do
stub_provider_request(provider,
path: '/provider_info',
response_body: {
capabilities: [
{ id: 'debug', version: '0.1' },
],
contactEmail: 'newcontact@example.com',
fediverseAccount: '@newfedi@social.example.com',
privacyPolicy: 'https::///example.com/privacy',
signInUrl: 'https://myprov.example.com/sign_in',
})
end
it 'displays key fingerprint and updates the provider on confirmation' do
visit new_admin_fasp_provider_registration_path(provider)
expect(page).to have_css('code', text: provider.provider_public_key_fingerprint)
click_on I18n.t('admin.fasp.providers.registrations.confirm')
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.edit'))
expect(provider.reload).to be_confirmed
end
end
end