about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorThibaut Girka <thib@sitedethib.com>2020-08-30 16:13:08 +0200
committerThibaut Girka <thib@sitedethib.com>2020-08-30 16:13:08 +0200
commit8c3c27bf063d648823da39a206be3efd285611ad (patch)
treec78c0bed2bab5ed64a7dfd546b91b21600947112 /app
parent30632adf9eda6d83a9b4269f23f11ced5e09cd93 (diff)
parent52157fdcba0837c782edbfd240be07cabc551de9 (diff)
Merge branch 'master' into glitch-soc/merge-upstream
Conflicts:
- `app/controllers/accounts_controller.rb`:
  Upstream change too close to a glitch-soc change related to
  instance-local toots. Merged upstream changes.
- `app/services/fan_out_on_write_service.rb`:
  Minor conflict due to glitch-soc's handling of Direct Messages,
  merged upstream changes.
- `yarn.lock`:
  Not really a conflict, caused by glitch-soc-only dependencies
  being textually too close to updated upstream dependencies.
  Merged upstream changes.
Diffstat (limited to 'app')
-rw-r--r--app/controllers/accounts_controller.rb12
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb8
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb23
-rw-r--r--app/controllers/api/v1/bookmarks_controller.rb7
-rw-r--r--app/controllers/api/v1/favourites_controller.rb7
-rw-r--r--app/controllers/api/v1/notifications_controller.rb8
-rw-r--r--app/controllers/api/v1/timelines/public_controller.rb20
-rw-r--r--app/controllers/api/v1/timelines/tag_controller.rb19
-rw-r--r--app/controllers/auth/sessions_controller.rb18
-rw-r--r--app/controllers/concerns/cache_concern.rb4
-rw-r--r--app/controllers/concerns/signature_verification.rb163
-rw-r--r--app/controllers/concerns/two_factor_authentication_concern.rb45
-rw-r--r--app/controllers/settings/two_factor_authentication/confirmations_controller.rb14
-rw-r--r--app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb42
-rw-r--r--app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb103
-rw-r--r--app/controllers/settings/two_factor_authentication_methods_controller.rb30
-rw-r--r--app/controllers/settings/two_factor_authentications_controller.rb53
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js2
-rw-r--r--app/javascript/mastodon/components/gifv.js2
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js5
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js4
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js5
-rw-r--r--app/javascript/mastodon/locales/bg.json2
-rw-r--r--app/javascript/mastodon/locales/br.json2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json4
-rw-r--r--app/javascript/mastodon/locales/en.json2
-rw-r--r--app/javascript/mastodon/locales/ga.json2
-rw-r--r--app/javascript/mastodon/locales/he.json2
-rw-r--r--app/javascript/mastodon/locales/hi.json2
-rw-r--r--app/javascript/mastodon/locales/hr.json2
-rw-r--r--app/javascript/mastodon/locales/io.json2
-rw-r--r--app/javascript/mastodon/locales/kab.json2
-rw-r--r--app/javascript/mastodon/locales/kn.json2
-rw-r--r--app/javascript/mastodon/locales/ku.json2
-rw-r--r--app/javascript/mastodon/locales/lt.json2
-rw-r--r--app/javascript/mastodon/locales/lv.json2
-rw-r--r--app/javascript/mastodon/locales/mk.json2
-rw-r--r--app/javascript/mastodon/locales/ml.json2
-rw-r--r--app/javascript/mastodon/locales/mr.json2
-rw-r--r--app/javascript/mastodon/locales/ms.json2
-rw-r--r--app/javascript/mastodon/locales/sr-Latn.json2
-rw-r--r--app/javascript/mastodon/locales/szl.json2
-rw-r--r--app/javascript/mastodon/locales/tai.json2
-rw-r--r--app/javascript/mastodon/locales/ug.json2
-rw-r--r--app/javascript/mastodon/locales/ur.json2
-rw-r--r--app/javascript/mastodon/stream.js12
-rw-r--r--app/javascript/packs/two_factor_authentication.js118
-rw-r--r--app/javascript/styles/mastodon/boost.scss2
-rw-r--r--app/javascript/styles/mastodon/forms.scss18
-rw-r--r--app/lib/activitypub/activity.rb20
-rw-r--r--app/lib/activitypub/activity/announce.rb14
-rw-r--r--app/lib/activitypub/activity/create.rb38
-rw-r--r--app/lib/activitypub/dereferencer.rb69
-rw-r--r--app/mailers/user_mailer.rb46
-rw-r--r--app/models/media_attachment.rb2
-rw-r--r--app/models/user.rb18
-rw-r--r--app/models/webauthn_credential.rb22
-rw-r--r--app/services/fan_out_on_write_service.rb10
-rw-r--r--app/services/hashtag_query_service.rb2
-rw-r--r--app/views/auth/sessions/two_factor.html.haml13
-rw-r--r--app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml18
-rw-r--r--app/views/auth/sessions/two_factor/_webauthn_form.html.haml17
-rw-r--r--app/views/settings/two_factor_authentication/confirmations/new.html.haml10
-rw-r--r--app/views/settings/two_factor_authentication/otp_authentication/show.html.haml9
-rw-r--r--app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml17
-rw-r--r--app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml16
-rw-r--r--app/views/settings/two_factor_authentication_methods/index.html.haml41
-rw-r--r--app/views/settings/two_factor_authentications/show.html.haml36
-rw-r--r--app/views/user_mailer/webauthn_credential_added.html.haml44
-rw-r--r--app/views/user_mailer/webauthn_credential_added.text.erb7
-rw-r--r--app/views/user_mailer/webauthn_credential_deleted.html.haml44
-rw-r--r--app/views/user_mailer/webauthn_credential_deleted.text.erb7
-rw-r--r--app/views/user_mailer/webauthn_disabled.html.haml43
-rw-r--r--app/views/user_mailer/webauthn_disabled.text.erb7
-rw-r--r--app/views/user_mailer/webauthn_enabled.html.haml43
-rw-r--r--app/views/user_mailer/webauthn_enabled.text.erb7
76 files changed, 1108 insertions, 304 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 5c8cdd174..54106933c 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -29,8 +29,7 @@ class AccountsController < ApplicationController
         end
 
         @pinned_statuses = cache_collection(@account.pinned_statuses.not_local_only, Status) if show_pinned_statuses?
-        @statuses        = filtered_status_page
-        @statuses        = cache_collection(@statuses, Status)
+        @statuses        = cached_filtered_status_page
         @rss_url         = rss_url
 
         unless @statuses.empty?
@@ -143,8 +142,13 @@ class AccountsController < ApplicationController
     request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
   end
 
-  def filtered_status_page
-    filtered_statuses.paginate_by_id(PAGE_SIZE, params_slice(:max_id, :min_id, :since_id))
+  def cached_filtered_status_page
+    cache_collection_paginated_by_id(
+      filtered_statuses,
+      Status,
+      PAGE_SIZE,
+      params_slice(:max_id, :min_id, :since_id)
+    )
   end
 
   def params_slice(*keys)
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index e25a4bc07..c33c15255 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -50,8 +50,12 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
     return unless page_requested?
 
     @statuses = @account.statuses.permitted_for(@account, signed_request_account)
-    @statuses = @statuses.paginate_by_id(LIMIT, params_slice(:max_id, :min_id, :since_id))
-    @statuses = cache_collection(@statuses, Status)
+    @statuses = cache_collection_paginated_by_id(
+      @statuses,
+      Status,
+      LIMIT,
+      params_slice(:max_id, :min_id, :since_id)
+    )
   end
 
   def page_requested?
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 114ee0a82..85a9133e3 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -22,10 +22,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def cached_account_statuses
-    cache_collection account_statuses, Status
-  end
-
-  def account_statuses
     statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
 
     statuses.merge!(only_media_scope) if truthy_param?(:only_media)
@@ -33,7 +29,12 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
     statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
     statuses.merge!(hashtag_scope)    if params[:tagged].present?
 
-    statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
+    cache_collection_paginated_by_id(
+      statuses,
+      Status,
+      limit_param(DEFAULT_STATUSES_LIMIT),
+      params_slice(:max_id, :since_id, :min_id)
+    )
   end
 
   def permitted_account_statuses
@@ -41,17 +42,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def only_media_scope
-    Status.where(id: account_media_status_ids)
-  end
-
-  def account_media_status_ids
-    # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
-    # Also, Avoid getting slow by not narrowing down by `statuses.account_id`.
-    # When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used
-    # and the table will be joined by `Merge Semi Join`, so the query will be slow.
-    @account.statuses.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account)
-            .paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
-            .reorder(id: :desc).distinct(:id).pluck(:id)
+    Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
   end
 
   def pinned_scope
diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb
index c15212f0a..5c72f4a1a 100644
--- a/app/controllers/api/v1/bookmarks_controller.rb
+++ b/app/controllers/api/v1/bookmarks_controller.rb
@@ -17,14 +17,11 @@ class Api::V1::BookmarksController < Api::BaseController
   end
 
   def cached_bookmarks
-    cache_collection(
-      Status.reorder(nil).joins(:bookmarks).merge(results),
-      Status
-    )
+    cache_collection(results.map(&:status), Status)
   end
 
   def results
-    @_results ||= account_bookmarks.paginate_by_id(
+    @_results ||= account_bookmarks.eager_load(:status).paginate_by_id(
       limit_param(DEFAULT_STATUSES_LIMIT),
       params_slice(:max_id, :since_id, :min_id)
     )
diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb
index 3e242905d..71a707d2a 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -17,14 +17,11 @@ class Api::V1::FavouritesController < Api::BaseController
   end
 
   def cached_favourites
-    cache_collection(
-      Status.reorder(nil).joins(:favourites).merge(results),
-      Status
-    )
+    cache_collection(results.map(&:status), Status)
   end
 
   def results
-    @_results ||= account_favourites.paginate_by_id(
+    @_results ||= account_favourites.eager_load(:status).paginate_by_id(
       limit_param(DEFAULT_STATUSES_LIMIT),
       params_slice(:max_id, :since_id, :min_id)
     )
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index 9dce9b807..9ff168367 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -40,11 +40,9 @@ class Api::V1::NotificationsController < Api::BaseController
   private
 
   def load_notifications
-    cache_collection paginated_notifications, Notification
-  end
-
-  def paginated_notifications
-    browserable_account_notifications.paginate_by_id(
+    cache_collection_paginated_by_id(
+      browserable_account_notifications,
+      Notification,
       limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
       params_slice(:max_id, :since_id, :min_id)
     )
diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb
index b449bcadf..52b5cb323 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -16,25 +16,25 @@ class Api::V1::Timelines::PublicController < Api::BaseController
   end
 
   def load_statuses
-    cached_public_statuses
+    cached_public_statuses_page
   end
 
-  def cached_public_statuses
-    cache_collection public_statuses, Status
-  end
-
-  def public_statuses
-    statuses = public_timeline_statuses.paginate_by_id(
+  def cached_public_statuses_page
+    cache_collection_paginated_by_id(
+      public_statuses,
+      Status,
       limit_param(DEFAULT_STATUSES_LIMIT),
       params_slice(:max_id, :since_id, :min_id)
     )
+  end
+
+  def public_statuses
+    statuses = public_timeline_statuses
 
     statuses = statuses.not_local_only unless truthy_param?(:local) || truthy_param?(:allow_local_only)
 
     if truthy_param?(:only_media)
-      # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
-      status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id)
-      statuses.where(id: status_ids)
+      statuses.joins(:media_attachments).group(:id)
     else
       statuses
     end
diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb
index 2d6ad5a80..76f7d3590 100644
--- a/app/controllers/api/v1/timelines/tag_controller.rb
+++ b/app/controllers/api/v1/timelines/tag_controller.rb
@@ -20,25 +20,18 @@ class Api::V1::Timelines::TagController < Api::BaseController
   end
 
   def cached_tagged_statuses
-    cache_collection tagged_statuses, Status
-  end
-
-  def tagged_statuses
     if @tag.nil?
       []
     else
-      statuses = tag_timeline_statuses.paginate_by_id(
+      statuses = tag_timeline_statuses
+      statuses = statuses.joins(:media_attachments) if truthy_param?(:only_media)
+
+      cache_collection_paginated_by_id(
+        statuses,
+        Status,
         limit_param(DEFAULT_STATUSES_LIMIT),
         params_slice(:max_id, :since_id, :min_id)
       )
-
-      if truthy_param?(:only_media)
-        # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
-        status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id)
-        statuses.where(id: status_ids)
-      else
-        statuses
-      end
     end
   end
 
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index 441833e85..1cf6a0a59 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -39,6 +39,22 @@ class Auth::SessionsController < Devise::SessionsController
     store_location_for(:user, tmp_stored_location) if continue_after?
   end
 
+  def webauthn_options
+    user = find_user
+
+    if user.webauthn_enabled?
+      options_for_get = WebAuthn::Credential.options_for_get(
+        allow: user.webauthn_credentials.pluck(:external_id)
+      )
+
+      session[:webauthn_challenge] = options_for_get.challenge
+
+      render json: options_for_get, status: :ok
+    else
+      render json: { error: t('webauthn_credentials.not_enabled') }, status: :unauthorized
+    end
+  end
+
   protected
 
   def find_user
@@ -53,7 +69,7 @@ class Auth::SessionsController < Devise::SessionsController
   end
 
   def user_params
-    params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt)
+    params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt, credential: {})
   end
 
   def after_sign_in_path_for(resource)
diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb
index c7d25ae00..189b92012 100644
--- a/app/controllers/concerns/cache_concern.rb
+++ b/app/controllers/concerns/cache_concern.rb
@@ -47,4 +47,8 @@ module CacheConcern
 
     raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact
   end
+
+  def cache_collection_paginated_by_id(raw, klass, limit, options)
+    cache_collection raw.cache_ids.paginate_by_id(limit, options), klass
+  end
 end
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index 10efbf2e0..18f549de9 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -7,6 +7,44 @@ module SignatureVerification
 
   include DomainControlHelper
 
+  EXPIRATION_WINDOW_LIMIT = 12.hours
+  CLOCK_SKEW_MARGIN       = 1.hour
+
+  class SignatureVerificationError < StandardError; end
+
+  class SignatureParamsParser < Parslet::Parser
+    rule(:token)         { match("[0-9a-zA-Z!#$%&'*+.^_`|~-]").repeat(1).as(:token) }
+    rule(:quoted_string) { str('"') >> (qdtext | quoted_pair).repeat.as(:quoted_string) >> str('"') }
+    # qdtext and quoted_pair are not exactly according to spec but meh
+    rule(:qdtext)        { match('[^\\\\"]') }
+    rule(:quoted_pair)   { str('\\') >> any }
+    rule(:bws)           { match('\s').repeat }
+    rule(:param)         { (token.as(:key) >> bws >> str('=') >> bws >> (token | quoted_string).as(:value)).as(:param) }
+    rule(:comma)         { bws >> str(',') >> bws }
+    # Old versions of node-http-signature add an incorrect "Signature " prefix to the header
+    rule(:buggy_prefix)  { str('Signature ') }
+    rule(:params)        { buggy_prefix.maybe >> (param >> (comma >> param).repeat).as(:params) }
+    root(:params)
+  end
+
+  class SignatureParamsTransformer < Parslet::Transform
+    rule(params: subtree(:p)) do
+      (p.is_a?(Array) ? p : [p]).each_with_object({}) { |(key, val), h| h[key] = val }
+    end
+
+    rule(param: { key: simple(:key), value: simple(:val) }) do
+      [key, val]
+    end
+
+    rule(quoted_string: simple(:string)) do
+      string.to_s
+    end
+
+    rule(token: simple(:string)) do
+      string.to_s
+    end
+  end
+
   def require_signature!
     render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
   end
@@ -24,72 +62,40 @@ module SignatureVerification
   end
 
   def signature_key_id
-    raw_signature    = request.headers['Signature']
-    signature_params = {}
-
-    raw_signature.split(',').each do |part|
-      parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
-      next if parsed_parts.nil? || parsed_parts.size != 3
-      signature_params[parsed_parts[1]] = parsed_parts[2]
-    end
-
     signature_params['keyId']
+  rescue SignatureVerificationError
+    nil
   end
 
   def signed_request_account
     return @signed_request_account if defined?(@signed_request_account)
 
-    unless signed_request?
-      @signature_verification_failure_reason = 'Request not signed'
-      @signed_request_account = nil
-      return
-    end
-
-    if request.headers['Date'].present? && !matches_time_window?
-      @signature_verification_failure_reason = 'Signed request date outside acceptable time window'
-      @signed_request_account = nil
-      return
-    end
+    raise SignatureVerificationError, 'Request not signed' unless signed_request?
+    raise SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
+    raise SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
+    raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
 
-    raw_signature    = request.headers['Signature']
-    signature_params = {}
-
-    raw_signature.split(',').each do |part|
-      parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
-      next if parsed_parts.nil? || parsed_parts.size != 3
-      signature_params[parsed_parts[1]] = parsed_parts[2]
-    end
-
-    if incompatible_signature?(signature_params)
-      @signature_verification_failure_reason = 'Incompatible request signature'
-      @signed_request_account = nil
-      return
-    end
+    verify_signature_strength!
 
     account = account_from_key_id(signature_params['keyId'])
 
-    if account.nil?
-      @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
-      @signed_request_account = nil
-      return
-    end
+    raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil?
 
     signature             = Base64.decode64(signature_params['signature'])
-    compare_signed_string = build_signed_string(signature_params['headers'])
+    compare_signed_string = build_signed_string
 
     return account unless verify_signature(account, signature, compare_signed_string).nil?
 
     account = stoplight_wrap_request { account.possibly_stale? ? account.refresh! : account_refresh_key(account) }
 
-    if account.nil?
-      @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
-      @signed_request_account = nil
-      return
-    end
+    raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil?
 
     return account unless verify_signature(account, signature, compare_signed_string).nil?
 
-    @signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}"
+    @signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)"
+    @signed_request_account = nil
+  rescue SignatureVerificationError => e
+    @signature_verification_failure_reason = e.message
     @signed_request_account = nil
   end
 
