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:
		@@ -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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										55
									
								
								lib/mastodon/accounts_cli.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								lib/mastodon/accounts_cli.rb
									
									
									
									
									
										Normal file
									
								
							@@ -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.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user