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