WebAuthnAbstractEvent::base64urlDecode()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\webauthn\WebAuthn;
6
7
use CBOR\Decoder;
8
use CBOR\Normalizable;
9
use CBOR\OtherObject;
10
use CBOR\StringStream;
11
use CBOR\Tag;
12
use SimpleSAML\Utils\HTTP as HTTPHelper;
13
14
/**
15
 * FIDO2/WebAuthn Authentication Processing filter
16
 *
17
 * Filter for registering or authenticating with a FIDO2/WebAuthn token after
18
 * having authenticated with the primary authsource.
19
 *
20
 * @author Stefan Winter <[email protected]>
21
 * @package SimpleSAMLphp
22
 */
23
abstract class WebAuthnAbstractEvent
24
{
25
    public const PRESENCE_LEVEL_PRESENT = 1;
26
27
    public const PRESENCE_LEVEL_VERIFIED = 4;
28
29
    public const PRESENCE_LEVEL_NONE = 0;
30
31
32
    /**
33
     * The SHA256 hash of the clientDataJSON
34
     *
35
     * @var string
36
     */
37
    protected string $clientDataHash;
38
39
    /**
40
     * the authenticator's signature counter
41
     *
42
     * @var int
43
     */
44
    protected int $counter;
45
46
    /**
47
     * the authenticator's signature algorithm
48
     *
49
     * @var int
50
     */
51
    protected int $algo;
52
53
    /**
54
     * UV or UP bit?
55
     */
56
    protected int $presenceLevel;
57
58
    /**
59
     * A string buffer to hold debug information in case we need it.
60
     *
61
     * @var string
62
     */
63
    protected string $debugBuffer = '';
64
65
    /**
66
     * A string buffer to hold raw validation data in case we want to look at it.
67
     *
68
     * @var string
69
     */
70
    protected string $validateBuffer = '';
71
72
    /**
73
     * the type of event requested. This is to be set in child class constructors
74
     * before calling the parent's.
75
     *
76
     * @var string
77
     */
78
    protected string $eventType;
79
80
    /**
81
     * The rpIdHash, available once validated during constructor of base class
82
     */
83
    protected string $rpIdHash;
84
85
    /**
86
     * the credential ID for this event (either the one that gets registered, or
87
     * the one that gets used to authenticate)
88
     *
89
     * To be set by the constructors of the child classes.
90
     *
91
     * @var string
92
     */
93
    protected string $credentialId;
94
95
    /**
96
     * the credential binary data for this event (either the one that gets
97
     * registered, or the one that gets used to authenticate)
98
     *
99
     * To be set by the constructors of the child classes.
100
     *
101
     * @var string
102
     */
103
    protected string $credential;
104
105
106
    /**
107
     * Initialize the event object.
108
     *
109
     * Validates and parses the configuration.
110
     *
111
     * @param string $pubkeyCredType  PublicKeyCredential.type
112
     * @param string $scope           the scope of the event
113
     * @param string $challenge       the challenge which was used to trigger this event
114
     * @param string $authData        the authData / authenticatorData structure which is present in all types of events
115
     * @param string $clientDataJSON  the client data JSON string which is present in all types of events
116
     * @param bool   $debugMode       shall we collect and output some extensive debugging information along the way?
117
     */
118
    public function __construct(
119
        string $pubkeyCredType,
120
        protected string $scope,
121
        protected string $challenge,
122
        string $authData,
123
        string $clientDataJSON,
124
        protected bool $debugMode = false,
125
    ) {
126
        $this->presenceLevel = self::PRESENCE_LEVEL_NONE;
127
        $this->debugBuffer .= "PublicKeyCredential.type: $pubkeyCredType<br/>";
128
        /**
129
         * This is not a required validation as per spec. Still odd that Firefox returns
130
         * "undefined" even though its own API spec says it will send "public-key".
131
         */
132
        switch ($pubkeyCredType) {
133
            case "public-key":
134
                $this->pass("Key Type OK");
135
                break;
136
            case "undefined":
137
                $this->warn("Key Type 'undefined' - Firefox or Yubikey issue?");
138
                break;
139
            default:
140
                $this->fail("Unknown Key Type: " . $_POST['type']);
141
                break;
142
        }
143
144
        /**
145
         * eventType is already set by child constructor, otherwise the function
146
         * will fail because of the missing type)
147
         */
148
        $this->clientDataHash = $this->verifyClientDataJSON($clientDataJSON);
149
        $this->counter = $this->validateAuthData($authData);
150
    }
151
152
153
    /**
154
     * @return int
155
     */
156
    public function getCounter(): int
157
    {
158
        return $this->counter;
159
    }
160
161
162
    /**
163
     * @return string
164
     */
165
    public function getCredential(): string
166
    {
167
        return $this->credential;
168
    }
169
170
171
    /**
172
     * @return int
173
     */
174
    public function getAlgo(): int
175
    {
176
        return $this->algo;
177
    }
178
179
180
    /**
181
     * @return int
182
     */
183
    public function getPresenceLevel(): int
184
    {
185
        return $this->presenceLevel;
186
    }
187
188
189
    /**
190
     * @return string
191
     */
192
    public function getCredentialId(): string
193
    {
194
        return $this->credentialId;
195
    }
196
197
198
    /**
199
     * @return string
200
     */
201
    public function getDebugBuffer(): string
202
    {
203
        return $this->debugBuffer;
204
    }
205
206
207
    /**
208
     * @return string
209
     */
210
    public function getValidateBuffer(): string
211
    {
212
        return $this->validateBuffer;
213
    }
214
215
216
    /**
217
     * The base64url decode function differs slightly from base64. Thanks.
218
     *
219
     * taken from https://gist.github.com/nathggns/6652997
220
     *
221
     * @param string $data the base64url-encoded source string
222
     * @return string the decoded string
223
     */
224
    public static function base64urlDecode(string $data): string
225
    {
226
        return base64_decode(strtr($data, '-_', '+/'));
227
    }
228
229
230
    /**
231
     * this function validates the content of clientDataJSON.
232
     * I.e. it performs
233
     *   - for a REGistration session
234
     *     - the validation steps 2 through 8 of the validation
235
     *     - the parts of step 14 that relate to clientDataJSON
236
     *   - for a AUTHentication session
237
     *     - the validation steps 6 through 11 of the validation
238
     *     - the parts of step 15 that relate to clientDataJSON
239
     *
240
     * @param string $clientDataJSON the incoming data
241
     *
242
     * @return string
243
     */
244
    private function verifyClientDataJSON(string $clientDataJSON): string
245
    {
246
        /**
247
         * §7.1 STEP 2 + 3 : convert to JSON and dissect JSON into PHP associative array
248
         * §7.2 STEP 6 + 7 : convert to JSON and dissect JSON into PHP associative array
249
         */
250
        $this->debugBuffer .= "ClientDataJSON hash: " . hash("sha256", $clientDataJSON) . "<br/>";
251
        $clientData = json_decode($clientDataJSON, true);
252
        $this->debugBuffer .= "<pre>" . /** @scrutinizer ignore-type */ print_r($clientData, true) . "</pre>";
253
        switch ($this->eventType) {
254
            case "REG":
255
                if ($clientData['type'] == "webauthn.create") {
256
                    /**
257
                     * §7.1 STEP 4 wheck for webauthn.create
258
                     */
259
                    $this->pass("Registration requested; type has expected value");
260
                } else {
261
                    $this->fail("REG requested, but did not receive webauthn.create.");
262
                }
263
                break;
264
            case "AUTH":
265
                if ($clientData['type'] == "webauthn.get") {
266
                    /**
267
                     * §7.2 STEP 8: check for webauthn.get
268
                     */
269
                    $this->pass("Authentication requested; type has expected value");
270
                } else {
271
                    $this->fail("AUTH requested, but did not receive webauthn.get.");
272
                }
273
                break;
274
            default:
275
                $this->fail("Unexpected operation " . $this->eventType);
276
                break;
277
        }
278
279
        /**
280
         * §7.1 STEP 5 : check if incoming challenge matches issued challenge
281
         * §7.2 STEP 9 : check if incoming challenge matches issued challenge
282
         */
283
        if ($this->challenge == bin2hex(WebAuthnAbstractEvent::base64urlDecode($clientData['challenge']))) {
284
            $this->pass("Challenge matches");
285
        } else {
286
            $this->fail("Challenge does not match");
287
        }
288
289
        /**
290
         * §7.1 STEP 6 : check if incoming origin matches our hostname (taken from IdP metadata prefix)
291
         * §7.2 STEP 10: check if incoming origin matches our hostname (taken from IdP metadata prefix)
292
         */
293
        $httpHeler = new HTTPHelper();
294
        $slash = strpos($httpHeler->getBaseURL(), '/', 8);
295
        $expectedOrigin = ($slash !== false) ? substr($httpHeler->getBaseURL(), 0, $slash) : $slash;
296
        if ($clientData['origin'] === $expectedOrigin) {
297
            $this->pass("Origin matches");
298
        } else {
299
            $this->fail("Origin does not match: " . $expectedOrigin);
300
        }
301
302
        /**
303
         * §7.1 STEP 7 : optional tokenBinding check. The Yubikey does not make use of that option.
304
         * §7.2 STEP 11: optional tokenBinding check. The Yubikey does not make use of that option.
305
         */
306
        if (!isset($clientData['tokenBinding'])) {
307
            $this->pass("No optional token binding data to validate.");
308
        } else {
309
            $this->warn("Validation of the present token binding data not implemented, continuing without!");
310
        }
311
312
        /**
313
         * §7.1 STEP 8 : SHA-256 hashing the clientData
314
         * §7.2 STEP 16: SHA-256 hashing the clientData
315
         */
316
        return hash("sha256", $clientDataJSON, true);
317
    }
318
319
320
    /**
321
     * This function performs the required checks on the authData (REG) or authenticatorData (AUTH) structure
322
     *
323
     * I.e. it performs
324
     *   - for a REGistration session
325
     *     - the validation steps 10-12 of the validation
326
     *     - the parts of step 14 that relate to authData
327
     *   - for a AUTHentication session
328
     *     - the validation steps 12-14 of the validation
329
     *     - the parts of step 15 that relate to authData
330
     *
331
     * @param string $authData           the authData / authenticatorData binary blob
332
     *
333
     * @return int the current counter value of the authenticator
334
     */
335
    private function validateAuthData(string $authData): int
336
    {
337
        $this->debugBuffer .= "AuthData: <pre>";
338
        $this->debugBuffer .= print_r($authData, true);
339
        $this->debugBuffer .= "</pre>";
340
341
        /**
342
         * §7.1 STEP 10: compare incoming RpId hash with expected value
343
         * §7.2 STEP 12: compare incoming RpId hash with expected value
344
         */
345
        if (bin2hex(substr($authData, 0, 32)) == hash("sha256", $this->scope)) {
346
            $this->pass("Relying Party hash correct.");
347
            $this->rpIdHash = hash("sha256", $this->scope);
348
        } else {
349
            $this->fail("Mismatching Relying Party hash.");
350
        }
351
        $bitfield = substr($authData, 32, 1);
352
353
        /**
354
         * §7.1 STEP 14 (authData part): no extensions were requested, so none are allowed to be present
355
         * §7.2 STEP 15 (authData part): no extensions were requested, so none are allowed to be present
356
         */
357
        if ((128 & ord($bitfield)) > 0) {
358
            // so we get Extensions. We typically ignore all, but the
359
            // "credProtect" one may be interesting in the future.
360
            $this->pass("ED: Extension Data included. Interesting bits may be extracted later.");
361
        } else {
362
            $this->pass("ED: Extension Data not present.");
363
        }
364
365
        switch ($this->eventType) {
366
            case "REG":
367
                if ((64 & ord($bitfield)) > 0) {
368
                    $this->pass("AT: Attested Credential Data Included.");
369
                } else {
370
                    $this->fail("AT: not present, but required during registration.");
371
                }
372
                // REG events parse their registration-related Extensions, if
373
                // any, in the WebAuthnRegistrationEvent subclass
374
                break;
375
            case "AUTH":
376
                if ((64 & ord($bitfield)) > 0) {
377
                    $this->fail("AT: Attested Credential Data Included.");
378
                } else {
379
                    $this->pass("AT: not present, like it should be during an authentication.");
380
                }
381
                // AUTH events can also have extensions. Extensions, if any,
382
                // would follow after the counter. We allow them to be present
383
                // but don't currently do anything with them, so let's
384
                // just make a useless extraction here as a reminder.
385
                $extensionBytes = substr($authData, 37);
386
                if (strlen($extensionBytes) > 0) {
387
                    // assign into variable and process if needed
388
                    $this->cborDecode($extensionBytes);
389
                }
390
                break;
391
            default:
392
                $this->fail("unknown type of operation!");
393
                break;
394
        }
395
396
        /**
397
         * §7.1 STEP 11 + 12 : check user presence (this implementation does not insist on verification currently)
398
         * §7.2 STEP 13 + 14 : check user presence (this implementation does not insist on verification currently)
399
         */
400
        if ((4 & ord($bitfield)) > 0) {
401
            $this->presenceLevel = self::PRESENCE_LEVEL_VERIFIED;
402
            $this->pass("UV indicated: User is personally identified by the token.");
403
        } elseif ((1 & ord($bitfield)) > 0) {
404
            $this->presenceLevel = self::PRESENCE_LEVEL_PRESENT;
405
            $this->pass("UP indicated: someone is present and triggered the token.");
406
        } else {
407
            $this->fail("Neither UV nor UP asserted: user is possibly not present at computer.");
408
        }
409
        $counterBin = substr($authData, 33, 4);
410
411
        $counterDec = intval(bin2hex($counterBin), 16);
412
        $this->debugBuffer .= "Signature Counter: $counterDec<br/>";
413
        return $counterDec;
414
    }
415
416
417
    /**
418
     * this function takes a binary CBOR blob and decodes it into an associative PHP array.
419
     *
420
     * @param string $rawData the binary CBOR blob
421
     * @return array the decoded CBOR data
422
     */
423
    protected function cborDecode(string $rawData): array
424
    {
425
        $otherObjectManager = new OtherObject\OtherObjectManager();
426
        $otherObjectManager->add(OtherObject\SimpleObject::class);
427
        $otherObjectManager->add(OtherObject\FalseObject::class);
428
        $otherObjectManager->add(OtherObject\TrueObject::class);
429
        $otherObjectManager->add(OtherObject\NullObject::class);
430
        $otherObjectManager->add(OtherObject\UndefinedObject::class);
431
        $otherObjectManager->add(OtherObject\HalfPrecisionFloatObject::class);
432
        $otherObjectManager->add(OtherObject\SinglePrecisionFloatObject::class);
433
        $otherObjectManager->add(OtherObject\DoublePrecisionFloatObject::class);
434
435
        $tagManager = new Tag\TagManager();
436
        $tagManager->add(Tag\DatetimeTag::class);
437
        $tagManager->add(Tag\TimestampTag::class);
438
        $tagManager->add(Tag\UnsignedBigIntegerTag::class);
439
        $tagManager->add(Tag\NegativeBigIntegerTag::class);
440
        $tagManager->add(Tag\DecimalFractionTag::class);
441
        $tagManager->add(Tag\BigFloatTag::class);
442
        $tagManager->add(Tag\Base64UrlEncodingTag::class);
443
        $tagManager->add(Tag\Base64EncodingTag::class);
444
        $tagManager->add(Tag\Base16EncodingTag::class);
445
446
        $decoder = new Decoder($tagManager, $otherObjectManager);
447
        $stream = new StringStream($rawData);
448
449
        $finalData = $decoder->decode($stream);
450
        if ($finalData instanceof Normalizable) {
451
            $finalData = $finalData->normalize();
452
        } else {
453
            $this->fail("CBOR normalization failed.");
454
        }
455
456
        if ($finalData === null) {
457
            $this->fail("CBOR data decoding failed.");
458
        }
459
        return $finalData;
460
    }
461
462
463
    /**
464
     * @param string $text
465
     * @return void
466
     */
467
    protected function warn(string $text): void
468
    {
469
        $this->validateBuffer .= "<span style='background-color:yellow;'>WARN: $text</span><br/>";
470
    }
471
472
473
    /**
474
     * @param string $text
475
     * @throws \Exception
476
     * @return void
477
     */
478
    protected function fail(string $text): void
479
    {
480
        $this->validateBuffer .= "<span style='background-color:red;'>FAIL: $text</span><br/>";
481
        if ($this->debugMode === true) {
482
            echo $this->debugBuffer;
483
            echo $this->validateBuffer;
484
        }
485
        throw new \Exception($text);
486
    }
487
488
489
    /**
490
     * @param string $text
491
     * @return void
492
     */
493
    protected function pass(string $text): void
494
    {
495
        $this->validateBuffer .= "<span style='background-color:green; color:white;'>PASS: $text</span><br/>";
496
    }
497
498
499
    /**
500
     * @param string $text
501
     * @return void
502
     */
503
    protected function ignore(string $text): void
504
    {
505
        $this->validateBuffer .= "<span style='background-color:blue; color:white;'>IGNORE: $text</span><br/>";
506
    }
507
}
508