Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ wheels/
# Virtual environment
venv/
venv312/
.venv/
ENV/
env/

Expand Down
90 changes: 90 additions & 0 deletions ALTCHA_DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# ALTCHA v2 deployment and rollback

This change replaces Google reCAPTCHA with ALTCHA v2 self-hosted **verification**.
The Python server uses `altcha==2.3.0`. The browser widget is the official
ALTCHA JavaScript **v3.2.1**, which supports the v2 challenge format.

## Configuration

1. Install requirements in a virtual environment: `python -m pip install -r requirements.txt`.
2. Generate **two independent**, high-entropy local secrets (do not commit them):

```bash
python -c 'import secrets; print(secrets.token_hex(32))'
python -c 'import secrets; print(secrets.token_hex(32))'
```

3. Copy `config/config.json.example` to `config/config.json` and set
`Altcha.HmacSecret` and `Altcha.HmacKeySecret` to the two different values.
4. Enable `Altcha.Enabled` and set `Altcha.Protect` to the desired forms.
The example protects `Crackme` and `Writeup`, not `Register` or `Comment`.
5. The MongoDB account must be able to insert into `altcha_used` and create its
`expires_at` TTL index. This index is provisioned once on application startup
when ALTCHA is enabled. An indexing failure **prevents startup** rather than
silently dropping replay protection.
6. For multi-worker production, set `RateLimiter.StorageUri` to a common Redis
backend (e.g. `redis://127.0.0.1:6379/0`). The example uses `memory://` so
developers without Redis can still start the project; it is **not suitable**
for distributed or multi-process rate limiting.
7. Deploy all updated templates, code and dependencies together. Do not enable
ALTCHA on only some of the Gunicorn workers.

## Browser widget: CDN vs first-party hosting

The sample configuration uses a version-pinned CDN script:

`https://cdn.jsdelivr.net/npm/altcha@3.2.1/dist/main/altcha.min.js`

The CAPTCHA challenges and proof verification are performed locally. The CDN
is a *separate frontend availability dependency*; if your goal is fully
first-party delivery, retrieve and commit the official MIT-licensed browser
asset before rollout:

```bash
python script/vendor_altcha_widget.py
```

The command installs `static/js/altcha-3.2.1.min.js` plus upstream license text,
checks that the downloaded file is JavaScript of the expected approximate size,
and prints its SHA-256. Review the downloaded file and include both files in
this PR. Configure `Altcha.WidgetUrl` as:

`/static/js/altcha-3.2.1.min.js`

**Important:** the complete asset cannot be regenerated from Python package
`altcha`. Do not enable the local URL before the file has been vendored.
For strict supply-chain requirements, independently pin/verify its digest or
npm package integrity before releasing. Ensure your CSP permits the selected
script source and web workers (and verify CSP in a real browser).

## Verification checklist

```bash
python -m pytest -v tests/test_altcha.py
python -m pytest --cov=app --cov=review --cov-branch --cov-report=term-missing --cov-fail-under=75
```

CI (`.github/workflows/tests.yml`) already runs Python 3.12 and the coverage
threshold, plus a separate MongoDB integration job. Follow the current GitHub
Actions run to confirm this revision passes on the repository's CI.

Before production, test browser flows with the *real* widget and real DB:
- POST without ALTCHA and with tampered/expired/cross-scope proof: rejected.
- Correct proof accepted once for both crackme and writeup uploads.
- Retries after a failed upload refresh the challenge and preserve form values.
- Multiple Gunicorn workers cannot consume the same proof twice.
- The challenge endpoint and widget asset respond under deployed HTTP security
headers, HTTPS, and reverse proxy configuration.

**Rollback:** disable `Altcha.Enabled` only as a deliberate emergency change
(uploads then have no CAPTCHA), or revert the PR and restore the old Google
reCAPTCHA configuration and credentials together. MongoDB collection
`altcha_used` may remain and expire automatically; do not drop it while
mixed versions of the application are running. Use staging first.

## Risk notes

