Passed
Pull Request — develop (#49)
by Pieter van der
10:27
created

Tiqr_Service   F

Complexity

Total Complexity 86

Size/Duplication

Total Lines 1036
Duplicated Lines 0 %

Test Coverage

Coverage 64.96%

Importance

Changes 28
Bugs 2 Features 2
Metric Value
wmc 86
eloc 288
c 28
b 2
f 2
dl 0
loc 1036
ccs 178
cts 274
cp 0.6496
rs 2

29 Methods

Rating   Name   Duplication   Size   Complexity  
A generateEnrollmentQR() 0 5 1
A finalizeEnrollment() 0 23 4
A _getStateValue() 0 3 1
A startEnrollmentSession() 0 15 2
A validateEnrollmentSecret() 0 17 5
A getAuthenticatedUser() 0 13 3
B authenticate() 0 60 10
A _setEnrollmentStatus() 0 7 3
A _unsetStateValue() 0 3 1
A generateAuthURL() 0 5 1
A startAuthenticationSession() 0 22 4
A getEnrollmentSecret() 0 24 4
A _hashKey() 0 3 1
B __construct() 0 46 6
A getIdentifier() 0 3 1
A _setStateValue() 0 5 1
A translateNotificationAddress() 0 6 4
A clearEnrollmentState() 0 9 3
A generateEnrollString() 0 3 1
A generateQR() 0 16 2
B sendAuthNotification() 0 35 8
A getEnrollmentStatus() 0 8 3
A logout() 0 7 2
A getEnrollmentMetadata() 0 25 2
B _getChallengeUrl() 0 39 6
A _getEnrollString() 0 11 3
A _uniqueSessionKey() 0 4 1
A resetEnrollmentSession() 0 7 2
A generateAuthQR() 0 5 1

How to fix   Complexity   

Complex Class

Complex classes like Tiqr_Service often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Tiqr_Service, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * This file is part of the tiqr project.
4
 * 
5
 * The tiqr project aims to provide an open implementation for 
6
 * authentication using mobile devices. It was initiated by 
7
 * SURFnet and developed by Egeniq.
8
 *
9
 * More information: http://www.tiqr.org
10
 *
11
 * @author Ivo Jansch <[email protected]>
12
 * 
13
 * @package tiqr
14
 *
15
 * @license New BSD License - See LICENSE file for details.
16
 *
17
 * @copyright (C) 2010-2011 SURFnet BV
18
 */
19
20
use Psr\Log\LoggerInterface;
21
22
/** 
23
 * The main Tiqr Service class.
24
 * This is the class that an application interacts with to implement authentication and enrollment using the tiqr
25
 * protocol, used with the tiqr.org mobile authentication apps
26
 * See https://tiqr.org/technical/protocol/ for a specification of the protocol
27
 */
28
class Tiqr_Service
29
{
30
    /**
31
     * @internal Various variables internal to the service class
32
     */
33
    /** @var array  */
34
    protected $_options;
35
36
    /** @var string */
37
    protected $_protocolAuth;
38
    /** @var string */
39
    protected $_protocolEnroll;
40
    /** @var string */
41
    protected $_identifier;
42
    /** @var string */
43
    protected $_ocraSuite;
44
    /** @var string */
45
    protected $_name;
46
    /** @var string */
47
    protected $_logoUrl;
48
    /** @var string */
49
    protected $_infoUrl;
50
    /** @var int */
51
    protected $_protocolVersion;
52
    /** @var Tiqr_StateStorage_StateStorageInterface */
53
    protected $_stateStorage;
54
    /** @var Tiqr_DeviceStorage_Abstract */
55
    protected $_deviceStorage;
56
    /** @var Tiqr_OcraService_Interface */
57
    protected $_ocraService;
58
    /** @var string */
59
    protected $_stateStorageSalt; // The salt used for creating stable hashes for use with the StateStorage
60
61
    /** @var LoggerInterface */
62
    private $logger;
63
64
    /**
65
     * Enrollment status codes
66
     */
67
    // IDLE: There is no enrollment going on in this session, or there was an error getting the enrollment status
68
    const ENROLLMENT_STATUS_IDLE = 1;
69
    // INITIALIZED: The enrollment session was started but the tiqr client has not retrieved the metadata yet
70
    const ENROLLMENT_STATUS_INITIALIZED = 2;
71
    // RETRIEVED: The tiqr client has retrieved the metadata
72
    const ENROLLMENT_STATUS_RETRIEVED = 3;
73
    // PROCESSED: The tiqr client has sent back the tiqr authentication secret
74
    const ENROLLMENT_STATUS_PROCESSED = 4;
75
    // FINALIZED: The server has stored the authentication secret
76
    const ENROLLMENT_STATUS_FINALIZED = 5;
77
    // VALIDATED: A first successful authentication was performed
78
    // Note: Not currently used
79
    const ENROLLMENT_STATUS_VALIDATED = 6;
80
81
    /**
82
     * Prefixes for StateStorage keys
83
     */
84
    const PREFIX_ENROLLMENT_SECRET = 'enrollsecret';
85
    const PREFIX_ENROLLMENT = 'enroll';
86
    const PREFIX_CHALLENGE = 'challenge';
87
    const PREFIX_ENROLLMENT_STATUS = 'enrollstatus';
88
    const PREFIX_AUTHENTICATED = 'authenticated_';
89
90
    /**
91
     * Default timeout values
92
     */
93
    const LOGIN_EXPIRE      = 3600; // Logins timeout after an hour
94
    const ENROLLMENT_EXPIRE = 300; // If enrollment isn't completed within 5 minutes, we discard data
95
    const CHALLENGE_EXPIRE  = 180; // If login is not performed within 3 minutes, we discard the challenge
96
97
    /**
98
     * Authentication result codes
99
     */
100
    // INVALID_REQUEST: Not currently used by the Tiqr_service
101
    const AUTH_RESULT_INVALID_REQUEST   = 1;
102
    // AUTHENTICATED: The user was successfully authenticated
103
    const AUTH_RESULT_AUTHENTICATED     = 2;
104
    // INVALID_RESPONSE: The response that was returned by the client was not correct
105
    const AUTH_RESULT_INVALID_RESPONSE  = 3;
106
    // INVALID_CHALLENGE: The server could find the challenge in its state storage. It may have been expired or the
107
    // client could have sent an invalid sessionKey
108
    const AUTH_RESULT_INVALID_CHALLENGE = 4;
109
    // INVALID_USERID: The client authenticated a different user than the server expected. This error is returned when
110
    // the application stated an authentication session specifying the userId and later during the authentication
111
    // provides a different userId
112
    const AUTH_RESULT_INVALID_USERID    = 5;
113
    
114
    /**
115
     * The default OCRA Suite (RFC 6287) to use for authentication in Tiqr
116
     * This basically calculates the HMAC-SHA1 over a buffer with:
117
     * - A 10 hex digit long challenge
118
     * - authentication session ID (32 hex digits)
119
     * - client secret key (64 hex digits)
120
     * and then from the calculated HMAC-SHA1 calculates a 6 decimal digit long response
121
     *
122
     * Refer to the included SECURITY.md for security consideration
123
     */
124
    const DEFAULT_OCRA_SUITE = "OCRA-1:HOTP-SHA1-6:QH10-S";
125
126
    /**
127
     * session keys are used in multiple places during authentication and enrollment
128
     * and are generated by _uniqueSessionKey() using a secure pseudo-random number generator
129
     * SESSION_KEY_LENGTH_BYTES specifies the number of bytes of entropy in these keys.
130
     * The OCRA session keys are HEX encoded, so a 16 byte key (128 bits) will be 32 characters long
131
     */
132
    const SESSION_KEY_LENGTH_BYTES = 16;
133
134
    /**
135
     * Construct an instance of the Tiqr_Service. 
136
     * The server is configured using an array of options. All options have
137
     * reasonable defaults but it's recommended to at least specify a custom 
138
     * name and identifier and a randomly generated sessions secret.
139
     * If you use the Tiqr Service with your own apps, you must also specify
140
     * a custom auth.protocol and enroll.protocol specifier.
141
     * 
142
     * The options are:
143
     * - auth.protocol: The protocol specifier that the server uses to communicate challenge urls to the
144
     *                  iOS/Android tiqr app. This must match the url handler configuration in the app's build
145
     *                  settings.
146
     *                  Default: "tiqr".
147
     *                  Two formats are supported:
148
     *                  1. Custom URL scheme: Set the scheme's name. E.g. "tiqrauth". Do not add '://'.
149
     *                     This will generate authentication URLs of the form:
150
     *                     tiqrauth://<userId>@<idp_identifier>/<session_key>/<challenge>/<sp_idenitfier>/<version>
151
     *                  2. Universal link: Set the http or https URL. E.g. "https://tiqr.org/tiqrauth/"
152
     *                     This will generate authentication URLs of the form:
153
     *                     https://tiqr.org/tiqrauth/?u=<userid>&s=<session_key>&q=<challenge/question>&i=<idp_identifier>&v=<version>
154
     *
155
     * - enroll.protocol: The protocol specifier that the server uses to start the enrollment of a new account in the
156
     *                    iOS/Android tiqr app. This must match the url handler configuration in the app's build
157
     *                    settings.
158
     *                    Default: "tiqrenroll"
159
     *                    Two formats are supported:
160
     *                    1. Custom URL scheme: Set the protocol name. E.g. "tiqrenroll". Do not add '://'.
161
     *                       This will generate enrollment URLs of the form:
162
     *                       tiqrenroll://<metadata URL>
163
     *                    2. Universal link: Set the http or https URL. "https://tiqr.org/tiqrenroll/"
164
     *                       This will generate enrollment URLs of the form:
165
     *                       https://eduid.nl/tiqrenroll/?metadata=<URL encoded metadata URL>
166
     *
167
     * - ocra.suite: The OCRA suite to use. Defaults to DEFAULT_OCRA_SUITE.
168
     *
169
     * - identifier: A short ASCII identifier for your service. Defaults to the SERVER_NAME of the server. This is what
170
     *               a tiqr client will use to identify the server.
171
     * - name: A longer description of your service. Defaults to the SERVER_NAME of the server. A descriptive name for
172
     *         display purposes
173
     *
174
     * - logoUrl: A full http url pointing to a logo for your service.
175
     * - infoUrl: An http url pointing to an info page of your service
176
     *
177
     * - ocraservice: Configuration for the OcraService to use.
178
     *                - type: The ocra service type. (default: "tiqr")
179
     *                - parameters depending on the ocra service. See classes inside to OcraService directory for
180
     *                  supported types and their parameters.
181
     *
182
     * - statestorage: An array with the configuration of the storage for temporary data. It has the following sub keys:
183
     *                 - type: The type of state storage. (default: "file")
184
     *                 - salt: The salt is used to hash the keys used the StateStorage
185
     *                 - parameters depending on the storage. See the classes inside the StateStorage folder for
186
     *                   supported types and their parameters.
187
     *
188
     *
189
     *  * For sending push notifications using the Apple push notification service (APNS)
190
     * - apns.certificate: The location of the file with the Apple push notification client certificate and private key
191
     *                     in PEM format.
192
     *                     Defaults to ../certificates/cert.pem
193
     * - apns.environment: Whether to use apple's "sandbox" or "production" apns environment
194
     * - apns.version:     Which version of the APNS protocol to use. Default: 2
195
     *                     Version 1 is the deprecated binary APNS protocol and is no longer supported
196
     *                     Version 2: The HTTP/2 based protocol (api.push.apple.com)
197
     * - apns.proxy_host_url: Use a HTTP/1.1 to HTTP/2 proxy to send the apns.version 2 push notification.
198
     *                        Note: The proxy must take care of the TLS Client authentication to the APNS server
199
     *                        Note: The apns.environment will have no effect, configure this in the proxy
200
     *                        Specify the host URL as scheme + hostname. E.g.: "http://localhost"
201
     * - apns.proxy_host_port: Set the proxy port to use with proxy_host_url. Optional. Defaults to 443.
202
     *
203
     * * For sending push notifications to Android devices using Google's firebase cloud messaging (FCM) API
204
     * - firebase.apikey: String containing the FCM API key
205
     *
206
     * - devicestorage: An array with the configuration of the storage for device push notification tokens. Only
207
     *                  necessary if you use the Tiqr Service to authenticate an already known userId (e.g. when using
208
     *                  tiqr a second authentication factor AND are using a tiqr client that uses the token exchange.
209
     *                  It has the following
210
     *                  keys:
211
     *                  - type: The type of  storage. (default: "dummy")
212
     *                  - parameters depending on the storage. See the classes inside the DeviceStorage folder for
213
     *                    supported types and their parameters.
214
     **
215
     * @param LoggerInterface $logger
216
     * @param array $options
217
     * @param int $version The tiqr protocol version to use (defaults to the latest)
218
     * @throws Exception
219
     */
220 8
    public function __construct(LoggerInterface $logger, array $options=array(), int $version = 2)
221
    {
222 8
        $this->_options = $options; // Used to later get settings for Tiqr_Message_*
223 8
        $this->logger = $logger;
224 8
        $this->_protocolAuth = $options["auth.protocol"] ?? 'tiqr';
225 8
        $this->_protocolEnroll = $options["enroll.protocol"] ?? 'tiqrenroll';
226 8
        $this->_ocraSuite = $options["ocra.suite"] ?? self::DEFAULT_OCRA_SUITE;
227 8
        $this->_identifier = $options["identifier"] ?? $_SERVER["SERVER_NAME"];
228 8
        $this->_name = $options["name"] ?? $_SERVER["SERVER_NAME"];
229 8
        $this->_logoUrl = $options["logoUrl"] ?? '';
230 8
        $this->_infoUrl = $options["infoUrl"] ?? '';
231
232
        // An idea is to create getStateStorage, getDeviceStorage and getOcraService functions to create these functions
233
        // at the point that we actually need them.
234
235
        // Create StateStorage
236 8
        if (!isset($options["statestorage"])) {
237
            throw new RuntimeException('No state storage configuration is configured, please provide one');
238
        }
239 8
        $this->_stateStorage = Tiqr_StateStorage::getStorage($options["statestorage"]["type"], $options["statestorage"], $logger);
240
        // Set a default salt, with the SESSION_KEY_LENGTH_BYTES (16) length keys we're using a publicly
241
        // known salt already gives excellent protection.
242 7
        $this->_stateStorageSalt = $options["statestorage"]['salt'] ?? '8xwk2pFd';
243
244
        // Create DeviceStorage - required when using Push Notification with a token exchange
245 7
        if (isset($options["devicestorage"])) {
246 6
            $this->_deviceStorage = Tiqr_DeviceStorage::getStorage($options["devicestorage"]["type"], $options["devicestorage"], $logger);
247
        } else {
248 1
            $this->_deviceStorage = Tiqr_DeviceStorage::getStorage('dummy', array(), $logger);
249
        }
250
251
        // Set Tiqr protocol version, only version 2 is currently supported
252 7
        if ($version !== 2) {
253
            throw new Exception("Unsupported protocol version '$version'");
254
        }
255 7
        $this->_protocolVersion = $version;
256
257
        // Create OcraService
258
        // Library versions before 3.0 (confusingly) used the usersecretstorage key for this configuration
259
        // and used 'tiqr' as type when no type explicitly set to oathserviceclient was configured
260 7
        if (isset($options['ocraservice']) && $options['ocraservice']['type'] != 'tiqr') {
261
            $options['ocraservice']['ocra.suite'] = $this->_ocraSuite;
262
            $this->_ocraService = Tiqr_OcraService::getOcraService($options['ocraservice']['type'], $options['ocraservice'], $logger);
263
        }
264
        else { // Create default ocraservice
265 7
            $this->_ocraService = Tiqr_OcraService::getOcraService('tiqr', array('ocra.suite' => $this->_ocraSuite), $logger);
266
        }
267 7
    }
268
    
269
    /**
270
     * Get the identifier of the service.
271
     * @return String identifier
272
     */
273 4
    public function getIdentifier(): string
274
    {
275 4
        return $this->_identifier;
276
    }
277
    
278
    /**
279
     * Generate an authentication challenge QR image in PNG format and send it directly to
280
     * the PHP output buffer
281
     *
282
     * You are responsible for sending the "Content-type: image/png" HTTP header when sending this output to a
283
     * webbrowser, e.g.: header('Content-type: image/png')
284
     *
285
     * @param String $sessionKey The sessionKey identifying this auth session (typically returned by startAuthenticationSession)
286
     * @throws Exception
287
     *
288
     * @see generateAuthURL
289
     *
290
     */
291
    public function generateAuthQR(string $sessionKey): void
292
    {
293
        $challengeUrl = $this->_getChallengeUrl($sessionKey);
294
295
        $this->generateQR($challengeUrl);
296
    }
297
298
    /**
299
     * Generate a QR image in PNG format and send it directly to
300
     * the PHP output buffer
301
     *
302
     * You are responsible for sending the "Content-type: image/png" HTTP header when sending this output to a
303
     * webbrowser, e.g.: header('Content-type: image/png')
304
     *
305
     * @param String $s The string to be encoded in the QR image
306
     */
307
    public function generateQR(string $s): void
308
    {
309
        try {
310
            $options = new \chillerlan\QRCode\QROptions;
311
            $options->imageBase64 = false; // output raw image instead of base64 data URI
312
            $options->eccLevel = \chillerlan\QRCode\QRCode::ECC_L;
313
            $options->outputType = \chillerlan\QRCode\QRCode::OUTPUT_IMAGE_PNG;
314
            $options->scale = 5;
315
316
            echo (new \chillerlan\QRCode\QRCode($options))->render($s);
317
        } catch (Exception $e) {
318
            $this->logger->error(
319
                "Error generating QR code",
320
                array('exception' =>$e)
321
            );
322
            throw $e;
323
        }
324
    }
325
326
    /**
327
     * Send a push notification to a user containing an authentication challenge
328
     * @param String $sessionKey          The session key identifying this authentication session
329
     * @param String $notificationType    Notification type returned by the tiqr client: APNS, GCM, FCM, APNS_DIRECT or FCM_DIRECT
330
     * @param String $notificationAddress Notification address, e.g. device token, phone number etc.
331
     **
332
     * @throws Exception
333
     */
334
    public function sendAuthNotification(string $sessionKey, string $notificationType, string $notificationAddress): void
335
    {
336
        $message = NULL;
337
        try {
338
            switch ($notificationType) {
339
                case 'APNS':
340
                case 'APNS_DIRECT':
341
                    $apns_version = $this->_options['apns.version'] ?? 2;
342
                    if ($apns_version !=2)
343
                        throw new InvalidArgumentException("Unsupported APNS version '$apns_version'");
344
                    $message = new Tiqr_Message_APNS2($this->_options, $this->logger);
345
                    break;
346
347
                case 'GCM':
348
                case 'FCM':
349
                case 'FCM_DIRECT':
350
                    $message = new Tiqr_Message_FCM($this->_options, $this->logger);
351
                    break;
352
353
                default:
354
                    throw new InvalidArgumentException("Unsupported notification type '$notificationType'");
355
            }
356
357
            $this->logger->info(sprintf('Creating and sending a %s push notification', $notificationType));
358
            $message->setId(time());
359
            $message->setText("Please authenticate for " . $this->_name);
360
            $message->setAddress($notificationAddress);
361
            $message->setCustomProperty('challenge', $this->_getChallengeUrl($sessionKey));
362
            $message->send();
363
        } catch (Exception $e) {
364
            $this->logger->error(
365
                sprintf('Sending "%s" push notification to address "%s" failed', $notificationType, $notificationAddress),
366
                array('exception' =>$e)
367
            );
368
            throw $e;
369
        }
370
    }
371
372
    /** 
373
     * Generate an authentication challenge URL.
374
     * This URL can be used to link directly to the authentication
375
     * application, for example to create a link in a mobile website on the
376
     * same device as where the application is installed
377
     *
378
     * Opening the URL in the authentication application start the authentication
379
     * of a previously enrolled account.
380
     *
381
     * You can encode this URL in a QR code to scan it in the Tiqr app using you own
382
     * QR code library, or use generateQR()
383
     *
384
     *
385
     * @param String $sessionKey The session key identifying this authentication session
386
     *
387
     * @return string Authentication URL for the tiqr client
388
     * @throws Exception
389
     *
390
     * @see Tiqr_Service::generateQR()
391
     */
392 3
    public function generateAuthURL(string $sessionKey): string
393
    {
394 3
        $challengeUrl = $this->_getChallengeUrl($sessionKey);  
395
        
396 3
        return $challengeUrl;
397
    }
398
399
    /**
400
     * Start an authentication session. This generates a challenge for this
401
     * session and stores it in memory. The returned sessionKey should be used
402
     * throughout the authentication process.
403
     *
404
     * @param String $userId The userId of the user to authenticate (optional), if this is left empty the
405
     *                       the client decides
406
     * @param String $sessionId The session id the application uses to identify its user sessions;
407
     *                          (optional defaults to the php session id).
408
     *                          This sessionId can later be used to get the authenticated user from the application
409
     *                          using getAuthenticatedUser(), or to clear the authentication state using logout()
410
     * @param String $spIdentifier If SP and IDP are 2 different things, pass the url/identifier of the SP the user is logging into.
411
     *                             For setups where IDP==SP, just leave this blank.
412
     * @return string The authentication sessionKey
413
     * @throws Exception when starting the authentication session failed
414
     */
415 3
    public function startAuthenticationSession(string $userId="", string $sessionId="", string $spIdentifier=""): string
416
    {
417 3
        if ($sessionId=="") {
418 2
            $sessionId = session_id();
419
        }
420
421 3
        if ($spIdentifier=="") {
422 3
            $spIdentifier = $this->_identifier;
423
        }
424
425 3
        $sessionKey = $this->_uniqueSessionKey();
426 3
        $challenge = $this->_ocraService->generateChallenge();
427
        
428 3
        $data = array("sessionId"=>$sessionId, "challenge"=>$challenge, "spIdentifier" => $spIdentifier);
429
        
430 3
        if ($userId!="") {
431 2
            $data["userId"] = $userId;
432
        }
433
        
434 3
        $this->_setStateValue(self::PREFIX_CHALLENGE, $sessionKey, $data, self::CHALLENGE_EXPIRE);
435
       
436 3
        return $sessionKey;
437
    }
438
    
439
    /**
440
     * Start an enrollment session. This can either be the enrollment of a new 
441
     * user or of an existing user, there is no difference from Tiqr's point
442
     * of view.
443
     * 
444
     * The call returns the temporary enrollmentKey that the phone needs to 
445
     * retrieve the metadata; you must therefor embed this key in the metadata
446
     * URL that you communicate to the phone.
447
     * 
448
     * @param String $userId The user's id
449
     * @param String $displayName The user's full name
450
     * @param String $sessionId The application's session identifier (defaults to php session)
451
     * @return String The enrollment key
452
     * @throws Exception when start the enrollement session failed
453
     */
454 2
    public function startEnrollmentSession(string $userId, string $displayName, string $sessionId=""): string
455
    {
456 2
        if ($sessionId=="") {
457 1
            $sessionId = session_id();
458
        }
459 2
        $enrollmentKey = $this->_uniqueSessionKey();
460
        $data = [
461 2
            "userId" => $userId,
462 2
            "displayName" => $displayName,
463 2
            "sessionId" => $sessionId
464
        ];
465 2
        $this->_setStateValue(self::PREFIX_ENROLLMENT, $enrollmentKey, $data, self::ENROLLMENT_EXPIRE);
466 2
        $this->_setEnrollmentStatus($sessionId, self::ENROLLMENT_STATUS_INITIALIZED);
467
468 2
        return $enrollmentKey;
469
    }
470
471
    /**
472
     * Reset an existing enrollment session. (start over)
473
     * @param string $sessionId The application's session identifier (defaults to php session)
474
     * @throws Exception when resetting the session failed
475
     */
476
    public function resetEnrollmentSession(string $sessionId=""): void
477
    {
478
        if ($sessionId=="") {
479
            $sessionId = session_id();
480
        }
481
482
        $this->_setEnrollmentStatus($sessionId, self::ENROLLMENT_STATUS_IDLE);
483
    }
484
485
    /**
486
     * Remove enrollment data based on the enrollment key (which is
487
     * encoded in the enrollment QR code).
488
     *
489
     * @param string $enrollmentKey returned by startEnrollmentSession
490
     * @throws Exception when clearing the enrollment state failed
491
     */
492
    public function clearEnrollmentState(string $enrollmentKey): void
493
    {
494
        $value = $this->_getStateValue(self::PREFIX_ENROLLMENT, $enrollmentKey);
495
        if (is_array($value) && array_key_exists('sessionId', $value)) {
496
            // Reset the enrollment session (used for polling the status of the enrollment)
497
            $this->resetEnrollmentSession($value['sessionId']);
498
        }
499
        // Remove the enrollment data for a specific enrollment key
500
        $this->_unsetStateValue(self::PREFIX_ENROLLMENT, $enrollmentKey);
501
    }
502
503
    /**
504
     * Retrieve the enrollment status of an enrollment session.
505
     * 
506
     * @param String $sessionId the application's session identifier 
507
     *                          (defaults to php session)
508
     * @return int Enrollment status.
509
     * @see Tiqr_Service for a definitation of the enrollment status codes
510
     *
511
     * @throws Exception when an error communicating with the state storage backend was detected
512
     */
513 1
    public function getEnrollmentStatus(string $sessionId=""): int
514
    { 
515 1
        if ($sessionId=="") {
516
            $sessionId = session_id(); 
517
        }
518 1
        $status = $this->_getStateValue(self::PREFIX_ENROLLMENT_STATUS, $sessionId);
519 1
        if (is_null($status)) return self::ENROLLMENT_STATUS_IDLE;
520 1
        return $status;
521
    }
522
        
523
    /**
524
     * Generate an enrollment QR code in PNG format and send it to the PHP
525
     * output buffer
526
     *
527
     * You are responsible for sending the "Content-type: image/png" HTTP header, e.g.:
528
     *       header('Content-type: image/png')
529
     *
530
     * @param String $metadataUrl The URL you provide to the phone to retrieve
531
     *                            metadata. This URL must contain the enrollmentKey
532
     *                            provided by startEnrollmentSession (you can choose
533
     *                            the variable name as you are responsible yourself
534
     *                            for retrieving this from the request and passing it
535
     *                            on to the Tiqr server.
536
     * @throws Exception
537
     * @see Tiqr_Service::generateEnrollString()
538
     *
539
     */
540
    public function generateEnrollmentQR(string $metadataUrl): void
541
    { 
542
        $enrollmentString = $this->_getEnrollString($metadataUrl);
543
544
        $this->generateQR($enrollmentString);
545
    }
546
547
    /**
548
     * Generate an enrollment URL
549
     *
550
     * This URL can be used to link directly to the authentication
551
     * application, for example to create a link in a mobile website on the
552
     * same device as where the application is installed
553
     *
554
     * Opening an enrollment url starts the enrollment process in the
555
     * authentication application (e.g. the Tiqr client)
556
     *
557
     * You can encode this URL in a QR code to scan it in the Tiqr app using you own
558
     * QR code library, or use generateQR()
559
     *
560
     * @return The enrollment URL
561
     *
562
     * @see Tiqr_Service::generateQR()
563
     */
564 2
    public function generateEnrollString(string $metadataUrl): string
565
    {
566 2
        return $this->_getEnrollString($metadataUrl);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->_getEnrollString($metadataUrl) returns the type string which is incompatible with the documented return type The.
Loading history...
567
    }
568
    
569
    /**
570
     * Retrieve the metadata for an enrollment session.
571
     * 
572
     * When the phone calls the url that you have passed to
573
     * generateEnrollmentQR, you must provide it with the output
574
     * of this function. (Don't forget to json_encode the output.)
575
     * 
576
     * Note, you can call this function only once, as the enrollment session
577
     * data will be destroyed as soon as it is retrieved.
578
     *
579
     * When successful the enrollment status will be set to ENROLLMENT_STATUS_RETRIEVED
580
     *
581
     * @param String $enrollmentKey The enrollmentKey that the phone has posted along with its request.
582
     * @param String $authenticationUrl The url you provide to the phone to post authentication responses
583
     * @param String $enrollmentUrl The url you provide to the phone to post the generated user secret. You must include
584
     *                              a temporary enrollment secret in this URL to make this process secure.
585
     *                              Use getEnrollmentSecret() to get this secret
586
     * @return array An array of metadata that the phone needs to complete
587
     *               enrollment. You must encode it in JSON before you send
588
     *               it to the phone.
589
     * @throws Exception when generating the metadata failed
590
     */
591 1
    public function getEnrollmentMetadata(string $enrollmentKey, string $authenticationUrl, string $enrollmentUrl): array
592
    {
593 1
        $data = $this->_getStateValue(self::PREFIX_ENROLLMENT, $enrollmentKey);
594 1
        if (!is_array($data)) {
595 1
            $this->logger->error('Unable to find enrollment metadata in state storage');
596 1
            throw new Exception('Unable to find enrollment metadata in state storage');
597
        }
598
599
        $metadata = array("service"=>
600 1
                               array("displayName"       => $this->_name,
601 1
                                     "identifier"        => $this->_identifier,
602 1
                                     "logoUrl"           => $this->_logoUrl,
603 1
                                     "infoUrl"           => $this->_infoUrl,
604 1
                                     "authenticationUrl" => $authenticationUrl,
605 1
                                     "ocraSuite"         => $this->_ocraSuite,
606 1
                                     "enrollmentUrl"     => $enrollmentUrl
607
                               ),
608
                          "identity"=>
609 1
                               array("identifier" =>$data["userId"],
610 1
                                     "displayName"=>$data["displayName"]));
611
612 1
        $this->_unsetStateValue(self::PREFIX_ENROLLMENT, $enrollmentKey);
613
614 1
        $this->_setEnrollmentStatus($data["sessionId"], self::ENROLLMENT_STATUS_RETRIEVED);
615 1
        return $metadata;
616
    }
617
618
    /** 
619
     * Get a temporary enrollment secret to be able to securely post a user 
620
     * secret.
621
     *
622
     * In the last step of the enrollment process the phone will send the OCRA user secret.
623
     * This is the shared secret is used in the authentication process. To prevent an
624
     * attacker from impersonating a user during enrollment and post a user secret that is known to the attacker,
625
     * a temporary enrollment secret is added to the metadata. This secret must be included in the enrollmentUrl that is
626
     * passed to the getMetadata function so that when the client sends the OCRA user secret to the server this
627
     * enrollment secret is included. The server uses the enrollment secret to authenticate the client, and will
628
     * allow only one submission of a user secret for one enrollment secret.
629
     *
630
     * You MUST use validateEnrollmentSecret() to validate enrollment secret that the client sends before accepting
631
     * the associated OCRA client secret
632
     *
633
     * @param String $enrollmentKey The enrollmentKey generated by startEnrollmentSession() at the start of the
634
     *                              enrollment process.
635
     * @return String The enrollment secret
636
     * @throws Exception when generating the enrollment secret failed
637
     */
638 1
    public function getEnrollmentSecret(string $enrollmentKey): string
639
    {
640 1
         $data = $this->_getStateValue(self::PREFIX_ENROLLMENT, $enrollmentKey);
641 1
         if (!is_array($data)) {
642
             $this->logger->error('getEnrollmentSecret: enrollment key not found');
643
             throw new RuntimeException('enrollment key not found');
644
         }
645 1
         $userId = $data["userId"] ?? NULL;
646 1
         $sessionId = $data["sessionId"] ?? NULL;
647 1
         if (!is_string($userId) || !(is_string($sessionId))) {
648
             throw new RuntimeException('getEnrollmentSecret: invalid enrollment data');
649
         }
650
         $enrollmentData = [
651 1
             "userId" => $userId,
652 1
             "sessionId" => $sessionId
653
         ];
654 1
         $enrollmentSecret = $this->_uniqueSessionKey();
655 1
         $this->_setStateValue(
656 1
             self::PREFIX_ENROLLMENT_SECRET,
657 1
             $enrollmentSecret,
658 1
             $enrollmentData,
659 1
             self::ENROLLMENT_EXPIRE
660
         );
661 1
         return $enrollmentSecret;
662
    }
663
664
    /**
665
     * Validate if an enrollmentSecret that was passed from the phone is valid.
666
     *
667
     * Note: After validating the enrollmentSecret you must call finalizeEnrollment() to
668
     *       invalidate the enrollment secret.
669
     *
670
     * When successful the enrollment state will be set to ENROLLMENT_STATUS_PROCESSED
671
     *
672
     * @param string $enrollmentSecret The enrollmentSecret that the phone posted; it must match
673
     *                                 the enrollmentSecret that was generated using
674
     *                                 getEnrollmentSecret earlier in the process and that the phone
675
     *                                 received as part of the metadata.
676
     *                                 Note that this is not the OCRA user secret that the Phone posts to the server
677
     * @return string The userid of the user that was being enrolled if the enrollment secret is valid. The application
678
     *                should use this userid to store the OCRA user secret that the phone posted.
679
     *
680
     * @throws Exception when the validation failed
681
     */
682 1
    public function validateEnrollmentSecret(string $enrollmentSecret): string
683
    {
684
        try {
685 1
            $data = $this->_getStateValue(self::PREFIX_ENROLLMENT_SECRET, $enrollmentSecret);
686 1
            if (NULL === $data) {
687 1
                throw new RuntimeException('Enrollment secret not found');
688
            }
689 1
            if ( !is_array($data) || !is_string($data["userId"] ?? NULL)) {
690
                throw new RuntimeException('Invalid enrollment data');
691
            }
692
693
            // Secret is valid, application may accept the user secret.
694 1
            $this->_setEnrollmentStatus($data["sessionId"], self::ENROLLMENT_STATUS_PROCESSED);
695 1
            return $data["userId"];
696 1
        } catch (Exception $e) {
697 1
            $this->logger->error('Validation of enrollment secret failed', array('exception' => $e));
698 1
            throw $e;
699
        }
700
    }
701
702
    /**
703
     * Finalize the enrollment process.
704
     *
705
     * Invalidates $enrollmentSecret
706
     *
707
     * Call this after validateEnrollmentSecret
708
     * When successfull the enrollment state will be set to ENROLLMENT_STATUS_FINALIZED
709
     *
710
     * @param String The enrollment secret that was posted by the phone. This is the same secret used in the call to
711
     *               validateEnrollmentSecret()
712
     * @return bool true when finalize was successful, false otherwise
713
     *
714
     * Does not throw
715
     */
716 1
    public function finalizeEnrollment(string $enrollmentSecret): bool
717
    {
718
        try {
719 1
            $data = $this->_getStateValue(self::PREFIX_ENROLLMENT_SECRET, $enrollmentSecret);
720 1
            if (NULL === $data) {
721 1
                throw new RuntimeException('Enrollment secret not found');
722
            }
723 1
            if (is_array($data)) {
724
                // Enrollment is finalized, destroy our session data.
725 1
                $this->_unsetStateValue(self::PREFIX_ENROLLMENT_SECRET, $enrollmentSecret);
726 1
                $this->_setEnrollmentStatus($data["sessionId"], self::ENROLLMENT_STATUS_FINALIZED);
727
            } else {
728
                $this->logger->error(
729
                    'Enrollment status is not finalized, enrollmentsecret was not found in state storage. ' .
730
                    'Warning! the method will still return "true" as a result.'
731
                );
732
            }
733 1
            return true;
734 1
        } catch (Exception $e) {
735
            // Cleanup failed
736 1
            $this->logger->warning('finalizeEnrollment failed', array('exception' => $e));
737
        }
738 1
        return false;
739
    }
740
741
    /**
742
     * Authenticate a user.
743
     * This method should be called when the phone (tiqr client) posts a response to an
744
     * authentication challenge to the server. This method will validate the response and
745
     * returns one of the self::AUTH_RESULT_* codes to indicate success or error
746
     *
747
     * When the authentication was successful the user's session is marked as authenticated.
748
     * This essentially logs the user in. Use getauthenticateduser() and logout() with the
749
     * application's session sessionID to respectively get the authenticated user and clear
750
     * the authentication state.
751
     *
752
     * SECURITY CONSIDERATIONS
753
     *
754
     * Read the included SECURITY.md for important security considerations about:
755
     *
756
     * * PIN guessing: The application must implement protections against response guessing and PIN guessing attacks.
757
     *                 This means that the application must check whether this function returns
758
     *                 AUTH_RESULT_INVALID_RESPONSE and handle that case appropriately.
759
     *
760
     * * Response guessing: When using the default OCRA suite the response is six digits long. This makes it feasible
761
     *                      for an attacker to try all responses in a brute force attack. The application must take
762
     *                      this into account by handing AUTH_RESULT_INVALID_RESPONSE
763
     *
764
     *
765
     * The other error results and exceptions mean that the response could not be validated on the server and should
766
     * therefore not reveal anything useful to the client.
767
     *
768
     * The UserStorage class supports (temporarily) locking a user account. It is the responsibility of the application
769
     * to implement these and other security measures.
770
     *
771
     * @param String $userId The userid of the user that should be authenticated, as sent in the POST back by the tiqr
772
     *                       client. If $userId does not match the optional userId in startAuthenticationSession()
773
     *                       AUTH_RESULT_INVALID_USERID is returned
774
     * @param String $userSecret The OCRA user secret that the application previously stored for $userId using
775
     *                           e.g. a Tiqr_UserSecretStorage
776
     *                           Leave empty when using a OcraService that does not require a user secret
777
     * @param String $sessionKey The authentication session key that was returned by startAuthenticationSession()
778
     *                           If the session key cannot be found in the StateStorage AUTH_RESULT_INVALID_CHALLENGE
779
     *                           is returned
780
     * @param String $response   The response to the challenge that the tiqr client posted back to the server
781
     *
782
     * @return Int The result of the authentication. This is one of the AUTH_RESULT_* constants of the Tiqr_Server class.
783
     * @throws Exception when there was an error during the authentication process
784
     */
785 1
    public function authenticate(string $userId, string $userSecret, string $sessionKey, string $response): int
786
    {
787
        try {
788 1
            $state = $this->_getStateValue(self::PREFIX_CHALLENGE, $sessionKey);
789 1
            if (is_null($state)) {
790 1
                $this->logger->notice('The auth challenge could not be found in the state storage');
791 1
                return self::AUTH_RESULT_INVALID_CHALLENGE;
792
            }
793
        } catch (Exception $e) {
794
            $this->logger->error('Error looking up challenge in state storage', array('exception' => $e));
795
            throw $e;
796
        }
797
798 1
        $sessionId = $state["sessionId"] ?? NULL;   // Application's sessionId
799 1
        $challenge = $state["challenge"] ?? NULL;   // The challenge we sent to the Tiqr client
800 1
        if (!is_string($sessionId) || (!is_string($challenge)) ) {
801
            throw new RuntimeException('Invalid state for state storage');
802
        }
803
804
        // The user ID is optional, it is set when the application requested authentication of a specific userId
805
        // instead of letting the client decide
806 1
        $challengeUserId = $state["userId"] ?? NULL;
807
808
        // If the application requested a specific userId, verify that that is that userId that we're now authenticating
809 1
        if ($challengeUserId!==NULL && ($userId !== $challengeUserId)) {
810 1
            $this->logger->error(
811 1
                sprintf('Authentication failed: the requested userId "%s" does not match userId "%s" that is being authenticated',
812 1
                $challengeUserId, $userId)
813
            );
814 1
            return self::AUTH_RESULT_INVALID_USERID; // requested and actual userId do not match
815
        }
816
817
        try {
818 1
            $equal = $this->_ocraService->verifyResponse($response, $userId, $userSecret, $challenge, $sessionKey);
819
        } catch (Exception $e) {
820
            $this->logger->error(sprintf('Error verifying OCRA response for user "%s"', $userId), array('exception' => $e));
821
            throw $e;
822
        }
823
824 1
        if ($equal) {
825
            // Set application session as authenticated
826 1
            $this->_setStateValue(self::PREFIX_AUTHENTICATED, $sessionId, $userId, self::LOGIN_EXPIRE);
827 1
            $this->logger->notice(sprintf('Authenticated user "%s" in session "%s"', $userId, $sessionId));
828
829
            // Cleanup challenge
830
            // Future authentication attempts with this sessionKey will get a AUTH_RESULT_INVALID_CHALLENGE
831
            // This QR code / push notification cannot be used again
832
            // Cleaning up only after successful authentication enables the user to retry authentication after e.g. an
833
            // invalid response
834
            try {
835 1
                $this->_unsetStateValue(self::PREFIX_CHALLENGE, $sessionKey); // May throw
836
            } catch (Exception $e) {
837
                // Only log error
838
                $this->logger->warning('Could not delete authentication session key', array('error' => $e));
839
            }
840
841 1
            return self::AUTH_RESULT_AUTHENTICATED;
842
        }
843 1
        $this->logger->error('Authentication failed: invalid response');
844 1
        return self::AUTH_RESULT_INVALID_RESPONSE;
845
    }
846
847
    /**
848
     * Log the user out.
849
     * It is not an error is the $sessionId does not exists, or when the $sessionId has expired
850
     *
851
     * @param String $sessionId The application's session identifier (defaults
852
     *                          to the php session).
853
     *                          This is the application's sessionId that was provided to startAuthenticationSession()
854
     *
855
     * @throws Exception when there was an error communicating with the storage backed
856
     */
857 1
    public function logout(string $sessionId=""): void
858
    {
859 1
        if ($sessionId=="") {
860
            $sessionId = session_id(); 
861
        }
862
        
863 1
        $this->_unsetStateValue(self::PREFIX_AUTHENTICATED, $sessionId);
864 1
    }
865
    
866
    /**
867
     * Exchange a notificationToken for a deviceToken.
868
     * 
869
     * During enrollment, the phone will post a notificationAddress that can be 
870
     * used to send notifications. To actually send the notification, 
871
     * this address should be converted to the real device address.
872
     *
873
     * @param String $notificationType    The notification type.
874
     * @param String $notificationAddress The address that was stored during enrollment.
875
     *
876
     * @return String|bool The device address that can be used to send a notification.
877
     *                     false on error
878
     */
879
    public function translateNotificationAddress(string $notificationType, string $notificationAddress)
880
    {
881
        if ($notificationType == 'APNS' || $notificationType == 'FCM' || $notificationType == 'GCM') {
882
            return $this->_deviceStorage->getDeviceToken($notificationAddress);
883
        } else {
884
            return $notificationAddress;
885
        }
886
    }
887
    
888
    /**
889
     * Retrieve the currently logged in user.
890
     * @param String $sessionId The application's session identifier (defaults
891
     *                          to the php session).
892
     *                          This is the application's sessionId that was provided to startAuthenticationSession()
893
     * @return string|NULL The userId of the authenticated user,
894
     *                     NULL if no user is logged in
895
     *                     NULL if the user's login state could not be determined
896
     *
897
     * Does not throw
898
     */
899 1
    public function getAuthenticatedUser(string $sessionId=""): ?string
900
    {
901 1
        if ($sessionId=="") {
902
            $this->logger->debug('Using the PHP session id, as no session id was provided');
903
            $sessionId = session_id(); 
904
        }
905
        
906
        try {
907 1
            return $this->_getStateValue("authenticated_", $sessionId);
908
        }
909
        catch (Exception $e) {
910
            $this->logger->error('getAuthenticatedUser failed', array('exception'=>$e));
911
            return NULL;
912
        }
913
    }
914
    
915
    /**
916
     * Generate a authentication challenge URL
917
     * @param String $sessionKey The authentication sessionKey
918
     *
919
     * @return string AuthenticationURL
920
     * @throws Exception
921
     */
922 3
    protected function _getChallengeUrl(string $sessionKey): string
923
    {
924
        // Lookup the authentication session data and use this to generate the application specific
925
        // authentication URL
926
        // The are two formats see: https://tiqr.org/technical/protocol/
927
        // We probably just generated the challenge and stored it in the StateStorage
928
        // We can save a roundtrip to the storage backend here by reusing this information
929
930 3
        $state = $this->_getStateValue(self::PREFIX_CHALLENGE, $sessionKey);
931 3
        if (is_null($state)) {
932
            $this->logger->error(
933
                sprintf(
934
                'Cannot get session key "%s"',
935
                    $sessionKey
936
                )
937
            );
938
            throw new Exception('Cannot find sessionkey');
939
        }
940
941 3
        $userId = $state["userId"] ?? NULL;
942 3
        $challenge = $state["challenge"] ?? '';
943 3
        $spIdentifier = $state["spIdentifier"] ?? '';
944
945 3
        if ( (strpos($this->_protocolAuth, 'https://') === 0) || (strpos($this->_protocolAuth, 'http://') === 0) ) {
946
            // Create universal Link
947 2
            $parameters=array();
948 2
            if (!is_null($userId)) {
949 1
                $parameters[]='u='.urlencode($userId);
950
            }
951 2
            $parameters[]='s='.urlencode($sessionKey);
952 2
            $parameters[]='q='.urlencode($challenge);
953 2
            $parameters[]='i='.urlencode($this->getIdentifier());
954 2
            $parameters[]='v='.urlencode($this->_protocolVersion);
955 2
            return $this->_protocolAuth.'?'.implode('&', $parameters);
956
        }
957
958
        // Create custom URL scheme
959
        // Last bit is the spIdentifier
960 1
        return $this->_protocolAuth."://".(!is_null($userId)?urlencode($userId).'@':'').$this->getIdentifier()."/".$sessionKey."/".$challenge."/".urlencode($spIdentifier)."/".$this->_protocolVersion;
961
    }
962
963
    /**
964
     * Generate an enrollment string
965
     * @param String $metadataUrl The URL you provide to the phone to retrieve metadata.
966
     */
967 2
    protected function _getEnrollString(string $metadataUrl): string
968
    {
969
        // The are two formats see: https://tiqr.org/technical/protocol/
970
971 2
        if ( (strpos($this->_protocolEnroll, 'https://') === 0) || (strpos($this->_protocolEnroll, 'http://') === 0) ) {
972
            // Create universal Link
973 1
            return $this->_protocolEnroll.'?metadata='.urlencode($metadataUrl);
974
        }
975
976
        // Create custom URL scheme
977 1
        return $this->_protocolEnroll."://".$metadataUrl;
978
    }
979
980
    /**
981
     * Generate a unique secure pseudo-random value to be used as session key in the
982
     * tiqr protocol. These keys are sent to the tiqr client during enrollment and authentication
983
     * And are used in the server as part of key for data in StateStorage
984
     * @return String The session key as HEX encoded string
985
     * @throws Exception When the key could not be generated
986
     */
987 5
    protected function _uniqueSessionKey(): string
988
    {
989
990 5
        return bin2hex( Tiqr_Random::randomBytes(self::SESSION_KEY_LENGTH_BYTES) );
991
    }
992
    
993
    /**
994
     * Internal function to set the enrollment status of a session.
995
     * @param String $sessionId The sessionId to set the status for
996
     * @param int $status The new enrollment status (one of the 
997
     *                    self::ENROLLMENT_STATUS_* constants)
998
     * @throws Exception when updating the status fails
999
     */
1000 2
    protected function _setEnrollmentStatus(string $sessionId, int $status): void
1001
    {
1002 2
        if (($status < 1) || ($status > 6)) {
1003
            // Must be one of the self::ENROLLMENT_STATUS_* constants
1004
            throw new InvalidArgumentException('Invalid enrollment status');
1005
        }
1006 2
        $this->_setStateValue(self::PREFIX_ENROLLMENT_STATUS, $sessionId, $status, self::ENROLLMENT_EXPIRE);
1007 2
    }
1008
1009
    /** Store a value in StateStorage
1010
     * @param string $key_prefix
1011
     * @param string $key
1012
     * @param mixed $value
1013
     * @param int $expire
1014
     * @return void
1015
     * @throws Exception
1016
     *
1017
     * @see Tiqr_StateStorage_StateStorageInterface::setValue()
1018
     */
1019 5
    protected function _setStateValue(string $key_prefix, string $key, $value, int $expire): void {
1020 5
        $this->_stateStorage->setValue(
1021 5
            $key_prefix . $this->_hashKey($key),
1022 5
            $value,
1023 5
            $expire
1024
        );
1025 5
    }
1026
1027
    /** Get a value from StateStorage
1028
     * @param string $key_prefix
1029
     * @param string $key
1030
     * @return mixed
1031
     * @throws Exception
1032
     *
1033
     * @see Tiqr_StateStorage_StateStorageInterface::getValue()
1034
     */
1035
1036 4
    protected function _getStateValue(string $key_prefix, string $key) {
1037 4
        return $this->_stateStorage->getValue(
1038 4
            $key_prefix . $this->_hashKey($key)
1039
        );
1040
    }
1041
1042
    /** Remove a key and its value from StateStorage
1043
     * @param string $key_prefix
1044
     * @param string $key
1045
     * @return void
1046
     * @throws Exception
1047
     *
1048
     * @see Tiqr_StateStorage_StateStorageInterface::unsetValue()
1049
     */
1050 2
    protected function _unsetStateValue(string $key_prefix, string $key): void {
1051 2
        $this->_stateStorage->unsetValue(
1052 2
            $key_prefix . $this->_hashKey($key)
1053
        );
1054 2
    }
1055
1056
    /**
1057
     * Create a stable hash of a $key. Used to improve the security of stored keys
1058
     * @param string $key
1059
     * @return string hashed $key
1060
     */
1061 5
    protected function _hashKey(string $key): string
1062
    {
1063 5
        return hash_hmac('sha256', $key, $this->_stateStorageSalt);
1064
    }
1065
}
1066