Files
l5y 027b6160df web: federation signature v2 migration (#805)
* web: federation signature v2 migration

* web: address review comments
2026-06-22 10:01:45 +02:00

108 lines
4.4 KiB
Ruby

# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
module PotatoMesh
module App
module Federation
# Validate a remote +/.well-known+ document, including signature checks
# against the supplied public key.
#
# @param document [Hash] decoded well-known document.
# @param domain [String] expected sanitized domain.
# @param pubkey [String] expected canonical PEM public key.
# @return [Array(Boolean, String, nil)] tuple containing the validation
# result and an optional human-readable failure reason.
def validate_well_known_document(document, domain, pubkey)
unless document.is_a?(Hash)
return [false, "document is not an object"]
end
remote_pubkey = sanitize_public_key_pem(document["public_key"] || document["publicKey"])
return [false, "public key missing"] unless remote_pubkey
return [false, "public key mismatch"] unless remote_pubkey == pubkey
remote_domain = string_or_nil(document["domain"])
return [false, "domain missing"] unless remote_domain
return [false, "domain mismatch"] unless remote_domain.casecmp?(domain)
algorithm = string_or_nil(document["signature_algorithm"] || document["signatureAlgorithm"])
unless algorithm&.casecmp?(PotatoMesh::Config.instance_signature_algorithm)
return [false, "unsupported signature algorithm"]
end
signed_payload_b64 = string_or_nil(document["signed_payload"] || document["signedPayload"])
signature_b64 = string_or_nil(document["signature"])
return [false, "missing signed payload"] unless signed_payload_b64
return [false, "missing signature"] unless signature_b64
signed_payload = Base64.strict_decode64(signed_payload_b64)
signature = Base64.strict_decode64(signature_b64)
key = OpenSSL::PKey::RSA.new(remote_pubkey)
unless key.verify(OpenSSL::Digest::SHA256.new, signature, signed_payload)
return [false, "invalid well-known signature"]
end
payload = JSON.parse(signed_payload)
unless payload.is_a?(Hash)
return [false, "signed payload is not an object"]
end
payload_domain = string_or_nil(payload["domain"])
payload_pubkey = sanitize_public_key_pem(payload["public_key"] || payload["publicKey"])
return [false, "signed payload domain mismatch"] unless payload_domain&.casecmp?(domain)
return [false, "signed payload public key mismatch"] unless payload_pubkey == pubkey
[true, nil]
rescue ArgumentError, OpenSSL::PKey::PKeyError => e
[false, e.message]
rescue JSON::ParserError => e
[false, "signed payload JSON error: #{e.message}"]
end
# Confirm a remote +/api/nodes+ payload contains a sufficient set of
# recently active nodes.
#
# @param nodes [Object] decoded array of remote node entries.
# @return [Array(Boolean, String, nil)] tuple of (is_fresh, optional reason).
def validate_remote_nodes(nodes)
unless nodes.is_a?(Array)
return [false, "node response is not an array"]
end
if nodes.length < PotatoMesh::Config.remote_instance_min_node_count
return [false, "insufficient nodes"]
end
latest = nodes.filter_map do |node|
next unless node.is_a?(Hash)
last_heard_values = []
last_heard_values << coerce_integer(node["last_heard"])
last_heard_values << coerce_integer(node["lastHeard"])
last_heard_values.compact.max
end.compact.max
return [false, "missing last_heard data"] unless latest
cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
return [false, "node data is stale"] if latest < cutoff
[true, nil]
end
end
end
end