From e4facd7f26cf42d372d6485dedf98347073c238f Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:52:59 +0100 Subject: [PATCH] web: fix federation signature validation and create fallback (#563) * web: fix federation signature validation and create fallback * web: cover missing unit test vectors --- .../potato_mesh/application/routes/ingest.rb | 10 ++ web/spec/app_spec.rb | 155 ++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/web/lib/potato_mesh/application/routes/ingest.rb b/web/lib/potato_mesh/application/routes/ingest.rb index e8f56362..52fcf579 100644 --- a/web/lib/potato_mesh/application/routes/ingest.rb +++ b/web/lib/potato_mesh/application/routes/ingest.rb @@ -132,6 +132,7 @@ module PotatoMesh raw_private = payload.key?("isPrivate") ? payload["isPrivate"] : payload["is_private"] is_private = coerce_boolean(raw_private) signature = string_or_nil(payload["signature"]) + contact_link = string_or_nil(payload["contactLink"]) attributes = { id: id, @@ -145,6 +146,7 @@ module PotatoMesh longitude: longitude, last_update_time: last_update_time, is_private: is_private, + contact_link: contact_link, } if [attributes[:id], attributes[:domain], attributes[:pubkey], signature, attributes[:last_update_time]].any?(&:nil?) @@ -157,6 +159,10 @@ module PotatoMesh end signature_valid = verify_instance_signature(attributes, signature, attributes[:pubkey]) + if !signature_valid && contact_link + stripped_attributes = attributes.merge(contact_link: nil) + signature_valid = verify_instance_signature(stripped_attributes, signature, attributes[:pubkey]) + end # Some remote peers sign payloads using a canonicalised lowercase # domain while still sending a mixed-case domain. Retry signature # verification with the original casing when the first attempt @@ -164,6 +170,10 @@ module PotatoMesh if !signature_valid && raw_domain && normalized_domain && raw_domain.casecmp?(normalized_domain) && raw_domain != normalized_domain alternate_attributes = attributes.merge(domain: raw_domain) signature_valid = verify_instance_signature(alternate_attributes, signature, attributes[:pubkey]) + if !signature_valid && contact_link + stripped_alternate = alternate_attributes.merge(contact_link: nil) + signature_valid = verify_instance_signature(stripped_alternate, signature, attributes[:pubkey]) + end end unless signature_valid diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 0ca33d85..d5e4fba0 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -1607,6 +1607,161 @@ RSpec.describe "Potato Mesh Sinatra app" do end end + it "accepts registrations when contactLink is part of the signed payload" do + contact_link = "https://example.test/contact" + linked_attributes = instance_attributes.merge(contact_link: contact_link) + linked_signature_payload = canonical_instance_payload(linked_attributes) + linked_signature = Base64.strict_encode64( + instance_key.sign(OpenSSL::Digest::SHA256.new, linked_signature_payload), + ) + linked_payload = instance_payload.merge( + "contactLink" => contact_link, + "signature" => linked_signature, + ) + + post "/api/instances", linked_payload.to_json, { "CONTENT_TYPE" => "application/json" } + + expect(last_response.status).to eq(201) + expect(JSON.parse(last_response.body)).to eq("status" => "registered") + + with_db(readonly: true) do |db| + db.results_as_hash = true + row = db.get_first_row( + "SELECT contact_link, signature FROM instances WHERE id = ?", + [instance_attributes[:id]], + ) + + expect(row).not_to be_nil + expect(row["contact_link"]).to eq(contact_link) + expect(row["signature"]).to eq(linked_signature) + end + end + + it "accepts instance announcement payloads produced by the application including contactLink" do + contact_link = "https://example.test/contact" + announcement_attributes = instance_attributes.merge(contact_link: contact_link) + announcement_signature = Base64.strict_encode64( + instance_key.sign( + OpenSSL::Digest::SHA256.new, + canonical_instance_payload(announcement_attributes), + ), + ) + announcement_payload = application_class.instance_announcement_payload( + announcement_attributes, + announcement_signature, + ) + + post "/api/instances", announcement_payload.to_json, { "CONTENT_TYPE" => "application/json" } + + expect(last_response.status).to eq(201) + expect(JSON.parse(last_response.body)).to eq("status" => "registered") + + with_db(readonly: true) do |db| + db.results_as_hash = true + row = db.get_first_row( + "SELECT contact_link, signature FROM instances WHERE id = ?", + [instance_attributes[:id]], + ) + + expect(row).not_to be_nil + expect(row["contact_link"]).to eq(contact_link) + expect(row["signature"]).to eq(announcement_signature) + end + end + + it "accepts signatures that omit contactLink for backwards compatibility" do + contact_link = "https://legacy.example/contact" + legacy_signature_payload = canonical_instance_payload(instance_attributes) + legacy_signature = Base64.strict_encode64( + instance_key.sign(OpenSSL::Digest::SHA256.new, legacy_signature_payload), + ) + legacy_payload = instance_payload.merge( + "contactLink" => contact_link, + "signature" => legacy_signature, + ) + + post "/api/instances", legacy_payload.to_json, { "CONTENT_TYPE" => "application/json" } + + expect(last_response.status).to eq(201) + expect(JSON.parse(last_response.body)).to eq("status" => "registered") + + with_db(readonly: true) do |db| + db.results_as_hash = true + row = db.get_first_row( + "SELECT contact_link, signature FROM instances WHERE id = ?", + [instance_attributes[:id]], + ) + + expect(row).not_to be_nil + expect(row["contact_link"]).to eq(contact_link) + expect(row["signature"]).to eq(legacy_signature) + end + end + + it "accepts mixed-case domains when the signature omits contactLink but the payload includes it" do + raw_domain = "Mesh.Example" + normalized_domain = raw_domain.downcase + contact_link = "https://mixed.example/contact" + mixed_attributes = instance_attributes.merge(domain: raw_domain) + mixed_signature_payload = canonical_instance_payload(mixed_attributes) + mixed_signature = Base64.strict_encode64( + instance_key.sign(OpenSSL::Digest::SHA256.new, mixed_signature_payload), + ) + mixed_payload = instance_payload.merge( + "domain" => raw_domain, + "contactLink" => contact_link, + "signature" => mixed_signature, + ) + + mixed_remote_payload = JSON.generate( + { + "publicKey" => pubkey, + "name" => instance_attributes[:name], + "version" => instance_attributes[:version], + "domain" => normalized_domain, + "lastUpdate" => last_update_time, + }, + sort_keys: true, + ) + + mixed_document = well_known_document.merge( + "domain" => normalized_domain, + "signedPayload" => Base64.strict_encode64(mixed_remote_payload), + "signature" => Base64.strict_encode64( + instance_key.sign(OpenSSL::Digest::SHA256.new, mixed_remote_payload), + ), + ) + + allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path| + case path + when "/.well-known/potato-mesh" + [mixed_document, URI("https://#{host}#{path}")] + when "/api/nodes" + [remote_nodes, URI("https://#{host}#{path}")] + else + [nil, []] + end + end + + post "/api/instances", mixed_payload.to_json, { "CONTENT_TYPE" => "application/json" } + + expect(last_response.status).to eq(201) + expect(JSON.parse(last_response.body)).to eq("status" => "registered") + + with_db(readonly: true) do |db| + db.results_as_hash = true + row = db.get_first_row( + "SELECT domain, contact_link, signature FROM instances WHERE id = ?", + [mixed_attributes[:id]], + ) + + expect(row).not_to be_nil + expect(row["domain"]).to eq(normalized_domain) + expect(row["contact_link"]).to eq(contact_link) + expect(row["signature"]).to eq(mixed_signature) + end + end + it "rejects registrations with invalid domains" do invalid_payload = instance_payload.merge("domain" => "mesh-instance")