CustomServer::getCredentialCreationOptions()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 13
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 10
dl 0
loc 13
ccs 12
cts 12
cp 1
rs 9.9332
c 0
b 0
f 0
cc 1
nc 1
nop 3
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace PhpMyAdmin\WebAuthn;
6
7
use Psr\Http\Message\ServerRequestInterface;
8
use SodiumException;
9
use Throwable;
10
use Webmozart\Assert\Assert;
11
use Webmozart\Assert\InvalidArgumentException;
12
13
use function hash;
14
use function hash_equals;
15
use function json_decode;
16
use function mb_strlen;
17
use function mb_substr;
18
use function ord;
19
use function parse_url;
20
use function random_bytes;
21
use function sodium_base642bin;
22
use function sodium_bin2base64;
23
use function unpack;
24
25
use const PHP_URL_HOST;
26
use const SODIUM_BASE64_VARIANT_ORIGINAL;
27
use const SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING;
28
29
/**
30
 * Web Authentication API server.
31
 *
32
 * @see https://www.w3.org/TR/webauthn-3/
33
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API
34
 * @see https://webauthn.guide/
35
 */
36
final class CustomServer implements Server
37
{
38
    /** @inheritDoc */
39 4
    public function getCredentialCreationOptions(string $userName, string $userId, string $relyingPartyId): array
40
    {
41 4
        return [
42 4
            'challenge' => $this->generateChallenge(),
43 4
            'rp' => ['name' => 'phpMyAdmin (' . $relyingPartyId . ')', 'id' => $relyingPartyId],
44 4
            'user' => ['id' => $userId, 'name' => $userName, 'displayName' => $userName],
45 4
            'pubKeyCredParams' => $this->getCredentialParameters(),
46 4
            'authenticatorSelection' => [
47 4
                'authenticatorAttachment' => 'cross-platform',
48 4
                'userVerification' => 'discouraged',
49 4
            ],
50 4
            'timeout' => 60000,
51 4
            'attestation' => 'none',
52 4
        ];
53
    }
54
55
    /** @inheritDoc */
56 4
    public function getCredentialRequestOptions(
57
        string $userName,
58
        string $userId,
59
        string $relyingPartyId,
60
        array $allowedCredentials,
61
    ): array {
62 4
        foreach ($allowedCredentials as $key => $credential) {
63 4
            $allowedCredentials[$key]['id'] = sodium_bin2base64(
64 4
                sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING),
65 4
                SODIUM_BASE64_VARIANT_ORIGINAL,
66 4
            );
67
        }
68
69 4
        return [
70 4
            'challenge' => $this->generateChallenge(),
71 4
            'allowCredentials' => $allowedCredentials,
72 4
            'timeout' => 60000,
73 4
            'attestation' => 'none',
74 4
            'userVerification' => 'discouraged',
75 4
        ];
76
    }
77
78
    /** @inheritDoc */
79 4
    public function parseAndValidateAssertionResponse(
80
        string $assertionResponseJson,
81
        array $allowedCredentials,
82
        string $challenge,
83
        ServerRequestInterface $request,
84
    ): void {
85 4
        $assertionCredential = $this->getAssertionCredential($assertionResponseJson);
86
87 4
        if ($allowedCredentials !== []) {
88 4
            Assert::true($this->isCredentialIdAllowed($assertionCredential['rawId'], $allowedCredentials));
89
        }
90
91 4
        $authenticatorData = $this->getAuthenticatorData($assertionCredential['response']['authenticatorData']);
92
93 4
        $clientData = $this->getCollectedClientData($assertionCredential['response']['clientDataJSON']);
94 4
        Assert::same($clientData['type'], 'webauthn.get');
95
96
        try {
97 4
            $knownChallenge = sodium_base642bin($challenge, SODIUM_BASE64_VARIANT_ORIGINAL);
98 4
            $cDataChallenge = sodium_base642bin($clientData['challenge'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
99
        } catch (SodiumException $exception) {
100
            throw new WebAuthnException((string) $exception);
101
        }
102
103 4
        Assert::true(hash_equals($knownChallenge, $cDataChallenge));
104
105 4
        $host = $request->getUri()->getHost();
106 4
        Assert::same($host, parse_url($clientData['origin'], PHP_URL_HOST));
107
108 4
        $rpIdHash = hash('sha256', $host, true);
109 4
        Assert::true(hash_equals($rpIdHash, $authenticatorData['rpIdHash']));
110
111 4
        $isUserPresent = (ord($authenticatorData['flags']) & 1) !== 0;
112 4
        Assert::true($isUserPresent);
113
    }
114
115
    /** @inheritDoc */
116 4
    public function parseAndValidateAttestationResponse(
117
        string $attestationResponse,
118
        string $credentialCreationOptions,
119
        ServerRequestInterface $request,
120
    ): array {
121
        try {
122 4
            $attestationCredential = $this->getAttestationCredential($attestationResponse);
123
        } catch (Throwable $exception) {
124
            throw new WebAuthnException('Invalid authenticator response.', (int) $exception->getCode(), $exception);
125
        }
126
127 4
        $creationOptions = json_decode($credentialCreationOptions, true);
128 4
        Assert::isArray($creationOptions);
129 4
        Assert::keyExists($creationOptions, 'challenge');
130 4
        Assert::string($creationOptions['challenge']);
131 4
        Assert::keyExists($creationOptions, 'user');
132 4
        Assert::isArray($creationOptions['user']);
133 4
        Assert::keyExists($creationOptions['user'], 'id');
134 4
        Assert::string($creationOptions['user']['id']);
135
136 4
        $clientData = $this->getCollectedClientData($attestationCredential['response']['clientDataJSON']);
137
138
        // Verify that the value of C.type is webauthn.create.
139 4
        Assert::same($clientData['type'], 'webauthn.create');
140
141
        // Verify that the value of C.challenge equals the base64url encoding of options.challenge.
142 4
        $optionsChallenge = sodium_base642bin($creationOptions['challenge'], SODIUM_BASE64_VARIANT_ORIGINAL);
143 4
        $clientDataChallenge = sodium_base642bin($clientData['challenge'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
144 4
        Assert::true(hash_equals($optionsChallenge, $clientDataChallenge));
145
146
        // Verify that the value of C.origin matches the Relying Party's origin.
147 4
        $host = $request->getUri()->getHost();
148 4
        Assert::same($host, parse_url($clientData['origin'], PHP_URL_HOST), 'Invalid origin.');
149
150
        // Perform CBOR decoding on the attestationObject field.
151 4
        $attestationObject = $this->getAttestationObject($attestationCredential['response']['attestationObject']);
152
153 4
        $authenticatorData = $this->getAuthenticatorData($attestationObject['authData']);
154 4
        Assert::notNull($authenticatorData['attestedCredentialData']);
155
156
        // Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
157 4
        $rpIdHash = hash('sha256', $host, true);
158 4
        Assert::true(hash_equals($rpIdHash, $authenticatorData['rpIdHash']), 'Invalid rpIdHash.');
159
160
        // Verify that the User Present bit of the flags in authData is set.
161 4
        $isUserPresent = (ord($authenticatorData['flags']) & 1) !== 0;
162 4
        Assert::true($isUserPresent);
163
164 4
        Assert::same($attestationObject['fmt'], 'none');
165 4
        Assert::same($attestationObject['attStmt'], []);
166
167 4
        $encodedCredentialId = sodium_bin2base64(
168 4
            $authenticatorData['attestedCredentialData']['credentialId'],
169 4
            SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING,
170 4
        );
171 4
        $encodedCredentialPublicKey = sodium_bin2base64(
172 4
            $authenticatorData['attestedCredentialData']['credentialPublicKey'],
173 4
            SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING,
174 4
        );
175 4
        $userHandle = sodium_bin2base64(
176 4
            sodium_base642bin($creationOptions['user']['id'], SODIUM_BASE64_VARIANT_ORIGINAL),
177 4
            SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING,
178 4
        );
179
180 4
        return [
181 4
            'publicKeyCredentialId' => $encodedCredentialId,
182 4
            'type' => 'public-key',
183 4
            'transports' => [],
184 4
            'attestationType' => $attestationObject['fmt'],
185 4
            'aaguid' => $authenticatorData['attestedCredentialData']['aaguid'],
186 4
            'credentialPublicKey' => $encodedCredentialPublicKey,
187 4
            'userHandle' => $userHandle,
188 4
            'counter' => $authenticatorData['signCount'],
189 4
        ];
190
    }
191
192
    /**
193
     * In order to prevent replay attacks, the challenges MUST contain enough entropy to make guessing them infeasible.
194
     * Challenges SHOULD therefore be at least 16 bytes long.
195
     *
196
     * @see https://www.w3.org/TR/webauthn-3/#sctn-cryptographic-challenges
197
     *
198
     * @psalm-return non-empty-string
199
     *
200
     * @throws WebAuthnException
201
     */
202 8
    private function generateChallenge(): string
203
    {
204
        try {
205 8
            return sodium_bin2base64(random_bytes(32), SODIUM_BASE64_VARIANT_ORIGINAL);
206
        } catch (Throwable) { // @codeCoverageIgnore
207
            throw new WebAuthnException('Error when generating challenge.'); // @codeCoverageIgnore
208
        }
209
    }
210
211
    /**
212
     * @see https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data
213
     *
214
     * @psalm-return array{
215
     *   rpIdHash: string,
216
     *   flags: string,
217
     *   signCount: int,
218
     *   attestedCredentialData: array{
219
     *     aaguid: string,
220
     *     credentialId: string,
221
     *     credentialPublicKey: string,
222
     *     credentialPublicKeyDecoded: mixed[]
223
     *   }|null,
224
     *   extensions: string|null
225
     * }
226
     *
227
     * @throws WebAuthnException
228
     */
229 8
    private function getAuthenticatorData(string $authData): array
230
    {
231 8
        $authDataLength = mb_strlen($authData, '8bit');
232 8
        Assert::true($authDataLength >= 37);
233 8
        $authDataStream = new DataStream($authData);
234
235 8
        $rpIdHash = $authDataStream->take(32);
236 8
        $flags = $authDataStream->take(1);
237
238
        // 32-bit unsigned big-endian integer
239 8
        $unpackedSignCount = unpack('N', $authDataStream->take(4));
240 8
        Assert::isArray($unpackedSignCount);
241 8
        Assert::keyExists($unpackedSignCount, 1);
242 8
        Assert::integer($unpackedSignCount[1]);
243 8
        $signCount = $unpackedSignCount[1];
244
245 8
        $attestedCredentialData = null;
246
        // Bit 6: Attested credential data included (AT).
247 8
        if ((ord($flags) & 64) !== 0) {
248
            /** Authenticator Attestation GUID */
249 4
            $aaguid = $authDataStream->take(16);
250
251
            // 16-bit unsigned big-endian integer
252 4
            $unpackedCredentialIdLength = unpack('n', $authDataStream->take(2));
253 4
            Assert::isArray($unpackedCredentialIdLength);
254 4
            Assert::keyExists($unpackedCredentialIdLength, 1);
255 4
            Assert::integer($unpackedCredentialIdLength[1]);
256 4
            $credentialIdLength = $unpackedCredentialIdLength[1];
257
258 4
            $credentialId = $authDataStream->take($credentialIdLength);
259
260 4
            $credentialPublicKeyDecoded = (new CBORDecoder())->decode($authDataStream);
261 4
            Assert::isArray($credentialPublicKeyDecoded);
262 4
            $credentialPublicKey = mb_substr(
263 4
                $authData,
264 4
                37 + 18 + $credentialIdLength,
265 4
                $authDataStream->getPosition(),
266 4
                '8bit',
267 4
            );
268
269 4
            $attestedCredentialData = [
270 4
                'aaguid' => $aaguid,
271 4
                'credentialId' => $credentialId,
272 4
                'credentialPublicKey' => $credentialPublicKey,
273 4
                'credentialPublicKeyDecoded' => $credentialPublicKeyDecoded,
274 4
            ];
275
        }
276
277 8
        return [
278 8
            'rpIdHash' => $rpIdHash,
279 8
            'flags' => $flags,
280 8
            'signCount' => $signCount,
281 8
            'attestedCredentialData' => $attestedCredentialData,
282 8
            'extensions' => null,
283 8
        ];
284
    }
285
286
    /**
287
     * @psalm-param non-empty-string $id
288
     * @psalm-param list<array{id: non-empty-string, type: non-empty-string}> $allowedCredentials
289
     *
290
     * @throws WebAuthnException
291
     */
292 4
    private function isCredentialIdAllowed(string $id, array $allowedCredentials): bool
293
    {
294 4
        foreach ($allowedCredentials as $credential) {
295
            try {
296 4
                $credentialId = sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
297
            } catch (SodiumException) {
298
                throw new WebAuthnException();
299
            }
300
301 4
            if (hash_equals($credentialId, $id)) {
302 4
                return true;
303
            }
304
        }
305
306
        return false;
307
    }
308
309
    /**
310
     * @see https://www.iana.org/assignments/cose/cose.xhtml#algorithms
311
     *
312
     * @psalm-return list<array{alg: int, type: 'public-key'}>
313
     */
314 4
    private function getCredentialParameters(): array
315
    {
316 4
        return [
317 4
            ['alg' => -257, 'type' => 'public-key'], // RS256
318 4
            ['alg' => -259, 'type' => 'public-key'], // RS512
319 4
            ['alg' => -37, 'type' => 'public-key'], // PS256
320 4
            ['alg' => -39, 'type' => 'public-key'], // PS512
321 4
            ['alg' => -7, 'type' => 'public-key'], // ES256
322 4
            ['alg' => -36, 'type' => 'public-key'], // ES512
323 4
            ['alg' => -8, 'type' => 'public-key'], // EdDSA
324 4
        ];
325
    }
326
327
    /**
328
     * @psalm-param non-empty-string $assertionResponseJson
329
     *
330
     * @psalm-return array{
331
     *   id: non-empty-string,
332
     *   type: 'public-key',
333
     *   rawId: non-empty-string,
334
     *   response: array{
335
     *     clientDataJSON: non-empty-string,
336
     *     authenticatorData: non-empty-string,
337
     *     signature: non-empty-string,
338
     *   }
339
     * }
340
     *
341
     * @throws SodiumException
342
     * @throws InvalidArgumentException
343
     */
344 4
    private function getAssertionCredential(string $assertionResponseJson): array
345
    {
346 4
        $credential = json_decode($assertionResponseJson, true);
347 4
        Assert::isArray($credential);
348 4
        Assert::keyExists($credential, 'id');
349 4
        Assert::stringNotEmpty($credential['id']);
350 4
        Assert::keyExists($credential, 'type');
351 4
        Assert::same($credential['type'], 'public-key');
352 4
        Assert::keyExists($credential, 'rawId');
353 4
        Assert::stringNotEmpty($credential['rawId']);
354 4
        Assert::keyExists($credential, 'response');
355 4
        Assert::isArray($credential['response']);
356 4
        Assert::keyExists($credential['response'], 'clientDataJSON');
357 4
        Assert::stringNotEmpty($credential['response']['clientDataJSON']);
358 4
        Assert::keyExists($credential['response'], 'authenticatorData');
359 4
        Assert::stringNotEmpty($credential['response']['authenticatorData']);
360 4
        Assert::keyExists($credential['response'], 'signature');
361 4
        Assert::stringNotEmpty($credential['response']['signature']);
362
363 4
        $id = sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
364 4
        $rawId = sodium_base642bin($credential['rawId'], SODIUM_BASE64_VARIANT_ORIGINAL);
365 4
        Assert::stringNotEmpty($id);
366 4
        Assert::stringNotEmpty($rawId);
367 4
        Assert::true(hash_equals($rawId, $id));
368
369 4
        $clientDataJSON = sodium_base642bin($credential['response']['clientDataJSON'], SODIUM_BASE64_VARIANT_ORIGINAL);
370 4
        Assert::stringNotEmpty($clientDataJSON);
371 4
        $authenticatorData = sodium_base642bin(
372 4
            $credential['response']['authenticatorData'],
373 4
            SODIUM_BASE64_VARIANT_ORIGINAL,
374 4
        );
375 4
        Assert::stringNotEmpty($authenticatorData);
376 4
        $signature = sodium_base642bin($credential['response']['signature'], SODIUM_BASE64_VARIANT_ORIGINAL);
377 4
        Assert::stringNotEmpty($signature);
378
379 4
        return [
380 4
            'id' => $credential['id'],
381 4
            'type' => 'public-key',
382 4
            'rawId' => $rawId,
383 4
            'response' => [
384 4
                'clientDataJSON' => $clientDataJSON,
385 4
                'authenticatorData' => $authenticatorData,
386 4
                'signature' => $signature,
387 4
            ],
388 4
        ];
389
    }
390
391
    /**
392
     * @see https://www.w3.org/TR/webauthn-3/#iface-authenticatorattestationresponse
393
     *
394
     * @psalm-param non-empty-string $attestationResponse
395
     *
396
     * @psalm-return array{
397
     *   id: non-empty-string,
398
     *   rawId: non-empty-string,
399
     *   type: 'public-key',
400
     *   response: array{clientDataJSON: non-empty-string, attestationObject: non-empty-string}
401
     * }
402
     *
403
     * @throws SodiumException
404
     * @throws InvalidArgumentException
405
     */
406 4
    private function getAttestationCredential(string $attestationResponse): array
407
    {
408 4
        $credential = json_decode($attestationResponse, true);
409 4
        Assert::isArray($credential);
410 4
        Assert::keyExists($credential, 'id');
411 4
        Assert::stringNotEmpty($credential['id']);
412 4
        Assert::keyExists($credential, 'rawId');
413 4
        Assert::stringNotEmpty($credential['rawId']);
414 4
        Assert::keyExists($credential, 'type');
415 4
        Assert::string($credential['type']);
416 4
        Assert::same($credential['type'], 'public-key');
417 4
        Assert::keyExists($credential, 'response');
418 4
        Assert::isArray($credential['response']);
419 4
        Assert::keyExists($credential['response'], 'clientDataJSON');
420 4
        Assert::stringNotEmpty($credential['response']['clientDataJSON']);
421 4
        Assert::keyExists($credential['response'], 'attestationObject');
422 4
        Assert::stringNotEmpty($credential['response']['attestationObject']);
423
424 4
        $id = sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
425 4
        $rawId = sodium_base642bin($credential['rawId'], SODIUM_BASE64_VARIANT_ORIGINAL);
426 4
        Assert::stringNotEmpty($id);
427 4
        Assert::stringNotEmpty($rawId);
428 4
        Assert::true(hash_equals($rawId, $id));
429
430 4
        $clientDataJSON = sodium_base642bin($credential['response']['clientDataJSON'], SODIUM_BASE64_VARIANT_ORIGINAL);
431 4
        Assert::stringNotEmpty($clientDataJSON);
432 4
        $attestationObject = sodium_base642bin(
433 4
            $credential['response']['attestationObject'],
434 4
            SODIUM_BASE64_VARIANT_ORIGINAL,
435 4
        );
436 4
        Assert::stringNotEmpty($attestationObject);
437
438 4
        return [
439 4
            'id' => $credential['id'],
440 4
            'rawId' => $rawId,
441 4
            'type' => 'public-key',
442 4
            'response' => ['clientDataJSON' => $clientDataJSON, 'attestationObject' => $attestationObject],
443 4
        ];
444
    }
445
446
    /**
447
     * @see https://www.w3.org/TR/webauthn-3/#dictionary-client-data
448
     *
449
     * @psalm-param non-empty-string $clientDataJSON
450
     *
451
     * @return array{
0 ignored issues
show
Documentation Bug introduced by
The doc comment array{ at position 2 could not be parsed: the token is null at position 2.
Loading history...
452
     *   type: 'webauthn.create'|'webauthn.get',
453
     *   challenge: non-empty-string,
454
     *   origin: non-empty-string
455
     * }
456
     */
457 8
    private function getCollectedClientData(string $clientDataJSON): array
458
    {
459 8
        $clientData = json_decode($clientDataJSON, true);
460
461 8
        Assert::isArray($clientData);
462 8
        Assert::keyExists($clientData, 'type');
463 8
        Assert::stringNotEmpty($clientData['type']);
464 8
        Assert::inArray($clientData['type'], ['webauthn.create', 'webauthn.get']);
465 8
        Assert::keyExists($clientData, 'challenge');
466 8
        Assert::stringNotEmpty($clientData['challenge']);
467 8
        Assert::keyExists($clientData, 'origin');
468 8
        Assert::stringNotEmpty($clientData['origin']);
469
470 8
        return [
471 8
            'type' => $clientData['type'],
472 8
            'challenge' => $clientData['challenge'],
473 8
            'origin' => $clientData['origin'],
474 8
        ];
475
    }
476
477
    /**
478
     * @psalm-param non-empty-string $attestationObjectEncoded
479
     *
480
     * @psalm-return array{fmt: string, attStmt: mixed[], authData: string}
481
     *
482
     * @throws WebAuthnException
483
     */
484 4
    private function getAttestationObject(string $attestationObjectEncoded): array
485
    {
486 4
        $decoded = (new CBORDecoder())->decode(new DataStream($attestationObjectEncoded));
487
488 4
        Assert::isArray($decoded);
489 4
        Assert::keyExists($decoded, 'fmt');
490 4
        Assert::string($decoded['fmt']);
491 4
        Assert::keyExists($decoded, 'attStmt');
492 4
        Assert::isArray($decoded['attStmt']);
493 4
        Assert::keyExists($decoded, 'authData');
494 4
        Assert::string($decoded['authData']);
495
496 4
        return $decoded;
497
    }
498
}
499