From 3acd87419cdf367b0d33d509b41fee801c1e5157 Mon Sep 17 00:00:00 2001
From: David Roetzel <david@roetzel.de>
Date: Fri, 23 May 2025 08:43:02 +0200
Subject: [PATCH] Update to linzer 0.7 (#34765)

---
 Gemfile                                      |  2 +-
 Gemfile.lock                                 | 10 ++++-
 app/controllers/api/fasp/base_controller.rb  | 33 +++++-----------
 app/lib/fasp/request.rb                      | 41 ++++++--------------
 config/initializers/linzer.rb                | 32 +++++++++++++++
 spec/support/fasp/provider_request_helper.rb | 35 ++++++++---------
 6 files changed, 79 insertions(+), 74 deletions(-)
 create mode 100644 config/initializers/linzer.rb

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)