Completed
Pull Request — master (#64)
by Scott
07:20
created

U2F::castObjectToRegistration()   A

Complexity

Conditions 5
Paths 16

Size

Total Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 17
rs 9.3888
c 0
b 0
f 0
cc 5
nc 16
nop 1
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
/** Error for the authentication message not matching any outstanding
36
 * authentication request */
37
const ERR_NO_MATCHING_REQUEST = 1;
38
39
/** Error for the authentication message not matching any registration */
40
const ERR_NO_MATCHING_REGISTRATION = 2;
41
42
/** Error for the signature on the authentication message not verifying with
43
 * the correct key */
44
const ERR_AUTHENTICATION_FAILURE = 3;
45
46
/** Error for the challenge in the registration message not matching the
47
 * registration challenge */
48
const ERR_UNMATCHED_CHALLENGE = 4;
49
50
/** Error for the attestation signature on the registration message not
51
 * verifying */
52
const ERR_ATTESTATION_SIGNATURE = 5;
53
54
/** Error for the attestation verification not verifying */
55
const ERR_ATTESTATION_VERIFICATION = 6;
56
57
/** Error for not getting good random from the system */
58
const ERR_BAD_RANDOM = 7;
59
60
/** Error when the counter is lower than expected */
61
const ERR_COUNTER_TOO_LOW = 8;
62
63
/** Error decoding public key */
64
const ERR_PUBKEY_DECODE = 9;
65
66
/** Error user-agent returned error */
67
const ERR_BAD_UA_RETURNING = 10;
68
69
/** Error old OpenSSL version */
70
const ERR_OLD_OPENSSL = 11;
71
72
/** @internal */
73
const PUBKEY_LEN = 65;
74
75
class U2F
76
{
77
    /** @var string  */
78
    private $appId;
79
80
    /** @var null|string */
81
    private $attestDir;
82
83
    /** @internal */
84
    private $FIXCERTS = array(
85
        '349bca1031f8c82c4ceca38b9cebf1a69df9fb3b94eed99eb3fb9aa3822d26e8',
86
        'dd574527df608e47ae45fbba75a2afdd5c20fd94a02419381813cd55a2a3398f',
87
        '1d8764f0f7cd1352df6150045c8f638e517270e8b5dda1c63ade9c2280240cae',
88
        'd0edc9a91a1677435a953390865d208c55b3183c6759c9b5a7ff494c322558eb',
89
        '6073c436dcd064a48127ddbf6032ac1a66fd59a0c24434f070d4e564c124c897',
90
        'ca993121846c464d666096d35f13bf44c1b05af205f9b4a1e00cf6cc10c5e511'
91
    );
92
93
    /**
94
     * @param string $appId Application id for the running application
95
     * @param string|null $attestDir Directory where trusted attestation roots may be found
96
     * @throws Error If OpenSSL older than 1.0.0 is used
97
     */
98
    public function __construct($appId, $attestDir = null)
99
    {
100
        if(OPENSSL_VERSION_NUMBER < 0x10000000) {
101
            throw new Error('OpenSSL has to be at least version 1.0.0, this is ' . OPENSSL_VERSION_TEXT, ERR_OLD_OPENSSL);
102
        }
103
        $this->appId = $appId;
104
        $this->attestDir = $attestDir;
105
    }
106
107
    /**
108
     * Called to get a registration request to send to a user.
109
     * Returns an array of one registration request and a array of sign requests.
110
     *
111
     * @param array $registrations List of current registrations for this
112
     * user, to prevent the user from registering the same authenticator several
113
     * times.
114
     * @return array An array of two elements, the first containing a
115
     * RegisterRequest the second being an array of SignRequest
116
     * @throws Error
117
     */
118
    public function getRegisterData(array $registrations = array())
119
    {
120
        $challenge = $this->createChallenge();
121
        $request = new RegisterRequest($challenge, $this->appId);
122
        $signs = $this->getAuthenticateData($registrations);
123
        return array($request, $signs);
124
    }
125
126
    /**
127
     * Called to verify and unpack a registration message.
128
     *
129
     * @param RegisterRequest $request this is a reply to
130
     * @param object $response response from a user
131
     * @param bool $includeCert set to true if the attestation certificate should be
132
     * included in the returned Registration object
133
     * @return Registration
134
     * @throws Error
135
     */
136
    public function doRegister($request, $response, $includeCert = true)
137
    {
138
        if( !is_object( $request ) ) {
139
            throw new \InvalidArgumentException('$request of doRegister() method only accepts object.');
140
        }
141
142
        if( !is_object( $response ) ) {
143
            throw new \InvalidArgumentException('$response of doRegister() method only accepts object.');
144
        }
145
146 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...
147
            throw new Error('User-agent returned error. Error code: ' . $response->errorCode, ERR_BAD_UA_RETURNING );
148
        }
149
150
        if( !is_bool( $includeCert ) ) {
151
            throw new \InvalidArgumentException('$include_cert of doRegister() method only accepts boolean.');
152
        }
153
154
        $rawReg = $this->base64u_decode($response->registrationData);
155
        $regData = array_values(unpack('C*', $rawReg));
156
        $clientData = $this->base64u_decode($response->clientData);
157
        $cli = json_decode($clientData);
158
159
        if($cli->challenge !== $request->challenge) {
160
            throw new Error('Registration challenge does not match', ERR_UNMATCHED_CHALLENGE );
161
        }
162
163
        $registration = new Registration();
164
        $offs = 1;
165
        $pubKey = substr($rawReg, $offs, PUBKEY_LEN);
166
        $offs += PUBKEY_LEN;
167
        // decode the pubKey to make sure it's good
168
        $tmpKey = $this->pubkey_to_pem($pubKey);
169
        if($tmpKey === null) {
170
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
171
        }
172
        $registration->publicKey = base64_encode($pubKey);
173
        $khLen = $regData[$offs++];
174
        $kh = substr($rawReg, $offs, $khLen);
175
        $offs += $khLen;
176
        $registration->keyHandle = $this->base64u_encode($kh);
177
178
        // length of certificate is stored in byte 3 and 4 (excluding the first 4 bytes)
179
        $certLen = 4;
180
        $certLen += ($regData[$offs + 2] << 8);
181
        $certLen += $regData[$offs + 3];
182
183
        $rawCert = $this->fixSignatureUnusedBits(substr($rawReg, $offs, $certLen));
184
        $offs += $certLen;
185
        $pemCert  = "-----BEGIN CERTIFICATE-----\r\n";
186
        $pemCert .= chunk_split(base64_encode($rawCert), 64);
187
        $pemCert .= "-----END CERTIFICATE-----";
188
        if($includeCert) {
189
            $registration->certificate = base64_encode($rawCert);
190
        }
191
        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...
192
            if(openssl_x509_checkpurpose($pemCert, -1, $this->get_certs()) !== true) {
193
                throw new Error('Attestation certificate can not be validated', ERR_ATTESTATION_VERIFICATION );
194
            }
195
        }
196
197
        if(!openssl_pkey_get_public($pemCert)) {
198
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
199
        }
200
        $signature = substr($rawReg, $offs);
201
202
        $dataToVerify  = pack('C', 0);
203
        $dataToVerify .= hash('sha256', $request->appId, true);
204
        $dataToVerify .= hash('sha256', $clientData, true);
205
        $dataToVerify .= $kh;
206
        $dataToVerify .= $pubKey;
207
208
        if(openssl_verify($dataToVerify, $signature, $pemCert, 'sha256') === 1) {
209
            return $registration;
210
        } else {
211
            throw new Error('Attestation signature does not match', ERR_ATTESTATION_SIGNATURE );
212
        }
213
    }
