from base64 import b64encode, b64decode
from hashlib import sha1
import hmac
from random import choice
import string
from six.moves.urllib.parse import urlencode
from six.moves.urllib.request import urlopen
from six.moves import xrange
[docs]class YubiClient10(object):
"""
Client for the Yubico validation service, version 1.0.
http://code.google.com/p/yubikey-val-server-php/wiki/ValidationProtocolV10
:param int api_id: Your API id.
:param bytes api_key: Your base64-encoded API key.
:param bool ssl: ``True`` if we should use https URLs by default.
.. attribute:: base_url
The base URL of the validation service. Set this if you want to use a
custom validation service. Defaults to
``'http[s]://api.yubico.com/wsapi/verify'``.
"""
_NONCE_CHARS = string.ascii_letters + string.digits
def __init__(self, api_id=1, api_key=None, ssl=False):
self.api_id = api_id
self.api_key = api_key
self.ssl = ssl
[docs] def verify(self, token):
"""
Verify a single Yubikey OTP against the validation service.
:param str token: A modhex-encoded YubiKey OTP, as generated by a YubiKey
device.
:returns: A response from the validation service.
:rtype: :class:`YubiResponse`
"""
nonce = self.nonce()
url = self.url(token, nonce)
stream = urlopen(url)
response = YubiResponse(stream.read().decode('utf-8'), self.api_key, token, nonce)
stream.close()
return response
[docs] def url(self, token, nonce=None):
"""
Generates the validation URL without sending a request.
:param str token: A modhex-encoded YubiKey OTP, as generated by a
YubiKey.
:param str nonce: A nonce string, or ``None`` to generate a random one.
:returns: The URL that we would use to validate the token.
:rtype: str
"""
if nonce is None:
nonce = self.nonce()
return '{0}?{1}'.format(self.base_url, self.param_string(token, nonce))
_base_url = None
@property
def base_url(self):
if self._base_url is None:
self._base_url = self.default_base_url()
return self._base_url
@base_url.setter
def base_url(self, url):
self._base_url = url
@base_url.deleter
[docs] def base_url(self):
delattr(self, '_base_url')
def default_base_url(self):
if self.ssl:
return 'https://api.yubico.com/wsapi/verify'
else:
return 'http://api.yubico.com/wsapi/verify'
def nonce(self):
return ''.join(choice(self._NONCE_CHARS) for i in xrange(32))
def param_string(self, token, nonce):
params = self.params(token, nonce)
if self.api_key is not None:
signature = param_signature(params, self.api_key)
params.append(('h', b64encode(signature)))
return urlencode(params)
def params(self, token, nonce):
return [
('id', self.api_id),
('otp', token),
]
[docs]class YubiClient11(YubiClient10):
"""
Client for the Yubico validation service, version 1.1.
http://code.google.com/p/yubikey-val-server-php/wiki/ValidationProtocolV11
:param int api_id: Your API id.
:param bytes api_key: Your base64-encoded API key.
:param bool ssl: ``True`` if we should use https URLs by default.
:param bool timestamp: ``True`` if we want the server to include timestamp
and counter information in the response.
.. attribute:: base_url
The base URL of the validation service. Set this if you want to use a
custom validation service. Defaults to
``'http[s]://api.yubico.com/wsapi/verify'``.
"""
def __init__(self, api_id=1, api_key=None, ssl=False, timestamp=False):
super(YubiClient11, self).__init__(api_id, api_key, ssl)
self.timestamp = timestamp
def params(self, token, nonce):
params = super(YubiClient11, self).params(token, nonce)
if self.timestamp:
params.append(('timestamp', '1'))
return params
[docs]class YubiClient20(YubiClient11):
"""
Client for the Yubico validation service, version 2.0.
http://code.google.com/p/yubikey-val-server-php/wiki/ValidationProtocolV20
:param int api_id: Your API id.
:param bytes api_key: Your base64-encoded API key.
:param bool ssl: ``True`` if we should use https URLs by default.
:param bool timestamp: ``True`` if we want the server to include timestamp
and counter information in the response.
:param sl: See protocol spec.
:param timeout: See protocol spec.
.. attribute:: base_url
The base URL of the validation service. Set this if you want to use a
custom validation service. Defaults to
``'http[s]://api.yubico.com/wsapi/2.0/verify'``.
"""
def __init__(self, api_id=1, api_key=None, ssl=False, timestamp=False, sl=None, timeout=None):
super(YubiClient20, self).__init__(api_id, api_key, ssl, timestamp)
self.sl = sl
self.timeout = timeout
def default_base_url(self):
if self.ssl:
return 'https://api.yubico.com/wsapi/2.0/verify'
else:
return 'http://api.yubico.com/wsapi/2.0/verify'
def params(self, token, nonce):
params = super(YubiClient20, self).params(token, nonce)
params.append(('nonce', nonce))
if self.sl is not None:
params.append(('sl', self.sl))
if self.timeout is not None:
params.append(('timeout', self.timeout))
return params
[docs]class YubiResponse(object):
"""
A response from the Yubico validation service.
.. attribute:: fields
A dictionary of the response fields (excluding 'h').
"""
def __init__(self, raw, api_key, token, nonce):
self.raw = raw
self.api_key = api_key
self.token = token
self.nonce = nonce
self.fields = {}
self.signature = None
self._parse_response()
def _parse_response(self):
self.fields = dict(tuple(line.split('=', 1)) for line in self.raw.splitlines() if '=' in line)
if 'h' in self.fields:
self.signature = b64decode(self.fields['h'].encode())
del self.fields['h']
[docs] def is_ok(self):
"""
Returns true if all validation checks pass and the status is 'OK'.
:rtype: bool
"""
return self.is_valid() and (self.fields.get('status') == 'OK')
[docs] def status(self):
"""
If the response is valid, this returns the value of the status field.
Otherwise, it returns the special status ``'BAD_RESPONSE'``
"""
status = self.fields.get('status')
if status == 'BAD_SIGNATURE' or self.is_valid(strict=False):
return status
else:
return 'BAD_RESPONSE'
[docs] def is_valid(self, strict=True):
"""
Performs all validity checks (signature, token, and nonce).
:param bool strict: If ``True``, all validity checks must pass
unambiguously. Otherwise, this only requires that no validity check
fails.
:returns: ``True`` if none of the validity checks fail.
:rtype: bool
"""
results = [
self.is_signature_valid(),
self.is_token_valid(),
self.is_nonce_valid(),
]
if strict:
is_valid = all(results)
else:
is_valid = False not in results
return is_valid
[docs] def is_signature_valid(self):
"""
Validates the response signature.
:returns: ``True`` if the signature is valid or if we did not sign the
request. ``False`` if the signature is invalid.
:rtype: bool
"""
if self.api_key is not None:
signature = param_signature(self.fields.items(), self.api_key)
is_valid = (signature == self.signature)
else:
is_valid = True
return is_valid
[docs] def is_token_valid(self):
"""
Validates the otp token sent in the response.
:returns: ``True`` if the token in the response is the same as the one
in the request; ``False`` if not; ``None`` if the response does not
contain a token.
:rtype: bool for a positive result or ``None`` for an ambiguous result.
"""
if 'otp' in self.fields:
is_valid = (self.fields['otp'] == self.token)
else:
is_valid = None
return is_valid
[docs] def is_nonce_valid(self):
"""
Validates the nonce value sent in the response.
:returns: ``True`` if the nonce in the response matches the one we sent
(or didn't send). ``False`` if the two do not match. ``None`` if we
sent a nonce and did not receive one in the response: this is often
true of error responses.
:rtype: bool for a positive result or ``None`` for an ambiguous result.
"""
reply = self.fields.get('nonce')
if (self.nonce is not None) and (reply is None):
is_valid = None
else:
is_valid = (reply == self.nonce)
return is_valid
@property
[docs] def public_id(self):
"""
Returns the public id of the response token as a modhex string.
:rtype: str or ``None``.
"""
try:
public_id = self.fields['otp'][:-32]
except KeyError:
public_id = None
return public_id
def param_signature(params, api_key):
"""
Returns the signature over a list of Yubico validation service parameters.
Note that the signature algorithm packs the paramters into a form similar
to URL parameters, but without any escaping.
:param params: An association list of parameters, such as you would give to
urllib.urlencode.
:type params: list of 2-tuples
:param bytes api_key: The Yubico API key (raw, not base64-encoded).
:returns: The parameter signature (raw, not base64-encoded).
:rtype: bytes
"""
param_string = '&'.join('{0}={1}'.format(k, v) for k, v in sorted(params))
signature = hmac.new(api_key, param_string.encode('utf-8'), sha1).digest()
return signature