From 15dbf8040e2c922eb61276367cf2080adab40cde Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 25 Mar 2026 15:52:12 +0100 Subject: [PATCH] Add support for multiple keypairs for remote accounts (#38279) --- .../concerns/signature_verification.rb | 39 ++++--- app/lib/activitypub/linked_data_signature.rb | 9 +- app/lib/signed_request.rb | 18 +-- app/models/concerns/account/associations.rb | 1 + app/models/keypair.rb | 77 +++++++++++++ .../activitypub/fetch_remote_key_service.rb | 5 +- .../activitypub/process_account_service.rb | 53 ++++++--- db/migrate/20260323105645_create_keypairs.rb | 20 ++++ db/schema.rb | 17 ++- spec/fabricators/keypair_fabricator.rb | 18 +++ .../activitypub/linked_data_signature_spec.rb | 20 ++-- spec/models/keypair_spec.rb | 58 ++++++++++ .../fetch_remote_key_service_spec.rb | 8 +- .../process_account_service_spec.rb | 103 ++++++++++++++++++ 14 files changed, 387 insertions(+), 59 deletions(-) create mode 100644 app/models/keypair.rb create mode 100644 db/migrate/20260323105645_create_keypairs.rb create mode 100644 spec/fabricators/keypair_fabricator.rb create mode 100644 spec/models/keypair_spec.rb diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 1e83ab9c69..f752e5cd93 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -53,19 +53,21 @@ module SignatureVerification raise Mastodon::SignatureVerificationError, 'Request not signed' unless signed_request? - actor = actor_from_key_id + keypair = keypair_from_key_id - raise Mastodon::SignatureVerificationError, "Public key not found for key #{signature_key_id}" if actor.nil? + raise Mastodon::SignatureVerificationError, "Public key not found for key #{signature_key_id}" if keypair.nil? - return (@signed_request_actor = actor) if signed_request.verified?(actor) + check_keypair_validity!(keypair) + return (@signed_request_actor = keypair.actor) if signed_request.verified?(keypair) - actor = stoplight_wrapper.run { actor_refresh_key!(actor) } + keypair = stoplight_wrapper.run { keypair_refresh_key!(keypair) } - raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_key_id}" if actor.nil? + raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_key_id}" if keypair.nil? - return (@signed_request_actor = actor) if signed_request.verified?(actor) + check_keypair_validity!(keypair) + return (@signed_request_actor = keypair.actor) if signed_request.verified?(keypair) - fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}" + fail_with! "Verification failed for #{keypair.actor.to_log_human_identifier} #{keypair.actor.uri} #{keypair.uri}" rescue Mastodon::MalformedHeaderError => e @signature_verification_failure_code = 400 fail_with! e.message @@ -89,7 +91,7 @@ module SignatureVerification @signed_request_actor = nil end - def actor_from_key_id + def keypair_from_key_id key_id = signed_request.key_id domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id @@ -101,9 +103,10 @@ module SignatureVerification if key_id.start_with?('acct:') stoplight_wrapper.run { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) } elsif !ActivityPub::TagManager.instance.local_uri?(key_id) - account = ActivityPub::TagManager.instance.uri_to_actor(key_id) - account ||= stoplight_wrapper.run { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) } - account + keypair = Keypair.from_keyid(key_id) + return keypair if keypair.present? + + stoplight_wrapper.run { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) } end rescue Mastodon::PrivateNetworkAddressError => e raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" @@ -120,14 +123,20 @@ module SignatureVerification ) end - def actor_refresh_key!(actor) - return if actor.local? || !actor.activitypub? - return actor.refresh! if actor.respond_to?(:refresh!) && actor.possibly_stale? + def keypair_refresh_key!(keypair) + # TODO: this currently only is concerned with refreshing the actor and returning the legacy key, this needs to be reworked + return if keypair.actor.local? || !keypair.actor.activitypub? + return keypair.actor.refresh! if keypair.actor.respond_to?(:refresh!) && keypair.actor.possibly_stale? - ActivityPub::FetchRemoteActorService.new.call(actor.uri, only_key: true, suppress_errors: false) + Keypair.from_legacy_account(ActivityPub::FetchRemoteActorService.new.call(keypair.actor.uri, only_key: true, suppress_errors: false)) rescue Mastodon::PrivateNetworkAddressError => e raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, Webfinger::Error => e raise Mastodon::SignatureVerificationError, e.message end + + def check_keypair_validity!(keypair) + raise Mastodon::SignatureVerification, "Key #{signature_key_id} is revoked" if keypair.revoked? + raise Mastodon::SignatureVerification, "Key #{signature_key_id} has expired" if keypair.expired? + end end diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb index c42313b05e..f6c4eeb90e 100644 --- a/app/lib/activitypub/linked_data_signature.rb +++ b/app/lib/activitypub/linked_data_signature.rb @@ -19,16 +19,15 @@ class ActivityPub::LinkedDataSignature return unless type == 'RsaSignature2017' - creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri) - creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri) if creator&.public_key.blank? - - return if creator.nil? + keypair = Keypair.from_keyid(creator_uri) + keypair = ActivityPub::FetchRemoteKeyService.new.call(creator_uri) if keypair&.public_key.blank? + return if keypair.nil? || !keypair.usable? options_hash = hash(@json['signature'].without('type', 'id', 'signatureValue').merge('@context' => CONTEXT)) document_hash = hash(@json.without('signature')) to_be_verified = options_hash + document_hash - creator if creator.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), Base64.decode64(signature), to_be_verified) + keypair.actor if keypair.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), Base64.decode64(signature), to_be_verified) rescue OpenSSL::PKey::RSAError false end diff --git a/app/lib/signed_request.rb b/app/lib/signed_request.rb index 1cea2955f5..6fd0772de3 100644 --- a/app/lib/signed_request.rb +++ b/app/lib/signed_request.rb @@ -23,14 +23,14 @@ class SignedRequest %w(rsa-sha256 hs2019).include?(signature_algorithm) end - def verified?(actor) + def verified?(keypair) signature = Base64.decode64(signature_params['signature']) compare_signed_string = build_signed_string(include_query_string: true) - return true unless verify_signature(actor, signature, compare_signed_string).nil? + return true unless verify_signature(keypair, signature, compare_signed_string).nil? compare_signed_string = build_signed_string(include_query_string: false) - return true unless verify_signature(actor, signature, compare_signed_string).nil? + return true unless verify_signature(keypair, signature, compare_signed_string).nil? false end @@ -99,8 +99,8 @@ class SignedRequest signature_params.fetch('headers', signature_algorithm == 'hs2019' ? '(created)' : 'date').downcase.split end - def verify_signature(actor, signature, compare_signed_string) - true if actor.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), signature, compare_signed_string) + def verify_signature(keypair, signature, compare_signed_string) + true if keypair.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), signature, compare_signed_string) rescue OpenSSL::PKey::RSAError nil end @@ -170,8 +170,8 @@ class SignedRequest true end - def verified?(actor) - key = Linzer.new_rsa_v1_5_sha256_public_key(actor.public_key) + def verified?(keypair) + key = Linzer.new_rsa_v1_5_sha256_public_key(keypair.public_key) Linzer.verify(key, @message, @signature) rescue Linzer::VerifyError @@ -243,7 +243,7 @@ class SignedRequest end end - def verified?(actor) + def verified?(keypair) missing_signature_parameters = @signature.missing_signature_parameters raise Mastodon::SignatureVerificationError, "Incompatible request signature. #{missing_signature_parameters.to_sentence} are required" if missing_signature_parameters raise Mastodon::SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless @signature.algorithm_supported? @@ -251,7 +251,7 @@ class SignedRequest @signature.verify_signature_strength! @signature.verify_body_digest! - @signature.verified?(actor) + @signature.verified?(keypair) end private diff --git a/app/models/concerns/account/associations.rb b/app/models/concerns/account/associations.rb index 4b4dfc8879..089f08fc59 100644 --- a/app/models/concerns/account/associations.rb +++ b/app/models/concerns/account/associations.rb @@ -37,6 +37,7 @@ module Account::Associations has_many :scheduled_statuses has_many :status_pins has_many :statuses + has_many :keypairs has_one :deletion_request, class_name: 'AccountDeletionRequest' has_one :follow_recommendation_suppression diff --git a/app/models/keypair.rb b/app/models/keypair.rb new file mode 100644 index 0000000000..80c313f4df --- /dev/null +++ b/app/models/keypair.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: keypairs +# +# id :bigint(8) not null, primary key +# expires_at :datetime +# private_key :string +# public_key :string not null +# revoked :boolean default(FALSE), not null +# type :integer not null +# uri :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint(8) not null +# + +class Keypair < ApplicationRecord + include Expireable + + self.inheritance_column = nil + + encrypts :private_key + + belongs_to :account + + enum :type, { rsa: 0 } + + attr_accessor :require_private_key + + validates :uri, presence: true, uniqueness: true + validates :public_key, presence: true + validates :private_key, presence: true, if: -> { account.local? } + + # NOTE: this should be true in production, but tests heavily rely on remote accounts having a keypair + validates :private_key, absence: true, if: -> { account.remote? && !require_private_key } + + scope :unexpired, -> { where(expires_at: nil).or(where.not(expires_at: ..Time.now.utc)) } + scope :usable, -> { unexpired.where(revoked: false) } + + alias actor account + + def keypair + @keypair ||= begin + case type + when 'rsa' + OpenSSL::PKey::RSA.new(private_key || public_key) + end + end + end + + def usable? + !revoked? && !expired? + end + + def self.from_keyid(uri) + keypair = find_by(uri: uri) + return keypair unless keypair.nil? + + # No keypair found, try the old way we used to store RSA keypairs + account = ActivityPub::TagManager.instance.uri_to_actor(uri) + return if account&.public_key.blank? + + from_legacy_account(account, uri: uri) + end + + def self.from_legacy_account(account, uri: nil) + Keypair.new( + account:, + uri: uri.presence || ActivityPub::TagManager.instance.key_uri_for(account), + public_key: account.public_key, + private_key: account.private_key, + type: :rsa + ) + end +end diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb index b6d9cfa733..7226f76737 100644 --- a/app/services/activitypub/fetch_remote_key_service.rb +++ b/app/services/activitypub/fetch_remote_key_service.rb @@ -14,7 +14,7 @@ class ActivityPub::FetchRemoteKeyService < BaseService raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil? raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json) || (supported_security_context?(@json) && @json['owner'].present? && !actor_type?) raise Error, "Unexpected object type for key #{uri}" unless expected_type? - return find_actor(@json['id'], @json, suppress_errors) if actor_type? + return Keypair.from_legacy_account(find_actor(@json['id'], @json, suppress_errors), uri: uri) if actor_type? @owner = fetch_resource(owner_uri, true) @@ -23,7 +23,8 @@ class ActivityPub::FetchRemoteKeyService < BaseService raise Error, "Unexpected object type for actor #{owner_uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_owner_type? raise Error, "publicKey id for #{owner_uri} does not correspond to #{@json['id']}" unless confirmed_owner? - find_actor(owner_uri, @owner, suppress_errors) + # TODO: change to fetch and persist key + Keypair.from_legacy_account(find_actor(owner_uri, @owner, suppress_errors), uri: uri) rescue Error => e Rails.logger.debug { "Fetching key #{uri} failed: #{e.message}" } raise unless suppress_errors diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index bececf4bac..bc282eeef5 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -6,6 +6,7 @@ class ActivityPub::ProcessAccountService < BaseService include Redisable include Lockable + MAX_PUBLIC_KEYS = 10 MAX_PROFILE_FIELDS = 50 SUBDOMAINS_RATELIMIT = 10 DISCOVERIES_PER_REQUEST = 400 @@ -33,8 +34,8 @@ class ActivityPub::ProcessAccountService < BaseService with_redis_lock("process_account:#{@uri}") do @account = Account.remote.find_by(uri: @uri) if @options[:only_key] @account ||= Account.find_remote(@username, @domain) - @old_public_key = @account&.public_key - @old_protocol = @account&.protocol + @old_public_keys = @account.present? ? (@account.keypairs.pluck(:public_key) + [@account.public_key.presence].compact) : [] + @old_protocol = @account&.protocol @suspension_changed = false if @account.nil? @@ -56,8 +57,9 @@ class ActivityPub::ProcessAccountService < BaseService end after_protocol_change! if protocol_changed? - after_key_change! if key_changed? && !@options[:signed_with_known_key] - clear_tombstones! if key_changed? + after_key_change! if all_public_keys_changed? && !@options[:signed_with_known_key] + # TODO: maybe tie tombstones to specific keys? i.e. we don't need to keep tombstones if all keys changed + clear_tombstones! if all_public_keys_changed? after_suspension_change! if suspension_changed? unless @options[:only_key] || @account.suspended? @@ -145,7 +147,11 @@ class ActivityPub::ProcessAccountService < BaseService end def set_fetchable_key! - @account.public_key = public_key || '' + @account.keypairs.upsert_all(public_keys, unique_by: :uri) + @account.keypairs.where.not(uri: public_keys.pluck(:uri)).delete_all + + # Unset legacy public key attribute + @account.public_key = '' end def set_fetchable_attributes! @@ -257,14 +263,35 @@ class ActivityPub::ProcessAccountService < BaseService [url, description] end - def public_key - value = first_of_value(@json['publicKey']) + def public_keys + # TODO: handle FEP-521a - return if value.nil? - return value['publicKeyPem'] if value.is_a?(Hash) + @public_keys ||= as_array(@json['publicKey']).take(MAX_PUBLIC_KEYS).filter_map do |value| + next if value.nil? - key = fetch_resource_without_id_validation(value) - key['publicKeyPem'] if key + if value.is_a?(Hash) + next unless value['owner'] == @account.uri + + key = value['publicKeyPem'] + value = value['id'] + + # Key is contained within the actor document, no need to fetch anything else + next { type: :rsa, public_key: key, uri: value } if value.split('#').first == @account.uri + end + + key_id = value + + # Key is fetched without ID validation because of a GoToSocial bug + value = fetch_resource_without_id_validation(key_id) + + # Special handling for GoToSocial which returns the whole actor for the key ID + value = first_of_value(value['publicKey']) if value.is_a?(Hash) && value.key?('publicKey') + + next unless value['owner'] == @account.uri + + value['publicKeyPem'] + { type: :rsa, public_key: :key, uri: key_id } + end end def url @@ -353,8 +380,8 @@ class ActivityPub::ProcessAccountService < BaseService @domain_block = DomainBlock.rule_for(@domain) end - def key_changed? - !@old_public_key.nil? && @old_public_key != @account.public_key + def all_public_keys_changed? + !@old_public_keys.empty? && @account.keypairs.none? { |keypair| keypair.usable? && @old_public_keys.include?(keypair.public_key) } end def suspension_changed? diff --git a/db/migrate/20260323105645_create_keypairs.rb b/db/migrate/20260323105645_create_keypairs.rb new file mode 100644 index 0000000000..e3ab970a7c --- /dev/null +++ b/db/migrate/20260323105645_create_keypairs.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class CreateKeypairs < ActiveRecord::Migration[8.0] + def change + create_table :keypairs do |t| + t.references :account, null: false, foreign_key: { on_delete: :cascade } + + t.string :uri, null: false + t.integer :type, null: false + t.string :public_key, null: false + t.string :private_key + t.datetime :expires_at + t.boolean :revoked, default: false, null: false + + t.timestamps + end + + add_index :keypairs, :uri, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 4b9dbbcd36..69a9ef45da 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_19_142348) do +ActiveRecord::Schema[8.1].define(version: 2026_03_23_105645) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -683,6 +683,20 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_19_142348) do t.index ["ip"], name: "index_ip_blocks_on_ip", unique: true end + create_table "keypairs", force: :cascade do |t| + t.bigint "account_id", null: false + t.datetime "created_at", null: false + t.datetime "expires_at" + t.string "private_key" + t.string "public_key", null: false + t.boolean "revoked", default: false, null: false + t.integer "type", null: false + t.datetime "updated_at", null: false + t.string "uri", null: false + t.index ["account_id"], name: "index_keypairs_on_account_id" + t.index ["uri"], name: "index_keypairs_on_uri", unique: true + end + create_table "list_accounts", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "follow_id" @@ -1492,6 +1506,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_19_142348) do add_foreign_key "identities", "users", name: "fk_bea040f377", on_delete: :cascade add_foreign_key "instance_moderation_notes", "accounts", on_delete: :cascade add_foreign_key "invites", "users", on_delete: :cascade + add_foreign_key "keypairs", "accounts", on_delete: :cascade add_foreign_key "list_accounts", "accounts", on_delete: :cascade add_foreign_key "list_accounts", "follow_requests", on_delete: :cascade add_foreign_key "list_accounts", "follows", on_delete: :cascade diff --git a/spec/fabricators/keypair_fabricator.rb b/spec/fabricators/keypair_fabricator.rb new file mode 100644 index 0000000000..5eae3872d2 --- /dev/null +++ b/spec/fabricators/keypair_fabricator.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +keypair = OpenSSL::PKey::RSA.new(2048) +public_key = keypair.public_key.to_pem +private_key = keypair.to_pem + +Fabricator(:keypair) do + account + type :rsa + public_key public_key + expires_at nil + revoked false + + after_build do |keypair| + keypair.uri ||= ActivityPub::TagManager.instance.key_uri_for(keypair.account) + keypair.private_key ||= private_key if keypair.account.local? + end +end diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb index 8128fdd070..7aaff9680e 100644 --- a/spec/lib/activitypub/linked_data_signature_spec.rb +++ b/spec/lib/activitypub/linked_data_signature_spec.rb @@ -7,6 +7,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do subject { described_class.new(json) } + let(:keyid) { 'http://example.com/alice#rsa-key' } let!(:sender) { Fabricate(:account, uri: 'http://example.com/alice', domain: 'example.com') } let(:raw_json) do @@ -25,7 +26,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do context 'when signature matches' do let(:raw_signature) do { - 'creator' => 'http://example.com/alice', + 'creator' => keyid, 'created' => '2017-09-23T20:21:34Z', } end @@ -40,7 +41,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do context 'when local account record is missing a public key' do let(:raw_signature) do { - 'creator' => 'http://example.com/alice', + 'creator' => keyid, 'created' => '2017-09-23T20:21:34Z', } end @@ -59,15 +60,14 @@ RSpec.describe ActivityPub::LinkedDataSignature do allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub) - allow(service_stub).to receive(:call).with('http://example.com/alice') do - sender.update!(public_key: old_key) - sender + allow(service_stub).to receive(:call).with(keyid) do + Keypair.new(account: sender, type: :rsa, public_key: old_key, uri: keyid) end end it 'fetches key and returns creator' do expect(subject.verify_actor!).to eq sender - expect(service_stub).to have_received(:call).with('http://example.com/alice').once + expect(service_stub).to have_received(:call).with(keyid).once end end @@ -82,7 +82,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do context 'when signature is tampered' do let(:raw_signature) do { - 'creator' => 'http://example.com/alice', + 'creator' => keyid, 'created' => '2017-09-23T20:21:34Z', } end @@ -100,7 +100,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do let(:raw_signature) do { - 'creator' => 'http://example.com/alice', + 'creator' => keyid, 'created' => '2017-09-23T20:21:34Z', } end @@ -116,7 +116,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do let(:raw_signature) do { - 'creator' => 'http://example.com/alice', + 'creator' => keyid, 'created' => '2017-09-23T20:21:34Z', } end @@ -132,7 +132,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do let(:raw_signature) do { - 'creator' => 'http://example.com/alice', + 'creator' => keyid, 'created' => '2017-09-23T20:21:34Z', } end diff --git a/spec/models/keypair_spec.rb b/spec/models/keypair_spec.rb new file mode 100644 index 0000000000..e7b18d8e68 --- /dev/null +++ b/spec/models/keypair_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Keypair do + describe '#keypair' do + let(:keypair) { Fabricate(:keypair) } + + it 'returns an RSA key pair' do + expect(keypair.keypair).to be_instance_of OpenSSL::PKey::RSA + end + end + + describe 'from_keyid' do + context 'when a key with the given key ID exists' do + let(:account) { Fabricate(:account, domain: 'example.com') } + let(:keypair) { Fabricate(:keypair, account: account) } + + it 'returns the expected Keypair' do + expect(described_class.from_keyid(keypair.uri)) + .to eq keypair + end + end + + context 'when no key with the expected key ID exists but there is an account with the same ID and a key' do + let(:account) { Fabricate(:account, domain: 'example.com') } + let(:keyid) { "#{ActivityPub::TagManager.instance.uri_for(account)}#main-rsa-key" } + + it 'returns the expected Keypair' do + expect(described_class.from_keyid(keyid)) + .to have_attributes( + account: account, + type: 'rsa', + uri: keyid + ) + end + end + + context 'when no key with the expected key ID exists but there is an account with the same ID and no key' do + let(:account) { Fabricate(:account, domain: 'example.com', public_key: '', private_key: nil) } + let(:keyid) { "#{ActivityPub::TagManager.instance.uri_for(account)}#main-rsa-key" } + + it 'returns nil' do + expect(described_class.from_keyid(keyid)) + .to be_nil + end + end + + context 'when no key with the expected key ID exists and no matching account exists' do + let(:keyid) { 'https://example.com/alice#main-key' } + + it 'returns nil' do + expect(described_class.from_keyid(keyid)) + .to be_nil + end + end + end +end diff --git a/spec/services/activitypub/fetch_remote_key_service_spec.rb b/spec/services/activitypub/fetch_remote_key_service_spec.rb index f5df52d5ba..cd61ebee22 100644 --- a/spec/services/activitypub/fetch_remote_key_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_key_service_spec.rb @@ -55,7 +55,7 @@ RSpec.describe ActivityPub::FetchRemoteKeyService do end describe '#call' do - let(:account) { subject.call(public_key_id) } + let(:keypair) { subject.call(public_key_id) } context 'when the key is a sub-object from the actor' do before do @@ -63,7 +63,7 @@ RSpec.describe ActivityPub::FetchRemoteKeyService do end it 'returns the expected account' do - expect(account.uri).to eq 'https://example.com/alice' + expect(keypair.account.uri).to eq 'https://example.com/alice' end end @@ -75,7 +75,7 @@ RSpec.describe ActivityPub::FetchRemoteKeyService do end it 'returns the expected account' do - expect(account.uri).to eq 'https://example.com/alice' + expect(keypair.account.uri).to eq 'https://example.com/alice' end end @@ -88,7 +88,7 @@ RSpec.describe ActivityPub::FetchRemoteKeyService do end it 'returns the nil' do - expect(account).to be_nil + expect(keypair).to be_nil end end end diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb index 949cc45618..8e8fbb6a44 100644 --- a/spec/services/activitypub/process_account_service_spec.rb +++ b/spec/services/activitypub/process_account_service_spec.rb @@ -94,6 +94,109 @@ RSpec.describe ActivityPub::ProcessAccountService do end end + context 'with a single keypair' do + let(:payload) do + { + id: 'https://foo.test/actor', + type: 'Actor', + inbox: 'https://foo.test/inbox', + preferredUsername: 'alice', + publicKey: { + id: 'https://foo.test/actor#key1', + owner: 'https://foo.test/actor', + publicKeyPem: 'foo', + }, + }.with_indifferent_access + end + + it 'stores the key' do + account = subject.call('alice', 'example.com', payload) + + expect(account.public_key).to eq '' + expect(account.keypairs).to contain_exactly( + have_attributes( + uri: 'https://foo.test/actor#key1', + type: 'rsa' + ) + ) + end + + context 'when the account was known with a legacy key' do + let!(:alice) { Fabricate(:account, uri: 'https://foo.test/actor', domain: 'example.com', username: 'alice') } + + it 'invalidates the legacy key and stores the new key' do + expect { subject.call('alice', 'example.com', payload) } + .to change { alice.reload.public_key }.to('') + .and change { alice.reload.keypairs.to_a }.from([]).to(contain_exactly(have_attributes({ uri: 'https://foo.test/actor#key1', type: 'rsa' }))) + end + end + + context 'when the account was known with an old key' do + let!(:alice) { Fabricate(:account, uri: 'https://foo.test/actor', domain: 'example.com', username: 'alice', public_key: '') } + + before do + Fabricate(:keypair, account: alice, uri: 'https://foo.test/actor#old-key', type: :rsa) + end + + it 'invalidates the legacy key and stores the new key' do + expect { subject.call('alice', 'example.com', payload) } + .to change { alice.reload.keypairs.to_a }.from(contain_exactly(have_attributes({ uri: 'https://foo.test/actor#old-key' }))).to(contain_exactly(have_attributes({ uri: 'https://foo.test/actor#key1', type: 'rsa' }))) + + expect(alice.reload.public_key) + .to eq '' + end + end + end + + context 'with multiple keypairs' do + let(:payload) do + { + id: 'https://foo.test/actor', + type: 'Actor', + inbox: 'https://foo.test/inbox', + preferredUsername: 'alice', + publicKey: [ + { + id: 'https://foo.test/actor#key1', + owner: 'https://foo.test/actor', + publicKeyPem: 'foo', + }, + { + id: 'https://foo.test/actor#key2', + owner: 'https://foo.test/actor', + publicKeyPem: 'bar', + }, + ], + }.with_indifferent_access + end + + it 'stores the keys' do + account = subject.call('alice', 'example.com', payload) + + expect(account.public_key).to eq '' + expect(account.keypairs).to contain_exactly( + have_attributes( + uri: 'https://foo.test/actor#key1', + type: 'rsa' + ), + have_attributes( + uri: 'https://foo.test/actor#key2', + type: 'rsa' + ) + ) + end + + context 'when the account was known with a legacy key' do + let!(:alice) { Fabricate(:account, uri: 'https://foo.test/actor', domain: 'example.com', username: 'alice') } + + it 'invalidates the legacy key and stores the new keys' do + expect { subject.call('alice', 'example.com', payload) } + .to change { alice.reload.public_key }.to('') + .and change { alice.keypairs.to_a }.from([]).to(contain_exactly(have_attributes({ uri: 'https://foo.test/actor#key1', type: 'rsa' }), have_attributes({ uri: 'https://foo.test/actor#key2', type: 'rsa' }))) + end + end + end + context 'with attribution domains' do let(:payload) do {