Passed
Push — master ( 329aee...b52d55 )
by Tim
03:30
created

WebAuthnAbstractEvent::cborDecode()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 32
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

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