Completed
Pull Request — master (#57)
by
unknown
02:34
created

U2F::createChallenge()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 6
nc 2
nop 0
dl 0
loc 11
rs 9.4285
c 0
b 0
f 0
1
<?php
2
/* Copyright (c) 2014 Yubico AB
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are
7
 * met:
8
 *
9
 *   * Redistributions of source code must retain the above copyright
10
 *     notice, this list of conditions and the following disclaimer.
11
 *
12
 *   * Redistributions in binary form must reproduce the above
13
 *     copyright notice, this list of conditions and the following
14
 *     disclaimer in the documentation and/or other materials provided
15
 *     with the distribution.
16
 *
17
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
 */
29
30
namespace u2flib_server;
31
32
/** Constant for the version of the u2f protocol */
33
const U2F_VERSION = "U2F_V2";
34
35
/** Constant for the type value in registration clientData */
36
const REQUEST_TYPE_REGISTER = "navigator.id.finishEnrollment";
37
38
/** Constant for the type value in authentication clientData */
39
const REQUEST_TYPE_AUTHENTICATE = "navigator.id.getAssertion";
40
41
/** Error for the authentication message not matching any outstanding
42
 * authentication request */
43
const ERR_NO_MATCHING_REQUEST = 1;
44
45
/** Error for the authentication message not matching any registration */
46
const ERR_NO_MATCHING_REGISTRATION = 2;
47
48
/** Error for the signature on the authentication message not verifying with
49
 * the correct key */
50
const ERR_AUTHENTICATION_FAILURE = 3;
51
52
/** Error for the challenge in the registration message not matching the
53
 * registration challenge */
54
const ERR_UNMATCHED_CHALLENGE = 4;
55
56
/** Error for the attestation signature on the registration message not
57
 * verifying */
58
const ERR_ATTESTATION_SIGNATURE = 5;
59
60
/** Error for the attestation verification not verifying */
61
const ERR_ATTESTATION_VERIFICATION = 6;
62
63
/** Error for not getting good random from the system */
64
const ERR_BAD_RANDOM = 7;
65
66
/** Error when the counter is lower than expected */
67
const ERR_COUNTER_TOO_LOW = 8;
68
69
/** Error decoding public key */
70
const ERR_PUBKEY_DECODE = 9;
71
72
/** Error user-agent returned error */
73
const ERR_BAD_UA_RETURNING = 10;
74
75
/** Error old OpenSSL version */
76
const ERR_OLD_OPENSSL = 11;
77
78
/** Error for the origin not matching the appId */
79
const ERR_NO_MATCHING_ORIGIN = 12;
80
81
/** Error for the type in clientData being invalid */
82
const ERR_BAD_TYPE = 13;
83
84
/** Error for bad user presence byte value */
85
const ERR_BAD_USER_PRESENCE = 14;
86
87
/** @internal */
88
const PUBKEY_LEN = 65;
89
90
class U2F
91
{
92
    /** @var string  */
93
    private $appId;
94
95
    /** @var null|string */
96
    private $attestDir;
97
98
    /** @internal */
99
    private $FIXCERTS = array(
100
        '349bca1031f8c82c4ceca38b9cebf1a69df9fb3b94eed99eb3fb9aa3822d26e8',
101
        'dd574527df608e47ae45fbba75a2afdd5c20fd94a02419381813cd55a2a3398f',
102
        '1d8764f0f7cd1352df6150045c8f638e517270e8b5dda1c63ade9c2280240cae',
103
        'd0edc9a91a1677435a953390865d208c55b3183c6759c9b5a7ff494c322558eb',
104
        '6073c436dcd064a48127ddbf6032ac1a66fd59a0c24434f070d4e564c124c897',
105
        'ca993121846c464d666096d35f13bf44c1b05af205f9b4a1e00cf6cc10c5e511'
106
    );
107
108
    /**
109
     * @param string $appId Application id for the running application
110
     * @param string|null $attestDir Directory where trusted attestation roots may be found
111
     * @throws Error If OpenSSL older than 1.0.0 is used
112
     */
113
    public function __construct($appId, $attestDir = null)
114
    {
115
        if(OPENSSL_VERSION_NUMBER < 0x10000000) {
116
            throw new Error('OpenSSL has to be at least version 1.0.0, this is ' . OPENSSL_VERSION_TEXT, ERR_OLD_OPENSSL);
117
        }
118
        $this->appId = $appId;
119
        $this->attestDir = $attestDir;
120
    }
121
122
    /**
123
     * Called to get a registration request to send to a user.
124
     * Returns an array of one registration request and a array of sign requests.
125
     *
126
     * @param array $registrations List of current registrations for this
127
     * user, to prevent the user from registering the same authenticator several
128
     * times.
129
     * @return array An array of two elements, the first containing a
130
     * RegisterRequest the second being an array of SignRequest
131
     * @throws Error
132
     */
133
    public function getRegisterData(array $registrations = array())
134
    {
135
        $challenge = $this->createChallenge();
136
        $request = new RegisterRequest($challenge, $this->appId);
137
        $signs = $this->getAuthenticateData($registrations);
138
        return array($request, $signs);
139
    }
140
141
    /**
142
     * Called to verify and unpack a registration message.
143
     *
144
     * @param RegisterRequest $request this is a reply to
145
     * @param object $response response from a user
146
     * @param bool $includeCert set to true if the attestation certificate should be
147
     * included in the returned Registration object
148
     * @return Registration
149
     * @throws Error
150
     */
151
    public function doRegister($request, $response, $includeCert = true)
152
    {
153
        if( !is_object( $request ) ) {
154
            throw new \InvalidArgumentException('$request of doRegister() method only accepts object.');
155
        }
156
157
        if( !is_object( $response ) ) {
158
            throw new \InvalidArgumentException('$response of doRegister() method only accepts object.');
159
        }
160
161 View Code Duplication
        if( property_exists( $response, 'errorCode') && $response->errorCode !== 0 ) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
162
            throw new Error('User-agent returned error. Error code: ' . $response->errorCode, ERR_BAD_UA_RETURNING );
163
        }
164
165
        if( !is_bool( $includeCert ) ) {
166
            throw new \InvalidArgumentException('$include_cert of doRegister() method only accepts boolean.');
167
        }
168
169
        $rawReg = $this->base64u_decode($response->registrationData);
170
        $regData = array_values(unpack('C*', $rawReg));
171
        $clientData = $this->base64u_decode($response->clientData);
172
        $cli = json_decode($clientData);
173
174
        if($cli->challenge !== $request->challenge) {
175
            throw new Error('Registration challenge does not match', ERR_UNMATCHED_CHALLENGE );
176
        }
177
178
        if(isset($cli->typ) && $cli->typ !== REQUEST_TYPE_REGISTER) {
179
            throw new Error('ClientData type is invalid', ERR_BAD_TYPE);
180
        }
181
182 View Code Duplication
        if(isset($cli->origin) && $cli->origin !== $request->appId) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
183
            throw new Error('App ID does not match the origin', ERR_NO_MATCHING_ORIGIN);
184
        }
185
186
        $registration = new Registration();
187
        $offs = 1;
188
        $pubKey = substr($rawReg, $offs, PUBKEY_LEN);
189
        $offs += PUBKEY_LEN;
190
        // decode the pubKey to make sure it's good
191
        $tmpKey = $this->pubkey_to_pem($pubKey);
192
        if($tmpKey === null) {
193
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
194
        }
195
        $registration->publicKey = base64_encode($pubKey);
196
        $khLen = $regData[$offs++];
197
        $kh = substr($rawReg, $offs, $khLen);
198
        $offs += $khLen;
199
        $registration->keyHandle = $this->base64u_encode($kh);
200
201
        // length of certificate is stored in byte 3 and 4 (excluding the first 4 bytes)
202
        $certLen = 4;
203
        $certLen += ($regData[$offs + 2] << 8);
204
        $certLen += $regData[$offs + 3];
205
206
        $rawCert = $this->fixSignatureUnusedBits(substr($rawReg, $offs, $certLen));
207
        $offs += $certLen;
208
        $pemCert  = "-----BEGIN CERTIFICATE-----\r\n";
209
        $pemCert .= chunk_split(base64_encode($rawCert), 64);
210
        $pemCert .= "-----END CERTIFICATE-----";
211
        if($includeCert) {
212
            $registration->certificate = base64_encode($rawCert);
213
        }
214
        if($this->attestDir) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->attestDir of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
215
            if(openssl_x509_checkpurpose($pemCert, -1, $this->get_certs()) !== true) {
216
                throw new Error('Attestation certificate can not be validated', ERR_ATTESTATION_VERIFICATION );
217
            }
218
        }
