CustomServer   A
last analyzed

Complexity

Total Complexity 21

Size/Duplication

Total Lines 461
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 0
Metric Value
eloc 231
dl 0
loc 461
ccs 0
cts 255
cp 0
rs 10
c 0
b 0
f 0
wmc 21

12 Methods

Rating   Name   Duplication   Size   Complexity  
A generateChallenge() 0 6 2
A getCredentialCreationOptions() 0 13 1
A getCollectedClientData() 0 17 1
A getAttestationCredential() 0 37 1
A parseAndValidateAttestationResponse() 0 73 2
A getAttestationObject() 0 13 1
A getCredentialRequestOptions() 0 19 2
A parseAndValidateAssertionResponse() 0 34 3
A getAuthenticatorData() 0 54 2
A isCredentialIdAllowed() 0 15 4
A getCredentialParameters() 0 10 1
A getAssertionCredential() 0 43 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
    public function getCredentialCreationOptions(string $userName, string $userId, string $relyingPartyId): array
40
    {
41
        return [
42
            'challenge' => $this->generateChallenge(),
43
            'rp' => ['name' => 'phpMyAdmin (' . $relyingPartyId . ')', 'id' => $relyingPartyId],
44
            'user' => ['id' => $userId, 'name' => $userName, 'displayName' => $userName],
45
            'pubKeyCredParams' => $this->getCredentialParameters(),
46
            'authenticatorSelection' => [
47
                'authenticatorAttachment' => 'cross-platform',
48
                'userVerification' => 'discouraged',
49
            ],
50
            'timeout' => 60000,
51
            'attestation' => 'none',
52
        ];
53
    }
54
55
    /** @inheritDoc */
56
    public function getCredentialRequestOptions(
57
        string $userName,
58
        string $userId,
59
        string $relyingPartyId,
60
        array $allowedCredentials,
61
    ): array {
62
        foreach ($allowedCredentials as $key => $credential) {
63
            $allowedCredentials[$key]['id'] = sodium_bin2base64(
64
                sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING),
65
                SODIUM_BASE64_VARIANT_ORIGINAL,
66
            );
67
        }
68
69
        return [
70
            'challenge' => $this->generateChallenge(),
71
            'allowCredentials' => $allowedCredentials,
72
            'timeout' => 60000,
73
            'attestation' => 'none',
74
            'userVerification' => 'discouraged',
75
        ];
76
    }
77
78
    /** @inheritDoc */
