[iOS · Registration] Sign-up fails with 'The CAPTCHA verification has failed' #31

Open
opened 2026-11-06 23:15:55 +00:00 by ann · 1 comment
Owner

What happens

Creating an account on the iOS app can end in the alert "The CAPTCHA verification has failed", and every further tap on Register gives the same text, while the same server (another.im) registers other libtacky clients fine.

Cause

It is not the proof of work itself. ejabberd 26.09 (mod_register with captcha_protected + top-level captcha_pow) checks the XEP-0158 challenge before anything else and deletes it whatever the verdict (ejabberd_captcha:check_captcha_pow/2, src/ejabberd_captcha.erl:716-733, reached from mod_register.erl:222-238). So once a submit is refused for any other reason — "User already exists", "Users are not allowed to register accounts so quickly" (another.im allows one registration per IP per 600 s, REGISTRATION_TIMEOUT), a malformed user name — the form's challenge is spent.

The app then keeps using that spent form:

  • AnotherIM/Screens/Registration/RegistrationScreen.swift:529-544 shows the error with Retry and OK.
  • OK calls dismissError() (AnotherIM/Screens/Registration/RegistrationViewModel.swift:203-206), which puts the phase back to .ready on the same form.
  • The next Register (submit(), :167-183) sends that form again. libtacky solves a new proof of work for a challenge id the server no longer has → not_found → "The CAPTCHA verification has failed". From then on every attempt shows this text instead of the real reason.

(The view model's own comment on retry(), :155-158, already says "a rejected submit can have spent the one behind it", but only the alert's Retry acted on it.)

Reproduced on libtacky's ejabberd 26.09 rig (captcha_pow only, as production), driving the register module exactly as RegistrationViewModel does (connect -host + token, form, submit with every visible non-fixed field):

  • a plain sign-up: <Success>;
  • a submit with a taken name: User already exists, then the same form submitted with a free name: The CAPTCHA verification has failed;
  • the same, but a fresh connect (Retry) before the second submit: <Success>;
  • 125 s between the form and the submit: <Success> (libtacky dials again for an expired stream).

The embedded libtacky (submodule pin 276241b1 = v1.1.15) is not at fault: its register.tcl is wire-identical to libtacky develop for registration, and the app sends exactly what anotherim-js sends (ui/src/state/registration.ts submission(): every non-hidden, non-fixed field; libtacky fills the hidden FORM_TYPE/from/challenge/sid and the SHA-256 answer).

How anotherim-js does it

Same flow (connect → <Form> → form → submit → <Success>/<Error>, ui/src/state/registration.ts). Its Refresh form button calls retry() with the typed answers. Note: after an <Error> the js sign-in card also keeps the form, and Create account would resubmit it, so the js app can hit the same text after a refused first submit. That is not fixed here; it is a possible follow-up.

Fix

PR from fix/registration-captcha: a submit's <Error> marks the form spent, and dismissing the error (OK) fetches a fresh form — same token, typed answers kept — just as Retry does. The server's real reason ("User already exists", "…so quickly") is then what the user sees on the next attempt.

## What happens Creating an account on the iOS app can end in the alert **"The CAPTCHA verification has failed"**, and every further tap on *Register* gives the same text, while the same server (another.im) registers other libtacky clients fine. ## Cause It is not the proof of work itself. ejabberd 26.09 (`mod_register` with `captcha_protected` + top-level `captcha_pow`) checks the XEP-0158 challenge **before** anything else and deletes it whatever the verdict (`ejabberd_captcha:check_captcha_pow/2`, `src/ejabberd_captcha.erl:716-733`, reached from `mod_register.erl:222-238`). So once a submit is refused for any *other* reason — "User already exists", "Users are not allowed to register accounts so quickly" (another.im allows one registration per IP per 600 s, `REGISTRATION_TIMEOUT`), a malformed user name — the form's challenge is spent. The app then keeps using that spent form: - `AnotherIM/Screens/Registration/RegistrationScreen.swift:529-544` shows the error with *Retry* and *OK*. - *OK* calls `dismissError()` (`AnotherIM/Screens/Registration/RegistrationViewModel.swift:203-206`), which puts the phase back to `.ready` **on the same form**. - The next *Register* (`submit()`, `:167-183`) sends that form again. libtacky solves a new proof of work for a challenge id the server no longer has → `not_found` → "The CAPTCHA verification has failed". From then on every attempt shows this text instead of the real reason. (The view model's own comment on `retry()`, `:155-158`, already says "a rejected submit can have spent the one behind it", but only the alert's *Retry* acted on it.) Reproduced on libtacky's ejabberd 26.09 rig (captcha_pow only, as production), driving the `register` module exactly as `RegistrationViewModel` does (`connect -host` + token, `form`, `submit` with every visible non-fixed field): - a plain sign-up: `<Success>`; - a submit with a taken name: `User already exists`, then the same form submitted with a free name: **`The CAPTCHA verification has failed`**; - the same, but a fresh `connect` (Retry) before the second submit: `<Success>`; - 125 s between the form and the submit: `<Success>` (libtacky dials again for an expired stream). The embedded libtacky (submodule pin `276241b1` = v1.1.15) is not at fault: its `register.tcl` is wire-identical to libtacky develop for registration, and the app sends exactly what anotherim-js sends (`ui/src/state/registration.ts` `submission()`: every non-hidden, non-fixed field; libtacky fills the hidden `FORM_TYPE`/`from`/`challenge`/`sid` and the `SHA-256` answer). ## How anotherim-js does it Same flow (`connect` → `<Form>` → `form` → `submit` → `<Success>`/`<Error>`, `ui/src/state/registration.ts`). Its *Refresh form* button calls `retry()` with the typed answers. Note: after an `<Error>` the js sign-in card also keeps the form, and *Create account* would resubmit it, so the js app can hit the same text after a refused first submit. That is not fixed here; it is a possible follow-up. ## Fix PR from `fix/registration-captcha`: a submit's `<Error>` marks the form spent, and dismissing the error (*OK*) fetches a fresh form — same token, typed answers kept — just as *Retry* does. The server's real reason ("User already exists", "…so quickly") is then what the user sees on the next attempt.
Contributor

Fixed in master by #34 (merged with feat/ui-redesign in #36). On feat/ui-redesign the cause was another one than master's: RegistrationModel.submit sent every hidden field, SHA-256 among them, and libtacky solves the proof of work only when that key is not among the values, so on another.im (captcha_pow) every submit failed with this text. The submit now sends only what the person answers. Not yet checked by a live sign-up on another.im.

Fixed in master by #34 (merged with feat/ui-redesign in #36). On feat/ui-redesign the cause was another one than master's: `RegistrationModel.submit` sent every hidden field, `SHA-256` among them, and libtacky solves the proof of work only when that key is not among the values, so on another.im (`captcha_pow`) every submit failed with this text. The submit now sends only what the person answers. Not yet checked by a live sign-up on another.im.
Sign in to join this conversation.
No labels
done
fixed
returned
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
another-im/anotherim-swift#31
No description provided.