Eternaltwin

Home | /api | v1

/api/v1/captcha

The proof-of-work captcha that stands in front of registration and of Eternaltwin sign-in. Defined in crates/rest/src/captcha.rs.

It is a proof of work, not a picture to squint at: the browser is given a signed challenge, spends a couple of seconds hashing, and trades the answers for a short-lived verification token. The token then travels in the protected form as cap-token.

The shape of the routes

Both routes carry a scope as their first path segment:

ScopeGuards
registerPOST /api/v1/users
loginPUT /api/v1/auth/self

The scope sits in the path rather than in a body because the widget is configured with a single base URI and appends challenge and redeem to it:

data-cap-api-endpoint="/api/v1/captcha/register/"
  → POST /api/v1/captcha/register/challenge
  → POST /api/v1/captcha/register/redeem

A token minted for one scope is refused by the other form, so a captcha solved on the sign-in page cannot be spent on the registration one.

Neither route is authenticated, and neither is rate-limited. Issuing a challenge is one signature and redeeming one is fifty hashes, while the work between them costs the caller seconds: the exchange is already far more expensive for whoever abuses it than for the server.

Whether the captcha is required at all is published by config as captcha.enabled.

POST /:scope/challenge

Issues a challenge. Takes no body — the widget posts nothing, and an extractor that insisted on a body would reject every one of its requests.

Example

POST /api/v1/captcha/register/challenge
{
  "token": "eyJ…",
  "challenge": {
    "c": 50,
    "s": 32,
    "d": 4
  },
  "expires": 1768486634015
}
FieldTypeMeaning
tokenstringThe signed challenge. Handed back at redemption.
challenge.cintegerNumber of sub-puzzles.
challenge.sintegerSalt length, in hexadecimal characters.
challenge.dintegerTarget prefix length, in hexadecimal characters. Each extra character multiplies the expected work by 16.
expiresintegerWhen the challenge stops being redeemable, in milliseconds since the epoch.

The puzzles are described, not listed: both ends derive the c salts and targets from token with the same hash, so the response stays a few dozen bytes whatever c is. The defaults are fifty puzzles of four hexadecimal characters, roughly two to three seconds of work in a browser.

Errors

StatusBodyWhen
500{"error": "captcha is misconfigured"}The configured parameters are out of range.
500{"error": "internal error"}Signing failed.

POST /:scope/redeem

Exchanges the solved puzzles for a verification token.

Body

FieldTypeMeaning
tokenstringThe challenge token, unchanged.
solutionsarray of integersOne nonce per sub-puzzle, in order.

Example

POST /api/v1/captcha/register/redeem
Content-Type: application/json

{"token": "eyJ…", "solutions": [11724, 43, 90781]}
{
  "success": true,
  "token": "b0e1…",
  "expires": 1768486934015
}

success is always true: a refused redemption is an error response, not a success: false body. The field exists because the widget expects it.

The scope in the path is not trusted for anything — the challenge token states its own scope, and that is what the minted token inherits. The path is checked against it all the same, because the two disagreeing means a page is wired to the wrong endpoint.

Errors

Every refusal is 400; the widget shows its own message and offers a retry for any 4xx, so which one it is only matters to whoever reads the logs.

StatusBodyWhen
400{"error": "invalid captcha challenge"}The challenge token is forged or expired.
400{"error": "captcha challenge was issued for another scope"}The path and the token disagree.
400{"error": "invalid captcha solutions"}At least one nonce does not answer its puzzle.
400{"error": "captcha challenge was already redeemed"}The challenge was spent.
500{"error": "internal error"}—

Spending the token

The verification token goes into the protected request as a field named cap-token, beside the rest of the body:

POST /api/v1/users
Content-Type: application/json

{
  "username": "alice",
  "display_name": "Alice",
  "password": "61616161616161616161",
  "cap-token": "b0e1…"
}

The name comes from the widget, which writes a hidden input called cap-token inside a <form>; the JSON path uses the same name so that both request shapes read alike.

A protected endpoint reports a captcha problem with two statuses, and the wording is part of the contract — the sign-in page matches the string in order to tell a refused captcha apart from a wrong password:

StatusBodyWhen
401{"error": "captcha is required"}The field is absent while the captcha is enabled.
403{"error": "captcha is invalid"}Forged, expired, issued for another form, or already spent.

Registration always asks for a captcha. Sign-in asks for one only once the client address has got a password wrong several times within the last half hour; see auth/self.