diff --git a/web/lib/potato_mesh/application/federation.rb b/web/lib/potato_mesh/application/federation.rb index 35bd659..23d5c82 100644 --- a/web/lib/potato_mesh/application/federation.rb +++ b/web/lib/potato_mesh/application/federation.rb @@ -299,6 +299,8 @@ module PotatoMesh http.min_version = :TLS1_2 if http.respond_to?(:min_version=) store = remote_instance_cert_store http.cert_store = store if store + callback = remote_instance_verify_callback + http.verify_callback = callback if callback http end @@ -324,6 +326,56 @@ module PotatoMesh @remote_instance_cert_store = nil end + # Build a TLS verification callback that tolerates CRL availability failures. + # + # Some certificate authorities publish CRL endpoints that may occasionally be + # unreachable. When OpenSSL cannot download the CRL it raises the + # V_ERR_UNABLE_TO_GET_CRL error which would otherwise cause HTTPS federation + # announcements to abort. The generated callback accepts those specific + # failures while preserving strict verification for all other errors. + # + # @return [Proc, nil] verification callback or nil when creation fails. + def remote_instance_verify_callback + if defined?(@remote_instance_verify_callback) && @remote_instance_verify_callback + return @remote_instance_verify_callback + end + + callback = lambda do |preverify_ok, store_context| + return true if preverify_ok + + if store_context && crl_unavailable_error?(store_context.error) + debug_log( + "Ignoring TLS CRL retrieval failure during federation request", + context: "federation.announce", + ) + true + else + false + end + end + + @remote_instance_verify_callback = callback + rescue StandardError => e + debug_log( + "Failed to initialize federation TLS verify callback: #{e.message}", + context: "federation.announce", + ) + @remote_instance_verify_callback = nil + end + + # Determine whether the supplied OpenSSL verification error corresponds to a + # missing certificate revocation list. + # + # @param error_code [Integer, nil] OpenSSL verification error value. + # @return [Boolean] true when the error should be ignored. + def crl_unavailable_error?(error_code) + allowed_errors = [OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL] + if defined?(OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL_ISSUER) + allowed_errors << OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL_ISSUER + end + allowed_errors.include?(error_code) + end + def validate_well_known_document(document, domain, pubkey) unless document.is_a?(Hash) return [false, "document is not an object"] diff --git a/web/spec/federation_spec.rb b/web/spec/federation_spec.rb index 694d78e..8673359 100644 --- a/web/spec/federation_spec.rb +++ b/web/spec/federation_spec.rb @@ -27,7 +27,7 @@ RSpec.describe PotatoMesh::App::Federation do @debug_messages ||= [] end - def debug_log(message) + def debug_log(message, **_metadata) debug_messages << message end @@ -40,6 +40,7 @@ RSpec.describe PotatoMesh::App::Federation do before do federation_helpers.instance_variable_set(:@remote_instance_cert_store, nil) + federation_helpers.instance_variable_set(:@remote_instance_verify_callback, nil) federation_helpers.reset_debug_messages end @@ -84,6 +85,33 @@ RSpec.describe PotatoMesh::App::Federation do end end + describe ".remote_instance_verify_callback" do + let(:callback) { federation_helpers.remote_instance_verify_callback } + + it "memoizes the generated callback" do + first = federation_helpers.remote_instance_verify_callback + second = federation_helpers.remote_instance_verify_callback + expect(second).to equal(first) + end + + it "allows the handshake to continue when CRLs are unavailable" do + store_context = instance_double(OpenSSL::X509::StoreContext, error: OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL) + + expect(callback.call(false, store_context)).to be(true) + expect(federation_helpers.debug_messages.last).to include("Ignoring TLS CRL retrieval failure") + end + + it "rejects other verification failures" do + store_context = instance_double(OpenSSL::X509::StoreContext, error: OpenSSL::X509::V_ERR_CERT_HAS_EXPIRED) + + expect(callback.call(false, store_context)).to be(false) + end + + it "falls back to the default behavior when the handshake is already valid" do + expect(callback.call(true, nil)).to be(true) + end + end + describe ".build_remote_http_client" do let(:timeout) { 15 } @@ -95,6 +123,8 @@ RSpec.describe PotatoMesh::App::Federation do uri = URI.parse("https://remote.example.com/api") store = OpenSSL::X509::Store.new allow(federation_helpers).to receive(:remote_instance_cert_store).and_return(store) + callback = proc { true } + allow(federation_helpers).to receive(:remote_instance_verify_callback).and_return(callback) http = federation_helpers.build_remote_http_client(uri) @@ -103,6 +133,7 @@ RSpec.describe PotatoMesh::App::Federation do expect(http.read_timeout).to eq(timeout) expect(http.cert_store).to eq(store) expect(http.verify_mode).to eq(OpenSSL::SSL::VERIFY_PEER) + expect(http.verify_callback).to eq(callback) if http.respond_to?(:min_version) expect(http.min_version).to eq(:TLS1_2) end @@ -122,10 +153,12 @@ RSpec.describe PotatoMesh::App::Federation do it "leaves the certificate store unset when unavailable" do uri = URI.parse("https://remote.example.com/api") allow(federation_helpers).to receive(:remote_instance_cert_store).and_return(nil) + allow(federation_helpers).to receive(:remote_instance_verify_callback).and_return(nil) http = federation_helpers.build_remote_http_client(uri) expect(http.cert_store).to be_nil + expect(http.verify_callback).to be_nil end end end