Completed
Pull Request — master (#62)
by
unknown
02:52
created

U2F::pubkey_to_pem()   B

Complexity

Conditions 3
Paths 2

Size

Total Lines 26
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 10
nc 2
nop 1
dl 0
loc 26
rs 8.8571
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
/** 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  = chr(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
231
            $sig = new SignRequest();
232
            $sig->appId = $this->appId;
233
            $sig->keyHandle = $reg->keyHandle;
234
            $sig->challenge = $challenge;
235
            $sigs[] = $sig;
236
        }
237
        return $sigs;
238
    }
239
240
    /**
241
     * Called to verify an authentication response.
242
     *
243
     * @param array $requests An array of outstanding authentication requests
244
     * @param array $registrations An array of current registrations
245
     * @param object $response A response from the authenticator
246
     * @return Registration
247
     * @throws Error
248
     *
249
     * The Registration object returned on success contains an updated counter
250
     * that should be saved for future authentications.
251
     * If the Error returned is ERR_COUNTER_TOO_LOW this is an indication of
252
     * token cloning or similar and appropriate action should be taken.
253
     */
254
    public function doAuthenticate(array $requests, array $registrations, $response)
255
    {
256
        if( !is_object( $response ) ) {
257
            throw new \InvalidArgumentException('$response of doAuthenticate() method only accepts object.');
258
        }
259
260 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...
261
            throw new Error('User-agent returned error. Error code: ' . $response->errorCode, ERR_BAD_UA_RETURNING );
262
        }
263
264
        /** @var object|null $req */
265
        $req = null;
266
267
        /** @var object|null $reg */
268
        $reg = null;
269
270
        $clientData = $this->base64u_decode($response->clientData);
271
        $decodedClient = json_decode($clientData);
272
        foreach ($requests as $req) {
273
            if( !is_object( $req ) ) {
274
                throw new \InvalidArgumentException('$requests of doAuthenticate() method only accepts array of object.');
275
            }
276
277
            if($req->keyHandle === $response->keyHandle && $req->challenge === $decodedClient->challenge) {
278
                break;
279
            }
280
281
            $req = null;
282
        }
283
        if($req === null) {
284
            throw new Error('No matching request found', ERR_NO_MATCHING_REQUEST );
285
        }
286
        foreach ($registrations as $reg) {
287
            if( !is_object( $reg ) ) {
288
                throw new \InvalidArgumentException('$registrations of doAuthenticate() method only accepts array of object.');
289
            }
290
291
            if($reg->keyHandle === $response->keyHandle) {
292
                break;
293
            }
294
            $reg = null;
295
        }
296
        if($reg === null) {
297
            throw new Error('No matching registration found', ERR_NO_MATCHING_REGISTRATION );
298
        }
299
        $pemKey = $this->pubkey_to_pem($this->base64u_decode($reg->publicKey));
300
        if($pemKey === null) {
301
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
302
        }
303
304
        $signData = $this->base64u_decode($response->signatureData);
305
        $dataToVerify  = hash('sha256', $req->appId, true);
306
        $dataToVerify .= substr($signData, 0, 5);
307
        $dataToVerify .= hash('sha256', $clientData, true);
308
        $signature = substr($signData, 5);
309
310
        if(openssl_verify($dataToVerify, $signature, $pemKey, 'sha256') === 1) {
311
            $ctr = unpack("Nctr", substr($signData, 1, 4));
312
            $counter = $ctr['ctr'];
313
            /* TODO: wrap-around should be handled somehow.. */
314
            if($counter > $reg->counter) {
315
                $reg->counter = $counter;
316
                return $reg;
317
            } else {
318
                throw new Error('Counter too low.', ERR_COUNTER_TOO_LOW );
319
            }
320
        } else {
321
            throw new Error('Authentication failed', ERR_AUTHENTICATION_FAILURE );
322
        }
323
    }
324
325
    /**
326
     * @return array
327
     */
328
    private function get_certs()
329
    {
330
        $files = array();
331
        $dir = $this->attestDir;
332
        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...
333
            while(false !== ($entry = readdir($handle))) {
334
                if(is_file("$dir/$entry")) {
335
                    $files[] = "$dir/$entry";
336
                }
337
            }
338
            closedir($handle);
339
        }
340
        return $files;
341
    }
342
343
    /**
344
     * Retrieves the challenge from a raw response.
345
     * @param object $response A response from the authenticator
346
     * @return mixed the decoded client data
347
     */
348
    public function extractChallenge($response)
349
    {
350
        $clientData = $this->base64u_decode($response->clientData);
351
        $cli = json_decode($clientData);
352
353
        return $cli->challenge;
354
    }
355
356
    /**
357
     * @param string $data
358
     * @return string
359
     */
360
    private function base64u_encode($data)
361
    {
362
        return trim(strtr(base64_encode($data), '+/', '-_'), '=');
363
    }
364
365
    /**
366
     * @param string $data
367
     * @return string
368
     */
369
    private function base64u_decode($data)
370
    {
371
        return base64_decode(strtr($data, '-_', '+/'));
372
    }
373
374
    /**
375
     * @param string $key
376
     * @return null|string
377
     */
378
    private function pubkey_to_pem($key)
379
    {
380
        if(strlen($key) !== PUBKEY_LEN || $key[0] !== "\x04") {
381
            return null;
382
        }
383
384
        /*
385
         * Convert the public key to binary DER format first
386
         * Using the ECC SubjectPublicKeyInfo OIDs from RFC 5480
387
         *
388
         *  SEQUENCE(2 elem)                        30 59
389
         *   SEQUENCE(2 elem)                       30 13
390
         *    OID1.2.840.10045.2.1 (id-ecPublicKey) 06 07 2a 86 48 ce 3d 02 01
391
         *    OID1.2.840.10045.3.1.7 (secp256r1)    06 08 2a 86 48 ce 3d 03 01 07
392
         *   BIT STRING(520 bit)                    03 42 ..key..
393
         */
394
        $der  = "\x30\x59\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01";
395
        $der .= "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07\x03\x42";
396
        $der .= "\0".$key;
397
398
        $pem  = "-----BEGIN PUBLIC KEY-----\r\n";
399
        $pem .= chunk_split(base64_encode($der), 64);
400
        $pem .= "-----END PUBLIC KEY-----";
401
402
        return $pem;
403
    }
404
405
    /**
406
     * called to create a challenge. For testing purposes, the U2F class can be inherited,
407
     * and this method overridden to always return the same challenge.
408
     *
409
     * @return string the challenge
410
     * @throws Error if openssl doesn't have cryptographically strong source
411
     */
412
    protected function createChallenge()
413
    {
414
        $challenge = openssl_random_pseudo_bytes(32, $crypto_strong );
415
        if( $crypto_strong !== true ) {
416
            throw new Error('Unable to obtain a good source of randomness', ERR_BAD_RANDOM);
417
        }
418
419
        $challenge = $this->base64u_encode( $challenge );
420
421
        return $challenge;
422
    }
423
424
    /**
425
     * Fixes a certificate where the signature contains unused bits.
426
     *
427
     * @param string $cert
428
     * @return mixed
429
     */
430
    private function fixSignatureUnusedBits($cert)
431
    {
432
        if(in_array(hash('sha256', $cert), $this->FIXCERTS)) {
433
            $cert[strlen($cert) - 257] = "\0";
434
        }
435
        return $cert;
436
    }
437
}
438