2

I would like to implement my own CAPTCHA challenge for a website. The framework I use implements client-side session cookies, which are not encrypted, only signed with server's private key.

The problem is persistence. How can one persist a CAPTCHA'ed request and response?

For example, the server generates a challenge, which consists of an image and a result. The image is then embedded in the page as a part of HTML. But what can one do with the answer?

  • I thought of placing the answer inside the cookie (encrypted or hashed), but the client could then solve one challenge and reuse the cookie for the other requests they make, even if the cookie is encrypted.

  • I thought to do the same, but for each CAPTCHA form: place the hash of the result inside the form and then check it server-side on form submission. But again, the client can easily forge the resulting hash, sending the one they know is correct question-answer wise, and make requests endlessly, having solved only one CAPTCHA challenge.

It seems like the only viable solution is using a server-side session or to persist the CAPTCHA in a database, but I'm really interested if it's possible by using only logic and cryptography to achieve this.

Can someone share their knowledge or experience with such an algorithm?

1
  • Can you please explain the reason for your requirement? In my understanding, you usually want the user to solve captcha, and then get a cookie. Therefore, whenever you have a user with valid cookie - it automatically means they have passed your captcha. If you want to limit the captcha to a certain duration, maybe you can include a signed (or encrypted) timestamp in your cookies.
    – Alex
    Sep 28, 2021 at 11:17

2 Answers 2

2

To solve the initial problem of storing the CAPTCHA answer without server-side state is, you want a signed and encrypted token. When you generate a CAPTCHA on the server side, you take the solution string, information about the request the CAPTCHA is being used for, and a validity period, and combine them into a token structure of some sort (e.g. JSON). You then use authenticated encryption to protect the confidentiality, integrity, and authenticity of that token, and send it to the client. When the client solves the CAPTCHA, they send back the solution along with the encrypted token. The server then validates the authenticity of the token, decrypts it, and checks that the solution is correct and that the other properties of the token are valid for the request. The encrypted token itself can be sent as a cookie.

Authenticated encryption provides confidentiality, integrity, and authenticity. If the user tries to forge a token or modify an existing one, the authenticity tag will not be valid. libsodium's secretbox is a good choice for providing the cryptography here, since it provides authenticated encryption and handles most of the cryptographic design for you. You should be able to find libsodium bindings for pretty much any language, including PHP, .NET, Python, and Ruby.

The cryptographic key you use would be set as a configuration variable on the server. The nonce for each token should be randomly generated. The only deficiency in libsodium's API here is that it doesn't sign the nonce as part of the message, so you have to do that part yourself. It's not overly tricky, though - here's a rough implementation in PHP:

// encrypt & auth a message with a given key and a random nonce
function authenticated_encrypt($message, $key)
{
  $base64mode = SODIUM_BASE64_VARIANT_URLSAFE;
  
  // pick a random nonce value. this doesn't need to be confidential.
  $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
  // apply authenticated encryption to the message using the nonce and key
  $ciphertext = sodium_crypto_secretbox($message, $nonce, $key);

  // encode the nonce and ciphertext as base64 and concat them with a separator
  $encodedNonce = sodium_bin2base64($nonce, $base64mode);
  $encodedCiphertext = sodium_bin2base64($ciphertext, $base64mode);
  $token = $encodedNonce . '|' . $encodedCiphertext;

  // generate an auth tag for the ciphertext and nonce together.
  // this is important because it prevents tampering with or swapping the nonce
  $authTag = sodium_crypto_auth($token, $key);

  // encode the auth tag as base64 and prepend it with a separator
  $encodedAuthTag = sodium_bin2base64($authTag, $base64mode);
  $token = $encodedAuthTag . '|' . $token;

  // end result is "authtag|nonce|ciphertext"
  return $token;
}