79
    public function parseAndValidateAssertionResponse(
80
        string $assertionResponseJson,
81
        array $allowedCredentials,
82
        string $challenge,
83
        ServerRequestInterface $request,
84
    ): void {
85
        $assertionCredential = $this->getAssertionCredential($assertionResponseJson);
86
87
        if ($allowedCredentials !== []) {
88
            Assert::true($this->isCredentialIdAllowed($assertionCredential['rawId'], $allowedCredentials));
89
        }
90
91
        $authenticatorData = $this->getAuthenticatorData($assertionCredential['response']['authenticatorData']);
92
93
        $clientData = $this->getCollectedClientData($assertionCredential['response']['clientDataJSON']);
94
        Assert::same($clientData['type'], 'webauthn.get');
95
96
        try {
97
            $knownChallenge = sodium_base642bin($challenge, SODIUM_BASE64_VARIANT_ORIGINAL);
98
            $cDataChallenge = sodium_base642bin($clientData['challenge'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
99
        } catch (SodiumException $exception) {
100
            throw new WebAuthnException((string) $exception);
101
        }
102
103
        Assert::true(hash_equals($knownChallenge, $cDataChallenge));
104
105
        $host = $request->getUri()->getHost();
106
        Assert::same($host, parse_url($clientData['origin'], PHP_URL_HOST));
107
108
        $rpIdHash = hash('sha256', $host, true);
109
        Assert::true(hash_equals($rpIdHash, $authenticatorData['rpIdHash']));
110
111
        $isUserPresent = (ord($authenticatorData['flags']) & 1) !== 0;
112
        Assert::true($isUserPresent);
113
    }
114
115
    /** @inheritDoc */
116
    public function parseAndValidateAttestationResponse(
117
        string $attestationResponse,
118
        string $credentialCreationOptions,
119
        ServerRequestInterface $request,
120
    ): array {
121
        try {
122
            $attestationCredential = $this->getAttestationCredential($attestationResponse);
123
        } catch (Throwable) {
124
            throw new WebAuthnException('Invalid authenticator response.');
125
        }
126
127
        $creationOptions = json_decode($credentialCreationOptions, true);
128
        Assert::isArray($creationOptions);
129
        Assert::keyExists($creationOptions, 'challenge');
130
        Assert::string($creationOptions['challenge']);
131
        Assert::keyExists($creationOptions, 'user');
132
        Assert::isArray($creationOptions['user']);
133
        Assert::keyExists($creationOptions['user'], 'id');
134
        Assert::string($creationOptions['user']['id']);
135
136
        $clientData = $this->getCollectedClientData($attestationCredential['response']['clientDataJSON']);
137
138
        // Verify that the value of C.type is webauthn.create.
139
        Assert::same($clientData['type'], 'webauthn.create');
140
141
        // Verify that the value of C.challenge equals the base64url encoding of options.challenge.
142
        $optionsChallenge = sodium_base642bin($creationOptions['challenge'], SODIUM_BASE64_VARIANT_ORIGINAL);
143
        $clientDataChallenge = sodium_base642bin($clientData['challenge'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
144
        Assert::true(hash_equals($optionsChallenge, $clientDataChallenge));
145
146
        // Verify that the value of C.origin matches the Relying Party's origin.
147
        $host = $request->getUri()->getHost();
148
        Assert::same($host, parse_url($clientData['origin'], PHP_URL_HOST), 'Invalid origin.');
149
150
        // Perform CBOR decoding on the attestationObject field.
151
        $attestationObject = $this->getAttestationObject($attestationCredential['response']['attestationObject']);
152
153
        $authenticatorData = $this->getAuthenticatorData($attestationObject['authData']);
154
        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
        $rpIdHash = hash('sha256', $host, true);
158
        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
        $isUserPresent = (ord($authenticatorData['flags']) & 1) !== 0;
162
        Assert::true($isUserPresent);
163
164
        Assert::same($attestationObject['fmt'], 'none');
165
        Assert::same($attestationObject['attStmt'], []);
166
167
        $encodedCredentialId = sodium_bin2base64(
168
            $authenticatorData['attestedCredentialData']['credentialId'],
169
            SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING,
170
        );
171
        $encodedCredentialPublicKey = sodium_bin2base64(
172
            $authenticatorData['attestedCredentialData']['credentialPublicKey'],
173
            SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING,
174
        );
175
        $userHandle = sodium_bin2base64(
176
            sodium_base642bin($creationOptions['user']['id'], SODIUM_BASE64_VARIANT_ORIGINAL),
177
            SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING,
178
        );
179
180
        return [
181
            'publicKeyCredentialId' => $encodedCredentialId,
182
            'type' => 'public-key',
183
            'transports' => [],
184
            'attestationType' => $attestationObject['fmt'],
185
            'aaguid' => $authenticatorData['attestedCredentialData']['aaguid'],
186
            'credentialPublicKey' => $encodedCredentialPublicKey,
187
            'userHandle' => $userHandle,
188
            'counter' => $authenticatorData['signCount'],
189
        ];
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
    private function generateChallenge(): string
203
    {
204
        try {
205
            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
    private function getAuthenticatorData(string $authData): array
230
    {
231
        $authDataLength = mb_strlen($authData, '8bit');
232
        Assert::true($authDataLength >= 37);
233
        $authDataStream = new DataStream($authData);
234
235
        $rpIdHash = $authDataStream->take(32);
236
        $flags = $authDataStream->take(1);
237
238
        // 32-bit unsigned big-endian integer
239
        $unpackedSignCount = unpack('N', $authDataStream->take(4));
240
        Assert::isArray($unpackedSignCount);
241
        Assert::keyExists($unpackedSignCount, 1);
242
        Assert::integer($unpackedSignCount[1]);
243
        $signCount = $unpackedSignCount[1];
244
245
        $attestedCredentialData = null;
246
        // Bit 6: Attested credential data included (AT).
247
        if ((ord($flags) & 64) !== 0) {
248
            /** Authenticator Attestation GUID */
249
            $aaguid = $authDataStream->take(16);
250
251
            // 16-bit unsigned big-endian integer
252
            $unpackedCredentialIdLength = unpack('n', $authDataStream->take(2));
253
            Assert::isArray($unpackedCredentialIdLength);
254
            Assert::keyExists($unpackedCredentialIdLength, 1);
255
            Assert::integer($unpackedCredentialIdLength[1]);
256
            $credentialIdLength = $unpackedCredentialIdLength[1];
257
258
            $credentialId = $authDataStream->take($credentialIdLength);
259
260
            $credentialPublicKeyDecoded = (new CBORDecoder())->decode($authDataStream);
261
            Assert::isArray($credentialPublicKeyDecoded);
262
            $credentialPublicKey = mb_substr(
263
                $authData,
264
                37 + 18 + $credentialIdLength,
265
                $authDataStream->getPosition(),
266
                '8bit',
267
            );
268
269
            $attestedCredentialData = [
270
                'aaguid' => $aaguid,
271
                'credentialId' => $credentialId,
272
                'credentialPublicKey' => $credentialPublicKey,
273
                'credentialPublicKeyDecoded' => $credentialPublicKeyDecoded,
274
            ];
275
        }
276
277
        return [
278
            'rpIdHash' => $rpIdHash,
279
            'flags' => $flags,
280
            'signCount' => $signCount,
281
            'attestedCredentialData' => $attestedCredentialData,
282
            'extensions' => null,
283
        ];
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
    private function isCredentialIdAllowed(string $id, array $allowedCredentials): bool
293
    {
294
        foreach ($allowedCredentials as $credential) {
295
            try {
296
                $credentialId = sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
297
            } catch (SodiumException) {
298
                throw new WebAuthnException();
299
            }
300
301
            if (hash_equals($credentialId, $id)) {
302
                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
    private function getCredentialParameters(): array
315
    {
316
        return [
317
            ['alg' => -257, 'type' => 'public-key'], // RS256
318
            ['alg' => -259, 'type' => 'public-key'], // RS512
319
            ['alg' => -37, 'type' => 'public-key'], // PS256
320
            ['alg' => -39, 'type' => 'public-key'], // PS512
321
            ['alg' => -7, 'type' => 'public-key'], // ES256
322
            ['alg' => -36, 'type' => 'public-key'], // ES512
323
            ['alg' => -8, 'type' => 'public-key'], // EdDSA
324
        ];
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
    private function getAssertionCredential(string $assertionResponseJson): array
345
    {
346
        $credential = json_decode($assertionResponseJson, true);
347
        Assert::isArray($credential);
348
        Assert::keyExists($credential, 'id');
349
        Assert::stringNotEmpty($credential['id']);
350
        Assert::keyExists($credential, 'type');
351
        Assert::same($credential['type'], 'public-key');
352
        Assert::keyExists($credential, 'rawId');
353
        Assert::stringNotEmpty($credential['rawId']);
354
        Assert::keyExists($credential, 'response');
355
        Assert::isArray($credential['response']);
356
        Assert::keyExists($credential['response'], 'clientDataJSON');
357
        Assert::stringNotEmpty($credential['response']['clientDataJSON']);
358
        Assert::keyExists($credential['response'], 'authenticatorData');
359
        Assert::stringNotEmpty($credential['response']['authenticatorData']);
360
        Assert::keyExists($credential['response'], 'signature');
361
        Assert::stringNotEmpty($credential['response']['signature']);
362
363
        $id = sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
364
        $rawId = sodium_base642bin($credential['rawId'], SODIUM_BASE64_VARIANT_ORIGINAL);
365
        Assert::stringNotEmpty($id);
366
        Assert::stringNotEmpty($rawId);
367
        Assert::true(hash_equals($rawId, $id));
368
369
        $clientDataJSON = sodium_base642bin($credential['response']['clientDataJSON'], SODIUM_BASE64_VARIANT_ORIGINAL);
370
        Assert::stringNotEmpty($clientDataJSON);
371
        $authenticatorData = sodium_base642bin(
372
            $credential['response']['authenticatorData'],
373
            SODIUM_BASE64_VARIANT_ORIGINAL,
374
        );
375
        Assert::stringNotEmpty($authenticatorData);
376
        $signature = sodium_base642bin($credential['response']['signature'], SODIUM_BASE64_VARIANT_ORIGINAL);
377
        Assert::stringNotEmpty($signature);
378
379
        return [
380
            'id' => $credential['id'],
381
            'type' => 'public-key',
382
            'rawId' => $rawId,
383
            'response' => [
384
                'clientDataJSON' => $clientDataJSON,
385
                'authenticatorData' => $authenticatorData,
386
                'signature' => $signature,
387
            ],
388
        ];
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
    private function getAttestationCredential(string $attestationResponse): array
407
    {
408
        $credential = json_decode($attestationResponse, true);
409
        Assert::isArray($credential);
410
        Assert::keyExists($credential, 'id');
411
        Assert::stringNotEmpty($credential['id']);
412
        Assert::keyExists($credential, 'rawId');
413
        Assert::stringNotEmpty($credential['rawId']);
414
        Assert::keyExists($credential, 'type');
415
        Assert::string($credential['type']);
416
        Assert::same($credential['type'], 'public-key');
417
        Assert::keyExists($credential, 'response');
418
        Assert::isArray($credential['response']);
419
        Assert::keyExists($credential['response'], 'clientDataJSON');
420
        Assert::stringNotEmpty($credential['response']['clientDataJSON']);
421
        Assert::keyExists($credential['response'], 'attestationObject');
422
        Assert::stringNotEmpty($credential['response']['attestationObject']);
423
424
        $id = sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
425
        $rawId = sodium_base642bin($credential['rawId'], SODIUM_BASE64_VARIANT_ORIGINAL);
426
        Assert::stringNotEmpty($id);
427
        Assert::stringNotEmpty($rawId);
428
        Assert::true(hash_equals($rawId, $id));
429
430
        $clientDataJSON = sodium_base642bin($credential['response']['clientDataJSON'], SODIUM_BASE64_VARIANT_ORIGINAL);
431
        Assert::stringNotEmpty($clientDataJSON);
432
        $attestationObject = sodium_base642bin(
433
            $credential['response']['attestationObject'],
434
            SODIUM_BASE64_VARIANT_ORIGINAL,
435
        );
436
        Assert::stringNotEmpty($attestationObject);
437
438
        return [
439
            'id' => $credential['id'],
440
            'rawId' => $rawId,
441
            'type' => 'public-key',
442
            'response' => ['clientDataJSON' => $clientDataJSON, 'attestationObject' => $attestationObject],
443
        ];
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
    private function getCollectedClientData(string $clientDataJSON): array
458
    {
459
        $clientData = json_decode($clientDataJSON, true);
460
461
        Assert::isArray($clientData);
462
        Assert::keyExists($clientData, 'type');
463
        Assert::stringNotEmpty($clientData['type']);
464
        Assert::inArray($clientData['type'], ['webauthn.create', 'webauthn.get']);
465
        Assert::keyExists($clientData, 'challenge');
466
        Assert::stringNotEmpty($clientData['challenge']);
467
        Assert::keyExists($clientData, 'origin');
468
        Assert::stringNotEmpty($clientData['origin']);
469
470
        return [
471
            'type' => $clientData['type'],
472
            'challenge' => $clientData['challenge'],
473
            'origin' => $clientData['origin'],
474
        ];
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
    private function getAttestationObject(string $attestationObjectEncoded): array
485
    {
486
        $decoded = (new CBORDecoder())->decode(new DataStream($attestationObjectEncoded));
487
488
        Assert::isArray($decoded);
489
        Assert::keyExists($decoded, 'fmt');
490
        Assert::string($decoded['fmt']);
491
        Assert::keyExists($decoded, 'attStmt');
492
        Assert::isArray($decoded['attStmt']);
493
        Assert::keyExists($decoded, 'authData');
494
        Assert::string($decoded['authData']);
495
496
        return $decoded;
497
    }
498
}
499