diff --git a/.gitignore b/.gitignore index bc7c8f4..c2a7b28 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ wheels/ # Virtual environment venv/ venv312/ +.venv/ ENV/ env/ diff --git a/ALTCHA_DEPLOYMENT.md b/ALTCHA_DEPLOYMENT.md new file mode 100644 index 0000000..e1f21b5 --- /dev/null +++ b/ALTCHA_DEPLOYMENT.md @@ -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. diff --git a/README.md b/README.md index 1b753d6..6f56449 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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) @@ -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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 69bd8e5..f7e6425 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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. diff --git a/app/__init__.py b/app/__init__.py index 461c546..ea4b825 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 @@ -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')) @@ -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', {}) @@ -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, diff --git a/app/controllers/comment.py b/app/controllers/comment.py index 0e93576..866f18e 100644 --- a/app/controllers/comment.py +++ b/app/controllers/comment.py @@ -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 @@ -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', '')) diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py index 07465b3..17124ca 100644 --- a/app/controllers/crackme.py +++ b/app/controllers/crackme.py @@ -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 @@ -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: diff --git a/app/controllers/register.py b/app/controllers/register.py index 413e56b..cdc8ac9 100644 --- a/app/controllers/register.py +++ b/app/controllers/register.py @@ -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 @@ -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 diff --git a/app/controllers/solution.py b/app/controllers/solution.py index 1c382fe..b272027 100644 --- a/app/controllers/solution.py +++ b/app/controllers/solution.py @@ -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 @@ -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', '')) diff --git a/app/services/altcha.py b/app/services/altcha.py new file mode 100644 index 0000000..e845d3c --- /dev/null +++ b/app/services/altcha.py @@ -0,0 +1,156 @@ +"""First-party ALTCHA v2 proof-of-work verification, no cloud/API key. + +Challenges are signed with per-form HMAC-derived secrets to prevent cross-form +reuse. MongoDB's unique ``_id`` index makes consumption atomic across Gunicorn +workers. Fail closed on invalid configuration, expiry or MongoDB failure. +""" + +import base64 +from datetime import datetime, timedelta, timezone +import hashlib +import hmac +import json +import logging +import secrets + +from altcha import create_challenge, verify_solution +from flask import current_app, jsonify +from pymongo.errors import DuplicateKeyError, PyMongoError + +from app.services.limiter import limit + +logger = logging.getLogger(__name__) +SCOPES = ('Crackme', 'Writeup', 'Register', 'Comment') +_config = {} + + +def init_altcha(app, config): + global _config + _config = dict(config or {}) + app.config['ALTCHA_CONFIG'] = _config + if _config.get('Enabled') is True: + # Provision at startup, never on an individual HTTP submission. + # An index/permission failure must prevent a protected deployment from + # starting rather than silently weakening replay protection. + app.config['MONGO_DB']['altcha_used'].create_index( + 'expires_at', expireAfterSeconds=0 + ) + + +def read_config(): + return _config + + +def is_enabled(scope=None): + if scope is not None and scope not in SCOPES: + raise ValueError('Unknown ALTCHA scope: ' + str(scope)) + if _config.get('Enabled') is not True: + return False + return scope is None or _config.get('Protect', {}).get(scope, True) is True + + +def _secret(name, scope): + """Derive a separate signing key for each form to prevent cross-scope use.""" + master = _config.get(name) + if not isinstance(master, str) or len(master) < 32: + raise ValueError('ALTCHA signing secrets must contain at least 32 characters') + return hmac.new(master.encode('utf-8'), scope.encode('ascii'), hashlib.sha256).hexdigest() + + +def _parameters(): + cost = _config.get('Cost', 500) + lower = _config.get('MinCounter', 250) + upper = _config.get('MaxCounter', 750) + ttl = _config.get('ExpiresMinutes', 20) + # Reject insane workloads and badly configured expiry rather than hanging + # visitors or creating challenges without a meaningful expiration. + if not (type(cost) is int and 100 <= cost <= 10000 + and type(lower) is int and type(upper) is int + and 1 <= lower < upper <= 20000 + and type(ttl) is int and 1 <= ttl <= 60): + raise ValueError('Invalid ALTCHA work/expiry configuration') + return cost, lower, upper, ttl + + +def register_altcha(app): + """Attach the limit *after* init_limiter, regardless of import order.""" + app.add_url_rule('/altcha/challenge/', endpoint='altcha_challenge', + view_func=limit('30 per minute')(challenge), methods=['GET']) + + +def challenge(scope): + if scope not in SCOPES or not is_enabled(scope): + return jsonify({'error': 'Not found'}), 404 + try: + cost, lower, upper, ttl = _parameters() + challenge_data = create_challenge( + algorithm='PBKDF2/SHA-256', + cost=cost, + counter=secrets.randbelow(upper - lower + 1) + lower, + hmac_secret=_secret('HmacSecret', scope), + hmac_key_secret=_secret('HmacKeySecret', scope), + expires_at=datetime.now(timezone.utc) + timedelta(minutes=ttl), + ) + response = jsonify(challenge_data.to_dict()) + response.headers['Cache-Control'] = 'no-store, max-age=0' + return response + except (ValueError, TypeError) as exc: + logger.error('ALTCHA challenge configuration invalid: %s', exc) + return jsonify({'error': 'CAPTCHA unavailable'}), 503 + + +def _challenge_fingerprint(payload): + """Identify the signed challenge, not the mutable PoW solution. + + A replay with changed JSON key order, whitespace, or solution metadata + must retain the same signature. Only a *verified* signed challenge reaches + this function, so using its signature as a stable identifier is safe. + """ + raw = base64.b64decode(payload, validate=True) + obj = json.loads(raw) + if not isinstance(obj, dict): + raise ValueError('Invalid ALTCHA JSON payload') + # v2 payload carries its challenge signature; some library versions nest + # the challenge data rather than putting the signature at the top level. + signature = obj.get('signature') + if signature is None and isinstance(obj.get('challenge'), dict): + signature = obj['challenge'].get('signature') + if not isinstance(signature, str) or len(signature) < 32 or len(signature) > 512: + raise ValueError('ALTCHA payload missing challenge signature') + return hashlib.sha256(signature.encode('utf-8')).hexdigest() + + +def verify(req, scope=None): + if not is_enabled(scope): + return True + if scope is None: + logger.error('ALTCHA scope is required for protected forms') + return False + payload = req.form.get('altcha', '') + if not isinstance(payload, str) or not 1 <= len(payload) <= 8192: + return False + try: + secret = _secret('HmacSecret', scope) + key_secret = _secret('HmacKeySecret', scope) + result = verify_solution(payload, secret, hmac_key_secret=key_secret) + if result.verified is not True or result.expired: + return False + fingerprint = _challenge_fingerprint(payload) + _, _, _, ttl = _parameters() + # MongoDB _id is unique; this is an atomic single-use check shared + # across all workers. No in-process sets or check-then-insert races. + collection = current_app.config['MONGO_DB']['altcha_used'] + collection.insert_one({ + '_id': fingerprint, + 'expires_at': datetime.now(timezone.utc) + timedelta(minutes=ttl + 2), + }) + return True + except DuplicateKeyError: + logger.info('ALTCHA replay blocked') + return False + except (ValueError, TypeError, KeyError, PyMongoError) as exc: + logger.warning('ALTCHA verification failed: %s', exc) + return False + except Exception: + logger.exception('ALTCHA verification unexpected error') + return False diff --git a/app/services/limiter.py b/app/services/limiter.py index d7b536a..18cb3c0 100644 --- a/app/services/limiter.py +++ b/app/services/limiter.py @@ -25,15 +25,17 @@ "RateLimiter": { "Enabled": true, - "StorageUri": "memory://" + "StorageUri": "redis://127.0.0.1:6379/0" } Storage options: -- "memory://" - In-memory storage (default, suitable for single instance) +- "memory://" - In-memory storage (fallback, single-process development only) - "redis://localhost:6379" - Redis storage (recommended for production) - "mongodb://localhost:27017" - MongoDB storage """ +import logging + from flask_limiter import Limiter from flask_limiter.util import get_remote_address @@ -74,7 +76,12 @@ def init_limiter(app, config): ) return - storage_uri = config.get('StorageUri', 'memory://') + storage_uri = limiter_config['StorageUri'] + if storage_uri.startswith('memory://'): + logging.getLogger(__name__).warning( + 'Rate limiting uses memory:// and is not shared across workers; ' + 'configure a Redis storage URI for production.' + ) limiter = Limiter( key_func=get_remote_address, diff --git a/app/services/recaptcha.py b/app/services/recaptcha.py deleted file mode 100644 index 68574bc..0000000 --- a/app/services/recaptcha.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -Google reCAPTCHA service. -""" - -import requests - -# Global recaptcha configuration -recaptcha_config = {} - - -def init_recaptcha(app, config): - """Initialize reCAPTCHA configuration.""" - global recaptcha_config - recaptcha_config = config - app.config['RECAPTCHA_CONFIG'] = config - - -def read_config(): - """Return the reCAPTCHA configuration.""" - return recaptcha_config - - -def is_enabled(): - """Check if reCAPTCHA is enabled.""" - return recaptcha_config.get('Enabled', False) - - -def get_site_key(): - """Get the reCAPTCHA site key.""" - if is_enabled(): - return recaptcha_config.get('SiteKey', '') - return '' - - -def verify(request) -> bool: - """Verify the reCAPTCHA response. - - Args: - request: Flask request object - - Returns: - True if verification passed or reCAPTCHA is disabled - """ - if not is_enabled(): - return True - - response_token = request.form.get('g-recaptcha-response', '') - if not response_token: - return False - - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - payload = { - 'secret': recaptcha_config.get('Secret', ''), - 'response': response_token, - 'remoteip': request.remote_addr - } - - response = requests.post(verify_url, data=payload, timeout=10) - result = response.json() - - return result.get('success', False) - except Exception as e: - print(f"reCAPTCHA verification error: {e}") - return False diff --git a/config/config.json.example b/config/config.json.example index dd3c487..66389b0 100644 --- a/config/config.json.example +++ b/config/config.json.example @@ -11,11 +11,6 @@ "SecretKey": "change-this-to-a-secure-random-string", "CookieName": "crackmesone" }, - "Recaptcha": { - "Enabled": false, - "SiteKey": "your-recaptcha-site-key", - "Secret": "your-recaptcha-secret" - }, "RateLimiter": { "Enabled": true, "StorageUri": "memory://", @@ -43,5 +38,21 @@ }, "Writeup": { "ObfuscationSalt": "change-this-to-a-secure-random-salt" + }, + "Altcha": { + "Enabled": false, + "HmacSecret": "", + "HmacKeySecret": "", + "Cost": 500, + "MinCounter": 250, + "MaxCounter": 750, + "ExpiresMinutes": 20, + "Protect": { + "Crackme": true, + "Writeup": true, + "Register": false, + "Comment": false + }, + "WidgetUrl": "https://cdn.jsdelivr.net/npm/altcha@3.2.1/dist/main/altcha.min.js" } } diff --git a/requirements.txt b/requirements.txt index a86bbf6..85a9dd2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,10 +3,12 @@ pymongo==4.6.1 Flask-WTF==1.2.1 Flask-Login==0.6.3 Flask-Limiter==3.5.0 +redis==5.0.8 # Shared rate limits across Gunicorn workers bcrypt==4.1.2 python-dotenv==1.0.0 bleach==6.1.0 requests==2.31.0 +altcha==2.3.0 # Self-hosted ALTCHA v2 server-side PoW verification Werkzeug==3.0.1 resend==2.0.0 rustyzipper==1.0.6 diff --git a/script/vendor_altcha_widget.py b/script/vendor_altcha_widget.py new file mode 100644 index 0000000..3b2ef2d --- /dev/null +++ b/script/vendor_altcha_widget.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Optionally vendor the official ALTCHA v3.2.1 browser bundle. + +Run once with network access, inspect the JS and its SHA-256, then commit both +resulting files. Python does not generate the upstream JS file. +""" +from __future__ import annotations + +import hashlib +from pathlib import Path +import sys +from urllib.error import URLError +from urllib.request import Request, urlopen + +VERSION = "3.2.1" +BASE = f"https://cdn.jsdelivr.net/npm/altcha@{VERSION}" +RESOURCES = { + f"altcha-{VERSION}.min.js": BASE + "/dist/main/altcha.min.js", + f"altcha-{VERSION}.LICENSE.txt": BASE + "/LICENSE.txt", +} +TARGET = Path(__file__).resolve().parents[1] / "static" / "js" + + +def fetch(url: str) -> bytes: + request = Request(url, headers={"User-Agent": "crackmesone-altcha-vendor/1.0"}) + with urlopen(request, timeout=25) as resp: + if resp.status != 200: + raise RuntimeError(f"Download returned HTTP {resp.status}: {url}") + return resp.read(2_000_000) + + +def main() -> int: + payloads = {} + try: + for filename, url in RESOURCES.items(): + payloads[filename] = fetch(url) + except (OSError, URLError, RuntimeError) as exc: + print(f"Download failed: {exc}", file=sys.stderr) + return 1 + + js = payloads[f"altcha-{VERSION}.min.js"] + license_text = payloads[f"altcha-{VERSION}.LICENSE.txt"] + if not (50_000 <= len(js) <= 1_000_000 and b'altcha' in js.lower() + and b' - {% block page_title %}Crackmes.one{% endblock %} {% block head %}{% endblock %} diff --git a/templates/crackme/create.html b/templates/crackme/create.html index 73815a0..c157994 100644 --- a/templates/crackme/create.html +++ b/templates/crackme/create.html @@ -1,7 +1,9 @@ {% extends "base.html" %} {% block title %}Upload crackme{% endblock %} {% block page_title %}Upload crackme{% endblock %} -{% block head %}{% endblock %} +{% block head %} +{% if ALTCHA_CRACKME %}{% endif %} +{% endblock %} {% block content %}
@@ -11,7 +13,7 @@

