Browse Source

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
Eugen Rochko 1 year ago
parent
commit
cabdbb7f9c

+ 1 - 1
app/lib/activitypub/activity/update.rb

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

+ 3 - 2
app/lib/activitypub/linked_data_signature.rb

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

+ 3 - 2
app/lib/request.rb

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

+ 3 - 2
app/services/activitypub/process_account_service.rb

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

+ 3 - 2
app/workers/activitypub/delivery_worker.rb

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

+ 3 - 2
app/workers/activitypub/update_distribution_worker.rb

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

+ 6 - 3
lib/cli.rb

@@ -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 - 0
lib/mastodon/accounts_cli.rb

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

+ 1 - 1
lib/mastodon/emoji_cli.rb

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

+ 1 - 1
lib/mastodon/media_cli.rb

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