mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-06 18:01:19 +02:00
027b6160df
* web: federation signature v2 migration * web: address review comments
108 lines
4.4 KiB
Ruby
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
|