Search code examples
pythonpython-3.xhmacsha1yubico

Generate YubiOTP verification HMAC-SHA-1 signatures in Python


I'm a little bit confused by what I need to do here for Python, but from the Yubikey API documentation for verifying Yubikeys that have YubiOTP the HMAC signature needs to be generated a specific way - from their documentation:

Generating signatures

The protocol uses HMAC-SHA-1 signatures. The HMAC key to use is the client API key.

Generate the signature over the parameters in the message. Each message contains a set of key/value pairs, and the signature is always over the entire set (excluding the signature itself), and sorted in alphabetical order of the keys. More precisely, to generate a message signature do:

  • Alphabetically sort the set of key/value pairs by key order.

  • Construct a single line with each ordered key/value pair concatenated using &, and each key and value contatenated with =. Do not add any linebreaks. Do not add whitespace. For example: a=2&b=1&c=3.

  • Apply the HMAC-SHA-1 algorithm on the line as an octet string using the API key as key (remember to base64decode the API key obtained from Yubico).

  • Base 64 encode the resulting value according to RFC 4648, for example, t2ZMtKeValdA+H0jVpj3LIichn4=.

  • Append the value under key h to the message.

Now my understanding of their API from their documentation states the following valid request parameters:

  • id - the Client ID from Yubico API
  • otp - the YubiOTP value from the YubiOTP component of a yubikey.
  • h - the HMAC-SHA1 signature for the request
  • timestamp - empty does nothing, 1 includes the timestamp in the reply from the server
  • nonce - A 16 to 40 character long string with random unique data.
  • sl - a value of 0 to 100 indicating percentage of syncing required by client, or strings "fast" or "Secure" to use server values; if nonexistent server decides
  • timeout - # of seconds to wait for sync responses; let server decide if absent.

I have a total of two functions I'm trying to use to try and handle all these things and generate the URL. Namely, we the HMAC support function and the verify_url_generate which generates the URL (and API_KEY is statically coded - my API Secret Key from Yubico):

def generate_signature(message, key=base64.b64decode(API_KEY)):
    message = bytes(message, 'UTF-8')

    digester = hmac.new(key, message, hashlib.sha1)
    digest = digester.digest()

    signature = base64.urlsafe_b64encode(digest)

    return str(signature, 'UTF-8')


def verify_url_generate(otp):
    nonce = "".join(secrets.choice(ascii_lowercase) for _ in range(40))

    data = OrderedDict(
        {
            "id": None,
            "nonce": None,
            "otp": None,
            "sl": 50,
            "timeout": 10,
            "timestamp": 1
        }
    )

    data['otp'] = otp
    data['id'] = CLIENT_ID
    data['nonce'] = nonce

    args = ""

    for key, value in data.items():
        args += f"{key}={value}&"

    sig = generate_signature(args[:-1])

    url = YUBICO_API_URL + args + "&h=" + sig

    print(url)
    return

Any URL generated by this triggers a notice about "BAD_SIGNATURE" from the remote site - any URL generated minus the HMAC sig (h=) parameter works. So we know the issue isn't the URL, it's the HMAC signature.

Does anyone know what I'm doing wrong with my HMAC generation approach, by passing the HMAC sig generator the concatenated args from the ordered dict in key=value separated by & for each parameter format?


Solution

  • Can you try using standard_b64encode and then using urllib.parse.quote(url) in your final URL?

    I ask because this page says that "As such, all parameters must be properly URL encoded. In particular, some base64 characters (such as "+") in the value fields needs to be escaped." which means it is expecting +(or %2B) in the args and does a unquote and then normal decode.