// verify auth and decrypt a message with a given key and a random nonce
function authenticated_decrypt($token, $key)
{
  try
  {
    $base64mode = SODIUM_BASE64_VARIANT_URLSAFE;

    // pull the base64 strings apart
    $tokenParts = explode('|', $token);
    // check we got 3 parts
    if (count($tokenParts) != 3)
    {
      return false;
    }

    // extract the three base64 strings
    $encodedAuthTag = $tokenParts[0];
    $encodedNonce = $tokenParts[1];
    $encodedCiphertext = $tokenParts[2];

    // decode base64 for auth tag and validate that the result was a string
    $authTag = sodium_base642bin($encodedAuthTag, $base64mode);
    if (!is_string($authTag))
    {
      return false;
    }

    // reconstruct the authenticated part of the string
    $signedTokenPart = $encodedNonce . '|' . $encodedCiphertext;
    // check that the auth tag matches
    if (sodium_crypto_auth_verify($authTag, $signedTokenPart, $key) !== true)
    {
      return false;
    }

    // decode base64 for nonce and validate that the result was a string
    $nonce = sodium_base642bin($encodedNonce, $base64mode);
    if (!is_string($nonce))
    {
      return false;
    }

    // decode base64 for ciphertext and validate that the result was a string
    $ciphertext = sodium_base642bin($encodedCiphertext, $base64mode);
    if (!is_string($ciphertext))
    {
      return false;
    }

    $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);
    return $plaintext;
  }
  catch (SodiumException $sx)
  {
    return false;
  }
}

Example usage when generating the CAPTCHA would be as follows:

// get the key used for CAPTCHA tokens from the webapp config
$key = $config->getCaptchaTokenKey();

// get the configured expiry time. this should be short (3-5 mins)
$expiry = $config->getCaptchaTokenExpiry();
$validFrom = time();
$validUntil = $validFrom + $expiry;

// run CAPTCHA captcha generation code you're using
$captcha = generateCaptcha();

// generate the contents of the token, which will be used to validate the solution later
$tokenData = [
  "solution" => $captcha->solution,
  "user_id" => $request->userId,
  "request" => $request->path,
  "client_ip" => $request->clientAddress,
  "valid_from" => $validFrom,
  "valid_until" => $validUntil,
];
$tokenStr = json_encode($tokenData);

// apply authenticated encryption
$token = authenticated_encrypt($tokenStr, $key);

// set the captcha token as a cookie, with the same expiry as the token itself
// set no path or domain info, and mark the cookie with the Secure and HttpOnly flags
setcookie($config->getCaptchaCookieName(), $token, $validUntil, "", "", true, true);

// show the CAPTCHA to the user
$captcha->render();

Checking a user's solution to the CAPTCHA would look something like this:

$userCaptchaAnswer = $request->get("captcha");
if (is_null($userCaptchaAnswer) || !is_string($userCaptchaAnswer))
{
  // request didn't include an answer to the CAPTCHA
  $response->error( ... );
  die();
}

// check that a CAPTCHA cookie was set
$captchaCookieName = $config->getCaptchaCookieName();
if (!array_key_exists($captchaCookieName, $_COOKIE))
{
  // missing cookie
  $response->error( ... );
  die();
}

// check that the cookie is a string, not an array
// cookies may be arrays if the request includes multiples of the same cookie!
$encryptedCaptchaToken = $_COOKIE[$captchaCookieName];
if (!is_string($encryptedCaptchaToken))
{
  // multiple cookies with the same name set
  $response->error( ... );
  die();
}

// attempt to authenticate and decrypt the token
$tokenKey = $config->getCaptchaTokenKey();
$captchaToken = authenticated_decrypt($encryptedCaptchaToken, $tokenKey);

if ($captchaToken === false)
{
  // failed to authenticate or decrypt the token
  $response->error( ... );
  die();
}

// check we got the right data in the token
$tokenData = json_decode($captchaToken, true);
if (is_null($tokenData))
{
  // invalid JSON
  $response->error( ... );
  die();
}

// validation
if (!array_key_exists("solution", $tokenData) ||
    !array_key_exists("user_id", $tokenData) ||
    !array_key_exists("request", $tokenData) ||
    !array_key_exists("client_ip", $tokenData) ||
    !array_key_exists("valid_from", $tokenData) ||
    !array_key_exists("valid_until", $tokenData))
{
  // missing data in token
  $response->error( ... );
  die();
}
if (!is_string($tokenData["solution"]) ||
    !is_int($tokenData["user_id"]) ||
    !is_string($tokenData["request"]) ||
    !is_string($tokenData["client_ip"]) ||
    !is_int($tokenData["valid_from"]) ||
    !is_int($tokenData["valid_until"]))
{
  // improper data in token
  $response->error( ... );
  die();
}

