Completed
Pull Request — master (#70)
by
unknown
01:32
created

U2F::getCerts()   A

Complexity

Conditions 5
Paths 2

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 2
nop 0
1
<?php
2
3
declare(strict_types=1);
4
5
/* Copyright (c) 2014 Yubico AB
6
 * All rights reserved.
7
 *
8
 * Redistribution and use in source and binary forms, with or without
9
 * modification, are permitted provided that the following conditions are
10
 * met:
11
 *
12
 *   * Redistributions of source code must retain the above copyright
13
 *     notice, this list of conditions and the following disclaimer.
14
 *
15
 *   * Redistributions in binary form must reproduce the above
16
 *     copyright notice, this list of conditions and the following
17
 *     disclaimer in the documentation and/or other materials provided
18
 *     with the distribution.
19
 *
20
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
 */
32
33
namespace u2flib_server;
34
35
use InvalidArgumentException;
36
use ParagonIE\ConstantTime\Encoding;
37
38
/** Constant for the version of the u2f protocol */
39
const U2F_VERSION = 'U2F_V2';
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
/** @internal */
79
const PUBKEY_LEN = 65;
80
81
/**
82
 * Class U2F
83
 *
84
 * @package u2flib_server
85
 */
86
class U2F
87
{
88
    /** @var string  */
89
    private $appId;
90
91
    /** @var null|string */
92
    private $attestDir;
93
94
    /** @internal */
95
    private $fixCerts = [
96
        '349bca1031f8c82c4ceca38b9cebf1a69df9fb3b94eed99eb3fb9aa3822d26e8',
97
        'dd574527df608e47ae45fbba75a2afdd5c20fd94a02419381813cd55a2a3398f',
98
        '1d8764f0f7cd1352df6150045c8f638e517270e8b5dda1c63ade9c2280240cae',
99
        'd0edc9a91a1677435a953390865d208c55b3183c6759c9b5a7ff494c322558eb',
100
        '6073c436dcd064a48127ddbf6032ac1a66fd59a0c24434f070d4e564c124c897',
101
        'ca993121846c464d666096d35f13bf44c1b05af205f9b4a1e00cf6cc10c5e511'
102
    ];
103
104
    const HASH_ALGORITHM = 'sha256';
105
106
    /**
107
     * U2F constructor.
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
     *
112
     * @throws Error If OpenSSL older than 1.0.0 is used
113
     */
114
    public function __construct(string $appId, string $attestDir = null)
115
    {
116
        if (OPENSSL_VERSION_NUMBER < 0x10000000) {
117
            throw new Error(
118
                'OpenSSL has to be at least version 1.0.0, this is ' . OPENSSL_VERSION_TEXT,
119
                ERR_OLD_OPENSSL
120
            );
121
        }
122
123
        $this->appId = $appId;
124
        $this->attestDir = $attestDir;
125
    }
126
127
    /**
128
     * Called to get a registration request to send to a user.
129
     * Returns an array of one registration request and a array of sign requests.
130
     *
131
     * @param array $registrations List of current registrations for this
132
     *                             user, to prevent the user from registering the same authenticator several
133
     *                             times.
134
     *
135
     * @return array An array of two elements, the first containing a
136
     * RegisterRequest the second being an array of SignRequest
137
     *
138
     * @throws \Exception
139
     */
140
    public function getRegisterData(array $registrations = []): array
141
    {
142
        $challenge = Utility::createChallenge();
143
        $request = new RegisterRequest($challenge, $this->appId);
144
        $signs = $this->getAuthenticateData($registrations);
145
146
        return [$request, $signs];
147
    }
148
149
    /**
150
     * Called to verify and unpack a registration message.
151
     *
152
     * @param RegisterRequest $request this is a reply to
153
     * @param object $response response from a user
154
     * @param bool $includeCert set to true if the attestation certificate should be
155
     * included in the returned Registration object
156
     * @return Registration
157
     * @throws Error
158
     */
159
    public function doRegister($request, $response, $includeCert = true): Registration
160
    {
161
        if (!is_object($request)) {
162
            throw new InvalidArgumentException('$request of doRegister() method only accepts object.');
163
        }
164
165
        if (!is_object($response)) {
166
            throw new InvalidArgumentException('$response of doRegister() method only accepts object.');
167
        }
168
169 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...
170
            throw new Error(
171
                'User-agent returned error. Error code: ' . $response->errorCode,
172
                ERR_BAD_UA_RETURNING
173
            );
174
        }
175
176
        if (!is_bool($includeCert)) {
177
            throw new InvalidArgumentException('$include_cert of doRegister() method only accepts boolean.');
178
        }
179
180
        $rawReg = Convert::base64uDecode($response->registrationData);
181
        $regData = array_values(unpack('C*', $rawReg));
182
183
        $clientData = Convert::base64uDecode($response->clientData);
184
        $cli = json_decode($clientData);
185
186
        if ($cli->challenge !== $request->challenge) {
187
            throw new Error('Registration challenge does not match', ERR_UNMATCHED_CHALLENGE);
188
        }
189
190
        $registration = new Registration();
191
        $offs = 1;
192
        $pubKey = substr($rawReg, $offs, PUBKEY_LEN);
193
        $offs += PUBKEY_LEN;
194
        // decode the pubKey to make sure it's good
195
        $tmpKey = Convert::pubkeyToPem($pubKey);
196
197
        if ($tmpKey === null) {
198
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE);
199
        }
200
201
        $registration->publicKey = Encoding::base64Encode($pubKey);
202
        $khLen = $regData[$offs++];
203
        $kh = substr($rawReg, $offs, $khLen);
204
        $offs += $khLen;
205
        $registration->keyHandle = Convert::base64uEncode($kh);
206
207
        // length of certificate is stored in byte 3 and 4 (excluding the first 4 bytes)
208
        $certLen = 4;
209
        $certLen += ($regData[$offs + 2] << 8);
210
        $certLen += $regData[$offs + 3];
211
212
        $rawCert = $this->fixSignatureUnusedBits(substr($rawReg, $offs, $certLen));
213
        $offs += $certLen;
214
215
        $pemCert  = "-----BEGIN CERTIFICATE-----\r\n";
216
        $pemCert .= chunk_split(Encoding::base64Encode($rawCert), 64);
217
        $pemCert .= '-----END CERTIFICATE-----';
218
219
        if ($includeCert) {
220
            $registration->certificate = Encoding::base64Encode($rawCert);
221
        }
222
223
        if ($this->attestDir && openssl_x509_checkpurpose($pemCert, -1, $this->getCerts()) !== true) {
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...
224
            throw new Error(
225
                'Attestation certificate can not be validated',
226
                ERR_ATTESTATION_VERIFICATION
227
            );
228
        }
229
230
        if (!openssl_pkey_get_public($pemCert)) {
231
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE);
232
        }