214
215
    /**
216
     * Called to get an authentication request.
217
     *
218
     * @param array $registrations An array of the registrations to create authentication requests for.
219
     * @return array An array of SignRequest
220
     * @throws Error
221
     */
222
    public function getAuthenticateData(array $registrations)
223
    {
224
        $sigs = array();
225
        $challenge = $this->createChallenge();
226
        foreach ($registrations as $reg) {
227
            if( !is_object( $reg ) ) {
228
                throw new \InvalidArgumentException('$registrations of getAuthenticateData() method only accepts array of object.');
229
            }
230
            /** @var Registration $reg */
231
232
            $sig = new SignRequest();
233
            $sig->appId = $this->appId;
234
            $sig->keyHandle = $reg->keyHandle;
235
            $sig->challenge = $challenge;
236
            $sigs[] = $sig;
237
        }
238
        return $sigs;
239
    }
240
241
    /**
242
     * Called to verify an authentication response
243
     *
244
     * @param array $requests An array of outstanding authentication requests
245
     * @param array $registrations An array of current registrations
246
     * @param object $response A response from the authenticator
247
     * @return Registration
248
     * @throws Error
249
     *
250
     * The Registration object returned on success contains an updated counter
251
     * that should be saved for future authentications.
252
     * If the Error returned is ERR_COUNTER_TOO_LOW this is an indication of
253
     * token cloning or similar and appropriate action should be taken.
254
     */