219
220
        if(!openssl_pkey_get_public($pemCert)) {
221
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
222
        }
223
        $signature = substr($rawReg, $offs);
224
225
        $dataToVerify  = chr(0);
226
        $dataToVerify .= hash('sha256', $request->appId, true);
227
        $dataToVerify .= hash('sha256', $clientData, true);
228
        $dataToVerify .= $kh;
229
        $dataToVerify .= $pubKey;
230
231
        if(openssl_verify($dataToVerify, $signature, $pemCert, 'sha256') === 1) {
232
            return $registration;
233
        } else {
234
            throw new Error('Attestation signature does not match', ERR_ATTESTATION_SIGNATURE );
235
        }
236
    }
237
238
    /**
239
     * Called to get an authentication request.
240
     *
241
     * @param array $registrations An array of the registrations to create authentication requests for.
242
     * @return array An array of SignRequest
243
     * @throws Error
244
     */
245
    public function getAuthenticateData(array $registrations)
246
    {
247
        $sigs = array();
248
        $challenge = $this->createChallenge();
249
        foreach ($registrations as $reg) {
250
            if( !is_object( $reg ) ) {
251
                throw new \InvalidArgumentException('$registrations of getAuthenticateData() method only accepts array of object.');
252
            }
253
254
            $sig = new SignRequest();
255
            $sig->appId = $this->appId;
256
            $sig->keyHandle = $reg->keyHandle;
257
            $sig->challenge = $challenge;
258
            $sigs[] = $sig;
259
        }
260
        return $sigs;
261
    }