Upload a crackme

Quick Rules

  1. Submit only original educational crackmes - NOT game cheats/mods, NOT commercial software cracks
  2. -
  3. Maximum file size: 5 MB
  4. +
  5. Maximum file size: 10 MB
  6. Must be your original work that you created
  7. No commercial packers/protectors (Themida, VMProtect, etc.)
  8. You must be able to solve your own crackme
  9. @@ -151,8 +153,8 @@

    Quick Rules

- {% if RECAPTCHA_SITEKEY %} -
+ {% if ALTCHA_CRACKME %} +



{% endif %} @@ -205,9 +207,10 @@

Quick Rules

errors.textContent = text; errors.style.display = 'block'; errors.scrollIntoView({ block: 'center' }); - // A used reCAPTCHA token is spent, so the widget needs a fresh one + // A submitted ALTCHA proof is single-use, so refresh after any upload error // before the user can try again. - if (window.grecaptcha) { try { window.grecaptcha.reset(); } catch (e) {} } + var widget = form.querySelector('altcha-widget'); + if (widget && typeof widget.reset === 'function') widget.reset(); } form.addEventListener('submit', function(e) { diff --git a/templates/crackme/read.html b/templates/crackme/read.html index 0464f7b..a9158c3 100644 --- a/templates/crackme/read.html +++ b/templates/crackme/read.html @@ -2,6 +2,7 @@ {% block title %}{{ username }}'s {{ name }}{% endblock %} {% block page_title %}{{ username }}'s {{ name }}{% endblock %} {% block head %} +{% if ALTCHA_COMMENT %}{% endif %} {% endblock %} {% block content %} @@ -376,8 +377,8 @@

