Skip to content
Extraits de code Groupes Projets
Non vérifiée Valider cabdbb7f rédigé par Eugen Rochko's avatar Eugen Rochko Validation de GitHub
Parcourir les fichiers

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
parent 8adf485c
Branches
Aucune étiquette associée trouvée
Aucune requête de fusion associée trouvée
...@@ -11,6 +11,6 @@ class ActivityPub::Activity::Update < ActivityPub::Activity ...@@ -11,6 +11,6 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
def update_account def update_account
return if @account.uri != object_uri 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
end end
...@@ -32,7 +32,7 @@ class ActivityPub::LinkedDataSignature ...@@ -32,7 +32,7 @@ class ActivityPub::LinkedDataSignature
end end
end end
def sign!(creator) def sign!(creator, sign_with: nil)
options = { options = {
'type' => 'RsaSignature2017', 'type' => 'RsaSignature2017',
'creator' => [ActivityPub::TagManager.instance.uri_for(creator), '#main-key'].join, 'creator' => [ActivityPub::TagManager.instance.uri_for(creator), '#main-key'].join,
...@@ -42,8 +42,9 @@ class ActivityPub::LinkedDataSignature ...@@ -42,8 +42,9 @@ class ActivityPub::LinkedDataSignature
options_hash = hash(options.without('type', 'id', 'signatureValue').merge('@context' => CONTEXT)) options_hash = hash(options.without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
document_hash = hash(@json.without('signature')) document_hash = hash(@json.without('signature'))
to_be_signed = options_hash + document_hash 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)) @json.merge('signature' => options.merge('signatureValue' => signature))
end end
......
...@@ -22,10 +22,11 @@ class Request ...@@ -22,10 +22,11 @@ class Request
set_digest! if options.key?(:body) set_digest! if options.key?(:body)
end 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? raise ArgumentError unless account.local?
@account = account @account = account
@keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : @account.keypair
@key_id_format = key_id_format @key_id_format = key_id_format
self self
...@@ -70,7 +71,7 @@ class Request ...@@ -70,7 +71,7 @@ class Request
def signature def signature
algorithm = 'rsa-sha256' 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}\"" "keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers}\",signature=\"#{signature}\""
end end
......
...@@ -5,9 +5,10 @@ class ActivityPub::ProcessAccountService < BaseService ...@@ -5,9 +5,10 @@ class ActivityPub::ProcessAccountService < BaseService
# Should be called with confirmed valid JSON # Should be called with confirmed valid JSON
# and WebFinger-resolved username and domain # 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']) return if json['inbox'].blank? || unsupported_uri_scheme?(json['id'])
@options = options
@json = json @json = json
@uri = @json['id'] @uri = @json['id']
@username = username @username = username
...@@ -31,7 +32,7 @@ class ActivityPub::ProcessAccountService < BaseService ...@@ -31,7 +32,7 @@ class ActivityPub::ProcessAccountService < BaseService
return if @account.nil? return if @account.nil?
after_protocol_change! if protocol_changed? 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? check_featured_collection! if @account.featured_collection_url.present?
@account @account
......
...@@ -10,7 +10,8 @@ class ActivityPub::DeliveryWorker ...@@ -10,7 +10,8 @@ class ActivityPub::DeliveryWorker
HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze 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 @json = json
@source_account = Account.find(source_account_id) @source_account = Account.find(source_account_id)
@inbox_url = inbox_url @inbox_url = inbox_url
...@@ -27,7 +28,7 @@ class ActivityPub::DeliveryWorker ...@@ -27,7 +28,7 @@ class ActivityPub::DeliveryWorker
def build_request def build_request
request = Request.new(:post, @inbox_url, body: @json) 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) request.add_headers(HEADERS)
end end
......
...@@ -5,7 +5,8 @@ class ActivityPub::UpdateDistributionWorker ...@@ -5,7 +5,8 @@ class ActivityPub::UpdateDistributionWorker
sidekiq_options queue: 'push' sidekiq_options queue: 'push'
def perform(account_id) def perform(account_id, options = {})
@options = options.with_indifferent_access
@account = Account.find(account_id) @account = Account.find(account_id)
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
...@@ -26,7 +27,7 @@ class ActivityPub::UpdateDistributionWorker ...@@ -26,7 +27,7 @@ class ActivityPub::UpdateDistributionWorker
end end
def signed_payload 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 end
def payload def payload
......
...@@ -3,13 +3,16 @@ ...@@ -3,13 +3,16 @@
require 'thor' require 'thor'
require_relative 'mastodon/media_cli' require_relative 'mastodon/media_cli'
require_relative 'mastodon/emoji_cli' require_relative 'mastodon/emoji_cli'
require_relative 'mastodon/accounts_cli'
module Mastodon module Mastodon
class CLI < Thor class CLI < Thor
desc 'media SUBCOMMAND ...ARGS', 'manage media files' desc 'media SUBCOMMAND ...ARGS', 'Manage media files'
subcommand 'media', Mastodon::MediaCLI subcommand 'media', Mastodon::MediaCLI
desc 'emoji SUBCOMMAND ...ARGS', 'manage custom emoji' desc 'emoji SUBCOMMAND ...ARGS', 'Manage custom emoji'
subcommand 'emoji', Mastodon::EmojiCLI subcommand 'emoji', Mastodon::EmojiCLI
desc 'accounts SUBCOMMAND ...ARGS', 'Manage accounts'
subcommand 'accounts', Mastodon::AccountsCLI
end end
end end
# 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 ...@@ -13,7 +13,7 @@ module Mastodon
option :suffix option :suffix
option :overwrite, type: :boolean option :overwrite, type: :boolean
option :unlisted, 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 long_desc <<-LONG_DESC
Imports custom emoji from a TAR archive specified by PATH. Imports custom emoji from a TAR archive specified by PATH.
......
...@@ -10,7 +10,7 @@ module Mastodon ...@@ -10,7 +10,7 @@ module Mastodon
class MediaCLI < Thor class MediaCLI < Thor
option :days, type: :numeric, default: 7 option :days, type: :numeric, default: 7
option :background, type: :boolean, default: false option :background, type: :boolean, default: false
desc 'remove', 'remove remote media files' desc 'remove', 'Remove remote media files'
long_desc <<-DESC long_desc <<-DESC
Removes locally cached copies of media attachments from other servers. Removes locally cached copies of media attachments from other servers.
......
0% Chargement en cours ou .
You are about to add 0 people to the discussion. Proceed with caution.
Veuillez vous inscrire ou vous pour commenter