Add support for multiple keypairs for remote accounts (#38279)

This commit is contained in:
Claire 2026-03-25 15:52:12 +01:00 committed by GitHub
parent 1820a03622
commit 15dbf8040e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 387 additions and 59 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

77
app/models/keypair.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
{