233
234
        $signature = substr($rawReg, $offs);
235
236
        $dataToVerify  = chr(0);
237
        $dataToVerify .= hash(static::HASH_ALGORITHM, $request->appId, true);
238
        $dataToVerify .= hash(static::HASH_ALGORITHM, $clientData, true);
239
        $dataToVerify .= $kh;
240
        $dataToVerify .= $pubKey;
241
242
        if (openssl_verify($dataToVerify, $signature, $pemCert, static::HASH_ALGORITHM) === 1) {
243
            return $registration;
244
        }
245
246
        throw new Error('Attestation signature does not match', ERR_ATTESTATION_SIGNATURE);
247
    }
248
249
    /**
250
     * Called to get an authentication request.
251
     *
252
     * @param array $registrations An array of the registrations to create authentication requests for.
253
     *
254
     * @return array An array of SignRequest
255
     * @throws \Exception
256
     */
257
    public function getAuthenticateData(array $registrations): array
258
    {
259
        $sigs = [];
260
261
        $challenge = Utility::createChallenge();
262
263
        foreach ($registrations as $reg) {
264
            if (!is_object($reg)) {
265
                throw new InvalidArgumentException(
266
                    '$registrations of getAuthenticateData() method only accepts array of object.'
267
                );
268
            }
269
270
            $sig = new SignRequest();
271
            $sig->appId = $this->appId;
272
            $sig->keyHandle = $reg->keyHandle;
273
            $sig->challenge = $challenge;
274
275
            $sigs[] = $sig;
276
        }
277
278
        return $sigs;
279
    }
