Files

136 lines
6.6 KiB
Markdown
Raw Permalink Normal View History

# mastodon-altcha-captcha
Self-hosted [ALTCHA](https://altcha.org) proof-of-work captcha for a Mastodon
sign-up form, built for anti-social.online (2026-09-17) after a bot farm
started hammering registrations. No vendor account, no external network call
at verify time — the whole thing is ~150 lines of Ruby (stdlib SHA-256 +
HMAC-SHA256) plus the vendored widget JS.
Deployed as an **untracked local-overrides bundle** — nothing here edits a
tracked Mastodon file directly, so `git checkout <tag>` on an upgrade never
reverts it. Same pattern as other customizations on this instance (see the
character-limit override).
## What's in here
| File | Installs to |
|---|---|
| `zz_altcha.rb` | `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` |
`zz_altcha.rb` is the whole thing: the ALTCHA protocol implementation
(`Local::Altcha`), the challenge-issuing controller, a `prepend_view_path`
so the view override above actually gets used, a `before_action` on
`Auth::RegistrationsController#create` that gates account creation on a
solved challenge, and — separately — a `before_action` on
`Api::V1::AccountsController#create` that blocks account creation via the
REST API entirely (see "Why the API is blocked" below).
`altcha.min.js` is vendored from **npm `altcha@3.2.2`, `dist/main/altcha.min.js`
specifically** (MIT license, github.com/altcha-org/altcha). Not `dist/external`
— see gotchas.
## Manual deploy steps (not covered by these files)
1. Copy the three files to the paths above and restart `mastodon-web`.
2. Add an HMAC secret to `.env.production`:
```
ALTCHA_HMAC_KEY=<openssl rand -hex 32>
```
3. nginx (`sites-available/mastodon`), inside the `server { listen 443 ... }` block:
```nginx
location = /local/altcha-challenge {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://backend;
proxy_cache off; # CRITICAL, see gotchas
add_header Cache-Control "no-store";
}
location ^~ /local/ {
add_header Cache-Control "public, max-age=86400";
try_files $uri =404;
}
```
Adjust `proxy_pass` target / upstream name to match your vhost.
## Why the API registration endpoint is blocked too
Deploying the web-form captcha alone did nothing — logs showed the bot was
never hitting the web form. It was using Mastodon's REST API registration
flow instead (`POST /api/v1/apps` to mint a throwaway OAuth app, then
`POST /api/v1/accounts`), a completely different controller
(`Api::V1::AccountsController`) that the web form's captcha can't reach.
Decision made here: disable API-based account creation outright rather than
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 is functionally a flat 403 anyway) — disabling is simpler.
Existing users' apps (login, posting, everything except *creating a brand
new account* in-app) are unaffected.
**If your instance actually needs API-based sign-up** (e.g. you rely on
users creating accounts through a mobile client), delete that
`Api::V1::AccountsController` block in `zz_altcha.rb` and think about a
different mitigation for that endpoint specifically.
## Gotchas (both cost a live outage to find)
1. **Use `dist/main`, not `dist/external`.** The `/dist/external` build (the
one the altcha docs recommend for strict-CSP setups) never calls
`algorithms.set(...)` anywhere in its source — it expects you to
separately import and register a worker per algorithm, same as the
documented Argon2/Scrypt pattern. Skip that step (easy to, since nothing
errors loudly) and every verification attempt fails with
`Unsupported algorithm SHA-256`. `dist/main` self-registers SHA-256 (and
friends) unconditionally at module load — use that instead, and allow its
one small inline `<style>` tag via a CSP hash (see the
`content_security_policy` block at the bottom of `zz_altcha.rb` for the
exact pattern — same technique Mastodon's own CSP initializer uses for
PgHero).
2. **Cache-bust the vendored JS.** `public/local/` gets a long
`Cache-Control` (fine — it's an immutable-ish vendored file), but if you
ever update `altcha.min.js` in place at the same URL, browsers that
already cached the old copy will keep serving it for the full max-age,
with no visible error pointing at caching as the cause. The view computes
a content hash at render time and appends it as a query param:
```ruby
- altcha_js_digest = Digest::MD5.file(Rails.public_path.join('local', 'altcha.min.js')).hexdigest[0, 10]
%script{ src: "/local/altcha.min.js?v=#{altcha_js_digest}", type: 'module', async: true, defer: true }
```
so any future swap of the file is automatically a new URL. Don't
reference the bare filename directly if you ever update the widget.
3. **`Rails.application.routes.append` gets shadowed by Mastodon's own
catch-all 404 route.** Use `.prepend` for any new route added from an
initializer, not `.append` (append is for routes meant to be tried
*last*, e.g. an engine's fallback).
4. Defining a bare `ActionController::Base` subclass at initializer
top-level crashes boot (`NameError: uninitialized constant
AccountsHelper`) — `ActionController::Base`'s `inherited` hook eagerly
runs `helper :all`, which isn't safe before eager loading finishes.
Define it inside `Rails.application.config.to_prepare do ... end` instead.
## Protocol notes
Ported by hand from `github.com/altcha-org/altcha-lib`'s **v1** (classic)
algorithm — `src/v1/index.ts` — not the newer v2 KDF-based protocol in the
same library. Salt embeds an expiry (`?expires=<unix_ts>&`), challenge is
`sha256(salt+number)`, signature is `hmac_sha256(secret, challenge)`, and the
server never sends the client the actual `number` — the widget brute-forces
it. Verification recomputes both from the payload's own fields and compares;
a one-time-use guard via `Rails.cache` prevents replay within the expiry
window (the core protocol alone doesn't).
Difficulty is `Local::Altcha::DEFAULT_MAX_NUMBER` (currently `100_000`,
sub-second solve time on modern hardware). This stops a script that doesn't
render pages at all (the observed bot's actual behavior) and raises the cost
of automation — it is not cryptographically unbreakable. The algorithm is
published; a sufficiently motivated attacker could reimplement the solver.
Raise the difficulty if that ever becomes the actual threat model.