Files
mastodon-altcha-captcha/zz_altcha.rb
T

231 lines
11 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
#
# LOCAL OVERRIDE for anti-social.online — self-hosted ALTCHA proof-of-work
# captcha on the sign-up form, added 2026-09-17 in response to a bot farm
# flooding registrations (see reject_spam_signups.rb in this same directory
# for the cleanup side of that incident).
#
# WHY A HOME-GROWN IMPLEMENTATION:
# Mastodon ships hCaptcha support out of the box (Gemfile, Auth::CaptchaConcern),
# but that's a third-party vendor relationship, which we specifically don't
# want here. ALTCHA (https://altcha.org, MIT licensed) is a fully self-hosted,
# stateless proof-of-work captcha -- no vendor account, no external network
# call at verify time. There's no Ruby gem needed: the protocol is just
# SHA-256 + HMAC-SHA256 over a few string fields, ported by hand below from
# the "classic" (v1) algorithm in github.com/altcha-org/altcha-lib
# (src/v1/index.ts, fetched 2026-09-17 to get the exact wire format right).
#
# WHAT THIS BUYS US:
# Stops the specific bot we saw (crafts raw POST /auth requests, no JS
# execution) dead -- it has no idea this extra step exists at all.
# HONEST LIMITATION: the PoW algorithm is published and open. A determined
# attacker who specifically targets this instance could reimplement the
# solver in a script and grind through it just as fast as a browser. This
# raises the cost of automation; it isn't cryptographically unbreakable.
# Bump Local::Altcha::DEFAULT_MAX_NUMBER upward if that ever happens.
#
# WHY IT SURVIVES UPGRADES:
# Everything here is defined in this UNTRACKED initializer (git checkout on
# upgrade never touches it) rather than editing Auth::RegistrationsController
# or its view directly. Canonical copy: /home/mastodon/local-overrides/ --
# run restore-overrides.sh to reinstate after a `git clean -fd`.
#
# MOVING PARTS (all untracked, all reinstalled by restore-overrides.sh):
# - this file -> config/initializers/zz_altcha.rb
# - views/auth/registrations/new.html.haml -> app/views_local/auth/registrations/new.html.haml
# - altcha.min.js -> public/local/altcha.min.js
# - ALTCHA_HMAC_KEY in .env.production (generated once, never rotated
# automatically -- rotating it invalidates any challenge issued in the
# last 5 minutes, harmless)
# - nginx: location = /local/altcha-challenge (proxy_cache OFF -- critical,
# see below) and location ^~ /local/ (static JS) in sites-available/mastodon
#
# NGINX CACHING GOTCHA (do not skip this if re-deploying by hand):
# The vhost's generic @proxy location has `proxy_cache_valid 200 7d`. If the
# challenge endpoint isn't given its own `proxy_cache off` location, nginx
# would cache the FIRST visitor's random challenge and serve that same
# challenge to everyone for a week -- letting one solved token be replayed
# instance-wide, and locking out everyone else once our own replay-guard
# below rejects the reused challenge. Must stay an exact-match, cache-off
# location block.
require 'base64'
module Local
module Altcha
DEFAULT_ALGORITHM = 'SHA-256'
DEFAULT_MAX_NUMBER = 100_000
SALT_BYTES = 12
EXPIRES_IN = 5.minutes
module_function
def hmac_key
ENV.fetch('ALTCHA_HMAC_KEY')
end
def digest_class(algorithm)
case algorithm
when 'SHA-1' then OpenSSL::Digest::SHA1
when 'SHA-256' then OpenSSL::Digest::SHA256
when 'SHA-512' then OpenSSL::Digest::SHA512
else raise ArgumentError, "unsupported altcha algorithm: #{algorithm}"
end
end
def hash_hex(algorithm, data)
digest_class(algorithm).hexdigest(data)
end
def hmac_hex(algorithm, data, key)
OpenSSL::HMAC.hexdigest(digest_class(algorithm).new, key, data)
end
# Mirrors altcha-lib's v1 createChallenge(): salt gets an embedded
# "?expires=<unix_ts>&" suffix, challenge = sha256(salt + number),
# signature = hmac_sha256(secret, challenge). The client widget brute
# forces `number` until it finds one that reproduces `challenge`.
def create_challenge(algorithm: DEFAULT_ALGORITHM, maxnumber: DEFAULT_MAX_NUMBER)
salt = SecureRandom.hex(SALT_BYTES)
expires_at = Time.now.utc.to_i + EXPIRES_IN.to_i
salt = "#{salt}?expires=#{expires_at}&"
number = SecureRandom.random_number(maxnumber) + 1
challenge = hash_hex(algorithm, "#{salt}#{number}")
signature = hmac_hex(algorithm, challenge, hmac_key)
{ algorithm: algorithm, challenge: challenge, maxnumber: maxnumber, salt: salt, signature: signature }
end
# Mirrors altcha-lib's v1 verifySolution(): recompute challenge+signature
# from the payload's own salt/number using OUR secret, and compare. A
# forged payload can't produce a matching signature without the secret;
# a payload that skipped the proof-of-work can't produce a matching
# challenge hash. Plus a one-time-use guard via Rails.cache so a solved
# payload can't be replayed within its expiry window.
def verify_solution(payload_b64)
payload = JSON.parse(Base64.decode64(payload_b64.to_s), symbolize_names: true)
algorithm = payload[:algorithm].to_s
salt = payload[:salt].to_s
number = payload[:number]
challenge = payload[:challenge].to_s
signature = payload[:signature].to_s
return false if [algorithm, salt, challenge, signature].any?(&:blank?) || number.nil?
query = salt.split('?', 2)[1].to_s.delete_suffix('&')
expires = Rack::Utils.parse_nested_query(query)['expires']
return false if expires.blank?
return false if Time.at(expires.to_i) < Time.now.utc
expected_challenge = hash_hex(algorithm, "#{salt}#{number}")
return false unless ActiveSupport::SecurityUtils.secure_compare(expected_challenge, challenge)
expected_signature = hmac_hex(algorithm, expected_challenge, hmac_key)
return false unless ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
cache_key = "altcha:used:#{challenge}"
return false if Rails.cache.exist?(cache_key)
Rails.cache.write(cache_key, true, expires_in: EXPIRES_IN + 1.minute)
true
rescue StandardError => e
Rails.logger.info("[altcha] verify_solution rejected: #{e.class}: #{e.message}")
false
end
end
end
# NOTE: `.append` inserts AFTER config/routes.rb's own routes -- including
# Mastodon's catch-all 404 route at the very end, which swallows our route
# before it's ever reached (confirmed: 404'd with Mastodon's own error page
# on first deploy, 2026-09-17). `.prepend` inserts before the app's routes,
# which is what a genuinely new route needs.
Rails.application.routes.prepend do
get '/local/altcha-challenge', to: 'local/altcha#challenge', as: :local_altcha_challenge
end
Rails.application.config.to_prepare do
# Standalone controller (not under Mastodon's ApplicationController -- this
# is a public, unauthenticated, read-only JSON endpoint with nothing to
# protect beyond what nginx's rate limit already covers). Defined here
# rather than at initializer top-level: ActionController::Base's
# `inherited` hook eagerly runs `helper :all`, which constantizes every
# app/helpers/*_helper.rb file -- those aren't loaded yet this early in
# boot, so defining the class before eager load finishes raises
# `NameError: uninitialized constant AccountsHelper` (hit this on first
# deploy, 2026-09-17). to_prepare runs after eager loading completes.
unless defined?(Local::AltchaController)
altcha_controller = Class.new(ActionController::Base) do
def challenge
response.headers['Cache-Control'] = 'no-store'
render json: Local::Altcha.create_challenge
end
end
Local.const_set(:AltchaController, altcha_controller)
end
# Serve our own copy of the sign-up view (prepended path is checked first,
# so this shadows app/views/auth/registrations/new.html.haml without ever
# touching that tracked file). Everything else on the site is untouched --
# this path only has the one file we put there.
views_local = Rails.root.join('app', 'views_local').to_s
ActionController::Base.prepend_view_path(views_local) unless ActionController::Base.view_paths.map(&:to_s).include?(views_local)
next unless defined?(Auth::RegistrationsController)
next if Auth::RegistrationsController.private_method_defined?(:check_altcha!)
Auth::RegistrationsController.class_eval do
before_action :check_altcha!, only: [:create]
private
def check_altcha!
return if Local::Altcha.verify_solution(params[:altcha])
build_resource(sign_up_params)
flash.now[:alert] = 'Verification failed -- please try again.'
render :new, status: :unprocessable_entity
end
end
# BLOCK API-BASED ACCOUNT CREATION, added 2026-09-17 same day as the web
# captcha above. Turns out the bot wasn't using the web sign-up form at
# all -- server logs showed it minting a fresh throwaway OAuth app via
# POST /api/v1/apps immediately before every POST /api/v1/accounts, which
# is Mastodon's REST API registration endpoint (used by third-party apps
# like Tusky/Ivory to let a user "Create Account" in-app). That's a
# completely different controller (Api::V1::AccountsController), so the
# web-form altcha guard above never saw this traffic at all -- confirmed
# bot accounts kept appearing minutes after the web captcha went live.
#
# Decision (Josh, 2026-09-17): disable new-account creation via the API
# outright rather than try to extend altcha to the API too. Both options
# block the same unmodified third-party apps in practice (none of them
# know to send an altcha token, so requiring one there is functionally
# equivalent to a flat 403) -- disabling is simpler. Existing users' apps
# are unaffected; this only blocks the "create a new account" in-app flow,
# and anyone who wants an account uses the website instead (which has the
# captcha).
if defined?(Api::V1::AccountsController) && !Api::V1::AccountsController.private_method_defined?(:block_api_account_creation)
Api::V1::AccountsController.class_eval do
before_action :block_api_account_creation, only: [:create]
private
def block_api_account_creation
forbidden
end
end
end
# The altcha widget (even the CSP-friendly /dist/external build) injects
# one small, fixed inline <style> tag for the custom element's default
# display. Confirmed via browser console during testing 2026-09-17:
# 'sha256-ZgqGuQlekW98cv0XQjYUGCLTvc3q5MkU+2SkqlFGoTM=' is what Chrome
# reported as the blocked content's hash. Same idiom Mastodon's own
# CSP initializer already uses for PgHero -- extend the directive rather
# than replace it, so the base style-src (self + nonce) still applies.
Auth::RegistrationsController.content_security_policy do |p|
existing = p.style_src.presence || [:self]
p.style_src(*existing, "'sha256-ZgqGuQlekW98cv0XQjYUGCLTvc3q5MkU+2SkqlFGoTM='")
end
end