1from base64 import b64decode, b64encode
2from hashlib import sha1
3import hmac
4from random import choice
5import string
6from urllib.parse import urlencode
7from urllib.request import urlopen
8
9
10class YubiClient10(object):
11    """
12    Client for the Yubico validation service, version 1.0.
13
14    http://code.google.com/p/yubikey-val-server-php/wiki/ValidationProtocolV10
15
16    :param int api_id: Your API id.
17    :param bytes api_key: Your base64-encoded API key.
18    :param bool ssl: ``True`` if we should use https URLs by default.
19
20    .. attribute:: base_url
21
22        The base URL of the validation service. Set this if you want to use a
23        custom validation service. Defaults to
24        ``'http[s]://api.yubico.com/wsapi/verify'``.
25    """
26    _NONCE_CHARS = string.ascii_letters + string.digits
27
28    def __init__(self, api_id=1, api_key=None, ssl=False):
29        self.api_id = api_id
30        self.api_key = api_key
31        self.ssl = ssl
32
33    def verify(self, token):
34        """
35        Verify a single Yubikey OTP against the validation service.
36
37        :param str token: A modhex-encoded YubiKey OTP, as generated by a YubiKey
38            device.
39
40        :returns: A response from the validation service.
41        :rtype: :class:`YubiResponse`
42        """
43        nonce = self.nonce()
44
45        url = self.url(token, nonce)
46        stream = urlopen(url)
47        response = YubiResponse(stream.read().decode('utf-8'), self.api_key, token, nonce)
48        stream.close()
49
50        return response
51
52    def url(self, token, nonce=None):
53        """
54        Generates the validation URL without sending a request.
55
56        :param str token: A modhex-encoded YubiKey OTP, as generated by a
57            YubiKey.
58        :param str nonce: A nonce string, or ``None`` to generate a random one.
59
60        :returns: The URL that we would use to validate the token.
61        :rtype: str
62        """
63        if nonce is None:
64            nonce = self.nonce()
65
66        return '{0}?{1}'.format(self.base_url, self.param_string(token, nonce))
67
68    _base_url = None
69
70    @property
71    def base_url(self):
72        if self._base_url is None:
73            self._base_url = self.default_base_url()
74
75        return self._base_url
76
77    @base_url.setter
78    def base_url(self, url):
79        self._base_url = url
80
81    @base_url.deleter
82    def base_url(self):
83        delattr(self, '_base_url')
84
85    def default_base_url(self):
86        if self.ssl:
87            return 'https://api.yubico.com/wsapi/verify'
88        else:
89            return 'http://api.yubico.com/wsapi/verify'
90
91    def nonce(self):
92        return ''.join(choice(self._NONCE_CHARS) for i in range(32))
93
94    def param_string(self, token, nonce):
95        params = self.params(token, nonce)
96
97        if self.api_key is not None:
98            signature = param_signature(params, self.api_key)
99            params.append(('h', b64encode(signature)))
100
101        return urlencode(params)
102
103    def params(self, token, nonce):
104        return [
105            ('id', self.api_id),
106            ('otp', token),
107        ]
108
109
110class YubiClient11(YubiClient10):
111    """
112    Client for the Yubico validation service, version 1.1.
113
114    http://code.google.com/p/yubikey-val-server-php/wiki/ValidationProtocolV11
115
116    :param int api_id: Your API id.
117    :param bytes api_key: Your base64-encoded API key.
118    :param bool ssl: ``True`` if we should use https URLs by default.
119    :param bool timestamp: ``True`` if we want the server to include timestamp
120        and counter information in the response.
121
122    .. attribute:: base_url
123
124        The base URL of the validation service. Set this if you want to use a
125        custom validation service. Defaults to
126        ``'http[s]://api.yubico.com/wsapi/verify'``.
127    """
128    def __init__(self, api_id=1, api_key=None, ssl=False, timestamp=False):
129        super(YubiClient11, self).__init__(api_id, api_key, ssl)
130
131        self.timestamp = timestamp
132
133    def params(self, token, nonce):
134        params = super(YubiClient11, self).params(token, nonce)
135
136        if self.timestamp:
137            params.append(('timestamp', '1'))
138
139        return params
140
141
142class YubiClient20(YubiClient11):
143    """
144    Client for the Yubico validation service, version 2.0.
145
146    http://code.google.com/p/yubikey-val-server-php/wiki/ValidationProtocolV20
147
148    :param int api_id: Your API id.
149    :param bytes api_key: Your base64-encoded API key.
150    :param bool ssl: ``True`` if we should use https URLs by default.
151    :param bool timestamp: ``True`` if we want the server to include timestamp
152        and counter information in the response.
153    :param sl: See protocol spec.
154    :param timeout: See protocol spec.
155
156    .. attribute:: base_url
157
158        The base URL of the validation service. Set this if you want to use a
159        custom validation service. Defaults to
160        ``'http[s]://api.yubico.com/wsapi/2.0/verify'``.
161    """
162    def __init__(self, api_id=1, api_key=None, ssl=False, timestamp=False, sl=None, timeout=None):
163        super(YubiClient20, self).__init__(api_id, api_key, ssl, timestamp)
164
165        self.sl = sl
166        self.timeout = timeout
167
168    def default_base_url(self):
169        if self.ssl:
170            return 'https://api.yubico.com/wsapi/2.0/verify'
171        else:
172            return 'http://api.yubico.com/wsapi/2.0/verify'
173
174    def params(self, token, nonce):
175        params = super(YubiClient20, self).params(token, nonce)
176
177        params.append(('nonce', nonce))
178
179        if self.sl is not None:
180            params.append(('sl', self.sl))
181
182        if self.timeout is not None:
183            params.append(('timeout', self.timeout))
184
185        return params
186
187
188class YubiResponse(object):
189    """
190    A response from the Yubico validation service.
191
192    .. attribute:: fields
193
194        A dictionary of the response fields (excluding 'h').
195    """
196    def __init__(self, raw, api_key, token, nonce):
197        self.raw = raw
198        self.api_key = api_key
199        self.token = token
200        self.nonce = nonce
201
202        self.fields = {}
203        self.signature = None
204
205        self._parse_response()
206
207    def _parse_response(self):
208        self.fields = dict(tuple(line.split('=', 1)) for line in self.raw.splitlines() if '=' in line)
209
210        if 'h' in self.fields:
211            self.signature = b64decode(self.fields['h'].encode())
212            del self.fields['h']
213
214    def is_ok(self):
215        """
216        Returns true if all validation checks pass and the status is 'OK'.
217
218        :rtype: bool
219        """
220        return self.is_valid() and (self.fields.get('status') == 'OK')
221
222    def status(self):
223        """
224        If the response is valid, this returns the value of the status field.
225        Otherwise, it returns the special status ``'BAD_RESPONSE'``
226        """
227        status = self.fields.get('status')
228
229        if status == 'BAD_SIGNATURE' or self.is_valid(strict=False):
230            return status
231        else:
232            return 'BAD_RESPONSE'
233
234    def is_valid(self, strict=True):
235        """
236        Performs all validity checks (signature, token, and nonce).
237
238        :param bool strict: If ``True``, all validity checks must pass
239            unambiguously. Otherwise, this only requires that no validity check
240            fails.
241        :returns: ``True`` if none of the validity checks fail.
242        :rtype: bool
243        """
244        results = [
245            self.is_signature_valid(),
246            self.is_token_valid(),
247            self.is_nonce_valid(),
248        ]
249
250        if strict:
251            is_valid = all(results)
252        else:
253            is_valid = False not in results
254
255        return is_valid
256
257    def is_signature_valid(self):
258        """
259        Validates the response signature.
260
261        :returns: ``True`` if the signature is valid or if we did not sign the
262            request. ``False`` if the signature is invalid.
263        :rtype: bool
264        """
265        if self.api_key is not None:
266            signature = param_signature(self.fields.items(), self.api_key)
267            is_valid = (signature == self.signature)
268        else:
269            is_valid = True
270
271        return is_valid
272
273    def is_token_valid(self):
274        """
275        Validates the otp token sent in the response.
276
277        :returns: ``True`` if the token in the response is the same as the one
278            in the request; ``False`` if not; ``None`` if the response does not
279            contain a token.
280        :rtype: bool for a positive result or ``None`` for an ambiguous result.
281        """
282        if 'otp' in self.fields:
283            is_valid = (self.fields['otp'] == self.token)
284        else:
285            is_valid = None
286
287        return is_valid
288
289    def is_nonce_valid(self):
290        """
291        Validates the nonce value sent in the response.
292
293        :returns: ``True`` if the nonce in the response matches the one we sent
294            (or didn't send). ``False`` if the two do not match. ``None`` if we
295            sent a nonce and did not receive one in the response: this is often
296            true of error responses.
297        :rtype: bool for a positive result or ``None`` for an ambiguous result.
298        """
299        reply = self.fields.get('nonce')
300
301        if (self.nonce is not None) and (reply is None):
302            is_valid = None
303        else:
304            is_valid = (reply == self.nonce)
305
306        return is_valid
307
308    @property
309    def public_id(self):
310        """
311        Returns the public id of the response token as a modhex string.
312
313        :rtype: str or ``None``.
314        """
315        try:
316            public_id = self.fields['otp'][:-32]
317        except KeyError:
318            public_id = None
319
320        return public_id
321
322
323def param_signature(params, api_key):
324    """
325    Returns the signature over a list of Yubico validation service parameters.
326    Note that the signature algorithm packs the paramters into a form similar
327    to URL parameters, but without any escaping.
328
329    :param params: An association list of parameters, such as you would give to
330        urllib.urlencode.
331    :type params: list of 2-tuples
332
333    :param bytes api_key: The Yubico API key (raw, not base64-encoded).
334
335    :returns: The parameter signature (raw, not base64-encoded).
336    :rtype: bytes
337    """
338    param_string = '&'.join('{0}={1}'.format(k, v) for k, v in sorted(params))
339    signature = hmac.new(api_key, param_string.encode('utf-8'), sha1).digest()
340
341    return signature
342