255
    public function doAuthenticate(array $requests, array $registrations, $response)
256
    {
257
        if( !is_object( $response ) ) {
258
            throw new \InvalidArgumentException('$response of doAuthenticate() method only accepts object.');
259
        }
260
261 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...
262
            throw new Error('User-agent returned error. Error code: ' . $response->errorCode, ERR_BAD_UA_RETURNING );
263
        }
264
265
        /** @var object|null $req */
266
        $req = null;
267
268
        /** @var object|null $reg */
269
        $reg = null;
270
271
        $clientData = $this->base64u_decode($response->clientData);
272
        $decodedClient = json_decode($clientData);
273
        foreach ($requests as $req) {
274
            if( !is_object( $req ) ) {
275
                throw new \InvalidArgumentException('$requests of doAuthenticate() method only accepts array of object.');
276
            }
277
278
            if($req->keyHandle === $response->keyHandle && $req->challenge === $decodedClient->challenge) {
279
                break;
280
            }
281
282
            $req = null;
283
        }
284
        if($req === null) {
285
            throw new Error('No matching request found', ERR_NO_MATCHING_REQUEST );
286
        }
287
        foreach ($registrations as $reg) {
288
            if( !is_object( $reg ) ) {
289
                throw new \InvalidArgumentException('$registrations of doAuthenticate() method only accepts array of object.');
290
            }
291
292
            if($reg->keyHandle === $response->keyHandle) {
293
                break;
294
            }
295
            $reg = null;
296
        }
297
        if($reg === null) {
298
            throw new Error('No matching registration found', ERR_NO_MATCHING_REGISTRATION );
299
        }
300
        $pemKey = $this->pubkey_to_pem($this->base64u_decode($reg->publicKey));
301
        if($pemKey === null) {
302
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
303
        }
304
305
        $signData = $this->base64u_decode($response->signatureData);
306
        $dataToVerify  = hash('sha256', $req->appId, true);
307
        $dataToVerify .= substr($signData, 0, 5);
308
        $dataToVerify .= hash('sha256', $clientData, true);
309
        $signature = substr($signData, 5);
310
311
        if(openssl_verify($dataToVerify, $signature, $pemKey, 'sha256') === 1) {
312
            $ctr = unpack("Nctr", substr($signData, 1, 4));
313
            $counter = $ctr['ctr'];
314
            /* TODO: wrap-around should be handled somehow.. */
315
            if($counter > $reg->counter) {
316
                $reg->counter = $counter;
317
                return self::castObjectToRegistration($reg);
318
            } else {
319
                throw new Error('Counter too low.', ERR_COUNTER_TOO_LOW );
320
            }
321
        } else {
322
            throw new Error('Authentication failed', ERR_AUTHENTICATION_FAILURE );
323
        }
324
    }
325
326
    /**
327
     * @param object $object
328
     * @return Registration
329
     */
330
    protected static function castObjectToRegistration($object)
331
    {
332
        $reg = new Registration();
333
        if (property_exists($object, 'publicKey')) {
334
            $reg->publicKey = $object->publicKey;
335
        }
336
        if (property_exists($object, 'certificate')) {
337
            $reg->certificate = $object->certificate;
338
        }
339
        if (property_exists($object, 'counter')) {
340
            $reg->counter = $object->counter;
341
        }
342
        if (property_exists($object, 'keyHandle')) {
343
            $reg->keyHandle = $object->keyHandle;
344
        }
345
        return $reg;
346
    }
347
348
    /**
349
     * @return array
350
     */
351
    private function get_certs()
352
    {
353
        $files = array();
354
        $dir = $this->attestDir;
355
        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...
356
            while(false !== ($entry = readdir($handle))) {
357
                if(is_file("$dir/$entry")) {
358
                    $files[] = "$dir/$entry";
359
                }
360
            }
361
            closedir($handle);
362
        }
363
        return $files;
364
    }
365
366
    /**
367
     * @param string $data
368
     * @return string
369
     */
