Add CLI task for rotating keys (#8466)
* If an Update is signed with known key, skip re-following procedure Because it means the remote actor did *not* lose their database * Add CLI method for rotating keys bin/tootctl accounts rotate [USERNAME] Generates a new RSA key per account and sends out an Update activity signed with the old key. * Key rotation: Space out Update fan-outs every 5 minutes per 1000 accounts * Skip suspended accounts in key rotation
This commit is contained in:
parent
8adf485c0f
commit
cabdbb7f9c
|
@ -11,6 +11,6 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
|
|||
|
||||
def update_account
|
||||
return if @account.uri != object_uri
|
||||
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object)
|
||||
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -32,7 +32,7 @@ class ActivityPub::LinkedDataSignature
|
|||
end
|
||||
end
|
||||
|
||||
def sign!(creator)
|
||||
def sign!(creator, sign_with: nil)
|
||||
options = {
|
||||
'type' => 'RsaSignature2017',
|
||||
'creator' => [ActivityPub::TagManager.instance.uri_for(creator), '#main-key'].join,
|
||||
|
@ -42,8 +42,9 @@ class ActivityPub::LinkedDataSignature
|
|||
options_hash = hash(options.without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
|
||||
document_hash = hash(@json.without('signature'))
|
||||
to_be_signed = options_hash + document_hash
|
||||
keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : creator.keypair
|
||||
|
||||
signature = Base64.strict_encode64(creator.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_signed))
|
||||
signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest::SHA256.new, to_be_signed))
|
||||
|
||||
@json.merge('signature' => options.merge('signatureValue' => signature))
|
||||
end
|
||||
|
|
|
@ -22,10 +22,11 @@ class Request
|
|||
set_digest! if options.key?(:body)
|
||||
end
|
||||
|
||||
def on_behalf_of(account, key_id_format = :acct)
|
||||
def on_behalf_of(account, key_id_format = :acct, sign_with: nil)
|
||||
raise ArgumentError unless account.local?
|
||||
|
||||
@account = account
|
||||
@keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : @account.keypair
|
||||
@key_id_format = key_id_format
|
||||
|
||||
self
|
||||
|
@ -70,7 +71,7 @@ class Request
|
|||
|
||||
def signature
|
||||
algorithm = 'rsa-sha256'
|
||||
signature = Base64.strict_encode64(@account.keypair.sign(OpenSSL::Digest::SHA256.new, signed_string))
|
||||
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest::SHA256.new, signed_string))
|
||||
|
||||
"keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers}\",signature=\"#{signature}\""
|
||||
end
|
||||
|
|
|
@ -5,9 +5,10 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
|
||||
# Should be called with confirmed valid JSON
|
||||
# and WebFinger-resolved username and domain
|
||||
def call(username, domain, json)
|
||||
def call(username, domain, json, options = {})
|
||||
return if json['inbox'].blank? || unsupported_uri_scheme?(json['id'])
|
||||
|
||||
@options = options
|
||||
@json = json
|
||||
@uri = @json['id']
|
||||
@username = username
|
||||
|
@ -31,7 +32,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
return if @account.nil?
|
||||
|
||||
after_protocol_change! if protocol_changed?
|
||||
after_key_change! if key_changed?
|
||||
after_key_change! if key_changed? && !@options[:signed_with_known_key]
|
||||
check_featured_collection! if @account.featured_collection_url.present?
|
||||
|
||||
@account
|
||||
|
|
|
@ -10,7 +10,8 @@ class ActivityPub::DeliveryWorker
|
|||
|
||||
HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
|
||||
|
||||
def perform(json, source_account_id, inbox_url)
|
||||
def perform(json, source_account_id, inbox_url, options = {})
|
||||
@options = options.with_indifferent_access
|
||||
@json = json
|
||||
@source_account = Account.find(source_account_id)
|
||||
@inbox_url = inbox_url
|
||||
|
@ -27,7 +28,7 @@ class ActivityPub::DeliveryWorker
|
|||
|
||||
def build_request
|
||||
request = Request.new(:post, @inbox_url, body: @json)
|
||||
request.on_behalf_of(@source_account, :uri)
|
||||
request.on_behalf_of(@source_account, :uri, sign_with: @options[:sign_with])
|
||||
request.add_headers(HEADERS)
|
||||
end
|
||||
|
||||
|
|
|
@ -5,7 +5,8 @@ class ActivityPub::UpdateDistributionWorker
|
|||
|
||||
sidekiq_options queue: 'push'
|
||||
|
||||
def perform(account_id)
|
||||
def perform(account_id, options = {})
|
||||
@options = options.with_indifferent_access
|
||||
@account = Account.find(account_id)
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
|
||||
|
@ -26,7 +27,7 @@ class ActivityPub::UpdateDistributionWorker
|
|||
end
|
||||
|
||||
def signed_payload
|
||||
@signed_payload ||= Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account))
|
||||
@signed_payload ||= Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account, sign_with: @options[:sign_with]))
|
||||
end
|
||||
|
||||
def payload
|
||||
|
|
|
@ -3,13 +3,16 @@
|
|||
require 'thor'
|
||||
require_relative 'mastodon/media_cli'
|
||||
require_relative 'mastodon/emoji_cli'
|
||||
|
||||
require_relative 'mastodon/accounts_cli'
|
||||
module Mastodon
|
||||
class CLI < Thor
|
||||
desc 'media SUBCOMMAND ...ARGS', 'manage media files'
|
||||
desc 'media SUBCOMMAND ...ARGS', 'Manage media files'
|
||||
subcommand 'media', Mastodon::MediaCLI
|
||||
|
||||
desc 'emoji SUBCOMMAND ...ARGS', 'manage custom emoji'
|
||||
desc 'emoji SUBCOMMAND ...ARGS', 'Manage custom emoji'
|
||||
subcommand 'emoji', Mastodon::EmojiCLI
|
||||
|
||||
desc 'accounts SUBCOMMAND ...ARGS', 'Manage accounts'
|
||||
subcommand 'accounts', Mastodon::AccountsCLI
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rubygems/package'
|
||||
require_relative '../../config/boot'
|
||||
require_relative '../../config/environment'
|
||||
require_relative 'cli_helper'
|
||||
|
||||
module Mastodon
|
||||
class AccountsCLI < Thor
|
||||
option :all, type: :boolean
|
||||
desc 'rotate [USERNAME]', 'Generate and broadcast new keys'
|
||||
long_desc <<-LONG_DESC
|
||||
Generate and broadcast new RSA keys as part of security
|
||||
maintenance.
|
||||
|
||||
With the --all option, all local accounts will be subject
|
||||
to the rotation. Otherwise, and by default, only a single
|
||||
account specified by the USERNAME argument will be
|
||||
processed.
|
||||
LONG_DESC
|
||||
def rotate(username = nil)
|
||||
if options[:all]
|
||||
processed = 0
|
||||
delay = 0
|
||||
|
||||
Account.local.without_suspended.find_in_batches do |accounts|
|
||||
accounts.each do |account|
|
||||
rotate_keys_for_account(account, delay)
|
||||
processed += 1
|
||||
say('.', :green, false)
|
||||
end
|
||||
|
||||
delay += 5.minutes
|
||||
end
|
||||
|
||||
say
|
||||
say("OK, rotated keys for #{processed} accounts", :green)
|
||||
elsif username.present?
|
||||
rotate_keys_for_account(Account.find_local(username))
|
||||
say('OK', :green)
|
||||
else
|
||||
say('No account(s) given', :red)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rotate_keys_for_account(account, delay = 0)
|
||||
old_key = account.private_key
|
||||
new_key = OpenSSL::PKey::RSA.new(2048).to_pem
|
||||
account.update(private_key: new_key)
|
||||
ActivityPub::UpdateDistributionWorker.perform_in(delay, account.id, sign_with: old_key)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -13,7 +13,7 @@ module Mastodon
|
|||
option :suffix
|
||||
option :overwrite, type: :boolean
|
||||
option :unlisted, type: :boolean
|
||||
desc 'import PATH', 'import emoji from a TAR archive at PATH'
|
||||
desc 'import PATH', 'Import emoji from a TAR archive at PATH'
|
||||
long_desc <<-LONG_DESC
|
||||
Imports custom emoji from a TAR archive specified by PATH.
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ module Mastodon
|
|||
class MediaCLI < Thor
|
||||
option :days, type: :numeric, default: 7
|
||||
option :background, type: :boolean, default: false
|
||||
desc 'remove', 'remove remote media files'
|
||||
desc 'remove', 'Remove remote media files'
|
||||
long_desc <<-DESC
|
||||
Removes locally cached copies of media attachments from other servers.
|
||||
|
||||
|
|
Loading…
Reference in New Issue