Passed
Push — master ( aeea93...08b2ed )
by Tim
05:13 queued 03:26
created

WebAuthnAbstractEvent   A

Complexity

Total Complexity 38

Size/Duplication

Total Lines 483
Duplicated Lines 0 %

Importance

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