forked from treehouse/mastodon
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 rotationsignup-info-prompt
parent
8adf485c0f
commit
cabdbb7f9c
|
@ -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
|
||||||
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
|
||||||
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
|
||||||
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
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
# 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
|
||||||
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
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
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
|
||||||
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 @@
|
||||||
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
|
||||||
|
|
|
@ -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 :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
|
||||||
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.
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue