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