262
263
    /**
264
     * Called to verify an authentication response
265
     *
266
     * @param array $requests An array of outstanding authentication requests
267
     * @param array $registrations An array of current registrations
268
     * @param object $response A response from the authenticator
269
     * @return Registration
270
     * @throws Error
271
     *
272
     * The Registration object returned on success contains an updated counter
273
     * that should be saved for future authentications.
274
     * If the Error returned is ERR_COUNTER_TOO_LOW this is an indication of
275
     * token cloning or similar and appropriate action should be taken.
276
     */
277
    public function doAuthenticate(array $requests, array $registrations, $response)
278
    {
279
        if( !is_object( $response ) ) {
280
            throw new \InvalidArgumentException('$response of doAuthenticate() method only accepts object.');
281
        }
282
283 View Code Duplication
        if( property_exists( $response, 'errorCode') && $response->errorCode !== 0 ) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
284
            throw new Error('User-agent returned error. Error code: ' . $response->errorCode, ERR_BAD_UA_RETURNING );
285
        }
286
287
        /** @var object|null $req */
288
        $req = null;
289
290
        /** @var object|null $reg */
291
        $reg = null;
292
293
        $clientData = $this->base64u_decode($response->clientData);
294
        $decodedClient = json_decode($clientData);
295
296
        if(isset($decodedClient->typ) && $decodedClient->typ !== REQUEST_TYPE_AUTHENTICATE) {
297
            throw new Error('ClientData type is invalid', ERR_BAD_TYPE);
298
        }
299
300
        foreach ($requests as $req) {
301
            if( !is_object( $req ) ) {
302
                throw new \InvalidArgumentException('$requests of doAuthenticate() method only accepts array of object.');
303
            }
304
305
            if($req->keyHandle === $response->keyHandle && $req->challenge === $decodedClient->challenge) {
306
                break;
307
            }
308
309
            $req = null;
310
        }
311
        if($req === null) {
312
            throw new Error('No matching request found', ERR_NO_MATCHING_REQUEST );
313
        }
314 View Code Duplication
        if(isset($decodedClient->origin) && $decodedClient->origin !== $req->appId) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
315
            throw new Error('App ID does not match the origin', ERR_NO_MATCHING_ORIGIN);
316
        }
317
        foreach ($registrations as $reg) {
318
            if( !is_object( $reg ) ) {
319
                throw new \InvalidArgumentException('$registrations of doAuthenticate() method only accepts array of object.');
320
            }
321
322
            if($reg->keyHandle === $response->keyHandle) {
323
                break;
324
            }
325
            $reg = null;
326
        }
327
        if($reg === null) {
328
            throw new Error('No matching registration found', ERR_NO_MATCHING_REGISTRATION );
329
        }
330
        $pemKey = $this->pubkey_to_pem($this->base64u_decode($reg->publicKey));
331
        if($pemKey === null) {
332
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
333
        }
334
335
        $signData = $this->base64u_decode($response->signatureData);
336
        $dataToVerify  = hash('sha256', $req->appId, true);
337
        $dataToVerify .= substr($signData, 0, 5);
338
        $dataToVerify .= hash('sha256', $clientData, true);
339
        $signature = substr($signData, 5);
340
341
        if(openssl_verify($dataToVerify, $signature, $pemKey, 'sha256') === 1) {
342
            $ctr = unpack("Nctr", substr($signData, 1, 4));
343
            $counter = $ctr['ctr'];
344
            /* TODO: wrap-around should be handled somehow.. */
345
            if($counter > $reg->counter) {
346
                $reg->counter = $counter;
347
                return $reg;
348
            } else {
349
                throw new Error('Counter too low.', ERR_COUNTER_TOO_LOW );
350
            }
351
        } else {
352
            throw new Error('Authentication failed', ERR_AUTHENTICATION_FAILURE );
353
        }
354
    }
355
356
    /**
357
     * @return array
358
     */
359
    private function get_certs()