280
281
    /**
282
     * Called to verify an authentication response
283
     *
284
     * @param array $requests An array of outstanding authentication requests
285
     * @param array $registrations An array of current registrations
286
     * @param object $response A response from the authenticator
287
     * @return Registration
288
     * @throws Error
289
     *
290
     * The Registration object returned on success contains an updated counter
291
     * that should be saved for future authentications.
292
     * If the Error returned is ERR_COUNTER_TOO_LOW this is an indication of
293
     * token cloning or similar and appropriate action should be taken.
294
     */
295
    public function doAuthenticate(array $requests, array $registrations, $response)
296
    {
297
        if (!is_object($response)) {
298
            throw new InvalidArgumentException('$response of doAuthenticate() method only accepts object.');
299
        }
300
301 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...
302
            throw new Error(
303
                'User-agent returned error. Error code: ' . $response->errorCode,
304
                ERR_BAD_UA_RETURNING
305
            );
306
        }
307
308
        /** @var object|null $request */
309
        $request = null;
310
311
        /** @var object|null $registration */
312
        $registration = null;
313
314
        $clientData = Convert::base64uDecode($response->clientData);
315
        $decodedClient = json_decode($clientData);
316
317
        foreach ($requests as $request) {
318
            if (!is_object($request)) {
319
                throw new InvalidArgumentException(
320
                    '$requests of doAuthenticate() method only accepts array of object.'
321
                );
322
            }
323
324
            if ($request->keyHandle === $response->keyHandle && $request->challenge === $decodedClient->challenge) {
325
                break;
326
            }
327
328
            $request = null;
329
        }
330
331
        if ($request === null) {
332
            throw new Error('No matching request found', ERR_NO_MATCHING_REQUEST);
333
        }
334
335
        foreach ($registrations as $registration) {
336
            if (!is_object($registration)) {
337
                throw new InvalidArgumentException(
338
                    '$registrations of doAuthenticate() method only accepts array of object.'
339
                );
340
            }
341
342
            if ($registration->keyHandle === $response->keyHandle) {
343
                break;
344
            }
345
346
            $registration = null;
347
        }
348
349
        if ($registration === null) {
350
            throw new Error('No matching registration found', ERR_NO_MATCHING_REGISTRATION);
351
        }
352
353
        $pemKey = Convert::pubkeyToPem(Convert::base64uDecode($registration->publicKey));
354
355
        if ($pemKey === null) {
356
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE);
357
        }
358
359
        $signData = Convert::base64uDecode($response->signatureData);
360
361
        $dataToVerify  = hash(static::HASH_ALGORITHM, $request->appId, true);
362
        $dataToVerify .= substr($signData, 0, 5);
363
        $dataToVerify .= hash(static::HASH_ALGORITHM, $clientData, true);
364
365
        $signature = substr($signData, 5);
366
367
        if (openssl_verify($dataToVerify, $signature, $pemKey, static::HASH_ALGORITHM) === 1) {
368
            $ctr = unpack('Nctr', substr($signData, 1, 4));
369
            $counter = $ctr['ctr'];
370
371
            /* TODO: wrap-around should be handled somehow.. */
372
            if ($counter > $registration->counter) {
373
                $registration->counter = $counter;
374
375
                return $registration;
376
            }
377
378
            throw new Error('Counter too low.', ERR_COUNTER_TOO_LOW);
379
        }
380
381
        throw new Error('Authentication failed', ERR_AUTHENTICATION_FAILURE);
382
    }
383
384
    /**
385
     * @return array
386
     */
387
    private function getCerts(): array
388
    {
389
        $files = [];
390
        $dir = $this->attestDir;
391
392
        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...
393
            while (($entry = readdir($handle)) !== false) {
394
                if (is_file("$dir/$entry")) {
395
                    $files[] = "$dir/$entry";
396
                }
397
            }
398
399
            closedir($handle);
400
        }
401
402
        return $files;
403
    }
404
405
    /**
406
     * Fixes a certificate where the signature contains unused bits.
407
     *
408
     * @param string $cert
409
     * @return string
410
     */
411
    private function fixSignatureUnusedBits(string $cert): string
412
    {
413
        if (in_array(hash(static::HASH_ALGORITHM, $cert), $this->fixCerts, true)) {
414
            $cert[strlen($cert) - 257] = "\0";
415
        }
416
417
        return $cert;
418
    }
419
}
420