ALTCHA PoW is an abuse deterrent, not a human-identity guarantee. Bots can
compute proof-of-work. Keep upload reviews, size checks, account controls and
shared rate limits. The HMAC keys must remain secret and constant across
workers, and MongoDB writes must be durable and shared.
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The source code for [crackmes.one](https://crackmes.one), a platform for sharing

## Requirements

- Python 3.8+
- Python 3.9+
- MongoDB 4.0+
- `zip` command (for creating password-protected archives when approving submissions)

Expand All @@ -24,7 +24,8 @@ pip install -r requirements.txt
3. Configure the application:
- Edit `config/config.json` with your settings
- Set up MongoDB connection details
- Configure reCAPTCHA if needed (set `Enabled: true`)
- Generate two independent local ALTCHA signing secrets before setting `Altcha.Enabled: true` (see `ALTCHA_DEPLOYMENT.md`)
- For production with multiple Gunicorn workers, configure a shared rate-limit backend such as Redis (the sample `memory://` URI is for development only)
- Configure Discord webhook for notifications (optional)
- Set a secure `SecretKey` for sessions

Expand Down Expand Up @@ -161,9 +162,23 @@ Edit `config/config.json`:
- **Server.Port**: Port to run on (default: `8081`)
- **Session.SecretKey**: Secret key for session signing (change in production!)
- **Session.CookieName**: Session cookie name
- **Recaptcha.Enabled**: Enable/disable reCAPTCHA
- **Recaptcha.SiteKey**: Your reCAPTCHA site key
- **Recaptcha.Secret**: Your reCAPTCHA secret key
- **Altcha.Enabled**: Master switch for the self-hosted ALTCHA v2 proof-of-work CAPTCHA (default: false).
- **Altcha.HmacSecret / HmacKeySecret**: Two independent, random, server-only secrets (at least 32 characters each). No Google account, API key or network verification required.
- **Altcha.Protect**: Independent switches for `Crackme`, `Writeup`, `Register`, `Comment`. Missing entries default to enabled when the master switch is on.
- **Altcha.Cost / MinCounter / MaxCounter / ExpiresMinutes**: Controls the PBKDF2 work factor and challenge expiry. Keep parameters within the documented limits.
- **RateLimiter.StorageUri**: Use `redis://127.0.0.1:6379/0` (or a shared backend) with multiple Gunicorn workers. `memory://` is for development only.

#### Self-hosted ALTCHA deployment

1. Generate **two different** secrets locally, e.g. run `python -c 'import secrets; print(secrets.token_hex(32))'` twice. Never commit the secrets. Store them in the private `config/config.json` (or inject them securely during deployment).
2. Replace the sample blank `Altcha.HmacSecret` and `Altcha.HmacKeySecret`, set `Altcha.Enabled` to `true`, and configure `Altcha.Protect`. The example protects crackme and writeup submissions, not comments or registration.
3. Ensure the `altcha==2.3.0` Python dependency is installed (`pip install -r requirements.txt` in a virtualenv). Frontend templates use ALTCHA widget v3.2.1 from a **version-pinned jsDelivr static CDN**. The actual single-file module is under `dist/main/altcha.min.js`, **not** `dist/altcha.min.js`. This has no verification API/key, but loading that script does require internet access from visitors. For a strictly self-hosted/no-third-party-request deployment, download that *exact* widget release into `static/vendor/altcha.min.js` and set `Altcha.WidgetUrl` to `/static/vendor/altcha.min.js`. Confirm the upstream license when vendoring.
4. MongoDB is mandatory for replay protection: each accepted challenge signature is inserted into the `altcha_used` collection using a unique `_id`. A TTL index removes the consumed entries after they expire. A database failure rejects submissions rather than allowing replay. This works across multiple Gunicorn workers.
5. Use **HTTPS** (or `localhost` during local testing) for the browser's WebCrypto. Test challenge generation (`GET /altcha/challenge/Crackme`), normal upload, missing token, replayed token, expired token and your existing rate limits. Rate-limit storage remains independent of the CAPTCHA replay registry.
6. Execute `python -m pytest -q` using the project environment before opening a PR. Tests use real locally solved ALTCHA challenges and simulated MongoDB, without external CAPTCHA services. A final manual browser smoke test is still essential.

The widget adds a base64 `altcha` field to each protected form. The server verifies its signature, expiry, per-form secret, and solution; then **atomically consumes it** in MongoDB. JavaScript form handlers reset the widget after failed AJAX submissions so the user can retry without losing the file selection or writeup text. ALTCHA's proof of work raises the cost of automated abuse, but does not replace CSRF protection, moderation or rate limiting.

- **Discord.Enabled**: Enable/disable Discord notifications for new submissions
- **Discord.WebhookURL**: Your Discord webhook URL (get from Discord channel settings → Integrations → Webhooks)
- **Reviewer.Enabled**: Enable/disable the reviewer tool (for moderating submissions)
Expand Down Expand Up @@ -241,3 +256,7 @@ Please see [SECURITY.md](SECURITY.md) for instructions on reporting security iss
## Previous Codebase

The site was originally written in Go. The old codebase is archived at [crackmesone/crackmes.one](https://github.com/crackmesone/crackmes.one).

## ALTCHA rollout

See [`ALTCHA_DEPLOYMENT.md`](ALTCHA_DEPLOYMENT.md) for secure configuration, offline asset hosting, rollout and rollback.
8 changes: 8 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,11 @@ b) the Mozilla Public License Version 2.0

This project uses DOMPurify under the Apache License Version 2.0. A copy of
the Apache License Version 2.0 is included in this repository's `LICENSE` file.

## ALTCHA (optional vendored JavaScript)

- Script: `static/js/altcha-3.2.1.min.js` (installed via
`python script/vendor_altcha_widget.py`, not bundled by default)
- Source: https://github.com/altcha-org/altcha ; npm version 3.2.1
- License: MIT. The corresponding upstream license text must be committed as
`static/js/altcha-3.2.1.LICENSE.txt` alongside the script if vendored.
11 changes: 8 additions & 3 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from app.services.database import init_db
from app.services.session import init_session
from app.services.recaptcha import init_recaptcha
from app.services.altcha import init_altcha, is_enabled as altcha_enabled, register_altcha
from app.services.limiter import init_limiter
from app.services.discord import init_discord
from app.services.email import configure as configure_email
Expand Down Expand Up @@ -52,7 +52,7 @@ def create_app(config_path=None, config=None):
# Initialize services
init_db(app, config['Database'])
init_session(app, config['Session'])
init_recaptcha(app, config['Recaptcha'])
init_altcha(app, config.get('Altcha', {}))
init_limiter(app, config.get('RateLimiter', {}))
init_discord(app, config.get('Discord'))

Expand All @@ -64,6 +64,7 @@ def create_app(config_path=None, config=None):
# Register blueprints
from app.controllers import register_blueprints
register_blueprints(app)
register_altcha(app)

# Register reviewer blueprint (separate authentication system)
reviewer_config = config.get('Reviewer', {})
Expand Down Expand Up @@ -98,7 +99,11 @@ def inject_globals():
'AuthLevel': 'auth' if username else 'anon',
'usersess': username,
'unread_notifications': unread_notifs,
'RECAPTCHA_SITEKEY': config['Recaptcha'].get('SiteKey', '') if config['Recaptcha'].get('Enabled') else '',
'ALTCHA_CRACKME': altcha_enabled('Crackme'),
'ALTCHA_WRITEUP': altcha_enabled('Writeup'),
'ALTCHA_REGISTER': altcha_enabled('Register'),
'ALTCHA_COMMENT': altcha_enabled('Comment'),
'ALTCHA_WIDGET_URL': config.get('Altcha', {}).get('WidgetUrl', 'https://cdn.jsdelivr.net/npm/altcha@3.2.1/dist/main/altcha.min.js'),
'DIFFICULTY_CHOICES': DIFFICULTY_CHOICES,
'LANG_CHOICES': LANG_CHOICES,
'ARCH_CHOICES': ARCH_CHOICES,
Expand Down
8 changes: 4 additions & 4 deletions app/controllers/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
)
from app.models.notification import notification_add
from app.models.errors import ErrNoResult
from app.services.recaptcha import verify as verify_recaptcha
from app.services.altcha import verify as verify_altcha
from app.services.limiter import limit
from app.services.discord import notify_new_comment, notify_spoiler_toggle
from app.services.view import FLASH_ERROR, FLASH_SUCCESS, validate_required
Expand Down Expand Up @@ -76,9 +76,9 @@ def leave_comment(hexid):
flash(f'Field missing: {missing}', FLASH_ERROR)
return redirect(f'/crackme/{hexid}')