// are we before the valid_from timestamp?
if (time() < $tokenData["valid_from"])
{
  // the token is not yet valid, somehow
  $response->error( ... );
  die();
}

// are we after the valid_until timestamp?
if (time() > $tokenData["valid_until"])
{
  // the token has expired
  $response->error( ... );
  die();
}

if (sodium_memcmp($tokenData["client_ip"], $request->clientAddress) !== 0)
{
  // the token was issued to a different IP
  $response->error( ... );
  die();
}

if (sodium_memcmp($tokenData["request"], $request->path) !== 0)
{
  // the token was issued for a different type of request
  $response->error( ... );
  die();
}

if ($tokenData["user_id"] != $request->userId))
{
  // the token was issued to a different user
  $response->error( ... );
  die();
}

if (sodium_memcmp($userCaptchaAnswer, $tokenData["solution"]) !== 0)
{
  // user got the CAPTCHA wrong
  $response->error( ... );
  die();
}

// if we got this far, everything's OK and the CAPTCHA has been validated

(note: I have done my best to check over this code but please do not copy-paste it into production without peer review)

I know it looks like a lot of code, but 80% of it is just validation logic. I recommend that you're at least that rigorous with validation in your own code.

This solution on its own doesn't solve the problem of figuring out that a user has previously solved a CAPTCHA, though. It only protects individual requests. If you need to implement a mechanism by which the server can tell how long ago the user successfully completed a CAPTCHA, you can store that in client-side state, e.g. along with whatever auth token you might be using (e.g. PASETO, or less preferably JWT), or just in its own cookie. The timestamp of the last solved CAPTCHA should be combined with information about the user, and protected by an authenticity record in the same way I showed for the CAPTCHA's nonce. Confidentiality is not required, so just signing the plaintext timestamp is sufficient. The server can then check that the timestamp info is signed, and that the identifying information matches the incoming request (so that one user/IP can't share their signed timestamp with another to avoid a CAPTCHA trigger) before checking whether the timestamp was short enough ago that another CAPTCHA does not need to be completed.

Tying the CAPTCHA to the same request, user, IP, etc. and providing a validity window is critical in preventing re-use attacks. Without these protections the CAPTCHA can be re-used. With these protections, re-use is limited to the same user/IP making the same type of request within a short token validity window.

0

When the client solves the CAPTCHA, you send them a cryptographically signed token/cookie (perhaps a JWT?) which contains:

  • Their username/unique identifier
  • An expiration timestamp (however long you want the CAPTCHA to be valid for).
  • Optionally their IP address

When they make subsequent requests that include this token (in a cookie or a header or whatever), you check that:

  • The signature is valid
  • The username (and IP?) match the request
  • The expiration time has not yet been reached.

If any of those is not true, then prompt them to complete another CAPTCHA.

4
  • JWT is a poor choice here. Implementations almost exclusively default to MAC signing only, not encryption, which would reveal the CAPTCHA result to the user. It's also kinda fragile due to the encryption and signing configuration being stored in the token itself, rather than selected by the server.
    – Polynomial
    Sep 28, 2021 at 14:44
  • If all you have in the token is a unique ID and an expiration time, it doesn't matter if the user can read it - there's no need for confidentiality. The fact that some people implement JWTs badly doesn't meant that they shouldn't be used - you just need to be careful with them.
    – Gh0stFish
    Sep 28, 2021 at 14:52
  • 1
    But that doesn't solve OP's problem. OP's problem is that they're displaying a CAPTCHA without any way to store the solution to the captcha on the server side.
    – Polynomial
    Sep 28, 2021 at 14:54
  • Perhaps I'm misunderstanding what OP is looking for. It sounded like they were looking for a way to store (client side) the fact that the user had solved a CAPTCHA and to prevent them from being able to indefinitely re-use it.
    – Gh0stFish
    Sep 28, 2021 at 15:00

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .