Add support for multiple keypairs for remote accounts (#38279)
This commit is contained in:
parent
1820a03622
commit
15dbf8040e
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
77
app/models/keypair.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
20
db/migrate/20260323105645_create_keypairs.rb
Normal file
20
db/migrate/20260323105645_create_keypairs.rb
Normal 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
|
||||
17
db/schema.rb
17
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
|
||||
|
||||
18
spec/fabricators/keypair_fabricator.rb
Normal file
18
spec/fabricators/keypair_fabricator.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
58
spec/models/keypair_spec.rb
Normal file
58
spec/models/keypair_spec.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user