# Validate reCAPTCHA
if not verify_recaptcha(request):
flash('reCAPTCHA invalid!', FLASH_ERROR)
# Validate ALTCHA
if not verify_altcha(request, 'Comment'):
flash('ALTCHA invalid!', FLASH_ERROR)
return redirect(f'/crackme/{hexid}')

comment_text = bleach.clean(request.form.get('comment', ''))
Expand Down
8 changes: 4 additions & 4 deletions app/controllers/crackme.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
)
from app.models.user import user_by_name, users_by_hexids
from app.models.errors import ErrNoResult
from app.services.recaptcha import verify as verify_recaptcha
from app.services.altcha import verify as verify_altcha
from app.services.limiter import configured_limit, limit
from app.services.view import FLASH_ERROR, FLASH_SUCCESS, FLASH_NOTICE, validate_required
from app.services.labels import get_label_groups, get_dataset_url, normalize_labels
Expand Down Expand Up @@ -287,9 +287,9 @@ def upload_crackme_post():
except (ValueError, TypeError):
return _upload_rejected('Wrong difficulty')

# Validate reCAPTCHA
if not verify_recaptcha(request):
return _upload_rejected('reCAPTCHA invalid!')
# Validate ALTCHA
if not verify_altcha(request, 'Crackme'):
return _upload_rejected('ALTCHA invalid!')

# Check for file
if 'file' not in request.files:
Expand Down
8 changes: 4 additions & 4 deletions app/controllers/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from app.models.user import user_by_name, user_by_mail, user_create
from app.models.errors import ErrNoResult
from app.services.passhash import hash_string
from app.services.recaptcha import verify as verify_recaptcha
from app.services.altcha import verify as verify_altcha
from app.services.limiter import limit
from app.services.view import FLASH_ERROR, FLASH_SUCCESS, authorized_chars_only
from app.controllers.decorators import anonymous_required
Expand Down Expand Up @@ -65,9 +65,9 @@ def register_post():
flash('Passwords do not match', FLASH_ERROR)
return render_template('register/register.html', name=name, email=email)

# Validate reCAPTCHA
if not verify_recaptcha(request):
flash('reCAPTCHA invalid!', FLASH_ERROR)
# Validate ALTCHA
if not verify_altcha(request, 'Register'):
flash('ALTCHA invalid!', FLASH_ERROR)
return render_template('register/register.html', name=name, email=email)

# Check authorized characters
Expand Down
6 changes: 3 additions & 3 deletions app/controllers/solution.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from app.models.notification import notification_add
from app.models.errors import ErrNoResult
from app.services.database import get_collection
from app.services.recaptcha import verify as verify_recaptcha
from app.services.altcha import verify as verify_altcha
from app.services.limiter import limit
from app.services.view import FLASH_ERROR, is_valid_hexid
from app.services.archive import is_archive_password_protected, is_pe_file, is_single_file_archive, is_unsupported_archive
Expand Down Expand Up @@ -173,8 +173,8 @@ def upload_solution_post(hexidcrackme):
"You've already submitted a solution to this crackme", redirect_url
)

if not verify_recaptcha(request):
return _submission_rejected('reCAPTCHA invalid!', redirect_url)
if not verify_altcha(request, 'Writeup'):
return _submission_rejected('ALTCHA invalid!', redirect_url)

# Summary
info = bleach.clean(request.form.get('info', ''))
Expand Down
Loading