Completed
Push — master ( 80937c...137639 )
by Paul
08:49
created

U2F.php (2 issues)

Labels
Severity

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 *
4
 * @copyright (c) 2015, Paul Sohier
5
 * @copyright (c) 2014 Yubico AB
6
 * @license BSD-2-Clause
7
 *
8
 *
9
 * Orignal Copyright:
10
 * Copyright (c) 2014 Yubico AB
11
 * All rights reserved.
12
 *
13
 * Redistribution and use in source and binary forms, with or without
14
 * modification, are permitted provided that the following conditions are
15
 * met:
16
 *
17
 *   * Redistributions of source code must retain the above copyright
18
 *     notice, this list of conditions and the following disclaimer.
19
 *
20
 *   * Redistributions in binary form must reproduce the above
21
 *     copyright notice, this list of conditions and the following
22
 *     disclaimer in the documentation and/or other materials provided
23
 *     with the distribution.
24
 *
25
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
26
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
27
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
28
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
29
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
30
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
31
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
32
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
33
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
34
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
35
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36
 */
37
38
namespace paul999\u2f;
39
40
/** Constant for the version of the u2f protocol */
41
use paul999\u2f\Exceptions\U2fError;
42
43
44
class U2F implements U2F_interface
45
{
46
    /** @var string */
47
    private $appId;
48
49
    /** @var null|string */
50
    private $attestDir;
51
52
    /** @internal */
53
    private $FIXCERTS = array(
54
        '349bca1031f8c82c4ceca38b9cebf1a69df9fb3b94eed99eb3fb9aa3822d26e8',
55
        'dd574527df608e47ae45fbba75a2afdd5c20fd94a02419381813cd55a2a3398f',
56
        '1d8764f0f7cd1352df6150045c8f638e517270e8b5dda1c63ade9c2280240cae',
57
        'd0edc9a91a1677435a953390865d208c55b3183c6759c9b5a7ff494c322558eb',
58
        '6073c436dcd064a48127ddbf6032ac1a66fd59a0c24434f070d4e564c124c897',
59
        'ca993121846c464d666096d35f13bf44c1b05af205f9b4a1e00cf6cc10c5e511'
60
    );
61
62
    /**
63
     * @param string $appId Application id for the running application
64
     * @param string|null $attestDir Directory where trusted attestation roots may be found
65
     * @throws U2fError If OpenSSL older than 1.0.0 is used
66
     */
67
    public function __construct($appId, $attestDir = null)
68
    {
69
        if (OPENSSL_VERSION_NUMBER < 0x10000000) {
70
            throw new U2fError('OpenSSL has to be at least version 1.0.0, this is ' . OPENSSL_VERSION_TEXT, U2fError::ERR_OLD_OPENSSL);
71
        }
72
        $this->appId = $appId;
73
        $this->attestDir = $attestDir;
74
    }
75
76
    public function getRegisterData(array $registrations = array())
77
    {
78
        $challenge = $this->createChallenge();
79
        $request = new RegisterRequest($challenge, $this->appId);
80
        $signs = $this->getAuthenticateData($registrations);
81
        return array($request, $signs);
82
    }
83
84
    public function doRegister(RegisterRequest $request, RegisterResponse $response, $includeCert = true)
85
    {
86
        if ($response->errorCode != null) {
87
            throw new U2fError('User-agent returned error. Error code: ' . $response->errorCode, U2fError::ERR_BAD_UA_RETURNING);
88
        }
89
90
        if (!is_bool($includeCert)) {
91
            throw new \InvalidArgumentException('$include_cert of doRegister() method only accepts boolean.');
92
        }
93
94
        $rawReg = $this->base64u_decode($response->registrationData);
95
        $regData = array_values(unpack('C*', $rawReg));
96
        $clientData = $this->base64u_decode($response->clientData);
97
        $cli = json_decode($clientData);
98
99
        if ($cli->challenge !== $request->challenge) {
100
            throw new U2fError('Registration challenge does not match', U2fError::ERR_UNMATCHED_CHALLENGE);
101
        }
102
103
        $registration = new Registration();
104
        $offs = 1;
105
        $pubKey = substr($rawReg, $offs, self::PUBKEY_LEN);
106
        $offs += self::PUBKEY_LEN;
107
        // decode the pubKey to make sure it's good
108
        $tmpKey = $this->pubkey_to_pem($pubKey);
109
        if ($tmpKey === null) {
110
            throw new U2fError('Decoding of public key failed', U2fError::ERR_PUBKEY_DECODE);
111
        }
112
        $registration->publicKey = base64_encode($pubKey);
113
        $khLen = $regData[$offs++];
114
        $kh = substr($rawReg, $offs, $khLen);
115
        $offs += $khLen;
116
        $registration->keyHandle = $this->base64u_encode($kh);
117
118
        // length of certificate is stored in byte 3 and 4 (excluding the first 4 bytes)
119
        $certLen = 4;
120
        $certLen += ($regData[$offs + 2] << 8);
121
        $certLen += $regData[$offs + 3];
122
123
        $rawCert = $this->fixSignatureUnusedBits(substr($rawReg, $offs, $certLen));
124
        $offs += $certLen;
125
        $pemCert = "-----BEGIN CERTIFICATE-----\r\n";
126
        $pemCert .= chunk_split(base64_encode($rawCert), 64);
127
        $pemCert .= "-----END CERTIFICATE-----";
128
        if ($includeCert) {
129
            $registration->certificate = base64_encode($rawCert);
130
        }
131
        if ($this->attestDir) {
132
            if (openssl_x509_checkpurpose($pemCert, -1, $this->get_certs()) !== true) {
133
                throw new U2fError('Attestation certificate can not be validated', U2fError::ERR_ATTESTATION_VERIFICATION);
134
            }
135
        }
136
137
        if (!openssl_pkey_get_public($pemCert)) {
138
            throw new U2fError('Decoding of public key failed', U2fError::ERR_PUBKEY_DECODE);
139
        }
140
        $signature = substr($rawReg, $offs);
141
142
        $dataToVerify = chr(0);
143
        $dataToVerify .= hash('sha256', $request->appId, true);
144
        $dataToVerify .= hash('sha256', $clientData, true);
145
        $dataToVerify .= $kh;
146
        $dataToVerify .= $pubKey;
147
148
        if (openssl_verify($dataToVerify, $signature, $pemCert, 'sha256') === 1) {
149
            return $registration;
150
        } else {
151
            throw new U2fError('Attestation signature does not match', U2fError::ERR_ATTESTATION_SIGNATURE);
152
        }
153
    }
154
155
    public function getAuthenticateData(array $registrations)
156
    {
157
        $sigs = array();
158
        foreach ($registrations as $reg) {
159
            if (! ($reg instanceof Registration)) {
160
                throw new \InvalidArgumentException('$registrations of getAuthenticateData() method only accepts array of object.');
161
            }
162
            $sigs[] = new SignRequest($this->createChallenge(), $reg->keyHandle, $this->appId);
163
        }
164
        return $sigs;
165
    }
166
167
    public function doAuthenticate(array $requests, array $registrations, AuthenticationResponse $response)
168
    {
169
        if ($response->errorCode != null) {
170
            throw new U2fError('User-agent returned error. Error code: ' . $response->errorCode, U2fError::ERR_BAD_UA_RETURNING);
171
        }
172
173
        $clientData = $this->base64u_decode($response->clientData);
174
        $decodedClient = json_decode($clientData);
175
        /**
176
         * @var SignRequest $req
177
         */
178
        foreach ($requests as $req) {
179
            if (! ($req instanceof SignRequest)) {
180
                throw new \InvalidArgumentException('$requests of doAuthenticate() method only accepts array of SignRequest.');
181
            }
182
183
            if ($req->keyHandle === $response->keyHandle && $req->challenge === $decodedClient->challenge) {
184
                break;
185
            }
186
187
            $req = null;
188
        }
189
        if ($req === null) {
0 ignored issues
show
The variable $req does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
190
            throw new U2fError('No matching request found', U2fError::ERR_NO_MATCHING_REQUEST);
191
        }
192
        /**
193
         * @var Registration reg
194
         */
195
        foreach ($registrations as $reg) {
196
            if (!($reg instanceof Registration)) {
197
                throw new \InvalidArgumentException('$registrations of doAuthenticate() method only accepts array of Registration.');
198
            }
199
200
            if ($reg->keyHandle === $response->keyHandle) {
201
                break;
202
            }
203
            $reg = null;
204
        }
205
        if ($reg === null) {
0 ignored issues
show
The variable $reg does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
206
            throw new U2fError('No matching registration found', U2fError::ERR_NO_MATCHING_REGISTRATION);
207
        }
208
        $pemKey = $this->pubkey_to_pem($this->base64u_decode($reg->publicKey));
209
        if ($pemKey === null) {
210
            throw new U2fError('Decoding of public key failed', U2fError::ERR_PUBKEY_DECODE);
211
        }
212
213
        $signData = $this->base64u_decode($response->signatureData);
214
        $dataToVerify = hash('sha256', $req->appId, true);
215
        $dataToVerify .= substr($signData, 0, 5);
216
        $dataToVerify .= hash('sha256', $clientData, true);
217
        $signature = substr($signData, 5);
218
219
        if (openssl_verify($dataToVerify, $signature, $pemKey, 'sha256') === 1) {
220
            $ctr = unpack("Nctr", substr($signData, 1, 4));
221
            $counter = $ctr['ctr'];
222
            /* TODO: wrap-around should be handled somehow.. */
223
            if ($counter > $reg->counter) {
224
                $reg->counter = $counter;
225
                return $reg;
226
            } else {
227
                throw new U2fError('Counter too low.', U2fError::ERR_COUNTER_TOO_LOW);
228
            }
229
        } else {
230
            throw new U2fError('Authentication failed', U2fError::ERR_AUTHENTICATION_FAILURE);
231
        }
232
    }
233
234
    /**
235
     * @return array
236
     */
237
    private function get_certs()
238
    {
239
        $files = array();
240
        $dir = $this->attestDir;
241
        if ($dir && $handle = opendir($dir)) {
242
            while (false !== ($entry = readdir($handle))) {
243
                if (is_file("$dir/$entry")) {
244
                    $files[] = "$dir/$entry";
245
                }
246
            }
247
            closedir($handle);
248
        }
249
        return $files;
250
    }
251
252
    /**
253
     * @param string $data
254
     * @return string
255
     */
256
    private function base64u_encode($data)
257
    {
258
        return trim(strtr(base64_encode($data), '+/', '-_'), '=');
259
    }
260
261
    /**
262
     * @param string $data
263
     * @return string
264
     */
265
    private function base64u_decode($data)
266
    {
267
        return base64_decode(strtr($data, '-_', '+/'));
268
    }
269
270
    /**
271
     * @param string $key
272
     * @return null|string
273
     */
274
    private function pubkey_to_pem($key)
275
    {
276
        if (strlen($key) !== self::PUBKEY_LEN || $key[0] !== "\x04") {
277
            return null;
278
        }
279
280
        /*
281
         * Convert the public key to binary DER format first
282
         * Using the ECC SubjectPublicKeyInfo OIDs from RFC 5480
283
         *
284
         *  SEQUENCE(2 elem)                        30 59
285
         *   SEQUENCE(2 elem)                       30 13
286
         *    OID1.2.840.10045.2.1 (id-ecPublicKey) 06 07 2a 86 48 ce 3d 02 01
287
         *    OID1.2.840.10045.3.1.7 (secp256r1)    06 08 2a 86 48 ce 3d 03 01 07
288
         *   BIT STRING(520 bit)                    03 42 ..key..
289
         */
290
        $der = "\x30\x59\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01";
291
        $der .= "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07\x03\x42";
292
        $der .= "\0" . $key;
293
294
        $pem = "-----BEGIN PUBLIC KEY-----\r\n";
295
        $pem .= chunk_split(base64_encode($der), 64);
296
        $pem .= "-----END PUBLIC KEY-----";
297
298
        return $pem;
299
    }
300
301
    /**
302
     * @return string
303
     * @throws U2fError
304
     */
305
    private function createChallenge()
306
    {
307
        $challenge = openssl_random_pseudo_bytes(32, $crypto_strong);
308
        if ($crypto_strong !== true) {
309
            throw new U2fError('Unable to obtain a good source of randomness', U2fError::ERR_BAD_RANDOM);
310
        }
311
312
        $challenge = $this->base64u_encode($challenge);
313
314
        return $challenge;
315
    }
316
317
    /**
318
     * Fixes a certificate where the signature contains unused bits.
319
     *
320
     * @param string $cert
321
     * @return mixed
322
     */
323
    private function fixSignatureUnusedBits($cert)
324
    {
325
        if (in_array(hash('sha256', $cert), $this->FIXCERTS)) {
326
            $cert[strlen($cert) - 257] = "\0";
327
        }
328
        return $cert;
329
    }
330
}
331