"Fossies" - the Fresh Open Source Software Archive

Member "LinOTP-release-2.10.5.2/linotpd/src/linotp/tokens/qrtoken/qrtoken.py" (13 May 2019, 32703 Bytes) of package /linux/misc/LinOTP-release-2.10.5.2.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Python source code syntax highlighting (style: standard) with prefixed line numbers. Alternatively you can here view or download the uninterpreted source code file. For more information about "qrtoken.py" see the Fossies "Dox" file reference documentation.

    1 # -*- coding: utf-8 -*-
    2 #
    3 #    LinOTP - the open source solution for two factor authentication
    4 #    Copyright (C) 2010 - 2019 KeyIdentity GmbH
    5 #
    6 #    This file is part of LinOTP server.
    7 #
    8 #    This program is free software: you can redistribute it and/or
    9 #    modify it under the terms of the GNU Affero General Public
   10 #    License, version 3, as published by the Free Software Foundation.
   11 #
   12 #    This program is distributed in the hope that it will be useful,
   13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
   14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   15 #    GNU Affero General Public License for more details.
   16 #
   17 #    You should have received a copy of the
   18 #               GNU Affero General Public License
   19 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
   20 #
   21 #
   22 #    E-mail: linotp@keyidentity.com
   23 #    Contact: www.linotp.org
   24 #    Support: www.keyidentity.com
   25 #
   26 
   27 import logging
   28 import struct
   29 import zlib
   30 from os import urandom
   31 from base64 import b64encode
   32 from base64 import b64decode
   33 from pylons import config
   34 from pysodium import crypto_scalarmult_curve25519 as calc_dh
   35 from pysodium import crypto_scalarmult_curve25519_base as calc_dh_base
   36 from Cryptodome.Cipher import AES
   37 
   38 from hashlib import sha256
   39 
   40 from linotp.lib.policy import get_partition
   41 from linotp.lib.policy import get_single_auth_policy
   42 from linotp.lib.challenges import Challenges
   43 from linotp.lib.challenges import transaction_id_to_u64
   44 from linotp.lib.reply import create_img
   45 from linotp.tokens.base import TokenClass
   46 from linotp.tokens.base.stateful_mixin import StatefulTokenMixin
   47 from linotp.lib.token import get_token_owner
   48 from linotp.tokens import tokenclass_registry
   49 
   50 from linotp.lib.crypto import zerome
   51 from linotp.lib.crypto import extract_tan
   52 from linotp.lib.crypto import encode_base64_urlsafe
   53 from linotp.lib.crypto import decode_base64_urlsafe
   54 from linotp.lib.config import getFromConfig
   55 from linotp.lib.error import InvalidFunctionParameter
   56 from linotp.lib.error import ParameterError
   57 from linotp.lib.pairing import generate_pairing_url
   58 
   59 # --------------------------------------------------------------------------- --
   60 
   61 try:
   62 
   63     from hmac import compare_digest
   64 
   65 except ImportError:
   66 
   67     # for python version < 2.7.7
   68 
   69     def compare_digest(a, b):
   70 
   71         if len(a) != len(b):
   72             return False
   73 
   74         result = 0
   75         for letter_a, letter_b in zip(a, b):
   76             result |= ord(letter_a) ^ ord(letter_b)
   77 
   78         return result == 0
   79 
   80 # --------------------------------------------------------------------------- --
   81 
   82 from linotp.lib.context import request_context as context
   83 
   84 
   85 log = logging.getLogger(__name__)
   86 
   87 FLAG_COMP = 0
   88 FLAG_CBURL = 1
   89 FLAG_CBSMS = 2
   90 FLAG_SRVSIG = 3
   91 
   92 CHALLENGE_HAS_COMPRESSION = 1
   93 CHALLENGE_HAS_URL = 2
   94 CHALLENGE_HAS_SMS_NUMBER = 4
   95 CHALLENGE_HAS_SIGNATURE = 8
   96 CHALLENGE_SHOULD_RESET_URL = 16
   97 
   98 CONTENT_TYPE_FREE = 0
   99 CONTENT_TYPE_PAIRING = 1
  100 CONTENT_TYPE_AUTH = 2
  101 
  102 QRTOKEN_VERSION = 1
  103 
  104 
  105 @tokenclass_registry.class_entry('qr')
  106 @tokenclass_registry.class_entry('linotp.tokens.qrtoken.QrTokenClass')
  107 class QrTokenClass(TokenClass, StatefulTokenMixin):
  108 
  109     """
  110 
  111     """
  112 
  113     def __init__(self, token_model_object):
  114         TokenClass.__init__(self, token_model_object)
  115         self.setType(u'qr')
  116         self.mode = ['challenge']
  117         self.supports_offline_mode = True
  118 
  119 # --------------------------------------------------------------------------- --
  120 
  121     def isActive(self):
  122 
  123         # overwritten, because QrTokenClass can receive validate
  124         # requests in 2 different states: pairing_finished (active
  125         # flag is 1) and pairing_challenge_sent (active flag is 0)
  126 
  127         is_completely_finished = TokenClass.isActive(self)
  128         return is_completely_finished or \
  129             self.current_state == 'pairing_response_received' or \
  130             self.current_state == 'pairing_challenge_sent'
  131 
  132 # --------------------------------------------------------------------------- --
  133 
  134     def get_enrollment_status(self):
  135         """ provide token enrollment status"""
  136 
  137         is_completely_finished = TokenClass.isActive(self)
  138 
  139         if is_completely_finished:
  140             return {'status': 'completed'}
  141         else:
  142             return {'status': 'not completed',
  143                     'detail': self.current_state}
  144 
  145 # --------------------------------------------------------------------------- --
  146 
  147 # type identifier interface
  148 
  149     @classmethod
  150     def getClassType(cls):
  151         return "qr"
  152 
  153     @classmethod
  154     def getClassPrefix(cls):
  155         # OATH standard compliant prefix: XXYY XX= vendor, YY - token type
  156         return "LSQR"
  157 
  158 # --------------------------------------------------------------------------- --
  159 
  160     # info interface definition
  161 
  162     @classmethod
  163     def getClassInfo(cls, key=None, ret='all'):
  164 
  165         _ = context['translate']
  166 
  167         info = {'type': 'qr', 'title': _('QRToken')}
  168 
  169         info['description'] = 'Challenge-Response-Token - Curve 25519 based'
  170 
  171         # ------------------------------------------------------------------- --
  172 
  173         info['policy'] = {}
  174 
  175         auth_policies = {}
  176 
  177         for policy_name in ['qrtoken_pairing_callback_url',
  178                             'qrtoken_pairing_callback_sms',
  179                             'qrtoken_challenge_callback_url',
  180                             'qrtoken_challenge_callback_sms']:
  181 
  182             auth_policies[policy_name] = {'type': 'str'}
  183 
  184         info['policy']['authentication'] = auth_policies
  185 
  186         info['policy']['selfservice'] = {'activate_QRToken':
  187                                          {'type': 'bool',
  188                                           'description': _('activate your '
  189                                                            'QRToken')}
  190                                          }
  191 
  192         # ------------------------------------------------------------------- --
  193 
  194         # wire the templates
  195 
  196         init_dict = {}
  197         init_dict['title'] = {'html': 'qrtoken.mako', 'scope': 'enroll.title'}
  198         init_dict['page'] = {'html': 'qrtoken.mako', 'scope': 'enroll'}
  199         info['init'] = init_dict
  200 
  201         config_dict = {}
  202         config_dict['title'] = {
  203             'html': 'qrtoken.mako', 'scope': 'config.title'}
  204         config_dict['page'] = {'html': 'qrtoken.mako', 'scope': 'config'}
  205         info['config'] = config_dict
  206 
  207         ss_enroll = {}
  208         ss_enroll['title'] = {'html': 'qrtoken.mako',
  209                               'scope': 'selfservice.title.enroll'}
  210         ss_enroll['page'] = {'html': 'qrtoken.mako',
  211                              'scope': 'selfservice.enroll'}
  212 
  213         ss_activate = {}
  214         ss_activate['title'] = {'html': 'qrtoken.mako',
  215                                 'scope': 'selfservice.title.activate'}
  216         ss_activate['page'] = {'html': 'qrtoken.mako',
  217                                'scope': 'selfservice.activate'}
  218 
  219         selfservice_dict = {}
  220         selfservice_dict['enroll'] = ss_enroll
  221         selfservice_dict['activate_QRToken'] = ss_activate
  222 
  223         info['selfservice'] = selfservice_dict
  224 
  225         # ------------------------------------------------------------------- --
  226 
  227         if key is not None:
  228             return info.get(key)
  229 
  230         return info
  231 
  232 # --------------------------------------------------------------------------- --
  233 
  234     def pair(self, pairing_data):
  235 
  236         """
  237         transfers the token to a paired state using the supplied
  238         data from the pairing response
  239 
  240         :param pairing_data: A QRTokenPairingData object
  241         """
  242 
  243         user_token_id = pairing_data.user_token_id
  244         user_public_key = pairing_data.user_public_key
  245 
  246         self.ensure_state('pairing_url_sent')
  247 
  248         self.addToTokenInfo('user_token_id', user_token_id)
  249         b64_user_public_key = b64encode(user_public_key)
  250         self.addToTokenInfo('user_public_key', b64_user_public_key)
  251 
  252         self.change_state('pairing_response_received')
  253 
  254 # --------------------------------------------------------------------------- --
  255 
  256     def unpair(self):
  257 
  258         """
  259         resets the stage to 'pairing_url_sent' so the token can be
  260         paired again.
  261         """
  262 
  263         self.removeFromTokenInfo('user_token_id')
  264         self.removeFromTokenInfo('user_public_key')
  265         self.change_state('pairing_url_sent')
  266 
  267 # --------------------------------------------------------------------------- --
  268 
  269     def splitPinPass(self, passw):
  270 
  271         # we split differently here, because we support pins, but no otp
  272         # so an incoming request with passw but without transaction_id
  273         # is a request with a pin
  274 
  275         return (passw, '')
  276 
  277 # --------------------------------------------------------------------------- --
  278 
  279     def create_challenge_url(self, transaction_id, content_type, message,
  280                              callback_url, callback_sms_number,
  281                              use_compression=False, reset_url=False):
  282         """
  283         creates a challenge url (looking like lseqr://chal/<base64string>)
  284         from a challenge dictionary as provided by Challanges.create_challenge
  285         in lib.challenge
  286 
  287         the version identifier of the challenge url is currently hardcoded
  288         to 1.
  289         """
  290 
  291         serial = self.getSerial()
  292 
  293         if content_type is None:
  294             content_type = CONTENT_TYPE_FREE
  295 
  296         # ------------------------------------------------------------------- --
  297 
  298         # sanity/format checks
  299 
  300         if content_type not in [CONTENT_TYPE_PAIRING,
  301                                 CONTENT_TYPE_AUTH, CONTENT_TYPE_FREE]:
  302             raise InvalidFunctionParameter('content_type', 'content_type must '
  303                                            'be CONTENT_TYPE_PAIRING, '
  304                                            'CONTENT_TYPE_AUTH or '
  305                                            'CONTENT_TYPE_FREE.')
  306 
  307         if content_type == CONTENT_TYPE_PAIRING and \
  308            message != serial:
  309             raise InvalidFunctionParameter('message', 'message must be equal '
  310                                            'to serial in pairing mode')
  311 
  312         if content_type == CONTENT_TYPE_AUTH:
  313             if '@' not in message:
  314                 raise InvalidFunctionParameter('message', 'For content type '
  315                                                'auth, message must have format '
  316                                                '<login>@<server>')
  317 
  318         # ------------------------------------------------------------------- --
  319 
  320         #  after the lseqr://chal/ prefix the following data is encoded
  321         #  in urlsafe base64:
  322 
  323         #            ---------------------------------------------------
  324         #  fields   | version | user token id |  R  | ciphertext | MAC |
  325         #            ---------------------------------------------------
  326         #           |          header         |     |    EAX enc data  |
  327         #            ---------------------------------------------------
  328         #  size     |    1    |       4       |  32 |      ?     | 16  |
  329         #            ---------------------------------------------------
  330         #
  331 
  332         r = urandom(32)
  333         R = calc_dh_base(r)
  334 
  335         user_token_id = self.getFromTokenInfo('user_token_id')
  336         data_header = struct.pack('<bI', QRTOKEN_VERSION, user_token_id)
  337 
  338         # the user public key is saved as base64 in
  339         # the token info since the byte format is
  340         # incompatible with the json backend.
  341 
  342         b64_user_public_key = self.getFromTokenInfo('user_public_key')
  343         user_public_key = b64decode(b64_user_public_key)
  344 
  345         ss = calc_dh(r, user_public_key)
  346         U1 = sha256(ss).digest()
  347         U2 = sha256(U1).digest()
  348         zerome(ss)
  349 
  350         skA = U1[0:16]
  351         skB = U2[0:16]
  352         nonce = U2[16:32]
  353         zerome(U1)
  354         zerome(U2)
  355 
  356         # ------------------------------------------------------------------- --
  357 
  358         # create plaintext section
  359 
  360         # ------------------------------------------------------------------- --
  361 
  362         # create the bitmap for flags
  363 
  364         flags = 0
  365 
  366         if use_compression:
  367             flags |= CHALLENGE_HAS_COMPRESSION
  368 
  369         # FIXME: sizecheck for message, callback url, sms number
  370         # wiki specs are utf-8 byte length (without \0)
  371 
  372         if callback_url is not None:
  373             flags |= CHALLENGE_HAS_URL
  374 
  375         if callback_sms_number is not None:
  376             flags |= CHALLENGE_HAS_SMS_NUMBER
  377 
  378         if (content_type == CONTENT_TYPE_PAIRING):
  379             flags |= CHALLENGE_HAS_SIGNATURE
  380 
  381         if reset_url:
  382             flags |= CHALLENGE_SHOULD_RESET_URL
  383             flags |= CHALLENGE_HAS_SIGNATURE
  384 
  385         # ------------------------------------------------------------------- --
  386 
  387         # generate plaintext header
  388 
  389         #            ----------------------------------------------
  390         #  fields   | content_type  | flags | transaction_id | ... |
  391         #            ----------------------------------------------
  392         #  size     |       1       |   1   |        8       |  ?  |
  393         #            ----------------------------------------------
  394 
  395         transaction_id = transaction_id_to_u64(transaction_id)
  396         pt_header = struct.pack('<bbQ', content_type, flags, transaction_id)
  397         plaintext = pt_header
  398 
  399         # ------------------------------------------------------------------- --
  400 
  401         # create data package
  402 
  403         #            -------------------------------
  404         #  fields   | header  | message | NUL | ... |
  405         #            -------------------------------
  406         #  size     |   10    |    ?    |  1  |  ?  |
  407         #            -------------------------------
  408 
  409         data_package = b''
  410         utf8_message = message.encode('utf8')
  411 
  412         # enforce max sizes specified by protocol
  413 
  414         if content_type == CONTENT_TYPE_FREE and len(utf8_message) > 511:
  415             raise ParameterError('message (encoded as utf8) can only be 511 '
  416                                  'characters long')
  417 
  418         elif content_type == CONTENT_TYPE_PAIRING and len(utf8_message) > 63:
  419             raise InvalidFunctionParameter('message', 'max string length '
  420                                            '(encoded as utf8) is 511 for '
  421                                            'content type PAIRING')
  422 
  423         elif content_type == CONTENT_TYPE_AUTH and len(utf8_message) > 511:
  424             raise InvalidFunctionParameter('message', 'max string length '
  425                                            '(encoded as utf8) is 511 for '
  426                                            'content type AUTH')
  427 
  428         data_package += utf8_message + b'\x00'
  429 
  430         # ------------------------------------------------------------------- --
  431 
  432         # depending on function parameters add callback url
  433         # and/or callback sms number
  434 
  435         #            -----------------------------------------------------
  436         #  fields   | ... | callback url | NUL | callback sms | NUL | ... |
  437         #            -----------------------------------------------------
  438         #  size     |  ?  |       ?      |  1  |       ?      |  1  |  ?  |
  439         #            -----------------------------------------------------
  440 
  441         # ------------------------------------------------------------------- --
  442 
  443         if callback_url is not None:
  444 
  445             utf8_callback_url = callback_url.encode('utf8')
  446 
  447             # enforce max url length as specified in protocol
  448 
  449             if len(utf8_callback_url) > 511:
  450                 raise InvalidFunctionParameter('callback_url', 'max string '
  451                                                'length (encoded as utf8) is '
  452                                                '511')
  453 
  454             data_package += utf8_callback_url + b'\x00'
  455 
  456         # ------------------------------------------------------------------- --
  457 
  458         if callback_sms_number is not None:
  459 
  460             utf8_callback_sms_number = callback_sms_number.encode('utf8')
  461 
  462             if len(utf8_callback_sms_number) > 31:
  463                 raise InvalidFunctionParameter('callback_sms_number',
  464                                                'max string length (encoded '
  465                                                'as utf8) is 31')
  466 
  467             data_package += utf8_callback_sms_number + b'\x00'
  468 
  469         # ------------------------------------------------------------------- --
  470 
  471         if use_compression:
  472             maybe_compressed_data_package = zlib.compress(data_package, 9)
  473         else:
  474             maybe_compressed_data_package = data_package
  475 
  476         # ------------------------------------------------------------------- --
  477 
  478         # when content type is pairing the protocol specifies that
  479         # the server must send a hmac based signature with the
  480         # response
  481 
  482         sig = ''
  483         sec_obj = self._get_secret_object()
  484 
  485         if flags & CHALLENGE_HAS_SIGNATURE:
  486 
  487             hmac_message = nonce + pt_header + maybe_compressed_data_package
  488 
  489             sig = sec_obj.hmac_digest(data_input=hmac_message,
  490                                       bkey=self.server_hmac_secret,
  491                                       hash_algo=sha256)
  492 
  493             plaintext += sig
  494 
  495         # ------------------------------------------------------------------- --
  496 
  497         plaintext += maybe_compressed_data_package
  498 
  499         # ------------------------------------------------------------------- --
  500 
  501         user_message = nonce + pt_header + sig + data_package
  502 
  503         user_sig = sec_obj.hmac_digest(data_input=user_message,
  504                                        bkey=skB,
  505                                        hash_algo=sha256)
  506 
  507         # the user sig will be given as urlsafe base64 in the
  508         # challenge response. for this reasons (and because we
  509         # need to serialize it into json) we convert the user_sig
  510         # into this format.
  511 
  512         user_sig = encode_base64_urlsafe(user_sig)
  513 
  514         # ------------------------------------------------------------------- --
  515 
  516         cipher = AES.new(skA, AES.MODE_EAX, nonce)
  517         cipher.update(data_header)
  518         ciphertext, tag = cipher.encrypt_and_digest(plaintext)
  519 
  520         raw_data = data_header + R + ciphertext + tag
  521         protocol_id = config.get('mobile_app_protocol_id', 'lseqr')
  522         url = protocol_id + '://chal/' + encode_base64_urlsafe(raw_data)
  523 
  524         return url, user_sig
  525 
  526 # --------------------------------------------------------------------------- --
  527 
  528     def update(self, params):
  529 
  530         param_keys = set(params.keys())
  531         init_rollout_state_keys = set(['type', 'hashlib', 'serial', '::scope::',
  532                                    'key_size', 'user.login', 'description',
  533                                    'user.realm', 'session', 'otplen', 'resConf',
  534                                    'user', 'realm', 'qr', 'pin'])
  535 
  536         # ------------------------------------------------------------------- --
  537 
  538         if not param_keys.issubset(init_rollout_state_keys):
  539 
  540             # make sure the call aborts, if request
  541             # type wasn't recognized
  542 
  543             raise Exception('Unknown request type for token type qr')
  544 
  545         # if param keys are in {'type', 'hashlib'} the token is
  546         # initialized for the first time. this is e.g. done on the
  547         # manage web ui. since the token doesn't exist in the database
  548         # yet, its rollout state must be None (that is: they data for
  549         # the rollout state doesn't exist yet)
  550 
  551         self.ensure_state(None)
  552 
  553         # --------------------------------------------------------------- --
  554 
  555         # we check if callback policies are set. this must be done here
  556         # because the token gets saved directly after the update method
  557         # in the TokenHandler
  558 
  559         _ = context['translate']
  560 
  561         owner = get_token_owner(self)
  562         if owner and owner.login and owner.realm:
  563             realms = [owner.realm]
  564         else:
  565             realms = self.getRealms()
  566 
  567         pairing_policies = ['qrtoken_pairing_callback_url',
  568                             'qrtoken_pairing_callback_sms']
  569 
  570         cb_url = get_single_auth_policy(pairing_policies[0],
  571                                         user=owner, realms=realms)
  572         cb_sms = get_single_auth_policy(pairing_policies[1],
  573                                         user=owner, realms=realms)
  574 
  575         if not cb_url and not cb_sms:
  576             raise Exception(_('Policy %s must have a value') %
  577                             _(" or ").join(pairing_policies))
  578 
  579         challenge_policies = ['qrtoken_challenge_callback_url',
  580                               'qrtoken_challenge_callback_sms']
  581 
  582         cb_url = get_single_auth_policy(challenge_policies[0],
  583                                         user=owner, realms=realms)
  584         cb_sms = get_single_auth_policy(challenge_policies[1],
  585                                         user=owner, realms=realms)
  586 
  587         if not cb_url and not cb_sms:
  588             raise Exception(_('Policy %s must have a value') %
  589                             _(" or ").join(challenge_policies))
  590 
  591         partition = get_partition(realms, owner)
  592         self.addToTokenInfo('partition', partition)
  593 
  594         # --------------------------------------------------------------- --
  595 
  596         # we set the the active state of the token to False, because
  597         # it should not be allowed to use it for validation before the
  598         # pairing process is done
  599 
  600         self.token.LinOtpIsactive = False
  601 
  602         # --------------------------------------------------------------- --
  603 
  604         if 'otplen' not in params:
  605             params['otplen'] = getFromConfig("QRTokenOtpLen", 8)
  606 
  607         # -------------------------------------------------------------- --
  608 
  609         TokenClass.update(self, params, reset_failcount=True)
  610 
  611 # --------------------------------------------------------------------------- --
  612 
  613     def getInitDetail(self, params, user=None):
  614 
  615         _ = context['translate']
  616         response_detail = {}
  617 
  618         param_keys = set(params.keys())
  619         init_rollout_state_keys = set(['type', 'hashlib', 'serial', '::scope::',
  620                                    'key_size', 'user.login', 'description',
  621                                    'user.realm', 'session', 'otplen', 'pin',
  622                                    'resConf', 'user', 'realm', 'qr'])
  623 
  624         # ------------------------------------------------------------------- --
  625 
  626         if param_keys.issubset(init_rollout_state_keys):
  627 
  628             # collect data used for generating the pairing url
  629 
  630             serial = self.getSerial()
  631             # for qrtoken hashlib is ignored
  632             hash_algorithm = None
  633             otp_pin_length = int(self.getOtpLen())
  634 
  635             owner = get_token_owner(self)
  636             if owner and owner.login and owner.realm:
  637                 realms = [owner.realm]
  638                 user = owner
  639             else:
  640                 realms = self.getRealms()
  641 
  642             pairing_policies = ['qrtoken_pairing_callback_url',
  643                                 'qrtoken_pairing_callback_sms']
  644 
  645             # it is guaranteed, that either cb_url or cb_sms has a value
  646             # because we checked it in the update method
  647 
  648             cb_url = get_single_auth_policy(pairing_policies[0],
  649                                             user=owner, realms=realms)
  650             cb_sms = get_single_auth_policy(pairing_policies[1],
  651                                             user=owner, realms=realms)
  652 
  653             # --------------------------------------------------------------- --
  654 
  655             partition = self.getFromTokenInfo('partition')
  656 
  657             # FIXME: certificate usage
  658 
  659             pairing_url = generate_pairing_url(token_type='qr',
  660                                                partition=partition,
  661                                                serial=serial,
  662                                                callback_url=cb_url,
  663                                                callback_sms_number=cb_sms,
  664                                                otp_pin_length=otp_pin_length,
  665                                                hash_algorithm=hash_algorithm,
  666                                                use_cert=False)
  667 
  668             # --------------------------------------------------------------- --
  669 
  670             self.addToInfo('pairing_url', pairing_url)
  671             response_detail['pairing_url'] = pairing_url
  672 
  673             # create response tabs
  674             response_detail['lse_qr_url'] = {
  675                 'description': _('QRToken Pairing Url'),
  676                 'img': create_img(pairing_url, width=250),
  677                 'order': 0,
  678                 'value': pairing_url}
  679             response_detail['lse_qr_cert'] = {
  680                 'description': _('QRToken Certificate'),
  681                 'img': create_img(pairing_url, width=250),
  682                 'order': 1,
  683                 'value': pairing_url}
  684 
  685             response_detail['serial'] = self.getSerial()
  686 
  687         # ------------------------------------------------------------------ --
  688 
  689         else:
  690 
  691             # make sure the call aborts, if request
  692             # type wasn't recognized
  693 
  694             raise Exception('Unknown request type for token type qr')
  695 
  696         # ------------------------------------------------------------------- --
  697 
  698         self.change_state('pairing_url_sent')
  699 
  700         return response_detail
  701 
  702 # --------------------------------------------------------------------------- --
  703 
  704     def checkOtp(self, passwd, counter, window, options=None):
  705 
  706         valid_states = ['pairing_challenge_sent',
  707                         'pairing_complete']
  708 
  709         self.ensure_state_is_in(valid_states)
  710 
  711         # ------------------------------------------------------------------- --
  712 
  713         filtered_challenges = []
  714         serial = self.getSerial()
  715 
  716         if options is None:
  717             options = {}
  718 
  719         max_fail = int(getFromConfig('QRMaxChallenges', '3'))
  720 
  721         # ------------------------------------------------------------------- --
  722 
  723         # TODO: from which point is checkOtp called, when there
  724         # is no challenge response in the request?
  725 
  726         if 'transactionid' in options:
  727 
  728             # --------------------------------------------------------------- --
  729 
  730             # fetch all challenges that match the transaction id or serial
  731 
  732             transaction_id = options.get('transaction_id')
  733 
  734             challenges = Challenges.lookup_challenges(serial, transaction_id)
  735 
  736             # --------------------------------------------------------------- --
  737 
  738             # filter into filtered_challenges
  739 
  740             for challenge in challenges:
  741 
  742                 (received_tan, tan_is_valid) = challenge.getTanStatus()
  743                 fail_counter = challenge.getTanCount()
  744 
  745                 # if we iterate over matching challenges (that is: challenges
  746                 # with the correct transaction id) we either find a fresh
  747                 # challenge, that didn't receive a TAN at all (first case)
  748                 # or a challenge, that already received a number of wrong
  749                 # TANs but still has tries left (second case).
  750 
  751                 if not received_tan:
  752                     filtered_challenges.append(challenge)
  753                 elif not tan_is_valid and fail_counter <= max_fail:
  754                     filtered_challenges.append(challenge)
  755 
  756             # --------------------------------------------------------------- --
  757 
  758         if not filtered_challenges:
  759             return -1
  760 
  761         for challenge in filtered_challenges:
  762 
  763             data = challenge.getData()
  764             correct_passwd = data['user_sig']
  765 
  766             # compare values with python's native constant
  767             # time comparison
  768 
  769             if compare_digest(correct_passwd, passwd):
  770 
  771                 return 1
  772 
  773             else:
  774 
  775                 # maybe we got a tan instead of a signature
  776 
  777                 correct_passwd_as_bytes = decode_base64_urlsafe(correct_passwd)
  778                 tan_length = self.getOtpLen()
  779                 correct_tan = extract_tan(correct_passwd_as_bytes, tan_length)
  780 
  781                 if compare_digest(correct_tan, passwd):
  782                     return 1
  783 
  784         # return the token counter which is at least 0, -1 indicates an error
  785         return -1
  786 
  787 # --------------------------------------------------------------------------- --
  788 
  789     def statusValidationSuccess(self):
  790 
  791         if self.current_state == 'pairing_challenge_sent':
  792             self.change_state('pairing_complete')
  793             self.enable(True)
  794 
  795 # --------------------------------------------------------------------------- --
  796 
  797     def createChallenge(self, transaction_id, options):
  798         """
  799         """
  800         _ = context['translate']
  801 
  802         valid_states = ['pairing_response_received', 'pairing_complete']
  803         self.ensure_state_is_in(valid_states)
  804 
  805         # ------------------------------------------------------------------- --
  806 
  807         if self.current_state == 'pairing_response_received':
  808             content_type = CONTENT_TYPE_PAIRING
  809             reset_url = True
  810         else:
  811 
  812             content_type_as_str = options.get('content_type')
  813             reset_url = False
  814 
  815             if content_type_as_str is None:
  816                 content_type = None
  817             else:
  818                 try:
  819                     # pylons silently converts all ints in json
  820                     # to unicode :(
  821                     content_type = int(content_type_as_str)
  822                 except:
  823                     raise ValueError('Unrecognized content type: %s'
  824                                      % content_type_as_str)
  825 
  826         # ------------------------------------------------------------------- --
  827 
  828         message = options.get('data')
  829 
  830         # ------------------------------------------------------------------- --
  831 
  832         owner = get_token_owner(self)
  833         if owner and owner.login and owner.realm:
  834             realms = [owner.realm]
  835         else:
  836             realms = self.getRealms()
  837 
  838         callback_policies = ['qrtoken_challenge_callback_url',
  839                              'qrtoken_challenge_callback_sms']
  840         callback_url = get_single_auth_policy(callback_policies[0],
  841                                               user=owner, realms=realms)
  842         callback_sms = get_single_auth_policy(callback_policies[1],
  843                                               user=owner, realms=realms)
  844 
  845         if not callback_url and not callback_sms:
  846             raise Exception(_('Policy %s must have a value') %
  847                             _(" or ").join(callback_policies))
  848 
  849         # TODO: get from policy/config
  850         compression = False
  851 
  852         # ------------------------------------------------------------------- --
  853 
  854         challenge_url, user_sig = self.create_challenge_url(transaction_id,
  855                                                             content_type,
  856                                                             message,
  857                                                             callback_url,
  858                                                             callback_sms,
  859                                                             compression,
  860                                                             reset_url)
  861 
  862         data = {'message': message, 'user_sig': user_sig}
  863 
  864         if self.current_state == 'pairing_response_received':
  865             self.change_state('pairing_challenge_sent')
  866 
  867         return (True, challenge_url, data, {})
  868 
  869     # ----------------------------------------------------------------------- --
  870 
  871     def getQRImageData(self, response_detail):
  872 
  873         url = None
  874         hparam = {}
  875 
  876         if response_detail is not None:
  877             if 'pairing_url' in response_detail:
  878                 url = response_detail.get('pairing_url')
  879                 hparam['alt'] = url
  880 
  881         return url, hparam
  882 
  883     # ----------------------------------------------------------------------- --
  884 
  885     def getOfflineInfo(self):
  886 
  887         public_key = self.getFromTokenInfo('user_public_key')
  888         user_token_id = self.getFromTokenInfo('user_token_id')
  889 
  890         return {'public_key': public_key,
  891                 'user_token_id': user_token_id}
  892 
  893     # ----------------------------------------------------------------------- --
  894 
  895     @property
  896     def server_hmac_secret(self):
  897         """ the server hmac secret for this specific token """
  898 
  899         partition = self.getFromTokenInfo('partition')
  900 
  901         # user public key is saved base64 encoded
  902 
  903         b64_user_public_key = self.getFromTokenInfo('user_public_key')
  904         user_public_key = b64decode(b64_user_public_key)
  905 
  906         sec_obj = self._get_secret_object()
  907         hmac_secret = sec_obj.calc_dh(partition=partition,
  908                                       data=user_public_key)
  909 
  910         return hmac_secret