@@ -99,6 +105,31 @@ module SignatureVerification
 
   private
 
+  def signature_params
+    @signature_params ||= begin
+      raw_signature = request.headers['Signature']
+      tree          = SignatureParamsParser.new.parse(raw_signature)
+      SignatureParamsTransformer.new.apply(tree)
+    end
+  rescue Parslet::ParseFailed
+    raise SignatureVerificationError, 'Error parsing signature parameters'
+  end
+
+  def signature_algorithm
+    signature_params.fetch('algorithm', 'hs2019')
+  end
+
+  def signed_headers
+    signature_params.fetch('headers', signature_algorithm == 'hs2019' ? '(created)' : 'date').downcase.split(' ')
+  end
+
+  def verify_signature_strength!
+    raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
+    raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest')
+    raise SignatureVerificationError, 'Mastodon requires the Host header to be signed' unless signed_headers.include?('host')
+    raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
+  end
+
   def verify_signature(account, signature, compare_signed_string)
     if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
       @signed_request_account = account
@@ -108,12 +139,20 @@ module SignatureVerification
     nil
   end
 
-  def build_signed_string(signed_headers)
-    signed_headers = 'date' if signed_headers.blank?
-
-    signed_headers.downcase.split(' ').map do |signed_header|
+  def build_signed_string
+    signed_headers.map do |signed_header|
       if signed_header == Request::REQUEST_TARGET
         "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
+      elsif signed_header == '(created)'
+        raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
+        raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
+
+        "(created): #{signature_params['created']}"
+      elsif signed_header == '(expires)'
+        raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
+        raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
+
+        "(expires): #{signature_params['expires']}"
       elsif signed_header == 'digest'
         "digest: #{body_digest}"
       else
@@ -123,13 +162,28 @@ module SignatureVerification
   end
 
   def matches_time_window?
+    created_time = nil
+    expires_time = nil
+
     begin
-      time_sent = Time.httpdate(request.headers['Date'])
+      if signature_algorithm == 'hs2019' && signature_params['created'].present?
+        created_time = Time.at(signature_params['created'].to_i).utc
+      elsif request.headers['Date'].present?
+        created_time = Time.httpdate(request.headers['Date']).utc
+      end
+
+      expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
     rescue ArgumentError
       return false
     end
 
-    (Time.now.utc - time_sent).abs <= 12.hours
+    expires_time ||= created_time + 5.minutes unless created_time.nil?
+    expires_time = [expires_time, created_time + EXPIRATION_WINDOW_LIMIT].min unless created_time.nil?
+
+    return false if created_time.present? && created_time > Time.now.utc + CLOCK_SKEW_MARGIN
+    return false if expires_time.present? && Time.now.utc > expires_time + CLOCK_SKEW_MARGIN
+
+    true
   end
 
   def body_digest
@@ -140,9 +194,8 @@ module SignatureVerification
     name.split(/-/).map(&:capitalize).join('-')
   end
 
-  def incompatible_signature?(signature_params)
-    signature_params['keyId'].blank? ||
-      signature_params['signature'].blank?
+  def missing_required_signature_parameters?
+    signature_params['keyId'].blank? || signature_params['signature'].blank?
   end
 
   def account_from_key_id(key_id)
diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb
index 35c0c27cf..6b043a804 100644
--- a/app/controllers/concerns/two_factor_authentication_concern.rb
+++ b/app/controllers/concerns/two_factor_authentication_concern.rb
@@ -8,7 +8,23 @@ module TwoFactorAuthenticationConcern
   end
 
   def two_factor_enabled?
-    find_user&.otp_required_for_login?
+    find_user&.two_factor_enabled?
+  end
+
+  def valid_webauthn_credential?(user, webauthn_credential)
+    user_credential = user.webauthn_credentials.find_by!(external_id: webauthn_credential.id)
+
+    begin
+      webauthn_credential.verify(
+        session[:webauthn_challenge],
+        public_key: user_credential.public_key,
+        sign_count: user_credential.sign_count
+      )
+
+      user_credential.update!(sign_count: webauthn_credential.sign_count)
+    rescue WebAuthn::Error
+      false
+    end
   end
 
   def valid_otp_attempt?(user)
@@ -21,14 +37,29 @@ module TwoFactorAuthenticationConcern
   def authenticate_with_two_factor
     user = self.resource = find_user
 
-    if user_params[:otp_attempt].present? && session[:attempt_user_id]
-      authenticate_with_two_factor_attempt(user)
+    if user.webauthn_enabled? && user_params[:credential].present? && session[:attempt_user_id]
+      authenticate_with_two_factor_via_webauthn(user)
+    elsif user_params[:otp_attempt].present? && session[:attempt_user_id]
+      authenticate_with_two_factor_via_otp(user)
     elsif user.present? && user.external_or_valid_password?(user_params[:password])
       prompt_for_two_factor(user)
     end
   end
 
-  def authenticate_with_two_factor_attempt(user)
+  def authenticate_with_two_factor_via_webauthn(user)
+    webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential])
+
+    if valid_webauthn_credential?(user, webauthn_credential)
+      session.delete(:attempt_user_id)
+      remember_me(user)
+      sign_in(user)
+      render json: { redirect_path: root_path }, status: :ok
+    else
+      render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity
+    end
+  end
+
+  def authenticate_with_two_factor_via_otp(user)
     if valid_otp_attempt?(user)
       session.delete(:attempt_user_id)
       remember_me(user)
@@ -44,6 +75,12 @@ module TwoFactorAuthenticationConcern
       session[:attempt_user_id] = user.id
       use_pack 'auth'
       @body_classes = 'lighter'
+      @webauthn_enabled = user.webauthn_enabled?
+      @scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank?
+                       'webauthn'
+                     else
+                       'totp'
+                     end
       render :two_factor
     end
   end
diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
index ef4df3339..9f23011a7 100644
--- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
@@ -18,18 +18,21 @@ module Settings
       end
 
       def create
-        if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt])
+        if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt], otp_secret: session[:new_otp_secret])
           flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success')
 
           current_user.otp_required_for_login = true
+          current_user.otp_secret = session[:new_otp_secret]
           @recovery_codes = current_user.generate_otp_backup_codes!
           current_user.save!
 
           UserMailer.two_factor_enabled(current_user).deliver_later!
 
+          session.delete(:new_otp_secret)
+
           render 'settings/two_factor_authentication/recovery_codes/index'
         else
-          flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
+          flash.now[:alert] = I18n.t('otp_authentication.wrong_code')
           prepare_two_factor_form
           render :new
         end
@@ -43,12 +46,15 @@ module Settings
 
       def prepare_two_factor_form
         @confirmation = Form::TwoFactorConfirmation.new
-        @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain)
+        @new_otp_secret = session[:new_otp_secret]
+        @provision_url = current_user.otp_provisioning_uri(current_user.email,
+                                                           otp_secret: @new_otp_secret,
+                                                           issuer: Rails.configuration.x.local_domain)
         @qrcode = RQRCode::QRCode.new(@provision_url)
       end
 
       def ensure_otp_secret
-        redirect_to settings_two_factor_authentication_path unless current_user.otp_secret
+        redirect_to settings_otp_authentication_path if session[:new_otp_secret].blank?
       end
     end
   end
