Failed Conditions
Push — master ( 104856...f1eb38 )
by Florent
02:37
created

RegistrationResponse::retrieveClientData()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 3
nc 2
nop 1
dl 0
loc 7
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * The MIT License (MIT)
7
 *
8
 * Copyright (c) 2018 Spomky-Labs
9
 *
10
 * This software may be modified and distributed under the terms
11
 * of the MIT license.  See the LICENSE file for details.
12
 */
13
14
namespace U2FAuthentication\Fido;
15
16
use Base64Url\Base64Url;
17
18
class RegistrationResponse
19
{
20
    private const SUPPORTED_PROTOCOL_VERSIONS = ['U2F_V2'];
21
    private const PUBLIC_KEY_LENGTH = 65;
22
    private const CERTIFICATES_HASHES = [
23
        '349bca1031f8c82c4ceca38b9cebf1a69df9fb3b94eed99eb3fb9aa3822d26e8',
24
        'dd574527df608e47ae45fbba75a2afdd5c20fd94a02419381813cd55a2a3398f',
25
        '1d8764f0f7cd1352df6150045c8f638e517270e8b5dda1c63ade9c2280240cae',
26
        'd0edc9a91a1677435a953390865d208c55b3183c6759c9b5a7ff494c322558eb',
27
        '6073c436dcd064a48127ddbf6032ac1a66fd59a0c24434f070d4e564c124c897',
28
        'ca993121846c464d666096d35f13bf44c1b05af205f9b4a1e00cf6cc10c5e511',
29
    ];
30
31
    /**
32
     * @var ClientData
33
     */
34
    private $clientData;
35
36
    /**
37
     * @var RegisteredKey
38
     */
39
    private $registeredKey;
40
41
    /**
42
     * @var string
43
     */
44
    private $signature;
45
46
    /**
47
     * RegistrationChallengeMiddleware constructor.
48
     *
49
     * @param array $data
50
     */
51
    private function __construct(array $data)
52
    {
53
        if (array_key_exists('errorCode', $data) && 0 !== $data['errorCode']) {
54
            throw new \InvalidArgumentException('Invalid response.');
55
        }
56
57
        $this->checkVersion($data);
58
        $clientData = $this->retrieveClientData($data);
59
        if ('navigator.id.finishEnrollment' !== $clientData->getType()) {
60
            throw new \InvalidArgumentException('Invalid response.');
61
        }
62
        $this->checkChallenge($data, $clientData);
63
        list($publicKey, $keyHandle, $pemCert, $signature) = $this->extractKeyData($data);
64
65
        $this->clientData = $clientData;
66
        $this->registeredKey = RegisteredKey::create($data['version'], $keyHandle, $publicKey, $pemCert);
67
        $this->signature = $signature;
68
    }
69
70
    /**
71
     * @param array $data
72
     *
73
     * @return RegistrationResponse
74
     */
75
    public static function create(array $data): self
76
    {
77
        return new self($data);
78
    }
79
80
    /**
81
     * @return ClientData
82
     */
83
    public function getClientData(): ClientData
84
    {
85
        return $this->clientData;
86
    }
87
88
    /**
89
     * @return RegisteredKey
90
     */
91
    public function getRegisteredKey(): RegisteredKey
92
    {
93
        return $this->registeredKey;
94
    }
95
96
    /**
97
     * @return string
98
     */
99
    public function getSignature(): string
100
    {
101
        return $this->signature;
102
    }
103
104
    /**
105
     * @param array $data
106
     *
107
     * @throws \InvalidArgumentException
108
     *
109
     * @return ClientData
110
     */
111
    private function retrieveClientData(array $data): ClientData
112
    {
113
        if (!array_key_exists('clientData', $data) || !is_string($data['clientData'])) {
114
            throw new \InvalidArgumentException('Invalid response.');
115
        }
116
117
        return ClientData::create($data['clientData']);
118
    }
119
120
    /**
121
     * @param array $data
122
     *
123
     * @throws \InvalidArgumentException
124
     */
125
    private function checkVersion(array $data): void
126
    {
127
        if (!array_key_exists('version', $data) || !is_string($data['version'])) {
128
            throw new \InvalidArgumentException('Invalid response.');
129
        }
130
        if (!in_array($data['version'], self::SUPPORTED_PROTOCOL_VERSIONS)) {
131
            throw new \InvalidArgumentException('Unsupported protocol version.');
132
        }
133
    }
134
135
    /**
136
     * @param array      $data
137
     * @param ClientData $clientData
138
     *
139
     * @throws \InvalidArgumentException
140
     */
141
    private function checkChallenge(array $data, ClientData $clientData): void
142
    {
143
        if (!array_key_exists('challenge', $data) || !is_string($data['challenge'])) {
144
            throw new \InvalidArgumentException('Invalid response.');
145
        }
146
        if (!hash_equals(Base64Url::decode($data['challenge']), $clientData->getChallenge())) {
147
            throw new \InvalidArgumentException('Invalid response.');
148
        }
149
    }
150
151
    /**
152
     * @param array $data
153
     *
154
     * @throws \InvalidArgumentException
155
     *
156
     * @return array
157
     */
158
    private function extractKeyData(array $data): array
159
    {
160
        if (!array_key_exists('registrationData', $data) || !is_string($data['registrationData'])) {
161
            throw new \InvalidArgumentException('Invalid response.');
162
        }
163
        $stream = fopen('php://memory', 'r+');
164
        if (false === $stream) {
165
            throw new \InvalidArgumentException('Unable to load the registration data.');
166
        }
167
        $registrationData = Base64Url::decode($data['registrationData']);
168
        fwrite($stream, $registrationData);
169
        rewind($stream);
170
171
        $reservedByte = fread($stream, 1);
172
        if ("\x05" !== $reservedByte) { // 1 byte reserved with value x05
173
            fclose($stream);
174
175
            throw new \InvalidArgumentException('Bad reserved byte.');
176
        }
177
178
        $publicKey = fread($stream, self::PUBLIC_KEY_LENGTH); // 65 bytes for the public key
179
        if (mb_strlen($publicKey, '8bit') !== self::PUBLIC_KEY_LENGTH) {
180
            fclose($stream);
181
182
            throw new \InvalidArgumentException('Bad public key length.');
183
        }
184
185
        $keyHandleLength = fread($stream, 1); // 1 byte for the key handle length
186
        if (ord($keyHandleLength) === 0) {
187
            fclose($stream);
188
189
            throw new \InvalidArgumentException('Bad key handle length.');
190
        }
191
192
        $keyHandle = fread($stream, ord($keyHandleLength)); // x bytes for the key handle
193
        if (mb_strlen($keyHandle, '8bit') !== ord($keyHandleLength)) {
194
            fclose($stream);
195
196
            throw new \InvalidArgumentException('Bad key handle.');
197
        }
198
199
        $certHeader = fread($stream, 4); // 4 bytes for the certificate header
200
        if (mb_strlen($certHeader, '8bit') !== 4) {
201
            fclose($stream);
202
203
            throw new \InvalidArgumentException('Bad certificate header.');
204
        }
205
206
        $highOrder = ord($certHeader[2]) << 8;
207
        $lowOrder = ord($certHeader[3]);
208
        $certLength = $highOrder + $lowOrder;
209
        $certBody = fread($stream, $certLength); // x bytes for the certificate
210
        if (mb_strlen($certBody, '8bit') !== $certLength) {
211
            fclose($stream);
212
213
            throw new \InvalidArgumentException('Bad certificate.');
214
        }
215
        $derCertificate = $this->unusedBytesFix($certHeader.$certBody);
216
        $pemCert = '-----BEGIN CERTIFICATE-----'.PHP_EOL;
217
        $pemCert .= chunk_split(base64_encode($derCertificate), 64, PHP_EOL);
218
        $pemCert .= '-----END CERTIFICATE-----'.PHP_EOL;
219
220
        $signature = ''; // The rest is the signature
221
        while (!feof($stream)) {
222
            $signature .= fread($stream, 1024);
223
        }
224
        fclose($stream);
225
226
        return [
227
            PublicKey::create($publicKey),
228
            KeyHandle::create($keyHandle),
229
            $pemCert,
230
            $signature,
231
        ];
232
    }
233
234
    /**
235
     * @param string $derCertificate
236
     *
237
     * @return string
238
     */
239
    private function unusedBytesFix(string $derCertificate): string
240
    {
241
        $certificateHash = hash('sha256', $derCertificate);
242
        if (in_array($certificateHash, self::CERTIFICATES_HASHES)) {
243
            $derCertificate[mb_strlen($derCertificate, '8bit') - 257] = "\0";
244
        }
245
246
        return $derCertificate;
247
    }
248
249
    /**
250
     * @param RegistrationRequest $challenge
251
     * @param string[]            $attestationCertificates
252
     *
253
     * @return bool
254
     */
255
    public function isValid(RegistrationRequest $challenge, array $attestationCertificates = []): bool
256
    {
257
        if (!hash_equals($challenge->getChallenge(), $this->clientData->getChallenge())) {
258
            return false;
259
        }
260
        if (!hash_equals($challenge->getApplicationId(), $this->clientData->getOrigin())) {
261
            return false;
262
        }
263
264
        if (!empty($attestationCertificates) && openssl_x509_checkpurpose($this->registeredKey->getAttestationCertificate(), X509_PURPOSE_ANY, $attestationCertificates) !== true) {
265
            return false;
266
        }
267
268
        $dataToVerify = "\0";
269
        $dataToVerify .= hash('sha256', $this->clientData->getOrigin(), true);
270
        $dataToVerify .= hash('sha256', $this->clientData->getRawData(), true);
271
        $dataToVerify .= $this->registeredKey->getKeyHandler();
272
        $dataToVerify .= $this->registeredKey->getPublicKey();
273
274
        return openssl_verify($dataToVerify, $this->signature, $this->registeredKey->getAttestationCertificate(), OPENSSL_ALGO_SHA256) === 1;
275
    }
276
}
277