370
    private function base64u_encode($data)
371
    {
372
        return trim(strtr(base64_encode($data), '+/', '-_'), '=');
373
    }
374
375
    /**
376
     * @param string $data
377
     * @return string
378
     */
379
    private function base64u_decode($data)
380
    {
381
        return base64_decode(strtr($data, '-_', '+/'));
382
    }
383
384
    /**
385
     * @param string $key
386
     * @return null|string
387
     */
388
    private function pubkey_to_pem($key)
389
    {
390
        if(strlen($key) !== PUBKEY_LEN || $key[0] !== "\x04") {
391
            return null;
392
        }
393
394
        /*
395
         * Convert the public key to binary DER format first
396
         * Using the ECC SubjectPublicKeyInfo OIDs from RFC 5480
397
         *
398
         *  SEQUENCE(2 elem)                        30 59
399
         *   SEQUENCE(2 elem)                       30 13
400
         *    OID1.2.840.10045.2.1 (id-ecPublicKey) 06 07 2a 86 48 ce 3d 02 01
401
         *    OID1.2.840.10045.3.1.7 (secp256r1)    06 08 2a 86 48 ce 3d 03 01 07
402
         *   BIT STRING(520 bit)                    03 42 ..key..
403
         */
404
        $der  = "\x30\x59\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01";
405
        $der .= "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07\x03\x42";
406
        $der .= "\0".$key;
407
408
        $pem  = "-----BEGIN PUBLIC KEY-----\r\n";
409
        $pem .= chunk_split(base64_encode($der), 64);
410
        $pem .= "-----END PUBLIC KEY-----";
411
412
        return $pem;
413
    }
414
415
    /**
416
     * @return string
417
     * @throws Error
418
     */
419
    private function createChallenge()
420
    {
421
        $challenge = random_bytes(32);
422
        $challenge = $this->base64u_encode( $challenge );
423
424
        return $challenge;
425
    }
426
427
    /**
428
     * Fixes a certificate where the signature contains unused bits.
429
     *
430
     * @param string $cert
431
     * @return mixed
432
     */
433
    private function fixSignatureUnusedBits($cert)
434
    {
435
        if(in_array(hash('sha256', $cert), $this->FIXCERTS, true)) {
436
            $cert[strlen($cert) - 257] = "\0";
437
        }
438
        return $cert;
439
    }
440
}
441
442
/**
443
 * Class for building a registration request
444
 *
445
 * @package u2flib_server
446
 */
447
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...
448
{
449
    /** @var string Protocol version */
450
    public $version = U2F_VERSION;
451
452
    /** @var string Registration challenge */
453
    public $challenge;
454
455
    /** @var string Application id */
456
    public $appId;
457
458
    /**
459
     * @param string $challenge
460
     * @param string $appId
461
     * @internal
462
     */
463
    public function __construct($challenge, $appId)
464
    {
465
        $this->challenge = $challenge;
466
        $this->appId = $appId;
467
    }
468
}
469
470
/**
471
 * Class for building up an authentication request
472
 *
473
 * @package u2flib_server
474
 */
475
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...
476
{
477
    /** @var string Protocol version */
478
    public $version = U2F_VERSION;
479
480
    /** @var string Authentication challenge */
481
    public $challenge = '';
482
483
    /** @var string Key handle of a registered authenticator */
484
    public $keyHandle = '';
485
486
    /** @var string Application id */
487
    public $appId = '';
488
}
489
490
/**
491
 * Class returned for successful registrations
492
 *
493
 * @package u2flib_server
494
 */
495
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...
496
{
497
    /** @var string The key handle of the registered authenticator */
498
    public $keyHandle = '';
499
500
    /** @var string The public key of the registered authenticator */
501
    public $publicKey = '';
502
503
    /** @var string The attestation certificate of the registered authenticator */
504
    public $certificate = '';
505
506
    /** @var int The counter associated with this registration */
507
    public $counter = -1;
508
}
509
510
/**
511
 * Error class, returned on errors
512
 *
513
 * @package u2flib_server
514
 */
515
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...
516
{
517
    /**
518
     * Override constructor and make message and code mandatory
519
     * @param string $message
520
     * @param int $code
521
     * @param \Exception|null $previous
522
     */
523
    public function __construct($message, $code, \Exception $previous = null) {
524
        parent::__construct($message, $code, $previous);
525
    }
526
}
527