diff --git a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb
new file mode 100644
index 000000000..6836f7ef6
--- /dev/null
+++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Settings
+  module TwoFactorAuthentication
+    class OtpAuthenticationController < BaseController
+      include ChallengableConcern
+
+      layout 'admin'
+
+      before_action :authenticate_user!
+      before_action :verify_otp_not_enabled, only: [:show]
+      before_action :require_challenge!, only: [:create]
+
+      skip_before_action :require_functional!
+
+      def show
+        @confirmation = Form::TwoFactorConfirmation.new
+      end
+
+      def create
+        session[:new_otp_secret] = User.generate_otp_secret(32)
+
+        redirect_to new_settings_two_factor_authentication_confirmation_path
+      end
+
+      private
+
+      def confirmation_params
+        params.require(:form_two_factor_confirmation).permit(:otp_attempt)
+      end
+
+      def verify_otp_not_enabled
+        redirect_to settings_two_factor_authentication_methods_path if current_user.otp_enabled?
+      end
+
+      def acceptable_code?
+        current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) ||
+          current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt])
+      end
+    end
+  end
+end
diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
new file mode 100644
index 000000000..a19c604f3
--- /dev/null
+++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+module Settings
+  module TwoFactorAuthentication
+    class WebauthnCredentialsController < BaseController
+      layout 'admin'
+
+      before_action :authenticate_user!
+      before_action :require_otp_enabled
+      before_action :require_webauthn_enabled, only: [:index, :destroy]
+
+      def new; end
+
+      def index; end
+
+      def options
+        current_user.update(webauthn_id: WebAuthn.generate_user_id) unless current_user.webauthn_id
+
+        options_for_create = WebAuthn::Credential.options_for_create(
+          user: {
+            name: current_user.account.username,
+            display_name: current_user.account.username,
+            id: current_user.webauthn_id,
+          },
+          exclude: current_user.webauthn_credentials.pluck(:external_id)
+        )
+
+        session[:webauthn_challenge] = options_for_create.challenge
+
+        render json: options_for_create, status: :ok
+      end
+
+      def create
+        webauthn_credential = WebAuthn::Credential.from_create(params[:credential])
+
+        if webauthn_credential.verify(session[:webauthn_challenge])
+          user_credential = current_user.webauthn_credentials.build(
+            external_id: webauthn_credential.id,
+            public_key: webauthn_credential.public_key,
+            nickname: params[:nickname],
+            sign_count: webauthn_credential.sign_count
+          )
+
+          if user_credential.save
+            flash[:success] = I18n.t('webauthn_credentials.create.success')
+            status = :ok
+
+            if current_user.webauthn_credentials.size == 1
+              UserMailer.webauthn_enabled(current_user).deliver_later!
+            else
+              UserMailer.webauthn_credential_added(current_user, user_credential).deliver_later!
+            end
+          else
+            flash[:error] = I18n.t('webauthn_credentials.create.error')
+            status = :internal_server_error
+          end
+        else
+          flash[:error] = t('webauthn_credentials.create.error')
+          status = :unauthorized
+        end
+
+        render json: { redirect_path: settings_two_factor_authentication_methods_path }, status: status
+      end
+
+      def destroy
+        credential = current_user.webauthn_credentials.find_by(id: params[:id])
+        if credential
+          credential.destroy
+          if credential.destroyed?
+            flash[:success] = I18n.t('webauthn_credentials.destroy.success')
+
+            if current_user.webauthn_credentials.empty?
+              UserMailer.webauthn_disabled(current_user).deliver_later!
+            else
+              UserMailer.webauthn_credential_deleted(current_user, credential).deliver_later!
+            end
+          else
+            flash[:error] = I18n.t('webauthn_credentials.destroy.error')
+          end
+        else
+          flash[:error] = I18n.t('webauthn_credentials.destroy.error')
+        end
+        redirect_to settings_two_factor_authentication_methods_path
+      end
+
+      private
+
+      def require_otp_enabled
+        unless current_user.otp_enabled?
+          flash[:error] = t('webauthn_credentials.otp_required')
+          redirect_to settings_two_factor_authentication_methods_path
+        end
+      end
+
+      def require_webauthn_enabled
+        unless current_user.webauthn_enabled?
+          flash[:error] = t('webauthn_credentials.not_enabled')
+          redirect_to settings_two_factor_authentication_methods_path
+        end
+      end
+    end
+  end
+end
diff --git a/app/controllers/settings/two_factor_authentication_methods_controller.rb b/app/controllers/settings/two_factor_authentication_methods_controller.rb
new file mode 100644
index 000000000..224d3a45c
--- /dev/null
+++ b/app/controllers/settings/two_factor_authentication_methods_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Settings
+  class TwoFactorAuthenticationMethodsController < BaseController
+    include ChallengableConcern
+
+    layout 'admin'
+
+    before_action :authenticate_user!
+    before_action :require_challenge!, only: :disable
+    before_action :require_otp_enabled
+
+    skip_before_action :require_functional!
+
+    def index; end
+
+    def disable
+      current_user.disable_two_factor!
+      UserMailer.two_factor_disabled(current_user).deliver_later!
+
+      redirect_to settings_otp_authentication_path, flash: { notice: I18n.t('two_factor_authentication.disabled_success') }
+    end
+
+    private
+
+    def require_otp_enabled
+      redirect_to settings_otp_authentication_path unless current_user.otp_enabled?
+    end
+  end
+end
diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb
deleted file mode 100644
index 9118a7933..000000000
--- a/app/controllers/settings/two_factor_authentications_controller.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-module Settings
-  class TwoFactorAuthenticationsController < BaseController
-    include ChallengableConcern
-
-    layout 'admin'
-
-    before_action :authenticate_user!
-    before_action :verify_otp_required, only: [:create]
-    before_action :require_challenge!, only: [:create]
-
-    skip_before_action :require_functional!
-
-    def show
-      @confirmation = Form::TwoFactorConfirmation.new
-    end
-
-    def create
-      current_user.otp_secret = User.generate_otp_secret(32)
-      current_user.save!
-      redirect_to new_settings_two_factor_authentication_confirmation_path
-    end
-
-    def destroy
-      if acceptable_code?
-        current_user.otp_required_for_login = false
-        current_user.save!
-        UserMailer.two_factor_disabled(current_user).deliver_later!
-        redirect_to settings_two_factor_authentication_path
-      else
-        flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
-        @confirmation = Form::TwoFactorConfirmation.new
-        render :show
-      end
-    end
-
-    private
-
-    def confirmation_params
-      params.require(:form_two_factor_confirmation).permit(:otp_attempt)
-    end
-
-    def verify_otp_required
-      redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login?
-    end
-
-    def acceptable_code?
-      current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) ||
-        current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt])
-    end
-  end
-end
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 4734e0f3f..09e3c9df8 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -205,7 +205,7 @@ export default class Dropdown extends React.PureComponent {
 
   handleClose = () => {
     if (this.activeElement) {
-      this.activeElement.focus();
+      this.activeElement.focus({ preventScroll: true });
       this.activeElement = null;
     }
     this.props.onClose(this.state.id);
diff --git a/app/javascript/mastodon/components/gifv.js b/app/javascript/mastodon/components/gifv.js
index 83cfae49c..b775e5200 100644
--- a/app/javascript/mastodon/components/gifv.js
+++ b/app/javascript/mastodon/components/gifv.js
@@ -54,8 +54,6 @@ export default class GIFV extends React.PureComponent {
 
         <video
           src={src}
-          width={width}
-          height={height}
           role='button'
           tabIndex='0'
           aria-label={alt}
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 231c517e9..b7babd4ad 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -7,6 +7,7 @@ import DropdownMenuContainer from '../containers/dropdown_menu_container';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { me, isStaff } from '../initial_state';
+import classNames from 'classnames';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -20,7 +21,7 @@ const messages = defineMessages({
   more: { id: 'status.more', defaultMessage: 'More' },
   replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
   reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
-  reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' },
+  reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
   cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
@@ -329,7 +330,7 @@ class StatusActionBar extends ImmutablePureComponent {
     return (
       <div className='status__action-bar'>
         <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
-        <IconButton className='status__action-bar-button' disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
+        <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
         <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
         {shareButton}
 
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index 96028e042..5223025fb 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -179,7 +179,7 @@ class PrivacyDropdown extends React.PureComponent {
     } else {
       const { top } = target.getBoundingClientRect();
       if (this.state.open && this.activeElement) {
-        this.activeElement.focus();
+        this.activeElement.focus({ preventScroll: true });
       }
       this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
       this.setState({ open: !this.state.open });
@@ -220,7 +220,7 @@ class PrivacyDropdown extends React.PureComponent {
 
   handleClose = () => {
     if (this.state.open && this.activeElement) {
-      this.activeElement.focus();
+      this.activeElement.focus({ preventScroll: true });
     }
     this.setState({ open: false });
   }
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 1c5d5ca0c..d7d504bc5 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
 import { defineMessages, injectIntl } from 'react-intl';
 import { me, isStaff } from '../../../initial_state';
+import classNames from 'classnames';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -14,7 +15,7 @@ const messages = defineMessages({
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
-  reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' },
+  reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
   cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
@@ -273,7 +274,7 @@ class ActionBar extends React.PureComponent {
     return (
       <div className='detailed-status__action-bar'>
         <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
-        <div className='detailed-status__button'><IconButton disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
+        <div className='detailed-status__button' ><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
         <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
         {shareButton}
         <div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 16520dbad..42c8997b7 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Споделяне",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} сподели",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json
index 3e01f2d77..72a6d0a0a 100644
--- a/app/javascript/mastodon/locales/br.json
+++ b/app/javascript/mastodon/locales/br.json
@@ -389,7 +389,7 @@
   "status.pinned": "Toud spilhennet",
   "status.read_more": "Lenn muioc'h",
   "status.reblog": "Skignañ",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 14febf5c3..fcec001e9 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -421,7 +421,7 @@
         "id": "status.reblog"
       },
       {
-        "defaultMessage": "Boost to original audience",
+        "defaultMessage": "Boost with original visibility",
         "id": "status.reblog_private"
       },
       {
@@ -2421,7 +2421,7 @@
         "id": "status.reblog"
       },
       {
-        "defaultMessage": "Boost to original audience",
+        "defaultMessage": "Boost with original visibility",
         "id": "status.reblog_private"
       },
       {
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 025ae6e7d..bb07bbc15 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -394,7 +394,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json
index 5de7b27a5..2b1546a57 100644
--- a/app/javascript/mastodon/locales/ga.json
+++ b/app/javascript/mastodon/locales/ga.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index 91fce039f..bec0c7636 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "הדהוד",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "הודהד על ידי {name}",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/hi.json b/app/javascript/mastodon/locales/hi.json
index ff207d640..cec0c689f 100644
--- a/app/javascript/mastodon/locales/hi.json
+++ b/app/javascript/mastodon/locales/hi.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "बूस्ट",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index 7179bc536..f20a384b9 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Podigni",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} je podigao",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index e79bee21d..e12bf697a 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Repetar",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} repetita",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/kab.json b/app/javascript/mastodon/locales/kab.json
index 4ce0115a9..fde037504 100644
--- a/app/javascript/mastodon/locales/kab.json
+++ b/app/javascript/mastodon/locales/kab.json
@@ -389,7 +389,7 @@
   "status.pinned": "Tijewwiqin yettwasentḍen",
   "status.read_more": "Issin ugar",
   "status.reblog": "Bḍu",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "Yebḍa-tt {name}",
   "status.reblogs.empty": "Ula yiwen ur yebḍi tajewwiqt-agi ar tura. Ticki yebḍa-tt yiwen, ad d-iban da.",
   "status.redraft": "Kkes tɛiwdeḍ tira",
diff --git a/app/javascript/mastodon/locales/kn.json b/app/javascript/mastodon/locales/kn.json
index e9618e0f2..8a0014e91 100644
--- a/app/javascript/mastodon/locales/kn.json
+++ b/app/javascript/mastodon/locales/kn.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/ku.json b/app/javascript/mastodon/locales/ku.json
index e5d833fe8..b3822ff08 100644
--- a/app/javascript/mastodon/locales/ku.json
+++ b/app/javascript/mastodon/locales/ku.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json
index e9618e0f2..8a0014e91 100644
--- a/app/javascript/mastodon/locales/lt.json
+++ b/app/javascript/mastodon/locales/lt.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json
index 2812760a6..41d983f48 100644
--- a/app/javascript/mastodon/locales/lv.json
+++ b/app/javascript/mastodon/locales/lv.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/mk.json b/app/javascript/mastodon/locales/mk.json
index 2bb9a2e2a..aeaf7355c 100644
--- a/app/javascript/mastodon/locales/mk.json
+++ b/app/javascript/mastodon/locales/mk.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/ml.json b/app/javascript/mastodon/locales/ml.json
index 4a390ad70..0c5a84204 100644
--- a/app/javascript/mastodon/locales/ml.json
+++ b/app/javascript/mastodon/locales/ml.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/mr.json b/app/javascript/mastodon/locales/mr.json
index 5a93ae851..e07baddff 100644
--- a/app/javascript/mastodon/locales/mr.json
+++ b/app/javascript/mastodon/locales/mr.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/ms.json b/app/javascript/mastodon/locales/ms.json
index 5fc777887..ffac61ed0 100644
--- a/app/javascript/mastodon/locales/ms.json
+++ b/app/javascript/mastodon/locales/ms.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json
index d7be7380f..a140fa36e 100644
--- a/app/javascript/mastodon/locales/sr-Latn.json
+++ b/app/javascript/mastodon/locales/sr-Latn.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Podrži",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} podržao(la)",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/szl.json b/app/javascript/mastodon/locales/szl.json
index e5d833fe8..b3822ff08 100644
--- a/app/javascript/mastodon/locales/szl.json
+++ b/app/javascript/mastodon/locales/szl.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/tai.json b/app/javascript/mastodon/locales/tai.json
index e5d833fe8..b3822ff08 100644
--- a/app/javascript/mastodon/locales/tai.json
+++ b/app/javascript/mastodon/locales/tai.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/ug.json b/app/javascript/mastodon/locales/ug.json
index e5d833fe8..b3822ff08 100644
--- a/app/javascript/mastodon/locales/ug.json
+++ b/app/javascript/mastodon/locales/ug.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/locales/ur.json b/app/javascript/mastodon/locales/ur.json
index b94b7c162..ada675496 100644
--- a/app/javascript/mastodon/locales/ur.json
+++ b/app/javascript/mastodon/locales/ur.json
@@ -389,7 +389,7 @@
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost with original visibility",
   "status.reblogged_by": "{name} boosted",
   "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
   "status.redraft": "Delete & re-draft",
diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js
index 640455b33..cf1388aed 100644
--- a/app/javascript/mastodon/stream.js
+++ b/app/javascript/mastodon/stream.js
@@ -112,11 +112,10 @@ const sharedCallbacks = {
   },
 
   disconnected () {
-    subscriptions.forEach(({ onDisconnect }) => onDisconnect());
+    subscriptions.forEach(subscription => unsubscribe(subscription));
   },
 
   reconnected () {
-    subscriptions.forEach(subscription => subscribe(subscription));
   },
 };
 
@@ -252,15 +251,8 @@ const createConnection = (streamingAPIBaseURL, accessToken, channelName, { conne
 
   const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${channelName}?${params.join('&')}`);
 
-  let firstConnect = true;
-
   es.onopen = () => {
-    if (firstConnect) {
-      firstConnect = false;
-      connected();
-    } else {
-      reconnected();
-    }
+    connected();
   };
 
   KNOWN_EVENT_TYPES.forEach(type => {
diff --git a/app/javascript/packs/two_factor_authentication.js b/app/javascript/packs/two_factor_authentication.js
new file mode 100644
index 000000000..dde06be8c
--- /dev/null
+++ b/app/javascript/packs/two_factor_authentication.js
@@ -0,0 +1,118 @@
+import axios from 'axios';
+import * as WebAuthnJSON from '@github/webauthn-json';
+import ready from '../mastodon/ready';
+import 'regenerator-runtime/runtime';
+
+function getCSRFToken() {
+  var CSRFSelector = document.querySelector('meta[name="csrf-token"]');
+  if (CSRFSelector) {
+    return CSRFSelector.getAttribute('content');
+  } else {
+    return null;
+  }
+}
+
+function hideFlashMessages() {
+  Array.from(document.getElementsByClassName('flash-message')).forEach(function(flashMessage) {
+    flashMessage.classList.add('hidden');
+  });
+}
+
+function callback(url, body) {
+  axios.post(url, JSON.stringify(body), {
+    headers: {
+      'Content-Type': 'application/json',
+      'Accept': 'application/json',
+      'X-CSRF-Token': getCSRFToken(),
+    },
+    credentials: 'same-origin',
+  }).then(function(response) {
+    window.location.replace(response.data.redirect_path);
+  }).catch(function(error) {
+    if (error.response.status === 422) {
+      const errorMessage = document.getElementById('security-key-error-message');
+      errorMessage.classList.remove('hidden');
+      console.error(error.response.data.error);
+    } else {
+      console.error(error);
+    }
+  });
+}
+
+ready(() => {
+  if (!WebAuthnJSON.supported()) {
+    const unsupported_browser_message = document.getElementById('unsupported-browser-message');
+    if (unsupported_browser_message) {
+      unsupported_browser_message.classList.remove('hidden');
+      document.querySelector('.btn.js-webauthn').disabled = true;
+    }
+  }
+
+
+  const webAuthnCredentialRegistrationForm = document.getElementById('new_webauthn_credential');
+  if (webAuthnCredentialRegistrationForm) {
+    webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => {
+      event.preventDefault();
+
+      var nickname = event.target.querySelector('input[name="new_webauthn_credential[nickname]"]');
+      if (nickname.value) {
+        axios.get('/settings/security_keys/options')
+          .then((response) => {
+            const credentialOptions = response.data;
+
+            WebAuthnJSON.create({ 'publicKey': credentialOptions }).then((credential) => {
+              var params = { 'credential': credential, 'nickname': nickname.value };
+              callback('/settings/security_keys', params);
+            }).catch((error) => {
+              const errorMessage = document.getElementById('security-key-error-message');
+              errorMessage.classList.remove('hidden');
+              console.error(error);
+            });
+          }).catch((error) => {
+            console.error(error.response.data.error);
+          });
+      } else {
+        nickname.focus();
+      }
+    });
+  }
+
+  const webAuthnCredentialAuthenticationForm = document.getElementById('webauthn-form');
+  if (webAuthnCredentialAuthenticationForm) {
+    webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => {
+      event.preventDefault();
+
+      axios.get('sessions/security_key_options')
+        .then((response) => {
+          const credentialOptions = response.data;
+
+          WebAuthnJSON.get({ 'publicKey': credentialOptions }).then((credential) => {
+            var params = { 'user': { 'credential': credential } };
+            callback('sign_in', params);
+          }).catch((error) => {
+            const errorMessage = document.getElementById('security-key-error-message');
+            errorMessage.classList.remove('hidden');
+            console.error(error);
+          });
+        }).catch((error) => {
+          console.error(error.response.data.error);
+        });
+    });
+
+    const otpAuthenticationForm = document.getElementById('otp-authentication-form');
+
+    const linkToOtp = document.getElementById('link-to-otp');
+    linkToOtp.addEventListener('click', () => {
+      webAuthnCredentialAuthenticationForm.classList.add('hidden');
+      otpAuthenticationForm.classList.remove('hidden');
+      hideFlashMessages();
+    });
+
+    const linkToWebAuthn = document.getElementById('link-to-webauthn');
+    linkToWebAuthn.addEventListener('click', () => {
+      otpAuthenticationForm.classList.add('hidden');
+      webAuthnCredentialAuthenticationForm.classList.remove('hidden');
+      hideFlashMessages();
+    });
+  }
+});
diff --git a/app/javascript/styles/mastodon/boost.scss b/app/javascript/styles/mastodon/boost.scss
index 4b6c9b82e..f94c0aa46 100644
--- a/app/javascript/styles/mastodon/boost.scss
+++ b/app/javascript/styles/mastodon/boost.scss
@@ -6,7 +6,7 @@ button.icon-button i.fa-retweet {
   }
 }
 
-.status-private button.icon-button i.fa-retweet {
+button.icon-button.reblogPrivate i.fa-retweet {
   background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 15.980703 3.0497656 15.339844 7.2597656 15.339844 L 11.869141 15.339844 L 11.869141 14.119141 L 11.869141 13.523438 L 11.869141 12.441406 C 11.869141 12.441406 11.869141 12.439453 11.869141 12.439453 L 7.2695312 12.439453 C 6.8295312 12.439453 6.5507814 12.140703 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 z M 17.150391 3.5800781 L 17.130859 3.5898438 C 16.580859 3.5698436 15.810469 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 14.699219 6.5195312 C 15.106887 6.5195312 15.397113 6.7872181 15.414062 7.2050781 C 15.738375 7.0991315 16.077769 7.0273437 16.435547 7.0273438 L 16.578125 7.0273438 C 17.24903 7.0273438 17.874081 7.2325787 18.400391 7.578125 L 18.400391 7.2402344 C 18.400391 4.0902344 18.800391 3.6200781 17.150391 3.5800781 z M 16.435547 8.0273438 C 15.143818 8.0273438 14.083984 9.0851838 14.083984 10.376953 L 14.083984 11.607422 L 13.570312 11.607422 C 13.375448 11.607422 13.210603 11.704118 13.119141 11.791016 C 13.027691 11.877916 12.983569 11.958238 12.951172 12.03125 C 12.886382 12.177277 12.867187 12.304789 12.867188 12.441406 L 12.867188 13.523438 L 12.867188 14.119141 L 12.867188 15.677734 L 12.867188 16.509766 L 13.570312 16.509766 L 19.472656 16.509766 L 20.173828 16.509766 L 20.173828 15.677734 L 20.173828 13.523438 L 20.173828 12.441406 C 20.173828 12.304794 20.156597 12.177281 20.091797 12.03125 C 20.059397 11.95824 20.015299 11.877916 19.923828 11.791016 C 19.832368 11.704116 19.667509 11.607422 19.472656 11.607422 L 18.927734 11.607422 L 18.927734 10.376953 C 18.927734 9.0851838 17.867902 8.0273438 16.576172 8.0273438 L 16.435547 8.0273438 z M 16.435547 9.2207031 L 16.576172 9.2207031 C 17.22782 9.2207031 17.734375 9.7251013 17.734375 10.376953 L 17.734375 11.607422 L 15.277344 11.607422 L 15.277344 10.376953 C 15.277344 9.7251013 15.7839 9.2207031 16.435547 9.2207031 z M 12.919922 9.9394531 C 12.559922 9.9594531 12.359141 10.480234 12.619141 10.740234 L 12.751953 10.904297 C 12.862211 10.870135 12.980058 10.842244 13.085938 10.802734 L 13.085938 10.378906 C 13.085938 10.228632 13.111295 10.084741 13.130859 9.9394531 L 12.919922 9.9394531 z M 19.882812 9.9394531 C 19.902378 10.084741 19.927734 10.228632 19.927734 10.378906 L 19.927734 10.791016 C 20.168811 10.875098 20.455966 10.916935 20.613281 11.066406 C 20.691227 11.140457 20.749315 11.223053 20.806641 11.302734 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 19.882812 9.9394531 z M 16.435547 10.220703 C 16.301234 10.220703 16.277344 10.244432 16.277344 10.378906 L 16.277344 10.607422 L 16.734375 10.607422 L 16.734375 10.378906 C 16.734375 10.244433 16.712442 10.220703 16.578125 10.220703 L 16.435547 10.220703 z ' fill='#{hex-color($action-button-color)}' stroke-width='0'/><path d='M 7.7792969 19.650391 L 7.7792969 19.660156 C 7.5392969 19.680156 7.3398437 19.910156 7.3398438 20.160156 L 7.3398438 22.619141 L 7.2792969 22.619141 C 6.1992969 22.619141 5.4208594 22.589844 4.8808594 22.589844 C 3.2408594 22.589844 3.6308594 23.020234 3.6308594 26.240234 L 3.6308594 30.710938 C 3.6308594 34.970937 3.0692969 34.330078 7.2792969 34.330078 L 8.5 34.330078 L 7.1992188 33.269531 C 7.0992188 33.189531 7.02 33.070703 7 32.970703 C 6.98 32.800703 7.0592186 32.619531 7.1992188 32.519531 L 8.5292969 31.419922 L 7.2792969 31.419922 C 6.8392969 31.419922 6.5605469 31.120703 6.5605469 30.720703 L 6.5605469 26.240234 C 6.5605469 25.800234 6.8392969 25.519531 7.2792969 25.519531 L 7.3398438 25.519531 L 7.3398438 28.019531 C 7.3398438 28.399531 7.8801564 28.650391 8.1601562 28.400391 L 13.060547 24.470703 C 13.310547 24.290703 13.310547 23.869453 13.060547 23.689453 L 8.1601562 19.769531 C 8.0601563 19.669531 7.9192969 19.630391 7.7792969 19.650391 z M 17.119141 22.580078 L 17.119141 22.589844 C 16.579141 22.569844 15.820703 22.609375 14.720703 22.609375 L 13.470703 22.609375 L 14.769531 23.679688 C 14.869531 23.749688 14.950703 23.879766 14.970703 24.009766 C 14.990703 24.169766 14.909531 24.310156 14.769531 24.410156 L 13.439453 25.509766 L 14.720703 25.509766 C 15.129702 25.509766 15.41841 25.778986 15.433594 26.199219 C 15.752266 26.097283 16.084896 26.027344 16.435547 26.027344 L 16.578125 26.027344 C 17.236645 26.027344 17.848901 26.228565 18.369141 26.5625 L 18.369141 26.240234 C 18.369141 23.090234 18.769141 22.620078 17.119141 22.580078 z M 16.435547 27.027344 C 15.143818 27.027344 14.083984 28.085184 14.083984 29.376953 L 14.083984 30.607422 L 13.570312 30.607422 C 13.375452 30.607422 13.210603 30.704118 13.119141 30.791016 C 13.027691 30.877916 12.983569 30.958238 12.951172 31.03125 C 12.886382 31.177277 12.867184 31.304789 12.867188 31.441406 L 12.867188 32.523438 L 12.867188 33.119141 L 12.867188 34.677734 L 12.867188 35.509766 L 13.570312 35.509766 L 19.472656 35.509766 L 20.173828 35.509766 L 20.173828 34.677734 L 20.173828 32.523438 L 20.173828 31.441406 C 20.173828 31.304794 20.156597 31.177281 20.091797 31.03125 C 20.059397 30.95824 20.015299 30.877916 19.923828 30.791016 C 19.832368 30.704116 19.667509 30.607422 19.472656 30.607422 L 18.927734 30.607422 L 18.927734 29.376953 C 18.927734 28.085184 17.867902 27.027344 16.576172 27.027344 L 16.435547 27.027344 z M 16.435547 28.220703 L 16.576172 28.220703 C 17.22782 28.220703 17.734375 28.725101 17.734375 29.376953 L 17.734375 30.607422 L 15.277344 30.607422 L 15.277344 29.376953 C 15.277344 28.725101 15.7839 28.220703 16.435547 28.220703 z M 13.109375 29.150391 L 8.9199219 32.509766 C 8.6599219 32.689766 8.6599219 33.109063 8.9199219 33.289062 L 11.869141 35.648438 L 11.869141 34.677734 L 11.869141 33.119141 L 11.869141 32.523438 L 11.869141 31.441406 C 11.869141 31.217489 11.912641 30.907486 12.037109 30.626953 C 12.093758 30.499284 12.228597 30.257492 12.429688 30.066406 C 12.580253 29.92335 12.859197 29.887344 13.085938 29.802734 L 13.085938 29.378906 C 13.085938 29.300761 13.104 29.227272 13.109375 29.150391 z M 16.435547 29.220703 C 16.301234 29.220703 16.277344 29.244432 16.277344 29.378906 L 16.277344 29.607422 L 16.734375 29.607422 L 16.734375 29.378906 C 16.734375 29.244433 16.712442 29.220703 16.578125 29.220703 L 16.435547 29.220703 z M 12.943359 36.509766 L 13.820312 37.210938 C 14.090314 37.460938 14.639141 37.210078 14.619141 36.830078 L 14.619141 36.509766 L 13.570312 36.509766 L 12.943359 36.509766 z M 10.330078 38.650391 L 10.339844 38.660156 C 10.099844 38.680156 9.9001562 38.910156 9.9101562 39.160156 L 9.9101562 41.630859 L 7.3007812 41.630859 C 6.2207812 41.630859 5.4403906 41.589844 4.9003906 41.589844 C 3.2603906 41.589844 3.6503906 42.020234 3.6503906 45.240234 L 3.6503906 49.710938 C 3.6503906 53.370936 3.4202344 53.409141 5.9902344 53.369141 L 4.6503906 52.269531 C 4.5503906 52.189531 4.4692187 52.070703 4.4492188 51.970703 C 4.4492188 51.800703 4.5203906 51.619531 4.6503906 51.519531 L 6.609375 49.919922 C 6.579375 49.859922 6.5703125 49.790703 6.5703125 49.720703 L 6.5703125 45.240234 C 6.5703125 44.800234 6.8490625 44.519531 7.2890625 44.519531 L 9.9003906 44.519531 L 9.9003906 47.019531 C 9.9003906 47.379531 10.399219 47.620391 10.699219 47.400391 L 15.630859 43.470703 C 15.870859 43.290703 15.870859 42.869453 15.630859 42.689453 L 10.689453 38.769531 C 10.589453 38.689531 10.460078 38.640391 10.330078 38.650391 z M 16.869141 41.585938 C 16.616211 41.581522 16.322969 41.584844 15.980469 41.589844 L 15.970703 41.589844 L 17.310547 42.689453 C 17.410547 42.759453 17.489766 42.889531 17.509766 43.019531 C 17.529766 43.179531 17.479609 43.319922 17.349609 43.419922 L 15.390625 45.019531 C 15.406724 45.075878 15.427133 45.132837 15.4375 45.197266 C 15.754974 45.096169 16.086404 45.027344 16.435547 45.027344 L 16.578125 45.027344 C 17.24129 45.027344 17.858323 45.230088 18.380859 45.568359 L 18.380859 45.25 C 18.380859 42.0475 18.639648 41.616836 16.869141 41.585938 z M 16.435547 46.027344 C 15.143818 46.027344 14.083984 47.085184 14.083984 48.376953 L 14.083984 49.607422 L 13.570312 49.607422 C 13.375448 49.607422 13.210603 49.704118 13.119141 49.791016 C 13.027691 49.877916 12.983569 49.958238 12.951172 50.03125 C 12.886382 50.177277 12.867187 50.304789 12.867188 50.441406 L 12.867188 51.523438 L 12.867188 52.119141 L 12.867188 53.677734 L 12.867188 54.509766 L 13.570312 54.509766 L 19.472656 54.509766 L 20.173828 54.509766 L 20.173828 53.677734 L 20.173828 51.523438 L 20.173828 50.441406 C 20.173828 50.304794 20.156597 50.177281 20.091797 50.03125 C 20.059397 49.95824 20.015299 49.877916 19.923828 49.791016 C 19.832368 49.704116 19.667509 49.607422 19.472656 49.607422 L 18.927734 49.607422 L 18.927734 48.376953 C 18.927734 47.085184 17.867902 46.027344 16.576172 46.027344 L 16.435547 46.027344 z M 16.435547 47.220703 L 16.576172 47.220703 C 17.22782 47.220703 17.734375 47.725101 17.734375 48.376953 L 17.734375 49.607422 L 15.277344 49.607422 L 15.277344 48.376953 C 15.277344 47.725101 15.7839 47.220703 16.435547 47.220703 z M 11.470703 47.490234 C 11.410703 47.510234 11.349063 47.539844 11.289062 47.589844 L 6.3496094 51.519531 C 6.1096094 51.699531 6.1096094 52.120781 6.3496094 52.300781 L 11.289062 56.220703 C 11.569064 56.440703 12.070312 56.199844 12.070312 55.839844 L 12.070312 55.509766 L 11.869141 55.509766 L 11.869141 53.677734 L 11.869141 52.119141 L 11.869141 51.523438 L 11.869141 50.441406 C 11.869141 50.217489 11.912641 49.907486 12.037109 49.626953 C 12.043809 49.611855 12.061451 49.584424 12.070312 49.566406 L 12.070312 47.960938 C 12.070312 47.660938 11.770703 47.430234 11.470703 47.490234 z M 16.435547 48.220703 C 16.301234 48.220703 16.277344 48.244432 16.277344 48.378906 L 16.277344 48.607422 L 16.734375 48.607422 L 16.734375 48.378906 C 16.734375 48.244433 16.712442 48.220703 16.578125 48.220703 L 16.435547 48.220703 z M 13.060547 57.650391 L 13.060547 57.660156 C 12.830547 57.690156 12.660156 57.920156 12.660156 58.160156 L 12.660156 60.630859 L 7.2792969 60.630859 C 6.1992969 60.630859 5.4208594 60.589844 4.8808594 60.589844 C 3.2408594 60.589844 3.6308594 61.020234 3.6308594 64.240234 L 3.6308594 69.109375 L 6.5605469 66.740234 L 6.5605469 64.240234 C 6.5605469 63.800234 6.8392969 63.519531 7.2792969 63.519531 L 12.660156 63.519531 L 12.660156 66.019531 C 12.660156 66.299799 12.960394 66.500006 13.226562 66.474609 C 13.625751 65.076914 14.904956 64.035678 16.421875 64.029297 L 18.380859 62.470703 C 18.620859 62.290703 18.620859 61.869453 18.380859 61.689453 L 13.439453 57.769531 C 13.339453 57.669531 13.200547 57.630391 13.060547 57.650391 z M 18.359375 63.810547 L 17.800781 64.269531 C 18.004793 64.350836 18.198411 64.450249 18.380859 64.568359 L 18.380859 64.25 L 18.380859 63.810547 L 18.359375 63.810547 z M 16.435547 65.027344 C 15.143818 65.027344 14.083984 66.085184 14.083984 67.376953 L 14.083984 68.607422 L 13.570312 68.607422 C 13.375448 68.607422 13.210603 68.704118 13.119141 68.791016 C 13.027691 68.877916 12.983569 68.958238 12.951172 69.03125 C 12.886382 69.177277 12.867187 69.304789 12.867188 69.441406 L 12.867188 70.523438 L 12.867188 71.119141 L 12.867188 72.677734 L 12.867188 73.509766 L 13.570312 73.509766 L 19.472656 73.509766 L 20.173828 73.509766 L 20.173828 72.677734 L 20.173828 70.523438 L 20.173828 69.441406 C 20.173828 69.304794 20.156597 69.177281 20.091797 69.03125 C 20.059397 68.95824 20.015299 68.877916 19.923828 68.791016 C 19.832368 68.704116 19.667509 68.607422 19.472656 68.607422 L 18.927734 68.607422 L 18.927734 67.376953 C 18.927734 66.085184 17.867902 65.027344 16.576172 65.027344 L 16.435547 65.027344 z M 16.435547 66.220703 L 16.576172 66.220703 C 17.22782 66.220703 17.734375 66.725101 17.734375 67.376953 L 17.734375 68.607422 L 15.277344 68.607422 L 15.277344 67.376953 C 15.277344 66.725101 15.7839 66.220703 16.435547 66.220703 z M 8.7207031 66.509766 C 8.6507031 66.529766 8.5895312 66.559375 8.5195312 66.609375 L 3.5996094 70.519531 C 3.3496094 70.699531 3.3496094 71.120781 3.5996094 71.300781 L 8.5292969 75.220703 C 8.8092969 75.440703 9.3105469 75.199844 9.3105469 74.839844 L 9.3105469 72.339844 L 11.869141 72.339844 L 11.869141 71.119141 L 11.869141 70.523438 L 11.869141 69.449219 L 9.3203125 69.449219 L 9.3203125 66.980469 C 9.3203125 66.680469 9.0007031 66.449766 8.7207031 66.509766 z M 16.435547 67.220703 C 16.301234 67.220703 16.277344 67.244432 16.277344 67.378906 L 16.277344 67.607422 L 16.734375 67.607422 L 16.734375 67.378906 C 16.734375 67.244433 16.712442 67.220703 16.578125 67.220703 L 16.435547 67.220703 z M 19.248047 78.800781 C 19.148558 78.831033 19.050295 78.90106 18.970703 78.970703 L 18.070312 79.869141 C 17.630312 79.569141 16.710703 79.619141 14.720703 79.619141 L 7.2792969 79.619141 C 6.1992969 79.619141 5.4208594 79.589844 4.8808594 79.589844 C 3.2408594 79.589844 3.6308594 80.020234 3.6308594 83.240234 L 3.6308594 83.939453 L 6.5605469 84.240234 L 6.5605469 83.240234 C 6.5605469 82.800234 6.8392969 82.519531 7.2792969 82.519531 L 14.720703 82.519531 C 14.920703 82.519531 15.090703 82.600703 15.220703 82.720703 L 13.419922 84.519531 C 13.279464 84.665607 13.281282 84.881022 13.363281 85.054688 C 13.880838 83.867655 15.067337 83.027344 16.435547 83.027344 L 16.578125 83.027344 C 18.290465 83.027344 19.703357 84.345788 19.890625 86.011719 L 19.960938 86.019531 C 20.240938 86.049531 20.520234 85.770234 20.490234 85.490234 L 19.789062 79.240234 C 19.789062 78.973661 19.498025 78.767523 19.25 78.800781 L 19.248047 78.800781 z M 16.435547 84.027344 C 15.143818 84.027344 14.083984 85.085184 14.083984 86.376953 L 14.083984 87.607422 L 13.570312 87.607422 C 13.375448 87.607422 13.210603 87.704118 13.119141 87.791016 C 13.027691 87.877916 12.983569 87.958238 12.951172 88.03125 C 12.886382 88.177277 12.867187 88.304789 12.867188 88.441406 L 12.867188 89.523438 L 12.867188 90.119141 L 12.867188 91.677734 L 12.867188 92.509766 L 13.570312 92.509766 L 19.472656 92.509766 L 20.173828 92.509766 L 20.173828 91.677734 L 20.173828 89.523438 L 20.173828 88.441406 C 20.173828 88.304794 20.156597 88.177281 20.091797 88.03125 C 20.059397 87.95824 20.015299 87.877916 19.923828 87.791016 C 19.832368 87.704116 19.667509 87.607422 19.472656 87.607422 L 18.927734 87.607422 L 18.927734 86.376953 C 18.927734 85.085184 17.867902 84.027344 16.576172 84.027344 L 16.435547 84.027344 z M 2.0507812 84.900391 C 1.8507824 84.970391 1.6907031 85.199453 1.7207031 85.439453 L 2.4199219 91.689453 C 2.4399219 92.049453 3 92.240929 3.25 91.960938 L 4.0507812 91.160156 C 4.0707812 91.160156 4.0898437 91.140156 4.0898438 91.160156 C 4.5498437 91.400156 5.4595313 91.330078 7.2695312 91.330078 L 11.869141 91.330078 L 11.869141 90.119141 L 11.869141 89.523438 L 11.869141 88.441406 C 11.869141 88.437991 11.871073 88.433136 11.871094 88.429688 L 7.2792969 88.429688 C 7.1292969 88.429688 6.9808594 88.400078 6.8808594 88.330078 L 8.8007812 86.400391 C 9.1007822 86.160391 8.8992969 85.600547 8.5292969 85.560547 L 2.25 84.910156 L 2.0507812 84.910156 L 2.0507812 84.900391 z M 16.435547 85.220703 L 16.576172 85.220703 C 17.22782 85.220703 17.734375 85.725101 17.734375 86.376953 L 17.734375 87.607422 L 15.277344 87.607422 L 15.277344 86.376953 C 15.277344 85.725101 15.7839 85.220703 16.435547 85.220703 z M 4.8808594 98.599609 C 3.5508594 98.599609 3.5400781 99.080402 3.5800781 100.90039 L 4.7207031 99.529297 C 4.8007031 99.429297 4.9405469 99.360078 5.0605469 99.330078 C 5.2205469 99.330078 5.4 99.409297 5.5 99.529297 L 7.1601562 101.56055 C 7.2001563 101.56055 7.2292969 101.5293 7.2792969 101.5293 L 14.720703 101.5293 C 15.060703 101.5293 15.289141 101.7293 15.369141 102.0293 L 12.939453 102.0293 C 12.599453 102.0793 12.410625 102.55055 12.640625 102.81055 L 13.470703 103.85742 C 14.029941 102.77899 15.146801 102.02734 16.435547 102.02734 L 16.578125 102.02734 C 18.158418 102.02734 19.491598 103.14879 19.835938 104.63086 L 21.279297 102.82031 C 21.499297 102.55031 21.260156 102.06078 20.910156 102.05078 L 18.400391 102.05078 C 18.420391 98.150792 19.000234 98.650391 14.740234 98.650391 L 7.2792969 98.650391 C 6.1992969 98.650391 5.4208594 98.609375 4.8808594 98.609375 L 4.8808594 98.599609 z M 5.0292969 101.06055 C 4.9292969 101.09055 4.83 101.15977 4.75 101.25977 L 0.81054688 106.16016 C 0.61054688 106.44016 0.8409375 106.92945 1.2109375 106.93945 L 3.5996094 106.93945 C 3.5796094 110.87945 3.1497656 110.33984 7.2597656 110.33984 L 11.869141 110.33984 L 11.869141 109.11914 L 11.869141 108.52344 L 11.869141 107.44141 L 11.869141 107.43945 L 7.2792969 107.43945 C 6.9292969 107.43945 6.7091406 107.23945 6.6191406 106.93945 L 9.0605469 106.93945 C 9.4305469 106.93945 9.6909375 106.44016 9.4609375 106.16016 L 5.5 101.25977 C 5.4 101.10977 5.1992969 101.03055 5.0292969 101.06055 z M 16.435547 103.02734 C 15.143818 103.02734 14.083984 104.08518 14.083984 105.37695 L 14.083984 106.60742 L 13.570312 106.60742 C 13.375448 106.60742 13.210603 106.70409 13.119141 106.79102 C 13.027691 106.87792 12.983569 106.95823 12.951172 107.03125 C 12.886382 107.17727 12.867187 107.30479 12.867188 107.44141 L 12.867188 108.52344 L 12.867188 109.11914 L 12.867188 110.67773 L 12.867188 111.50977 L 13.570312 111.50977 L 19.472656 111.50977 L 20.173828 111.50977 L 20.173828 110.67773 L 20.173828 108.52344 L 20.173828 107.44141 C 20.173828 107.3048 20.156597 107.17728 20.091797 107.03125 C 20.059397 106.95825 20.015299 106.87792 19.923828 106.79102 C 19.832368 106.70412 19.667509 106.60742 19.472656 106.60742 L 18.927734 106.60742 L 18.927734 105.37695 C 18.927734 104.08518 17.867902 103.02734 16.576172 103.02734 L 16.435547 103.02734 z M 16.435547 104.2207 L 16.576172 104.2207 C 17.22782 104.2207 17.734375 104.7251 17.734375 105.37695 L 17.734375 106.60742 L 15.277344 106.60742 L 15.277344 105.37695 C 15.277344 104.7251 15.7839 104.2207 16.435547 104.2207 z M 16.435547 105.2207 C 16.301234 105.2207 16.277344 105.24444 16.277344 105.37891 L 16.277344 105.60742 L 16.734375 105.60742 L 16.734375 105.37891 C 16.734375 105.24441 16.712442 105.2207 16.578125 105.2207 L 16.435547 105.2207 z M 4.8808594 117.58984 L 4.8808594 117.59961 C 3.7208594 117.59961 3.5800781 117.90016 3.5800781 119.16016 L 4.7207031 117.7793 C 4.8007031 117.6793 4.9405469 117.63914 5.0605469 117.61914 C 5.2205469 117.61914 5.4 117.6593 5.5 117.7793 L 7.7207031 120.5293 L 14.720703 120.5293 C 15.123595 120.5293 15.408576 120.79174 15.431641 121.20117 C 15.750992 121.09876 16.08404 121.02734 16.435547 121.02734 L 16.578125 121.02734 C 17.24903 121.02734 17.874081 121.23262 18.400391 121.57812 L 18.400391 121.25 C 18.400391 117.05 19.120234 117.61914 14.740234 117.61914 L 7.2792969 117.61914 C 6.1992969 117.61914 5.4208594 117.58984 4.8808594 117.58984 z M 4.9804688 119.33984 C 4.8804688 119.36984 4.81 119.44 4.75 119.5 L 0.80078125 124.43945 C 0.60078125 124.71945 0.8292182 125.2107 1.1992188 125.2207 L 3.5996094 125.2207 L 3.5996094 125.7207 C 3.5996094 129.9807 3.0497656 129.33984 7.2597656 129.33984 L 11.869141 129.33984 L 11.869141 128.11914 L 11.869141 127.52344 L 11.869141 126.44141 C 11.869141 126.43799 11.871073 126.43314 11.871094 126.42969 L 7.2792969 126.42969 C 6.8392969 126.42969 6.5605469 126.13094 6.5605469 125.71094 L 6.5605469 125.21094 L 9.0605469 125.21094 C 9.4305469 125.23094 9.6909375 124.70969 9.4609375 124.42969 L 5.5 119.5 C 5.3820133 119.35252 5.1682348 119.28513 4.9804688 119.33984 z M 12.839844 121.7793 C 12.539844 121.8793 12.410625 122.32055 12.640625 122.56055 L 13.267578 123.34375 C 13.473522 122.72168 13.852237 122.1828 14.353516 121.7793 L 12.839844 121.7793 z M 18.658203 121.7793 C 19.393958 122.37155 19.878978 123.25738 19.916016 124.25781 L 21.279297 122.56055 C 21.499297 122.28055 21.260156 121.7893 20.910156 121.7793 L 18.658203 121.7793 z M 16.435547 122.02734 C 15.143818 122.02734 14.083984 123.08518 14.083984 124.37695 L 14.083984 125.60742 L 13.570312 125.60742 C 13.375448 125.60742 13.210603 125.70409 13.119141 125.79102 C 13.027691 125.87792 12.983569 125.95823 12.951172 126.03125 C 12.886382 126.17727 12.867187 126.30479 12.867188 126.44141 L 12.867188 127.52344 L 12.867188 128.11914 L 12.867188 129.67773 L 12.867188 130.50977 L 13.570312 130.50977 L 19.472656 130.50977 L 20.173828 130.50977 L 20.173828 129.67773 L 20.173828 127.52344 L 20.173828 126.44141 C 20.173828 126.3048 20.156597 126.17728 20.091797 126.03125 C 20.059397 125.95825 20.015299 125.87792 19.923828 125.79102 C 19.832368 125.70412 19.667509 125.60742 19.472656 125.60742 L 18.927734 125.60742 L 18.927734 124.37695 C 18.927734 123.08518 17.867902 122.02734 16.576172 122.02734 L 16.435547 122.02734 z M 16.435547 123.2207 L 16.576172 123.2207 C 17.22782 123.2207 17.734375 123.7251 17.734375 124.37695 L 17.734375 125.60742 L 15.277344 125.60742 L 15.277344 124.37695 C 15.277344 123.7251 15.7839 123.2207 16.435547 123.2207 z M 16.435547 124.2207 C 16.301234 124.2207 16.277344 124.24444 16.277344 124.37891 L 16.277344 124.60742 L 16.734375 124.60742 L 16.734375 124.37891 C 16.734375 124.24441 16.712442 124.2207 16.578125 124.2207 L 16.435547 124.2207 z M 5.9394531 136.58984 L 5.9394531 136.59961 L 8.3105469 139.5293 L 14.730469 139.5293 C 15.131912 139.5293 15.414551 139.79039 15.439453 140.19727 C 15.756409 140.09653 16.087055 140.02734 16.435547 140.02734 L 16.578125 140.02734 C 17.24903 140.02734 17.874081 140.23261 18.400391 140.57812 L 18.400391 140.25 C 18.400391 136.05 19.120234 136.61914 14.740234 136.61914 L 7.2792969 136.61914 C 6.6792969 136.61914 6.3594531 136.59984 5.9394531 136.58984 z M 4.2207031 136.66016 C 3.8207031 136.74016 3.6791406 136.96016 3.6191406 137.41016 L 4.2207031 136.66992 L 4.2207031 136.66016 z M 5.0605469 137.57031 L 5.0605469 137.58984 C 4.9405469 137.58984 4.8197656 137.66953 4.7597656 137.76953 L 0.81054688 142.66992 C 0.57054688 142.96992 0.8109375 143.50023 1.2109375 143.49023 L 3.5996094 143.49023 L 3.5996094 144.71094 C 3.5996094 148.97094 3.0497656 148.33008 7.2597656 148.33008 L 11.869141 148.33008 L 11.869141 147.11914 L 11.869141 146.52344 L 11.869141 145.44141 C 11.869141 145.43799 11.871073 145.43314 11.871094 145.42969 L 7.2792969 145.42969 C 6.8392969 145.42969 6.5605469 145.13094 6.5605469 144.71094 L 6.5605469 143.49023 L 9.0605469 143.49023 C 9.4605469 143.53023 9.7309375 142.95945 9.4609375 142.68945 L 5.5 137.76953 C 5.4 137.63953 5.2305469 137.57031 5.0605469 137.57031 z M 16.435547 141.02734 C 15.143818 141.02734 14.083984 142.08518 14.083984 143.37695 L 14.083984 144.60742 L 13.570312 144.60742 C 13.375448 144.60742 13.210603 144.70409 13.119141 144.79102 C 13.027691 144.87792 12.983569 144.95823 12.951172 145.03125 C 12.886382 145.17727 12.867187 145.30479 12.867188 145.44141 L 12.867188 146.52344 L 12.867188 147.11914 L 12.867188 148.67773 L 12.867188 149.50977 L 13.570312 149.50977 L 19.472656 149.50977 L 20.173828 149.50977 L 20.173828 148.67773 L 20.173828 146.52344 L 20.173828 145.44141 C 20.173828 145.3048 20.156597 145.17728 20.091797 145.03125 C 20.059397 144.95825 20.015299 144.87792 19.923828 144.79102 C 19.832368 144.70412 19.667509 144.60742 19.472656 144.60742 L 18.927734 144.60742 L 18.927734 143.37695 C 18.927734 142.08518 17.867902 141.02734 16.576172 141.02734 L 16.435547 141.02734 z M 12.849609 141.5 C 12.549609 141.6 12.420391 142.0393 12.650391 142.2793 L 13.136719 142.88672 C 13.213026 142.38119 13.390056 141.90696 13.667969 141.5 L 12.849609 141.5 z M 19.34375 141.5 C 19.710704 142.03735 19.927734 142.68522 19.927734 143.37891 L 19.927734 143.79102 C 19.965561 143.80421 20.005506 143.81448 20.044922 143.82617 L 21.289062 142.2793 C 21.509062 141.9993 21.269922 141.51 20.919922 141.5 L 19.34375 141.5 z M 16.435547 142.2207 L 16.576172 142.2207 C 17.22782 142.2207 17.734375 142.7251 17.734375 143.37695 L 17.734375 144.60742 L 15.277344 144.60742 L 15.277344 143.37695 C 15.277344 142.7251 15.7839 142.2207 16.435547 142.2207 z M 16.435547 143.2207 C 16.301234 143.2207 16.277344 143.24444 16.277344 143.37891 L 16.277344 143.60742 L 16.734375 143.60742 L 16.734375 143.37891 C 16.734375 143.24441 16.712442 143.2207 16.578125 143.2207 L 16.435547 143.2207 z M 17.130859 155.59961 C 16.580859 155.57961 15.810469 155.63086 14.730469 155.63086 L 6.5292969 155.63086 L 8.9101562 158.5293 L 14.730469 158.5293 C 15.131912 158.5293 15.414551 158.79039 15.439453 159.19727 C 15.756409 159.09653 16.087055 159.02734 16.435547 159.02734 L 16.578125 159.02734 C 17.24903 159.02734 17.874081 159.23261 18.400391 159.57812 L 18.400391 159.25977 C 18.400391 156.10977 18.800391 155.63961 17.150391 155.59961 L 17.130859 155.59961 z M 5.0292969 155.86914 L 5.0292969 155.88086 C 4.9292969 155.90086 4.83 155.98055 4.75 156.06055 L 0.81054688 160.96094 C 0.61054688 161.26094 0.8409375 161.73977 1.2109375 161.75977 L 3.5996094 161.75977 L 3.5996094 163.7207 C 3.5996094 167.9807 3.0497656 167.33984 7.2597656 167.33984 L 11.869141 167.33984 L 11.869141 166.11914 L 11.869141 165.52344 L 11.869141 164.44141 L 11.869141 164.43945 L 7.2792969 164.43945 C 6.8392969 164.43945 6.5605469 164.1407 6.5605469 163.7207 L 6.5605469 161.75 L 9.0605469 161.75 C 9.4305469 161.77 9.6909375 161.2507 9.4609375 160.9707 L 5.5 156.07031 C 5.4 155.92031 5.1992969 155.84914 5.0292969 155.86914 z M 16.435547 160.02734 C 15.143818 160.02734 14.083984 161.08518 14.083984 162.37695 L 14.083984 163.60742 L 13.570312 163.60742 C 13.375448 163.60742 13.210603 163.70409 13.119141 163.79102 C 13.027691 163.87792 12.983569 163.95823 12.951172 164.03125 C 12.886382 164.17727 12.867187 164.30479 12.867188 164.44141 L 12.867188 165.52344 L 12.867188 166.11914 L 12.867188 167.67773 L 12.867188 168.50977 L 13.570312 168.50977 L 19.472656 168.50977 L 20.173828 168.50977 L 20.173828 167.67773 L 20.173828 165.52344 L 20.173828 164.44141 C 20.173828 164.3048 20.156597 164.17728 20.091797 164.03125 C 20.059397 163.95825 20.015299 163.87792 19.923828 163.79102 C 19.832368 163.70412 19.667509 163.60742 19.472656 163.60742 L 18.927734 163.60742 L 18.927734 162.37695 C 18.927734 161.08518 17.867902 160.02734 16.576172 160.02734 L 16.435547 160.02734 z M 12.900391 161.2207 C 12.580391 161.2807 12.419141 161.74 12.619141 162 L 13.085938 162.58594 L 13.085938 162.37891 C 13.085938 161.97087 13.170592 161.58376 13.306641 161.2207 L 12.900391 161.2207 z M 16.435547 161.2207 L 16.576172 161.2207 C 17.22782 161.2207 17.734375 161.7251 17.734375 162.37695 L 17.734375 163.60742 L 15.277344 163.60742 L 15.277344 162.37695 C 15.277344 161.7251 15.7839 161.2207 16.435547 161.2207 z M 19.708984 161.23047 C 19.842743 161.59081 19.927734 161.97449 19.927734 162.37891 L 19.927734 162.79102 C 20.119162 162.85779 20.322917 162.91147 20.484375 163 L 21.279297 162.00977 C 21.499297 161.72977 21.260156 161.24047 20.910156 161.23047 L 19.708984 161.23047 z M 16.435547 162.2207 C 16.301234 162.2207 16.277344 162.24444 16.277344 162.37891 L 16.277344 162.60742 L 16.734375 162.60742 L 16.734375 162.37891 C 16.734375 162.24441 16.712442 162.2207 16.578125 162.2207 L 16.435547 162.2207 z M 5.0996094 174.49023 L 5.1308594 174.5 C 4.9808594 174.5 4.83 174.56922 4.75 174.69922 L 0.80078125 179.59961 C 0.56078125 179.86961 0.7992182 180.42039 1.1992188 180.40039 L 3.5996094 180.40039 L 3.5996094 182.7207 C 3.5996094 186.9807 3.0497656 186.33984 7.2597656 186.33984 L 11.869141 186.33984 L 11.869141 185.11914 L 11.869141 184.52344 L 11.869141 183.44141 L 11.869141 183.43945 L 7.25 183.43945 C 6.82 183.43945 6.5507814 183.1407 6.5507812 182.7207 L 6.5507812 180.41992 L 9.0507812 180.41992 C 9.4307824 180.44992 9.7092187 179.87984 9.4492188 179.58984 L 5.4804688 174.68945 C 5.3804688 174.55945 5.2496094 174.49023 5.0996094 174.49023 z M 17.150391 174.58008 L 17.130859 174.59961 C 16.580859 174.57961 15.810469 174.63086 14.730469 174.63086 L 6.8300781 174.63086 L 9.1796875 177.5293 L 14.699219 177.5293 C 15.104107 177.5293 15.391475 177.79407 15.412109 178.20703 C 15.737096 178.1006 16.076913 178.02734 16.435547 178.02734 L 16.578125 178.02734 C 17.24903 178.02734 17.874081 178.2326 18.400391 178.57812 L 18.400391 178.24023 C 18.400391 175.09023 18.800391 174.62008 17.150391 174.58008 z M 16.435547 179.02734 C 15.143818 179.02734 14.083984 180.08518 14.083984 181.37695 L 14.083984 182.60742 L 13.570312 182.60742 C 13.375448 182.60742 13.210603 182.70409 13.119141 182.79102 C 13.027691 182.87792 12.983569 182.95823 12.951172 183.03125 C 12.886382 183.17727 12.867187 183.30479 12.867188 183.44141 L 12.867188 184.52344 L 12.867188 185.11914 L 12.867188 186.67773 L 12.867188 187.50977 L 13.570312 187.50977 L 19.472656 187.50977 L 20.173828 187.50977 L 20.173828 186.67773 L 20.173828 184.52344 L 20.173828 183.44141 C 20.173828 183.3048 20.156597 183.17728 20.091797 183.03125 C 20.059397 182.95825 20.015299 182.87792 19.923828 182.79102 C 19.832368 182.70412 19.667509 182.60742 19.472656 182.60742 L 18.927734 182.60742 L 18.927734 181.37695 C 18.927734 180.08518 17.867902 179.02734 16.576172 179.02734 L 16.435547 179.02734 z M 16.435547 180.2207 L 16.576172 180.2207 C 17.22782 180.2207 17.734375 180.7251 17.734375 181.37695 L 17.734375 182.60742 L 15.277344 182.60742 L 15.277344 181.37695 C 15.277344 180.7251 15.7839 180.2207 16.435547 180.2207 z M 19.816406 180.57031 C 19.882311 180.83091 19.927734 181.09907 19.927734 181.37891 L 19.927734 181.79102 C 20.168811 181.87511 20.455966 181.91694 20.613281 182.06641 C 20.630645 182.0829 20.639883 182.10199 20.65625 182.11914 L 21.259766 181.36914 C 21.479766 181.06914 21.240625 180.59031 20.890625 180.57031 L 19.816406 180.57031 z M 12.820312 180.58984 C 12.520316 180.68984 12.389141 181.11914 12.619141 181.36914 L 12.990234 181.83203 C 13.022029 181.82207 13.055579 181.81406 13.085938 181.80273 L 13.085938 181.37891 C 13.085938 181.10616 13.128698 180.84442 13.191406 180.58984 L 12.820312 180.58984 z M 16.435547 181.2207 C 16.301234 181.2207 16.277344 181.24444 16.277344 181.37891 L 16.277344 181.60742 L 16.734375 181.60742 L 16.734375 181.37891 C 16.734375 181.24441 16.712442 181.2207 16.578125 181.2207 L 16.435547 181.2207 z M 4.9609375 193.15039 L 4.9707031 193.16016 C 4.8707031 193.19016 4.8 193.25984 4.75 193.33984 L 0.81054688 198.24023 C 0.61054688 198.54023 0.8409375 199.01906 1.2109375 199.03906 L 3.5996094 199.03906 L 3.5996094 201.7207 C 3.5996094 205.9807 3.0497656 205.33984 7.2597656 205.33984 L 11.869141 205.33984 L 11.869141 204.11914 L 11.869141 203.52344 L 11.869141 202.44141 C 11.869141 202.44141 11.869141 202.43945 11.869141 202.43945 L 7.2695312 202.43945 C 6.8295312 202.43945 6.5507814 202.1407 6.5507812 201.7207 L 6.5507812 199.01953 L 9.0507812 199.01953 C 9.4207814 199.04953 9.6792188 198.54 9.4492188 198.25 L 5.4902344 193.34961 C 5.3702344 193.17961 5.1509375 193.10039 4.9609375 193.15039 z M 17.150391 193.58008 L 17.130859 193.58984 C 16.580859 193.56984 15.810469 193.61914 14.730469 193.61914 L 7.0996094 193.61914 L 9.4199219 196.46094 L 9.4492188 196.51953 L 14.699219 196.51953 C 15.106887 196.51953 15.397075 196.78718 15.414062 197.20508 C 15.738375 197.09913 16.077769 197.02734 16.435547 197.02734 L 16.578125 197.02734 C 17.24903 197.02734 17.874081 197.23259 18.400391 197.57812 L 18.400391 197.24023 C 18.400391 194.09023 18.800391 193.62008 17.150391 193.58008 z M 16.435547 198.02734 C 15.143818 198.02734 14.083984 199.08518 14.083984 200.37695 L 14.083984 201.60742 L 13.570312 201.60742 C 13.375448 201.60742 13.210603 201.70409 13.119141 201.79102 C 13.027691 201.87792 12.983569 201.95823 12.951172 202.03125 C 12.886382 202.17727 12.867187 202.30479 12.867188 202.44141 L 12.867188 203.52344 L 12.867188 204.11914 L 12.867188 205.67773 L 12.867188 206.50977 L 13.570312 206.50977 L 19.472656 206.50977 L 20.173828 206.50977 L 20.173828 205.67773 L 20.173828 203.52344 L 20.173828 202.44141 C 20.173828 202.3048 20.156597 202.17728 20.091797 202.03125 C 20.059397 201.95825 20.015299 201.87792 19.923828 201.79102 C 19.832368 201.70412 19.667509 201.60742 19.472656 201.60742 L 18.927734 201.60742 L 18.927734 200.37695 C 18.927734 199.08518 17.867902 198.02734 16.576172 198.02734 L 16.435547 198.02734 z M 16.435547 199.2207 L 16.576172 199.2207 C 17.22782 199.2207 17.734375 199.7251 17.734375 200.37695 L 17.734375 201.60742 L 15.277344 201.60742 L 15.277344 200.37695 C 15.277344 199.7251 15.7839 199.2207 16.435547 199.2207 z M 12.919922 199.93945 C 12.559922 199.95945 12.359141 200.48023 12.619141 200.74023 L 12.751953 200.9043 C 12.862211 200.87013 12.980058 200.84224 13.085938 200.80273 L 13.085938 200.37891 C 13.085938 200.22863 13.111295 200.08474 13.130859 199.93945 L 12.919922 199.93945 z M 19.882812 199.93945 C 19.902378 200.08474 19.927734 200.22863 19.927734 200.37891 L 19.927734 200.79102 C 20.168811 200.87511 20.455966 200.91694 20.613281 201.06641 C 20.691227 201.14046 20.749315 201.22305 20.806641 201.30273 L 21.259766 200.74023 C 21.519766 200.46023 21.260625 199.90945 20.890625 199.93945 L 19.882812 199.93945 z M 16.435547 200.2207 C 16.301234 200.2207 16.277344 200.24444 16.277344 200.37891 L 16.277344 200.60742 L 16.734375 200.60742 L 16.734375 200.37891 C 16.734375 200.24441 16.712442 200.2207 16.578125 200.2207 L 16.435547 200.2207 z ' fill='#{hex-color($highlight-text-color)}' stroke-width='0' /></svg>");
 
   &:hover {
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index a6df51f95..a54a5fded 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -12,6 +12,10 @@ code {
 }
 
 .simple_form {
+  &.hidden {
+    display: none;
+  }
+
   .input {
     margin-bottom: 15px;
     overflow: hidden;
@@ -100,6 +104,14 @@ code {
     }
   }
 
+  .title {
+    color: #d9e1e8;
+    font-size: 20px;
+    line-height: 28px;
+    font-weight: 400;
+    margin-bottom: 30px;
+  }
+
   .hint {
     color: $darker-text-color;
 
@@ -142,7 +154,7 @@ code {
     }
   }
 
-  .otp-hint {
+  .authentication-hint {
     margin-bottom: 25px;
   }
 
@@ -592,6 +604,10 @@ code {
     color: $error-value-color;
   }
 
+  &.hidden {
+    display: none;
+  }
+
   a {
     display: inline-block;
     color: $darker-text-color;
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index ab946470b..94aee7939 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -71,7 +71,15 @@ class ActivityPub::Activity
   end
 
   def object_uri
-    @object_uri ||= value_or_id(@object)
+    @object_uri ||= begin
+      str = value_or_id(@object)
+
+      if str.start_with?('bear:')
+        Addressable::URI.parse(str).query_values['u']
+      else
+        str
+      end
+    end
   end
 
   def unsupported_object_type?
@@ -159,20 +167,20 @@ class ActivityPub::Activity
 
   def dereference_object!
     return unless @object.is_a?(String)
-    return if invalid_origin?(@object)
 
-    object = fetch_resource(@object, true, signed_fetch_account)
-    return unless object.present? && object.is_a?(Hash) && supported_context?(object)
+    dereferencer = ActivityPub::Dereferencer.new(@object, permitted_origin: @account.uri, signature_account: signed_fetch_account)
 
-    @object = object
+    @object = dereferencer.object unless dereferencer.object.nil?
   end
 
   def signed_fetch_account
+    return Account.find(@options[:delivered_to_account_id]) if @options[:delivered_to_account_id].present?
+
     first_mentioned_local_account || first_local_follower
   end
 
   def first_mentioned_local_account
-    audience = (as_array(@json['to']) + as_array(@json['cc'])).uniq
+    audience = (as_array(@json['to']) + as_array(@json['cc'])).map { |x| value_or_id(x) }.uniq
     local_usernames = audience.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }
                               .map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }
 
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index 9e108985a..349e8f77e 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -34,12 +34,20 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
 
   private
 
+  def audience_to
+    as_array(@json['to']).map { |x| value_or_id(x) }
+  end
+
+  def audience_cc
+    as_array(@json['cc']).map { |x| value_or_id(x) }
+  end
+
   def visibility_from_audience
-    if equals_or_includes?(@json['to'], ActivityPub::TagManager::COLLECTIONS[:public])
+    if audience_to.include?(ActivityPub::TagManager::COLLECTIONS[:public])
       :public
-    elsif equals_or_includes?(@json['cc'], ActivityPub::TagManager::COLLECTIONS[:public])
+    elsif audience_cc.include?(ActivityPub::TagManager::COLLECTIONS[:public])
       :unlisted
-    elsif equals_or_includes?(@json['to'], @account.followers_url)
+    elsif audience_to.include?(@account.followers_url)
       :private
     else
       :direct
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index f09caaae4..3a9f83978 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -15,7 +15,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   private
 
   def create_encrypted_message
-    return reject_payload! if invalid_origin?(@object['id']) || @options[:delivered_to_account_id].blank?
+    return reject_payload! if invalid_origin?(object_uri) || @options[:delivered_to_account_id].blank?
 
     target_account = Account.find(@options[:delivered_to_account_id])
     target_device  = target_account.devices.find_by(device_id: @object.dig('to', 'deviceId'))
@@ -43,7 +43,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def create_status
-    return reject_payload! if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity?
+    return reject_payload! if unsupported_object_type? || invalid_origin?(object_uri) || tombstone_exists? || !related_to_local_activity?
 
     RedisLock.acquire(lock_options) do |lock|
       if lock.acquired?
@@ -65,11 +65,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def audience_to
-    @object['to'] || @json['to']
+    as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) }
   end
 
   def audience_cc
-    @object['cc'] || @json['cc']
+    as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
   end
 
   def process_status
@@ -90,7 +90,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     fetch_replies(@status)
     check_for_spam
     distribute(@status)
-    forward_for_reply if @status.distributable?
+    forward_for_reply
   end
 
   def find_existing_status
@@ -102,8 +102,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   def process_status_params
     @params = begin
       {
-        uri: @object['id'],
-        url: object_url || @object['id'],
+        uri: object_uri,
+        url: object_url || object_uri,
         account: @account,
         text: text_from_content || '',
         language: detected_language,
@@ -122,7 +122,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def process_audience
-    (as_array(audience_to) + as_array(audience_cc)).uniq.each do |audience|
+    (audience_to + audience_cc).uniq.each do |audience|
       next if audience == ActivityPub::TagManager::COLLECTIONS[:public]
 
       # Unlike with tags, there is no point in resolving accounts we don't already
@@ -313,7 +313,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     RedisLock.acquire(poll_lock_options) do |lock|
       if lock.acquired?
         already_voted = poll.votes.where(account: @account).exists?
-        poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: @object['id'])
+        poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: object_uri)
       else
         raise Mastodon::RaceConditionError
       end
@@ -352,11 +352,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def visibility_from_audience
-    if equals_or_includes?(audience_to, ActivityPub::TagManager::COLLECTIONS[:public])
+    if audience_to.include?(ActivityPub::TagManager::COLLECTIONS[:public])
       :public
-    elsif equals_or_includes?(audience_cc, ActivityPub::TagManager::COLLECTIONS[:public])
+    elsif audience_cc.include?(ActivityPub::TagManager::COLLECTIONS[:public])
       :unlisted
-    elsif equals_or_includes?(audience_to, @account.followers_url)
+    elsif audience_to.include?(@account.followers_url)
       :private
     elsif direct_message == false
       :limited
@@ -367,7 +367,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
   def audience_includes?(account)
     uri = ActivityPub::TagManager.instance.uri_for(account)
-    equals_or_includes?(audience_to, uri) || equals_or_includes?(audience_cc, uri)
+    audience_to.include?(uri) || audience_cc.include?(uri)
   end
 
   def direct_message
@@ -391,7 +391,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def text_from_content
-    return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || @object['id']].join(' ')) if converted_object_type?
+    return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type?
 
     if @object['content'].present?
       @object['content']
@@ -483,19 +483,23 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   def addresses_local_accounts?
     return true if @options[:delivered_to_account_id]
 
-    local_usernames = (as_array(audience_to) + as_array(audience_cc)).uniq.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }
+    local_usernames = (audience_to + audience_cc).uniq.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }
 
     return false if local_usernames.empty?
 
     Account.local.where(username: local_usernames).exists?
   end
 
+  def tombstone_exists?
+    Tombstone.exists?(uri: object_uri)
+  end
+
   def check_for_spam
     SpamCheck.perform(@status)
   end
 
   def forward_for_reply
-    return unless @json['signature'].present? && reply_to_local?
+    return unless @status.distributable? && @json['signature'].present? && reply_to_local?
 
     ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])
   end
@@ -513,7 +517,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def lock_options
-    { redis: Redis.current, key: "create:#{@object['id']}" }
+    { redis: Redis.current, key: "create:#{object_uri}" }
   end
 
   def poll_lock_options
diff --git a/app/lib/activitypub/dereferencer.rb b/app/lib/activitypub/dereferencer.rb
new file mode 100644
index 000000000..bea69608f
--- /dev/null
+++ b/app/lib/activitypub/dereferencer.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+class ActivityPub::Dereferencer
+  include JsonLdHelper
+
+  def initialize(uri, permitted_origin: nil, signature_account: nil)
+    @uri               = uri
+    @permitted_origin  = permitted_origin
+    @signature_account = signature_account
+  end
+
+  def object
+    @object ||= fetch_object!
+  end
+
+  private
+
+  def bear_cap?
+    @uri.start_with?('bear:')
+  end
+
+  def fetch_object!
+    if bear_cap?
+      fetch_with_token!
+    else
+      fetch_with_signature!
+    end
+  end
+
+  def fetch_with_token!
+    perform_request(bear_cap['u'], headers: { 'Authorization' => "Bearer #{bear_cap['t']}" })
+  end
+
+  def fetch_with_signature!
+    perform_request(@uri)
+  end
+
+  def bear_cap
+    @bear_cap ||= Addressable::URI.parse(@uri).query_values
+  end
+
+  def perform_request(uri, headers: nil)
+    return if invalid_origin?(uri)
+
+    req = Request.new(:get, uri)
+
+    req.add_headers('Accept' => 'application/activity+json, application/ld+json')
+    req.add_headers(headers) if headers
+    req.on_behalf_of(@signature_account) if @signature_account
+
+    req.perform do |res|
+      if res.code == 200
+        json = body_to_json(res.body_with_limit)
+        json if json.present? && json['id'] == uri
+      else
+        raise Mastodon::UnexpectedResponseError, res unless response_successful?(res) || response_error_unsalvageable?(res)
+      end
+    end
+  end
+
+  def invalid_origin?(uri)
+    return true if unsupported_uri_scheme?(uri)
+
+    needle   = Addressable::URI.parse(uri).host
+    haystack = Addressable::URI.parse(@permitted_origin).host
+
+    !haystack.casecmp(needle).zero?
+  end
+end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 2cd58e60a..b55768551 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -91,6 +91,52 @@ class UserMailer < Devise::Mailer
     end
   end
 
+  def webauthn_enabled(user, **)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject')
+    end
+  end
+
+  def webauthn_disabled(user, **)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject')
+    end
+  end
+
+  def webauthn_credential_added(user, webauthn_credential)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+    @webauthn_credential = webauthn_credential
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject')
+    end
+  end
+
+  def webauthn_credential_deleted(user, webauthn_credential)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+    @webauthn_credential = webauthn_credential
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject')
+    end
+  end
+
   def welcome(user)
     @resource = user
     @instance = Rails.configuration.x.local_domain
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index cfdd95b22..cc81b648c 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -338,7 +338,7 @@ class MediaAttachment < ApplicationRecord
 
     raise Mastodon::StreamValidationError, 'Video has no video stream' if movie.width.nil? || movie.frame_rate.nil?
     raise Mastodon::DimensionsValidationError, "#{movie.width}x#{movie.height} videos are not supported" if movie.width * movie.height > MAX_VIDEO_MATRIX_LIMIT
-    raise Mastodon::DimensionsValidationError, "#{movie.frame_rate.to_i}fps videos are not supported" if movie.frame_rate > MAX_VIDEO_FRAME_RATE
+    raise Mastodon::DimensionsValidationError, "#{movie.frame_rate.floor}fps videos are not supported" if movie.frame_rate.floor > MAX_VIDEO_FRAME_RATE
   end
 
   def set_meta
diff --git a/app/models/user.rb b/app/models/user.rb
index a05d98d88..77b50d966 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -40,6 +40,7 @@
 #  approved                  :boolean          default(TRUE), not null
 #  sign_in_token             :string
 #  sign_in_token_sent_at     :datetime
+#  webauthn_id               :string
 #
 
 class User < ApplicationRecord
@@ -77,6 +78,7 @@ class User < ApplicationRecord
   has_many :backups, inverse_of: :user
   has_many :invites, inverse_of: :user
   has_many :markers, inverse_of: :user, dependent: :destroy
+  has_many :webauthn_credentials, dependent: :destroy
 
   has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
   accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
@@ -198,9 +200,25 @@ class User < ApplicationRecord
     prepare_returning_user!
   end
 
+  def otp_enabled?
+    otp_required_for_login
+  end
+
+  def webauthn_enabled?
+    webauthn_credentials.any?
+  end
+
+  def two_factor_enabled?
+    otp_required_for_login? || webauthn_credentials.any?
+  end
+
   def disable_two_factor!
     self.otp_required_for_login = false
+    self.otp_secret = nil
     otp_backup_codes&.clear
+
+    webauthn_credentials.destroy_all if webauthn_enabled?
+
     save!
   end
 
diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb
new file mode 100644
index 000000000..4129ce539
--- /dev/null
+++ b/app/models/webauthn_credential.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: webauthn_credentials
+#
+#  id          :bigint(8)        not null, primary key
+#  external_id :string           not null
+#  public_key  :string           not null
+#  nickname    :string           not null
+#  sign_count  :bigint(8)        default(0), not null
+#  user_id     :bigint(8)
+#  created_at  :datetime         not null
+#  updated_at  :datetime         not null
+#
+
+class WebauthnCredential < ApplicationRecord
+  validates :external_id, :public_key, :nickname, :sign_count, presence: true
+  validates :external_id, uniqueness: true
+  validates :nickname, uniqueness: { scope: :user_id }
+  validates :sign_count,
+            numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 }
+end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index dd9c1264d..5f3feeed9 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -8,8 +8,6 @@ class FanOutOnWriteService < BaseService
 
     deliver_to_self(status) if status.account.local?
 
-    render_anonymous_payload(status)
-
     if status.direct_visibility?
       deliver_to_mentioned_followers(status)
       deliver_to_direct_timelines(status)
@@ -24,6 +22,8 @@ class FanOutOnWriteService < BaseService
     return if status.account.silenced? || !status.public_visibility?
     return if status.reblog? && !Setting.show_reblogs_in_public_timelines
 
+    render_anonymous_payload(status)
+
     deliver_to_hashtags(status)
 
     return if status.reply? && status.in_reply_to_account_id != status.account_id && !Setting.show_replies_in_public_timelines
@@ -63,8 +63,10 @@ class FanOutOnWriteService < BaseService
   def deliver_to_mentioned_followers(status)
     Rails.logger.debug "Delivering status #{status.id} to limited followers"
 
-    FeedInsertWorker.push_bulk(status.mentions.includes(:account).map(&:account).select { |mentioned_account| mentioned_account.local? && mentioned_account.following?(status.account) }) do |follower|
-      [status.id, follower.id, :home]
+    status.mentions.joins(:account).merge(status.account.followers_for_local_distribution).select(:id).reorder(nil).find_in_batches do |followers|
+      FeedInsertWorker.push_bulk(followers) do |follower|
+        [status.id, follower.id, :home]
+      end
     end
   end
 
diff --git a/app/services/hashtag_query_service.rb b/app/services/hashtag_query_service.rb
index 196de0639..0bdf60221 100644
--- a/app/services/hashtag_query_service.rb
+++ b/app/services/hashtag_query_service.rb
@@ -8,7 +8,7 @@ class HashtagQueryService < BaseService
     all  = tags_for(params[:all])
     none = tags_for(params[:none])
 
-    Status.distinct
+    Status.group(:id)
           .as_tag_timeline(tags, account, local)
           .tagged_with_all(all)
           .tagged_with_none(none)
diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml
index b2e36f6bc..f2f6fe19d 100644
--- a/app/views/auth/sessions/two_factor.html.haml
+++ b/app/views/auth/sessions/two_factor.html.haml
@@ -1,14 +1,9 @@
 - content_for :page_title do
   = t('auth.login')
 
-= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
-  %p.hint.otp-hint= t('simple_form.hints.sessions.otp')
+=javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous'
 
-  .fields-group
-    = f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, autofocus: true
+- if @webauthn_enabled
+  = render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' }
 
-  .actions
-    = f.button :button, t('auth.login'), type: :submit
-
-  - if Setting.site_contact_email.present?
-    %p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil))
+= render partial: 'auth/sessions/two_factor/otp_authentication_form', locals: { hidden: @scheme_type != 'totp' }
diff --git a/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml b/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml
new file mode 100644
index 000000000..ab2d48c0a
--- /dev/null
+++ b/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml
@@ -0,0 +1,18 @@
+= simple_form_for(resource,
+                  as: resource_name,
+                  url: session_path(resource_name),
+                  html: { method: :post, id: 'otp-authentication-form' }.merge(hidden ? { class: 'hidden' } : {})) do |f|
+  %p.hint.authentication-hint= t('simple_form.hints.sessions.otp')
+
+  .fields-group
+    = f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, autofocus: true
+
+  .actions
+    = f.button :button, t('auth.login'), type: :submit
+
+  - if Setting.site_contact_email.present?
+    %p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil))
+
+  - if @webauthn_enabled
+    .form-footer
+      = link_to(t('auth.link_to_webauth'), '#', id: 'link-to-webauthn')
diff --git a/app/views/auth/sessions/two_factor/_webauthn_form.html.haml b/app/views/auth/sessions/two_factor/_webauthn_form.html.haml
new file mode 100644
index 000000000..32ed1294a
--- /dev/null
+++ b/app/views/auth/sessions/two_factor/_webauthn_form.html.haml
@@ -0,0 +1,17 @@
+%p.flash-message.hidden#unsupported-browser-message= t 'webauthn_credentials.not_supported'
+%p.flash-message.alert.hidden#security-key-error-message= t 'webauthn_credentials.invalid_credential'
+
+
+= simple_form_for(resource,
+                  as: resource_name,
+                  url: session_path(resource_name),
+                  html: { method: :post, id: 'webauthn-form' }.merge(hidden ? { class: 'hidden' } : {})) do |f|
+  %h3.title= t('simple_form.title.sessions.webauthn')
+  %p.hint= t('simple_form.hints.sessions.webauthn')
+
+  .actions
+    = f.button :button, t('auth.use_security_key'), class: 'js-webauthn', type: :submit
+
+  .form-footer
+    %p= t('auth.dont_have_your_security_key')
+    = link_to(t('auth.link_to_otp'), '#', id: 'link-to-otp')
diff --git a/app/views/settings/two_factor_authentication/confirmations/new.html.haml b/app/views/settings/two_factor_authentication/confirmations/new.html.haml
index 86cf1f695..671237db5 100644
--- a/app/views/settings/two_factor_authentication/confirmations/new.html.haml
+++ b/app/views/settings/two_factor_authentication/confirmations/new.html.haml
@@ -2,17 +2,17 @@
   = t('settings.two_factor_authentication')
 
 = simple_form_for @confirmation, url: settings_two_factor_authentication_confirmation_path, method: :post do |f|
-  %p.hint= t('two_factor_authentication.instructions_html')
+  %p.hint= t('otp_authentication.instructions_html')
 
   .qr-wrapper
     .qr-code!= @qrcode.as_svg(padding: 0, module_size: 4)
 
     .qr-alternative
-      %p.hint= t('two_factor_authentication.manual_instructions')
-      %samp.qr-alternative__code= current_user.otp_secret.scan(/.{4}/).join(' ')
+      %p.hint= t('otp_authentication.manual_instructions')
+      %samp.qr-alternative__code= @new_otp_secret.scan(/.{4}/).join(' ')
 
   .fields-group
-    = f.input :otp_attempt, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
+    = f.input :otp_attempt, wrapper: :with_label, hint: t('otp_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
 
   .actions
-    = f.button :button, t('two_factor_authentication.enable'), type: :submit
+    = f.button :button, t('otp_authentication.enable'), type: :submit
diff --git a/app/views/settings/two_factor_authentication/otp_authentication/show.html.haml b/app/views/settings/two_factor_authentication/otp_authentication/show.html.haml
new file mode 100644
index 000000000..d069ba12a
--- /dev/null
+++ b/app/views/settings/two_factor_authentication/otp_authentication/show.html.haml
@@ -0,0 +1,9 @@
+- content_for :page_title do
+  = t('settings.two_factor_authentication')
+
+.simple_form
+  %p.hint= t('otp_authentication.description_html')
+
+  %hr.spacer/
+
+  = link_to t('otp_authentication.setup'), settings_otp_authentication_path, data: { method: :post }, class: 'block-button'
diff --git a/app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml b/app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml
new file mode 100644
index 000000000..0dfd94ab9
--- /dev/null
+++ b/app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml
@@ -0,0 +1,17 @@
+- content_for :page_title do
+  = t('settings.webauthn_authentication')
+
+.table-wrapper
+  %table.table
+    %tbody
+      - current_user.webauthn_credentials.each do |credential|
+        %tr
+          %td= credential.nickname
+          %td= t('webauthn_credentials.registered_on', date: l(credential.created_at.to_date, format: :with_month_name))
+          %td
+            = table_link_to 'trash', t('webauthn_credentials.delete'), settings_webauthn_credential_path(credential.id), method: :delete, data: { confirm: t('webauthn_credentials.delete_confirmation') }
+
+%hr.spacer/
+
+.simple_form
+  = link_to t('webauthn_credentials.add'), new_settings_webauthn_credential_path, class: 'block-button'
diff --git a/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
new file mode 100644
index 000000000..0b23bb689
--- /dev/null
+++ b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
@@ -0,0 +1,16 @@
+- content_for :page_title do
+  = t('settings.webauthn_authentication')
+
+= simple_form_for(:new_webauthn_credential, url: settings_webauthn_credentials_path, html: { id: :new_webauthn_credential }) do |f|
+  %p.flash-message.hidden#unsupported-browser-message= t 'webauthn_credentials.not_supported'
+  %p.flash-message.alert.hidden#security-key-error-message= t 'webauthn_credentials.invalid_credential'
+
+  %p.hint= t('webauthn_credentials.description_html')
+
+  .fields_group
+    = f.input :nickname, wrapper: :with_block_label, hint: t('webauthn_credentials.nickname_hint'), input_html: { :autocomplete => 'off' }, required: true
+
+  .actions
+    = f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit
+
+= javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous'
diff --git a/app/views/settings/two_factor_authentication_methods/index.html.haml b/app/views/settings/two_factor_authentication_methods/index.html.haml
new file mode 100644
index 000000000..315443e6d
--- /dev/null
+++ b/app/views/settings/two_factor_authentication_methods/index.html.haml
@@ -0,0 +1,41 @@
+- content_for :page_title do
+  = t('settings.two_factor_authentication')
+
+- content_for :heading_actions do
+  = link_to t('two_factor_authentication.disable'), disable_settings_two_factor_authentication_methods_path, class: 'button button--destructive', method: :post
+
+%p.hint
+  %span.positive-hint
+    = fa_icon 'check'
+    = ' '
+    = t 'two_factor_authentication.enabled'
+
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('two_factor_authentication.methods')
+        %th
+    %tbody
+      %tr
+        %td= t('two_factor_authentication.otp')
+        %td
+          = table_link_to 'pencil', t('two_factor_authentication.edit'), settings_otp_authentication_path, method: :post
+      %tr
+        %td= t('two_factor_authentication.webauthn')
+        - if current_user.webauthn_enabled?
+          %td
+            = table_link_to 'pencil', t('two_factor_authentication.edit'), settings_webauthn_credentials_path, method: :get
+        - else
+          %td
+            = table_link_to 'key', t('two_factor_authentication.add'), new_settings_webauthn_credential_path, method: :get
+
+%hr.spacer/
+
+%h3= t('two_factor_authentication.recovery_codes')
+%p.muted-hint= t('two_factor_authentication.lost_recovery_codes')
+
+%hr.spacer/
+
+.simple_form
+  = link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'
diff --git a/app/views/settings/two_factor_authentications/show.html.haml b/app/views/settings/two_factor_authentications/show.html.haml
deleted file mode 100644
index f1eecd000..000000000
--- a/app/views/settings/two_factor_authentications/show.html.haml
+++ /dev/null
@@ -1,36 +0,0 @@
-- content_for :page_title do
-  = t('settings.two_factor_authentication')
-
-- if current_user.otp_required_for_login
-  %p.hint
-    %span.positive-hint
-      = fa_icon 'check'
-      = ' '
-      = t 'two_factor_authentication.enabled'
-
-  %hr.spacer/
-
-  = simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f|
-    .fields-group
-      = f.input :otp_attempt, wrapper: :with_block_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
-
-    .actions
-      = f.button :button, t('two_factor_authentication.disable'), type: :submit, class: 'negative'
-
-  %hr.spacer/
-
-  %h3= t('two_factor_authentication.recovery_codes')
-  %p.muted-hint= t('two_factor_authentication.lost_recovery_codes')
-
-  %hr.spacer/
-
-  .simple_form
-    = link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'
-
-- else
-  .simple_form
-    %p.hint= t('two_factor_authentication.description_html')
-
-    %hr.spacer/
-
-    = link_to t('two_factor_authentication.setup'), settings_two_factor_authentication_path, data: { method: :post }, class: 'block-button'
diff --git a/app/views/user_mailer/webauthn_credential_added.html.haml b/app/views/user_mailer/webauthn_credential_added.html.haml
new file mode 100644
index 000000000..81de84b56
--- /dev/null
+++ b/app/views/user_mailer/webauthn_credential_added.html.haml
@@ -0,0 +1,44 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
+
+                              %h1= t 'devise.mailer.webauthn_credential.added.title'
+                              %p.lead= "#{t 'devise.mailer.webauthn_credential.added.explanation' }:"
+                              %p.lead= @webauthn_credential.nickname
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t('settings.account_settings')
diff --git a/app/views/user_mailer/webauthn_credential_added.text.erb b/app/views/user_mailer/webauthn_credential_added.text.erb
new file mode 100644
index 000000000..4319dddbf
--- /dev/null
+++ b/app/views/user_mailer/webauthn_credential_added.text.erb
@@ -0,0 +1,7 @@
+<%= t 'devise.mailer.two_factor_enabled.title' %>
+
+===
+
+<%= t 'devise.mailer.two_factor_enabled.explanation' %>
+
+=> <%= edit_user_registration_url %>
diff --git a/app/views/user_mailer/webauthn_credential_deleted.html.haml b/app/views/user_mailer/webauthn_credential_deleted.html.haml
new file mode 100644
index 000000000..7b47f0c88
--- /dev/null
+++ b/app/views/user_mailer/webauthn_credential_deleted.html.haml
@@ -0,0 +1,44 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
+
+                              %h1= t 'devise.mailer.webauthn_credential.deleted.title'
+                              %p.lead= "#{t 'devise.mailer.webauthn_credential.deleted.explanation' }:"
+                              %p.lead= @webauthn_credential.nickname
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t('settings.account_settings')
diff --git a/app/views/user_mailer/webauthn_credential_deleted.text.erb b/app/views/user_mailer/webauthn_credential_deleted.text.erb
new file mode 100644
index 000000000..53e5bc78c
--- /dev/null
+++ b/app/views/user_mailer/webauthn_credential_deleted.text.erb
@@ -0,0 +1,7 @@
+<%= t 'devise.mailer.webauthn_credential.deleted.title' %>
+
+===
+
+<%= t 'devise.mailer.webauthn_credential.deleted.explanation' %>
+
+=> <%= edit_user_registration_url %>
diff --git a/app/views/user_mailer/webauthn_disabled.html.haml b/app/views/user_mailer/webauthn_disabled.html.haml
new file mode 100644
index 000000000..81a2a7954
--- /dev/null
+++ b/app/views/user_mailer/webauthn_disabled.html.haml
@@ -0,0 +1,43 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
+
+                              %h1= t 'devise.mailer.webauthn_disabled.title'
+                              %p.lead= t 'devise.mailer.webauthn_disabled.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t('settings.account_settings')
diff --git a/app/views/user_mailer/webauthn_disabled.text.erb b/app/views/user_mailer/webauthn_disabled.text.erb
new file mode 100644
index 000000000..962df77ca
--- /dev/null
+++ b/app/views/user_mailer/webauthn_disabled.text.erb
@@ -0,0 +1,7 @@
+<%= t 'devise.mailer.webauthn_disabled.title' %>
+
+===
+
+<%= t 'devise.mailer.webauthn_disabled.explanation' %>
+
+=> <%= edit_user_registration_url %>
diff --git a/app/views/user_mailer/webauthn_enabled.html.haml b/app/views/user_mailer/webauthn_enabled.html.haml
new file mode 100644
index 000000000..f08e764e8
--- /dev/null
+++ b/app/views/user_mailer/webauthn_enabled.html.haml
@@ -0,0 +1,43 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
+
+                              %h1= t 'devise.mailer.webauthn_enabled.title'
+                              %p.lead= t 'devise.mailer.webauthn_enabled.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t('settings.account_settings')
diff --git a/app/views/user_mailer/webauthn_enabled.text.erb b/app/views/user_mailer/webauthn_enabled.text.erb
new file mode 100644
index 000000000..4c233fefb
--- /dev/null
+++ b/app/views/user_mailer/webauthn_enabled.text.erb
@@ -0,0 +1,7 @@
+<%= t 'devise.mailer.webauthn_credentia.added.title' %>
+
+===
+
+<%= t 'devise.mailer.webauthn_credentia.added.explanation' %>
+
+=> <%= edit_user_registration_url %>