360
    {
361
        $files = array();
362
        $dir = $this->attestDir;
363
        if($dir && $handle = opendir($dir)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $dir of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
364
            while(false !== ($entry = readdir($handle))) {
365
                if(is_file("$dir/$entry")) {
366
                    $files[] = "$dir/$entry";
367
                }
368
            }
369
            closedir($handle);
370
        }
371
        return $files;
372
    }
373
374
    /**
375
     * @param string $data
376
     * @return string
377
     */
378
    private function base64u_encode($data)
379
    {
380
        return trim(strtr(base64_encode($data), '+/', '-_'), '=');
381
    }
382
383
    /**
384
     * @param string $data
385
     * @return string
386
     */
387
    private function base64u_decode($data)
388
    {
389
        return base64_decode(strtr($data, '-_', '+/'));
390
    }
391
392
    /**
393
     * @param string $key
394
     * @return null|string
395
     */
396
    private function pubkey_to_pem($key)
397
    {
398
        if(strlen($key) !== PUBKEY_LEN || $key[0] !== "\x04") {
399
            return null;
400
        }
401
402
        /*
403
         * Convert the public key to binary DER format first
404
         * Using the ECC SubjectPublicKeyInfo OIDs from RFC 5480
405
         *
406
         *  SEQUENCE(2 elem)                        30 59
407
         *   SEQUENCE(2 elem)                       30 13
408
         *    OID1.2.840.10045.2.1 (id-ecPublicKey) 06 07 2a 86 48 ce 3d 02 01
409
         *    OID1.2.840.10045.3.1.7 (secp256r1)    06 08 2a 86 48 ce 3d 03 01 07
410
         *   BIT STRING(520 bit)                    03 42 ..key..
411
         */
412
        $der  = "\x30\x59\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01";
413
        $der .= "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07\x03\x42";
414
        $der .= "\0".$key;
415
416
        $pem  = "-----BEGIN PUBLIC KEY-----\r\n";
417
        $pem .= chunk_split(base64_encode($der), 64);
418
        $pem .= "-----END PUBLIC KEY-----";
419
420
        return $pem;
421
    }
422
423
    /**
424
     * @return string
425
     * @throws Error
426
     */
427
    private function createChallenge()
428
    {
429
        $challenge = openssl_random_pseudo_bytes(32, $crypto_strong );
430
        if( $crypto_strong !== true ) {
431
            throw new Error('Unable to obtain a good source of randomness', ERR_BAD_RANDOM);
432
        }
433
434
        $challenge = $this->base64u_encode( $challenge );
435
436
        return $challenge;
437
    }
438
439
    /**
440
     * Fixes a certificate where the signature contains unused bits.
441
     *
442
     * @param string $cert
443
     * @return mixed
444
     */
445
    private function fixSignatureUnusedBits($cert)
446
    {
447
        if(in_array(hash('sha256', $cert), $this->FIXCERTS)) {
448
            $cert[strlen($cert) - 257] = "\0";
449
        }
450
        return $cert;
451
    }
452
}
453
454
/**
455
 * Class for building a registration request
456
 *
457
 * @package u2flib_server
458
 */
459
class RegisterRequest
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
460
{
461
    /** Protocol version */
462
    public $version = U2F_VERSION;
463
464
    /** Registration challenge */
465
    public $challenge;
466
467
    /** Application id */
468
    public $appId;
469
470
    /**
471
     * @param string $challenge
472
     * @param string $appId
473
     * @internal
474
     */
475
    public function __construct($challenge, $appId)
476
    {
477
        $this->challenge = $challenge;
478
        $this->appId = $appId;
479
    }
480
}
481
482
/**
483
 * Class for building up an authentication request
484
 *
485
 * @package u2flib_server
486
 */
487
class SignRequest
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
488
{
489
    /** Protocol version */
490
    public $version = U2F_VERSION;
491
492
    /** Authentication challenge */
493
    public $challenge;
494
495
    /** Key handle of a registered authenticator */
496
    public $keyHandle;
497
498
    /** Application id */
499
    public $appId;
500
}
501
502
/**
503
 * Class returned for successful registrations
504
 *
505
 * @package u2flib_server
506
 */
507
class Registration
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
508
{
509
    /** The key handle of the registered authenticator */
510
    public $keyHandle;
511
512
    /** The public key of the registered authenticator */
513
    public $publicKey;
514
515
    /** The attestation certificate of the registered authenticator */
516
    public $certificate;
517
518
    /** The counter associated with this registration */
519
    public $counter = -1;
520
}
521
522
/**
523
 * Error class, returned on errors
524
 *
525
 * @package u2flib_server
526
 */
527
class Error extends \Exception
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
528
{
529
    /**
530
     * Override constructor and make message and code mandatory
531
     * @param string $message
532
     * @param int $code
533
     * @param \Exception|null $previous
534
     */
535
    public function __construct($message, $code, \Exception $previous = null) {
536
        parent::__construct($message, $code, $previous);
537
    }
538
}
539