commit 2d16b608449be112023cbeb3bccfe901502c5e44 Author: Isis Lovecruft isis@torproject.org Date: Wed May 14 21:54:39 2014 +0000
Add a ScheduledInterval timeout to make GimpCaptcha stale.
* FIXES #11215 which describes a problem where a successful challenge string and CAPTCHA solution pair can be replayed to the server, once per bridge hashring rotation period (every three hours) to get a new set of bridges without solving a CAPTCHA.
* ADD a `GimpCaptcha.sched` attribute which holds a `bridgedb.schedule.ScheduledInterval` for determining which time period the CAPTCHA was created in, as well as for determining if we're still in the same time period during CAPTCHA solution verification.
* ADD a padded timestamp to the encrypted bloc, ENC_BLOB, inside the format of the GimpCaptcha.challenge strings.
* ADD a captcha.CaptchaExpired exception which is raised when we're checking a CAPTCHA solution that is no longer in the allowed time interval. --- lib/bridgedb/HTTPServer.py | 12 +++++-- lib/bridgedb/captcha.py | 79 ++++++++++++++++++++++++++++++++------------ 2 files changed, 67 insertions(+), 24 deletions(-)
diff --git a/lib/bridgedb/HTTPServer.py b/lib/bridgedb/HTTPServer.py index 8289be3..9d76e28 100644 --- a/lib/bridgedb/HTTPServer.py +++ b/lib/bridgedb/HTTPServer.py @@ -313,14 +313,20 @@ class GimpCaptchaProtectedResource(CaptchaProtectedResource): :rtupe: bool :returns: True, if the CAPTCHA solution was valid; False otherwise. """ + valid = False challenge, solution = self.extractClientSolution(request) clientIP = self.getClientIP(request) clientHMACKey = crypto.getHMAC(self.hmacKey, clientIP) - valid = captcha.GimpCaptcha.check(challenge, solution, - self.secretKey, clientHMACKey) + + try: + valid = captcha.GimpCaptcha.check(challenge, solution, + self.secretKey, clientHMACKey) + except captcha.CaptchaExpired as error: + logging.warn(error) + valid = False + logging.debug("%sorrect captcha from %r: %r." % ("C" if valid else "Inc", clientIP, solution)) - return valid
def getCaptchaImage(self, request): diff --git a/lib/bridgedb/captcha.py b/lib/bridgedb/captcha.py index e10bc81..758c1f3 100644 --- a/lib/bridgedb/captcha.py +++ b/lib/bridgedb/captcha.py @@ -39,6 +39,7 @@ _ GimpCaptcha - Class for obtaining a CAPTCHA from a local cache. |- hmacKey - A client-specific key for HMAC generation. |- cacheDir - The path to the local CAPTCHA cache directory. + |- sched - A class for timing out CAPTCHAs after an interval. _ get() - Get a CAPTCHA image from the cache and create a challenge.
.. @@ -58,6 +59,7 @@ from base64 import urlsafe_b64decode import logging import random import os +import time import urllib2
from BeautifulSoup import BeautifulSoup @@ -65,9 +67,13 @@ from BeautifulSoup import BeautifulSoup from zope.interface import Interface, Attribute, implements
from bridgedb import crypto +from bridgedb import schedule from bridgedb.txrecaptcha import API_SSL_SERVER
+class CaptchaExpired(ValueError): + """Raised when a client's CAPTCHA is too stale.""" + class CaptchaKeyError(Exception): """Raised if a CAPTCHA system's keys are invalid or missing."""
@@ -200,6 +206,8 @@ class GimpCaptcha(Captcha): .. _gimp-captcha: https://github.com/isislovecruft/gimp-captcha """
+ sched = schedule.ScheduledInterval('minutes', 30) + def __init__(self, publicKey=None, secretKey=None, hmacKey=None, cacheDir=None): """Create a ``GimpCaptcha`` which retrieves images from **cacheDir**. @@ -242,37 +250,52 @@ class GimpCaptcha(Captcha): :param str secretKey: A PKCS#1 OAEP-padded, private RSA key, used for verifying the client's solution to the CAPTCHA. :param bytes hmacKey: A private key for generating HMACs. + :raises CaptchaExpired: if the **solution** was for a stale CAPTCHA. :rtype: bool - :returns: True if the CAPTCHA solution was correct. + :returns: ``True`` if the CAPTCHA solution was correct and not + stale. ``False`` otherwise. """ - validHMAC = False + hmacIsValid = False
if not solution: - return validHMAC + return hmacIsValid
logging.debug("Checking CAPTCHA solution %r against challenge %r" % (solution, challenge)) try: decoded = urlsafe_b64decode(challenge) - hmac = decoded[:20] - original = decoded[20:] - verified = crypto.getHMAC(hmacKey, original) - validHMAC = verified == hmac + hmacFromBlob = decoded[:20] + encBlob = decoded[20:] + hmacNew = crypto.getHMAC(hmacKey, encBlob) + hmacIsValid = hmacNew == hmacFromBlob except Exception: return False finally: - if validHMAC: + if hmacIsValid: try: - decrypted = secretKey.decrypt(original) + answerBlob = secretKey.decrypt(encBlob) + + timestamp = answerBlob[:12].lstrip('0') + then = cls.sched.nextIntervalStarts(int(timestamp)) + now = int(time.time()) + answer = answerBlob[12:] except Exception as error: logging.warn(error.message) else: - if solution.lower() == decrypted.lower(): + # If the beginning of the 'next' interval (the interval + # after the one when the CAPTCHA timestamp was created) + # has already passed, then the CAPTCHA is stale. + if now >= then: + exp = schedule.fromUnixSeconds(then).isoformat(sep=' ') + raise CaptchaExpired("Solution %r was for a CAPTCHA " + "which already expired at %s." + % (solution, exp)) + if solution.lower() == answer.lower(): return True return False
def createChallenge(self, answer): - """Encrypt-then-HMAC the CAPTCHA **answer**. + """Encrypt-then-HMAC a timestamp plus the CAPTCHA **answer**.
A challenge string consists of a URL-safe, base64-encoded string which contains an ``HMAC`` concatenated with an ``ENC_BLOB``, in the @@ -295,34 +318,48 @@ class GimpCaptcha(Captcha): | | applying :func:`~crypto.getHMAC` to the | | | | ``ENC_BLOB``. | | +-------------+--------------------------------------------+----------+ - | ENC_BLOB | An encrypted ``ANSWER``, created with | varies | + | ENC_BLOB | An encrypted ``ANSWER_BLOB``, created with | varies | | | a PKCS#1 OAEP-padded RSA :ivar:`publicKey`.| | +-------------+--------------------------------------------+----------+ + | ANSWER_BLOB | Contains the concatenated ``TIMESTAMP`` | varies | + | | and ``ANSWER``. | | + +-------------+--------------------------------------------+----------+ + | TIMESTAMP | A Unix Epoch timestamp, in seconds, | 12 bytes | + | | left-padded with "0"s. | | + +-------------+--------------------------------------------+----------+ | ANSWER | A string containing answer to this | 8 bytes | | | CAPTCHA :ivar:`image`. | | +-------------+--------------------------------------------+----------+
The steps taken to produce a ``CHALLENGE`` are then:
- 1. Encrypt the ``ANSWER`` to :ivar:`publicKey` to create - the ``ENC_BLOB``. + 1. Create a ``TIMESTAMP``, and pad it on the left with ``0``s to 12 + bytes in length. + + 2. Next, take the **answer** to this CAPTCHA :ivar:`image: and + concatenate the padded ``TIMESTAMP`` and the ``ANSWER``, forming + an ``ANSWER_BLOB``. + + 3. Encrypt the resulting ``ANSWER_BLOB`` to :ivar:`publicKey` to + create the ``ENC_BLOB``.
- 2. Use the client-specific :ivar:`hmacKey` to apply the + 4. Use the client-specific :ivar:`hmacKey` to apply the :func:`~crypto.getHMAC` function to the ``ENC_BLOB``, obtaining an ``HMAC``.
- 3. Create the final ``CHALLENGE`` string by concatenating the + 5. Create the final ``CHALLENGE`` string by concatenating the ``HMAC`` and ``ENC_BLOB``, then base64-encoding the result.
:param str answer: The answer to a CAPTCHA. :rtype: str :returns: A challenge string. """ - encrypted = self.publicKey.encrypt(answer) - hmac = crypto.getHMAC(self.hmacKey, encrypted) - challenge = hmac + encrypted - encoded = urlsafe_b64encode(challenge) - return encoded + timestamp = str(int(time.time())).zfill(12) + blob = timestamp + answer + encBlob = self.publicKey.encrypt(blob) + hmac = crypto.getHMAC(self.hmacKey, encBlob) + challenge = urlsafe_b64encode(hmac + encBlob) + return challenge
def get(self): """Get a random CAPTCHA from the cache directory.
tor-commits@lists.torproject.org