{{ username }}'s {{ name }}

Mark as spoiler (hides content until clicked) - {% if RECAPTCHA_SITEKEY %} -
+ {% if ALTCHA_COMMENT %} + {% endif %} diff --git a/templates/register/register.html b/templates/register/register.html index 88e4dd1..1f044fb 100644 --- a/templates/register/register.html +++ b/templates/register/register.html @@ -1,7 +1,9 @@ {% extends "base.html" %} {% block title %}Create an Account{% endblock %} {% block page_title %}Create an Account{% endblock %} -{% block head %}{% endblock %} +{% block head %} +{% if ALTCHA_REGISTER %}{% endif %} +{% endblock %} {% block content %}
@@ -41,8 +43,8 @@
- {% if RECAPTCHA_SITEKEY %} -
+ {% if ALTCHA_REGISTER %} +



{% endif %} diff --git a/templates/solution/editor.html b/templates/solution/editor.html index 0ff4c79..dea4856 100644 --- a/templates/solution/editor.html +++ b/templates/solution/editor.html @@ -2,6 +2,7 @@ {% block title %}Submit a Writeup{% endblock %} {% block page_title %}Submit a Writeup{% endblock %} {% block head %} +{% if ALTCHA_WRITEUP %}{% endif %} {% endblock %} @@ -75,10 +76,10 @@

Submit a Writeup

Optional supporting files (scripts, keygen, screenshots). No patched PE binaries. Max 10MB. - {% if RECAPTCHA_SITEKEY %} -
+ {% if ALTCHA_WRITEUP %} + {% endif %} - + @@ -198,7 +199,8 @@

Submit a Writeup

errors.textContent = text; errors.style.display = 'block'; errors.scrollIntoView({ block: 'center' }); - if (window.grecaptcha) { try { window.grecaptcha.reset(); } catch (e) {} } + var widget = form.querySelector('altcha-widget'); + if (widget && typeof widget.reset === 'function') widget.reset(); } updateCharCount(); diff --git a/tests/conftest.py b/tests/conftest.py index 1d7b8b0..95dfea4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,7 @@ def _test_config(mongo_client, database_name): 'Name': database_name, 'Client': mongo_client, }, - 'Recaptcha': {'Enabled': False, 'SiteKey': '', 'Secret': ''}, + 'Altcha': {'Enabled': False}, 'Session': {'SecretKey': 'test-secret-key', 'CookieName': 'test-session'}, 'RateLimiter': {'Enabled': False, 'StorageUri': 'memory://'}, 'Discord': {'Enabled': False}, @@ -66,11 +66,11 @@ def client(app): @pytest.fixture(autouse=True) def reset_external_service_config(app): """Prevent tests that change process-global service config leaking state.""" - from app.services.recaptcha import init_recaptcha + from app.services.altcha import init_altcha from review import auth, routes from app.services.labels import reload_vocabulary - init_recaptcha(app, app.config['APP_CONFIG']['Recaptcha']) + init_altcha(app, app.config['APP_CONFIG']['Altcha']) auth.configure(routes.users) # The label vocabulary is cached per-process; drop it so each test re-reads # the (usually empty -> default) vocabulary against its fresh database. diff --git a/tests/test_altcha.py b/tests/test_altcha.py new file mode 100644 index 0000000..2da08e1 --- /dev/null +++ b/tests/test_altcha.py @@ -0,0 +1,257 @@ +"""Self-hosted ALTCHA v2 verification, real crypto, route and replay tests.""" + +import base64 +from datetime import datetime, timedelta, timezone +import json +from io import BytesIO +from concurrent.futures import ThreadPoolExecutor + +import pytest +from altcha import Challenge, Payload, create_challenge, solve_challenge + +from app.services import altcha + + +ALTCHA_CONFIG = { + 'Enabled': True, + 'HmacSecret': 'unit-test-signing-key-012345678901234567890123456789', + 'HmacKeySecret': 'unit-test-pow-key-012345678901234567890123456789', + 'Cost': 100, + 'MinCounter': 1, + 'MaxCounter': 3, + 'ExpiresMinutes': 20, + 'Protect': {'Crackme': True, 'Writeup': True, + 'Register': False, 'Comment': False}, +} + + +def configure_altcha_for_test(app, **overrides): + config = {**ALTCHA_CONFIG, **overrides} + altcha.init_altcha(app, config) + + +def solved(client, scope='Crackme'): + response = client.get('/altcha/challenge/' + scope) + assert response.status_code == 200, response.get_json() + assert response.headers['Cache-Control'] == 'no-store, max-age=0' + challenge = Challenge.from_dict(response.get_json()) + solution = solve_challenge(challenge, timeout=10) + assert solution is not None + return Payload(challenge, solution).to_base64() + + +def post_request(app, token, scope): + with app.test_request_context('/', method='POST', data={'altcha': token}): + return altcha.verify(__import__('flask').request, scope) + + +def test_enabled_switches_and_invalid_scope(app): + configure_altcha_for_test(app) + assert altcha.is_enabled('Crackme') + assert altcha.is_enabled('Writeup') + assert not altcha.is_enabled('Register') + assert not altcha.is_enabled('Comment') + with pytest.raises(ValueError): + altcha.is_enabled('Wrong') + altcha.init_altcha(app, {'Enabled': False}) + with app.test_request_context('/', method='POST'): + assert altcha.verify(__import__('flask').request, 'Crackme') + + +def test_challenge_endpoint_not_exposed_when_disabled(app, client): + configure_altcha_for_test(app, Protect={'Crackme': False}) + assert client.get('/altcha/challenge/Crackme').status_code == 404 + assert client.get('/altcha/challenge/Evil').status_code == 404 + assert client.get('/altcha/challenge/Writeup').status_code == 200 + + +@pytest.mark.parametrize('bad', [ + {'HmacSecret': ''}, + {'HmacKeySecret': 'weak'}, + {'Cost': 0}, + {'Cost': 10000000}, + {'MinCounter': 100, 'MaxCounter': 1}, + {'ExpiresMinutes': 0}, +]) +def test_invalid_config_fails_closed(app, client, bad): + configure_altcha_for_test(app, **bad) + response = client.get('/altcha/challenge/Crackme') + assert response.status_code == 503 + + +def test_ttl_index_created_when_altcha_is_enabled(app, db): + configure_altcha_for_test(app) + index = db.altcha_used.index_information()['expires_at_1'] + assert index['expireAfterSeconds'] == 0 + + +def test_verifying_proof_does_not_create_index_per_request(app, client, db, + monkeypatch): + configure_altcha_for_test(app) + token = solved(client) + + class GuardedCollection: + def insert_one(self, document): + return db.altcha_used.insert_one(document) + + def create_index(self, *_args, **_kwargs): + pytest.fail('Index creation must happen during init, not request') + + class GuardedDB: + def __getitem__(self, name): + assert name == 'altcha_used' + return GuardedCollection() + + monkeypatch.setitem(app.config, 'MONGO_DB', GuardedDB()) + assert post_request(app, token, 'Crackme') is True + assert post_request(app, token, 'Crackme') is False + + +def test_real_crypto_and_atomic_single_use(app, client, db): + configure_altcha_for_test(app) + token = solved(client) + assert post_request(app, token, 'Crackme') is True + assert post_request(app, token, 'Crackme') is False + assert db.altcha_used.count_documents({}) == 1 + assert 'expires_at_1' in db.altcha_used.index_information() + + +def test_replay_with_different_json_format_is_rejected(app, client, db): + configure_altcha_for_test(app) + token = solved(client) + assert post_request(app, token, 'Crackme') is True + obj = json.loads(base64.b64decode(token)) + changed = base64.b64encode(json.dumps(obj, separators=(',', ':'), sort_keys=True).encode()).decode() + assert post_request(app, changed, 'Crackme') is False + assert db.altcha_used.count_documents({}) == 1 + + +def test_real_crypto_cross_form_is_blocked(app, client): + configure_altcha_for_test(app) + token = solved(client, 'Crackme') + assert post_request(app, token, 'Writeup') is False + assert post_request(app, token, 'Crackme') is True + + +def test_missing_and_tampered_payloads_are_blocked(app, client): + configure_altcha_for_test(app) + assert post_request(app, '', 'Crackme') is False + assert post_request(app, 'Z'*10000, 'Crackme') is False + assert post_request(app, 'not-base64', 'Crackme') is False + token = solved(client) + obj = json.loads(base64.b64decode(token)) + obj['challenge']['parameters']['cost'] += 1 + tampered = base64.b64encode(json.dumps(obj).encode()).decode() + assert post_request(app, tampered, 'Crackme') is False + + +def test_expired_challenge_is_rejected(app): + configure_altcha_for_test(app) + secret = altcha._secret('HmacSecret', 'Crackme') + key_secret = altcha._secret('HmacKeySecret', 'Crackme') + challenge = create_challenge(algorithm='PBKDF2/SHA-256', cost=100, + counter=1, hmac_secret=secret, + hmac_key_secret=key_secret, + expires_at=datetime.now(timezone.utc) - timedelta(minutes=1)) + result = solve_challenge(challenge, timeout=10) + token = Payload(challenge, result).to_base64() + assert post_request(app, token, 'Crackme') is False + + +def test_broken_db_rejects_submission(app, client, monkeypatch): + configure_altcha_for_test(app) + token = solved(client) + class BrokenCollection: + def insert_one(self, *_args, **_kwargs): + raise __import__('pymongo').errors.PyMongoError('database offline') + class BrokenDB: + def __getitem__(self, name): + return BrokenCollection() + monkeypatch.setitem(app.config, 'MONGO_DB', BrokenDB()) + assert post_request(app, token, 'Crackme') is False + + +def test_concurrent_reuse_only_one_wins(app, client): + configure_altcha_for_test(app) + token = solved(client) + with ThreadPoolExecutor(max_workers=6) as executor: + results = list(executor.map(lambda _: post_request(app, token, 'Crackme'), range(6))) + assert results.count(True) == 1 + assert results.count(False) == 5 + + +def _crackme_data(token=None): + data = {'name': 'ALTCHA challenge', 'info': 'Analysis', 'lang': 'C/C++', + 'arch': 'x86-64', 'platform': 'Linux', 'difficulty': '3', + 'file': (BytesIO(b'ELF dummy'), 'test.bin')} + if token is not None: + data['altcha'] = token + return data + + +def _writeup_data(token=None): + data = {'info': 'Solution summary', 'content': 'a' * 250} + if token is not None: + data['altcha'] = token + return data + + +@pytest.mark.parametrize('scope', ['Crackme', 'Writeup']) +@pytest.mark.parametrize('mode', ['missing', 'bad', 'valid']) +def test_direct_uploads_validate_altcha(app, alice_client, client, db, + sample_crackme, monkeypatch, tmp_path, + scope, mode): + from app.controllers import crackme as cc, solution as sc + monkeypatch.setattr(cc, 'UPLOAD_FOLDER', str(tmp_path)) + monkeypatch.setattr(sc, 'UPLOAD_FOLDER', str(tmp_path)) + configure_altcha_for_test(app) + if scope == 'Crackme': + path = '/upload/crackme' + collection = db.crackme + builder = _crackme_data + else: + path = '/upload/solution/' + sample_crackme['hexid'] + collection = db.solution + builder = _writeup_data + before = collection.count_documents({}) + token = None if mode == 'missing' else ('fake' if mode == 'bad' else solved(client, scope)) + response = alice_client.post(path, data=builder(token), + content_type='multipart/form-data', + headers={'X-Requested-With': 'XMLHttpRequest'}) + if mode == 'valid': + assert response.status_code == 200, response.get_data(as_text=True) + assert response.json['ok'] is True + assert collection.count_documents({}) == before + 1 + else: + assert response.status_code == 400 + assert response.json['error'] == 'ALTCHA invalid!' + assert collection.count_documents({}) == before + + +def test_templates_only_protected_features_render_widget(app, alice_client, client, sample_crackme): + configure_altcha_for_test(app) + upload = alice_client.get('/upload/crackme').data + writeup = alice_client.get('/upload/solution/' + sample_crackme['hexid']).data + registration = client.get('/register').data + comment = client.get('/crackme/' + sample_crackme['hexid']).data + assert b'