Passed
Push — master ( a25f1e...4c4275 )
by Stefan
02:54
created

WebAuthnAbstractEvent::getPresenceLevel()   A

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
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
rs 10
1
<?php
2
3
namespace SimpleSAML\Module\webauthn\WebAuthn;
4
5
use CBOR\Decoder;
6
use CBOR\OtherObject;
7
use CBOR\Tag;
8
use CBOR\StringStream;
9
10
/**
11
 * FIDO2/WebAuthn Authentication Processing filter
12
 *
13
 * Filter for registering or authenticating with a FIDO2/WebAuthn token after
14
 * having authenticated with the primary authsource.
15
 *
16
 * @author Stefan Winter <[email protected]>
17
 * @package SimpleSAMLphp
18
 */
19
abstract class WebAuthnAbstractEvent
20
{
21
    /**
22
     * Scope of the FIDO2 attestation. Can only be in the own domain.
23
     *
24
     * @var string
25
     */
26
    private string $scope;
27
28
    /**
29
     * The SHA256 hash of the clientDataJSON
30
     *
31
     * @var string
32
     */
33
    protected string $clientDataHash;
34
35
    /**
36
     * The challenge that was used to trigger this event
37
     *
38
     * @var string
39
     */
40
    private string $challenge;
41
42
    /**
43
     * Our IdP EntityId
44
     *
45
     * @var string
46
     */
47
    private string $idpEntityId;
48
49
    /**
50
     * the authenticator's signature counter
51
     *
52
     * @var int
53
     */
54
    protected int $counter;
55
56
    /**
57
     * the authenticator's signature algorithm
58
     *
59
     * @var int
60
     */
61
    protected int $algo;
62
63
    const PRESENCE_LEVEL_PRESENT = 1;
64
    const PRESENCE_LEVEL_VERIFIED = 4;
65
    const PRESENCE_LEVEL_NONE = 0;
66
    /**
67
     * UV or UP bit?
68
     */
69
    protected int $presenceLevel;
70
71
    /**
72
     * extensive debug information collection?
73
     *
74
     * @var bool
75
     */
76
    protected bool $debugMode = false;
77
78
    /**
79
     * A string buffer to hold debug information in case we need it.
80
     *
81
     * @var string
82
     */
83
    protected string $debugBuffer = '';
84
85
    /**
86
     * A string buffer to hold raw validation data in case we want to look at it.
87
     *
88
     * @var string
89
     */
90
    protected string $validateBuffer = '';
91
92
    /**
93
     * the type of event requested. This is to be set in child class constructors
94
     * before calling the parent's.
95
     *
96
     * @var string
97
     */
98
    protected string $eventType;
99
100
    /**
101
     * The rpIdHash, available once validated during constructor of base class
102
     */
103
    protected string $rpIdHash;
104
105
    /**
106
     * the credential ID for this event (either the one that gets registered, or
107
     * the one that gets used to authenticate)
108
     *
109
     * To be set by the constructors of the child classes.
110
     *
111
     * @var string
112
     */
113
    protected string $credentialId;
114
115
    /**
116
     * the credential binary data for this event (either the one that gets
117
     * registered, or the one that gets used to authenticate)
118
     *
119
     * To be set by the constructors of the child classes.
120
     *
121
     * @var string
122
     */
123
    protected string $credential;
124
125
126
    /**
127
     * Initialize the event object.
128
     *
129
     * Validates and parses the configuration.
130
     *
131
     * @param string $pubkeyCredType  PublicKeyCredential.type
132
     * @param string $scope           the scope of the event
133
     * @param string $challenge       the challenge which was used to trigger this event
134
     * @param string $idpEntityId     the entity ID of our IdP
135
     * @param string $authData        the authData / authenticatorData structure which is present in all types of events
136
     * @param string $clientDataJSON  the client data JSON string which is present in all types of events
137
     * @param bool   $debugMode       shall we collect and output some extensive debugging information along the way?
138
     */
139
    public function __construct(
140
        string $pubkeyCredType,
141
        string $scope,
142
        string $challenge,
143
        string $idpEntityId,
144
        string $authData,
145
        string $clientDataJSON,
146
        bool $debugMode = false
147
    ) {
148
        $this->scope = $scope;
149
        $this->challenge = $challenge;
150
        $this->idpEntityId = $idpEntityId;
151
        $this->debugMode = $debugMode;
152
        $this->debugBuffer .= "PublicKeyCredential.type: $pubkeyCredType<br/>";
153
        /**
154
         * This is not a required validation as per spec. Still odd that Firefox returns
155
         * "undefined" even though its own API spec says it will send "public-key".
156
         */
157
        switch ($pubkeyCredType) {
158
            case "public-key":
159
                $this->pass("Key Type OK");
160
                break;
161
            case "undefined":
162
                $this->warn("Key Type 'undefined' - Firefox or Yubikey issue?");
163
                break;
164
            default:
165
                $this->fail("Unknown Key Type: " . $_POST['type']);
166
                break;
167
        }
168
169
        /**
170
         * eventType is already set by child constructor, otherwise the function
171
         * will fail because of the missing type)
172
         */
173
        $this->clientDataHash = $this->verifyClientDataJSON($clientDataJSON);
174
        $this->counter = $this->validateAuthData($authData);
175
        $this->presenceLevel = self::PRESENCE_LEVEL_NONE;
176
    }
177
178
    /**
179
     * @return int
180
     */
181
    public function getCounter(): int
182
    {
183
        return $this->counter;
184
    }
185
186
    /**
187
     * @return string
188
     */
189
    public function getCredential(): string
190
    {
191
        return $this->credential;
192
    }
193
194
   /**
195
     * @return int
196
     */
197
    public function getAlgo(): int
198
    {
199
        return $this->algo;
200
    }
201
202
    /**
203
     * @return int
204
     */
205
    public function getPresenceLevel(): int
206
    {
207
        return $this->presenceLevel;
208
    }
209
210
    /**
211
     * @return string
212
     */
213
    public function getCredentialId(): string
214
    {
215
        return $this->credentialId;
216
    }
217
218
    /**
219
     * @return string
220
     */
221
    public function getDebugBuffer(): string
222
    {
223
        return $this->debugBuffer;
224
    }
225
226
    /**
227
     * @return string
228
     */
229
    public function getValidateBuffer(): string
230
    {
231
        return $this->validateBuffer;
232
    }
233
234
    /**
235
     * The base64url decode function differs slightly from base64. Thanks.
236
     *
237
     * taken from https://gist.github.com/nathggns/6652997
238
     *
239
     * @param string $data the base64url-encoded source string
240
     * @return string the decoded string
241
     */
242
    public static function base64urlDecode(string $data): string
243
    {
244
        return base64_decode(strtr($data, '-_', '+/'));
245
    }
246
247
    /**
248
     * this function validates the content of clientDataJSON.
249
     * I.e. it performs
250
     *   - for a REGistration session
251
     *     - the validation steps 2 through 8 of the validation
252
     *     - the parts of step 14 that relate to clientDataJSON
253
     *   - for a AUTHentication session
254
     *     - the validation steps 6 through 11 of the validation
255
     *     - the parts of step 15 that relate to clientDataJSON
256
     *
257
     * @param string $clientDataJSON the incoming data
258
     *
259
     * @return string
260
     */
261
    private function verifyClientDataJSON(string $clientDataJSON): string
262
    {
263
        /**
264
         * §7.1 STEP 2 + 3 : convert to JSON and dissect JSON into PHP associative array
265
         * §7.2 STEP 6 + 7 : convert to JSON and dissect JSON into PHP associative array
266
         */
267
        $this->debugBuffer .= "ClientDataJSON hash: " . hash("sha256", $clientDataJSON) . "<br/>";
268
        $clientData = json_decode($clientDataJSON, true);
269
        $this->debugBuffer .= "<pre>" . print_r($clientData, true) . "</pre>";
0 ignored issues
show
Bug introduced by
Are you sure print_r($clientData, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

269
        $this->debugBuffer .= "<pre>" . /** @scrutinizer ignore-type */ print_r($clientData, true) . "</pre>";
Loading history...
270
        switch ($this->eventType) {
271
            case "REG":
272
                if ($clientData['type'] == "webauthn.create") {
273
                    /**
274
                     * §7.1 STEP 4 wheck for webauthn.create
275
                     */
276
                    $this->pass("Registration requested; type has expected value");
277
                } else {
278
                    $this->fail("REG requested, but did not receive webauthn.create.");
279
                }
280
                break;
281
            case "AUTH":
282
                if ($clientData['type'] == "webauthn.get") {
283
                    /**
284
                     * §7.2 STEP 8: check for webauthn.get
285
                     */
286
                    $this->pass("Authentication requested; type has expected value");
287
                } else {
288
                    $this->fail("AUTH requested, but did not receive webauthn.get.");
289
                }
290
                break;
291
            default:
292
                $this->fail("Unexpected operation " . $this->eventType);
293
                break;
294
        }
295
        /**
296
         * §7.1 STEP 5 : check if incoming challenge matches issued challenge
297
         * §7.2 STEP 9 : check if incoming challenge matches issued challenge
298
         */
299
        if ($this->challenge == bin2hex(WebAuthnAbstractEvent::base64urlDecode($clientData['challenge']))) {
300
            $this->pass("Challenge matches");
301
        } else {
302
            $this->fail("Challenge does not match");
303
        }
304
        /**
305
         * §7.1 STEP 6 : check if incoming origin matches our hostname (taken from IdP metadata prefix)
306
         * §7.2 STEP 10: check if incoming origin matches our hostname (taken from IdP metadata prefix)
307
         */
308
        $slash = strpos($this->idpEntityId, '/', 8);
309
        $expectedOrigin = ($slash !== false) ? substr($this->idpEntityId, 0, $slash) : $slash;
310
        if ($clientData['origin'] === $expectedOrigin) {
311
            $this->pass("Origin matches");
312
        } else {
313
            $this->fail("Origin does not match: " . $expectedOrigin);
314
        }
315
        /**
316
         * §7.1 STEP 7 : optional tokenBinding check. The Yubikey does not make use of that option.
317
         * §7.2 STEP 11: optional tokenBinding check. The Yubikey does not make use of that option.
318
         */
319
        if (!isset($clientData['tokenBinding'])) {
320
            $this->pass("No optional token binding data to validate.");
321
        } else {
322
            $this->warn("Validation of the present token binding data not implemented, continuing without!");
323
        }
324
        /**
325
         * §7.1 STEP 14 (clientData part): we did not request any client extensions, and do not allow any to be present
326
         * §7.2 STEP 15 (clientData part): we did not request any client extensions, and do not allow any to be present
327
         */
328
        if (!isset($clientData['clientExtensions']) || count($clientData['clientExtensions']) == 0) {
329
            $this->pass("As expected, no client extensions.");
330
        } else {
331
            $this->fail("Incoming client extensions even though none were requested.");
332
        }
333
        /**
334
         * §7.1 STEP 8 : SHA-256 hashing the clientData
335
         * §7.2 STEP 16: SHA-256 hashing the clientData
336
         */
337
        return hash("sha256", $clientDataJSON, true);
338
    }
339
340
    /**
341
     * This function performs the required checks on the authData (REG) or authenticatorData (AUTH) structure
342
     *
343
     * I.e. it performs
344
     *   - for a REGistration session
345
     *     - the validation steps 10-12 of the validation
346
     *     - the parts of step 14 that relate to authData
347
     *   - for a AUTHentication session
348
     *     - the validation steps 12-14 of the validation
349
     *     - the parts of step 15 that relate to authData
350
     *
351
     * @param string $authData           the authData / authenticatorData binary blob
352
     *
353
     * @return int the current counter value of the authenticator
354
     */
355
    private function validateAuthData(string $authData): int
356
    {
357
        $this->debugBuffer .= "AuthData: <pre>";
358
        $this->debugBuffer .= print_r($authData, true);
359
        $this->debugBuffer .= "</pre>";
360
        /**
361
         * §7.1 STEP 10: compare incoming RpId hash with expected value
362
         * §7.2 STEP 12: compare incoming RpId hash with expected value
363
         */
364
        if (bin2hex(substr($authData, 0, 32)) == hash("sha256", $this->scope)) {
365
            $this->pass("Relying Party hash correct.");
366
            $this->rpIdHash = hash("sha256", $this->scope);
367
        } else {
368
            $this->fail("Mismatching Relying Party hash.");
369
        }
370
        $bitfield = substr($authData, 32, 1);
371
        /**
372
         * §7.1 STEP 14 (authData part): no extensions were requested, so none are allowed to be present
373
         * §7.2 STEP 15 (authData part): no extensions were requested, so none are allowed to be present
374
         */
375
        if ((128 & ord($bitfield)) > 0) {
376
            $this->fail("ED: Extension Data Included, even though we did not request any.");
377
        } else {
378
            $this->pass("ED: Extension Data not present.");
379
        }
380
        switch ($this->eventType) {
381
            case "REG":
382
                if ((64 & ord($bitfield)) > 0) {
383
                    $this->pass("AT: Attested Credential Data Included.");
384
                } else {
385
                    $this->fail("AT: not present, but required during registration.");
386
                }
387
                break;
388
            case "AUTH":
389
                if ((64 & ord($bitfield)) > 0) {
390
                    $this->fail("AT: Attested Credential Data Included.");
391
                } else {
392
                    $this->pass("AT: not present, like it should be during an authentication.");
393
                }
394
                break;
395
            default:
396
                $this->fail("unknown type of operation!");
397
                break;
398
        }
399
        /**
400
         * §7.1 STEP 11 + 12 : check user presence (this implementation does not insist on verification currently)
401
         * §7.2 STEP 13 + 14 : check user presence (this implementation does not insist on verification currently)
402
         */
403
        if ((4 & ord($bitfield)) > 0) {
404
            $this->presenceLevel = self::PRESENCE_LEVEL_VERIFIED;
405
            $this->pass("UV indicated: User is personally identified by the token.");
406
        } elseif ((1 & ord($bitfield)) > 0) {
407
            $this->presenceLevel = self::PRESENCE_LEVEL_PRESENT;
408
            $this->pass("UP indicated: someone is present and triggered the token.");
409
        } else {
410
            $this->fail("Neither UV nor UP asserted: user is possibly not present at computer.");
411
        }
412
        $counterBin = substr($authData, 33, 4);
413
414
        $counterDec = intval(bin2hex($counterBin), 16);
415
        $this->debugBuffer .= "Signature Counter: $counterDec<br/>";
416
        return $counterDec;
417
    }
418
419
    /**
420
     * this function takes a binary CBOR blob and decodes it into an associative PHP array.
421
     *
422
     * @param string $rawData the binary CBOR blob
423
     * @return array the decoded CBOR data
424
     */
425
    protected function cborDecode(string $rawData): array
426
    {
427
        $otherObjectManager = new OtherObject\OtherObjectManager();
428
        $otherObjectManager->add(OtherObject\SimpleObject::class);
429
        $otherObjectManager->add(OtherObject\FalseObject::class);
430
        $otherObjectManager->add(OtherObject\TrueObject::class);
431
        $otherObjectManager->add(OtherObject\NullObject::class);
432
        $otherObjectManager->add(OtherObject\UndefinedObject::class);
433
        $otherObjectManager->add(OtherObject\HalfPrecisionFloatObject::class);
434
        $otherObjectManager->add(OtherObject\SinglePrecisionFloatObject::class);
435
        $otherObjectManager->add(OtherObject\DoublePrecisionFloatObject::class);
436
437
        $tagManager = new Tag\TagObjectManager();
0 ignored issues
show
Deprecated Code introduced by
The class CBOR\Tag\TagObjectManager has been deprecated: Will be removed in v3.0. Please use TagManager instead ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

437
        $tagManager = /** @scrutinizer ignore-deprecated */ new Tag\TagObjectManager();
Loading history...
438
        $tagManager->add(Tag\EpochTag::class);
439
        $tagManager->add(Tag\TimestampTag::class);
440
        $tagManager->add(Tag\PositiveBigIntegerTag::class);
441
        $tagManager->add(Tag\NegativeBigIntegerTag::class);
442
        $tagManager->add(Tag\DecimalFractionTag::class);
443
        $tagManager->add(Tag\BigFloatTag::class);
444
        $tagManager->add(Tag\Base64UrlEncodingTag::class);
445
        $tagManager->add(Tag\Base64EncodingTag::class);
446
        $tagManager->add(Tag\Base16EncodingTag::class);
447
448
        $decoder = new Decoder($tagManager, $otherObjectManager);
449
        $stream = new StringStream($rawData);
450
        $object = $decoder->decode($stream);
451
        $finalData = $object->getNormalizedData(true);
0 ignored issues
show
Deprecated Code introduced by
The function CBOR\CBORObject::getNormalizedData() has been deprecated: The method will be removed on v3.0. Please rely on the CBOR\Normalizable interface ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

451
        $finalData = /** @scrutinizer ignore-deprecated */ $object->getNormalizedData(true);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
452
        if ($finalData === null) {
453
            $this->fail("CBOR data decoding failed.");
454
        }
455
        return $finalData;
456
    }
457
458
    /**
459
     * @param string $text
460
     * @return void
461
     */
462
    protected function warn(string $text): void
463
    {
464
        $this->validateBuffer .= "<span style='background-color:yellow;'>WARN: $text</span><br/>";
465
    }
466
467
    /**
468
     * @param string $text
469
     * @throws \Exception
470
     * @return void
471
     */
472
    protected function fail(string $text): void
473
    {
474
        $this->validateBuffer .= "<span style='background-color:red;'>FAIL: $text</span><br/>";
475
        if ($this->debugMode === true) {
476
            echo $this->debugBuffer;
477
            echo $this->validateBuffer;
478
        }
479
        throw new \Exception($text);
480
    }
481
482
    /**
483
     * @param string $text
484
     * @return void
485
     */
486
    protected function pass(string $text): void
487
    {
488
        $this->validateBuffer .= "<span style='background-color:green; color:white;'>PASS: $text</span><br/>";
489
    }
490
491
    /**
492
     * @param string $text
493
     * @return void
494
     */
495
    protected function ignore(string $text): void
496
    {
497
        $this->validateBuffer .= "<span style='background-color:blue; color:white;'>IGNORE: $text</span><br/>";
498
    }
499
}
500