Passed
Push — master ( 1ed7bc...1383c2 )
by Tim
02:36
created

WebAuthnAbstractEvent::getAlgo()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 0
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 $scope;
27
28
    /**
29
     * The SHA256 hash of the clientDataJSON
30
     *
31
     * @var string
32
     */
33
    protected $clientDataHash;
34
35
    /**
36
     * The challenge that was used to trigger this event
37
     *
38
     * @var string
39
     */
40
    private $challenge;
41
42
    /**
43
     * Our IdP EntityId
44
     *
45
     * @var string
46
     */
47
    private $idpEntityId;
48
49
    /**
50
     * the authenticator's signature counter
51
     *
52
     * @var int
53
     */
54
    protected $counter;
55
    
56
    /**
57
     * the authenticator's signature algorithm
58
     *
59
     * @var int
60
     */
61
    protected $algo;
62
    
63
64
    /**
65
     * extensive debug information collection?
66
     *
67
     * @var bool
68
     */
69
    protected $debugMode = false;
70
71
    /**
72
     * A string buffer to hold debug information in case we need it.
73
     *
74
     * @var string
75
     */
76
    protected $debugBuffer = "";
77
78
    /**
79
     * A string buffer to hold raw validation data in case we want to look at it.
80
     *
81
     * @var string
82
     */
83
    protected $validateBuffer = "";
84
85
    /**
86
     * the type of event requested. This is to be set in child class constructors
87
     * before calling the parent's.
88
     *
89
     * @var string
90
     */
91
    protected $eventType;
92
93
    /**
94
     * The rpIdHash, available once validated during constructor of base class
95
     */
96
    protected $rpIdHash;
97
98
    /**
99
     * the credential ID for this event (either the one that gets registered, or
100
     * the one that gets used to authenticate)
101
     *
102
     * To be set by the constructors of the child classes.
103
     *
104
     * @var string
105
     */
106
    protected $credentialId;
107
108
    /**
109
     * the credential binary data for this event (either the one that gets
110
     * registered, or the one that gets used to authenticate)
111
     *
112
     * To be set by the constructors of the child classes.
113
     *
114
     * @var string
115
     */
116
    protected $credential;
117
118
    /**
119
     * Initialize the event object.
120
     *
121
     * Validates and parses the configuration.
122
     *
123
     * @param string $pubkeyCredType  PublicKeyCredential.type
124
     * @param string $scope           the scope of the event
125
     * @param string $challenge       the challenge which was used to trigger this event
126
     * @param string $idpEntityId     the entity ID of our IdP
127
     * @param string $authData        the authData / authenticatorData structure which is present in all types of events
128
     * @param string $clientDataJSON  the client data JSON string which is present in all types of events
129
     * @param bool   $debugMode       shall we collect and output some extensive debugging information along the way?
130
     */
131
    public function __construct(
132
        string $pubkeyCredType,
133
        string $scope,
134
        string $challenge,
135
        string $idpEntityId,
136
        string $authData,
137
        string $clientDataJSON,
138
        bool $debugMode = false
139
    ) {
140
        $this->scope = $scope;
141
        $this->challenge = $challenge;
142
        $this->idpEntityId = $idpEntityId;
143
        $this->debugMode = $debugMode;
144
        $this->debugBuffer .= "PublicKeyCredential.type: $pubkeyCredType<br/>";
145
        /**
146
         * This is not a required validation as per spec. Still odd that Firefox returns
147
         * "undefined" even though its own API spec says it will send "public-key".
148
         */
149
        switch ($pubkeyCredType) {
150
            case "public-key":
151
                $this->pass("Key Type OK");
152
                break;
153
            case "undefined":
154
                $this->warn("Key Type 'undefined' - Firefox or Yubikey issue?");
155
                break;
156
            default:
157
                $this->fail("Unknown Key Type: " . $_POST['type']);
158
                break;
159
        }
160
161
        /**
162
         * eventType is already set by child constructor, otherwise the function
163
         * will fail because of the missing type)
164
         */
165
        $this->clientDataHash = $this->verifyClientDataJSON($clientDataJSON);
166
        $this->counter = $this->validateAuthData($authData);
167
    }
168
169
    /**
170
     * @return int
171
     */
172
    public function getCounter(): int
173
    {
174
        return $this->counter;
175
    }
176
177
    /**
178
     * @return string
179
     */
180
    public function getCredential(): string
181
    {
182
        return $this->credential;
183
    }
184
185
   /**
186
     * @return int
187
     */
188
    public function getAlgo(): int
189
    {
190
        return $this->algo;
191
    }
192
193
    /**
194
     * @return string
195
     */
196
    public function getCredentialId(): string
197
    {
198
        return $this->credentialId;
199
    }
200
201
    /**
202
     * @return string
203
     */
204
    public function getDebugBuffer(): string
205
    {
206
        return $this->debugBuffer;
207
    }
208
209
    /**
210
     * @return string
211
     */
212
    public function getValidateBuffer(): string
213
    {
214
        return $this->validateBuffer;
215
    }
216
217
    /**
218
     * The base64url decode function differs slightly from base64. Thanks.
219
     *
220
     * taken from https://gist.github.com/nathggns/6652997
221
     *
222
     * @param string $data the base64url-encoded source string
223
     * @return string the decoded string
224
     */
225
    public static function base64urlDecode(string $data): string
226
    {
227
        return base64_decode(strtr($data, '-_', '+/'));
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>" . 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

252
        $this->debugBuffer .= "<pre>" . /** @scrutinizer ignore-type */ print_r($clientData, true) . "</pre>";
Loading history...
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
         * §7.1 STEP 5 : check if incoming challenge matches issued challenge
280
         * §7.2 STEP 9 : check if incoming challenge matches issued challenge
281
         */
282
        if ($this->challenge == bin2hex(WebAuthnAbstractEvent::base64urlDecode($clientData['challenge']))) {
283
            $this->pass("Challenge matches");
284
        } else {
285
            $this->fail("Challenge does not match");
286
        }
287
        /**
288
         * §7.1 STEP 6 : check if incoming origin matches our hostname (taken from IdP metadata prefix)
289
         * §7.2 STEP 10: check if incoming origin matches our hostname (taken from IdP metadata prefix)
290
         */
291
        $slash = strpos($this->idpEntityId, '/', 8);
292
        $expectedOrigin = ($slash !== false) ? substr($this->idpEntityId, 0, $slash) : $slash;
293
        if ($clientData['origin'] === $expectedOrigin) {
294
            $this->pass("Origin matches");
295
        } else {
296
            $this->fail("Origin does not match: " . $expectedOrigin);
297
        }
298
        /**
299
         * §7.1 STEP 7 : optional tokenBinding check. The Yubikey does not make use of that option.
300
         * §7.2 STEP 11: optional tokenBinding check. The Yubikey does not make use of that option.
301
         */
302
        if (!isset($clientData['tokenBinding'])) {
303
            $this->pass("No optional token binding data to validate.");
304
        } else {
305
            $this->warn("Validation of the present token binding data not implemented, continuing without!");
306
        }
307
        /**
308
         * §7.1 STEP 14 (clientData part): we did not request any client extensions, and do not allow any to be present
309
         * §7.2 STEP 15 (clientData part): we did not request any client extensions, and do not allow any to be present
310
         */
311
        if (!isset($clientData['clientExtensions']) || count($clientData['clientExtensions']) == 0) {
312
            $this->pass("As expected, no client extensions.");
313
        } else {
314
            $this->fail("Incoming client extensions even though none were requested.");
315
        }
316
        /**
317
         * §7.1 STEP 8 : SHA-256 hashing the clientData
318
         * §7.2 STEP 16: SHA-256 hashing the clientData
319
         */
320
        return hash("sha256", $clientDataJSON, true);
321
    }
322
323
    /**
324
     * This function performs the required checks on the authData (REG) or authenticatorData (AUTH) structure
325
     *
326
     * I.e. it performs
327
     *   - for a REGistration session
328
     *     - the validation steps 10-12 of the validation
329
     *     - the parts of step 14 that relate to authData
330
     *   - for a AUTHentication session
331
     *     - the validation steps 12-14 of the validation
332
     *     - the parts of step 15 that relate to authData
333
     *
334
     * @param string $authData           the authData / authenticatorData binary blob
335
     *
336
     * @return int the current counter value of the authenticator
337
     */
338
    private function validateAuthData(string $authData): int
339
    {
340
        $this->debugBuffer .= "AuthData: <pre>";
341
        $this->debugBuffer .= print_r($authData, true);
342
        $this->debugBuffer .= "</pre>";
343
        /**
344
         * §7.1 STEP 10: compare incoming RpId hash with expected value
345
         * §7.2 STEP 12: compare incoming RpId hash with expected value
346
         */
347
        if (bin2hex(substr($authData, 0, 32)) == hash("sha256", $this->scope)) {
348
            $this->pass("Relying Party hash correct.");
349
            $this->rpIdHash = hash("sha256", $this->scope);
350
        } else {
351
            $this->fail("Mismatching Relying Party hash.");
352
        }
353
        $bitfield = substr($authData, 32, 1);
354
        /**
355
         * §7.1 STEP 14 (authData part): no extensions were requested, so none are allowed to be present
356
         * §7.2 STEP 15 (authData part): no extensions were requested, so none are allowed to be present
357
         */
358
        if ((128 & ord($bitfield)) > 0) {
359
            $this->fail("ED: Extension Data Included, even though we did not request any.");
360
        } else {
361
            $this->pass("ED: Extension Data not present.");
362
        }
363
        switch ($this->eventType) {
364
            case "REG":
365
                if ((64 & ord($bitfield)) > 0) {
366
                    $this->pass("AT: Attested Credential Data Included.");
367
                } else {
368
                    $this->fail("AT: not present, but required during registration.");
369
                }
370
                break;
371
            case "AUTH":
372
                if ((64 & ord($bitfield)) > 0) {
373
                    $this->fail("AT: Attested Credential Data Included.");
374
                } else {
375
                    $this->pass("AT: not present, like it should be during an authentication.");
376
                }
377
                break;
378
            default:
379
                $this->fail("unknown type of operation!");
380
                break;
381
        }
382
        /**
383
         * §7.1 STEP 11 + 12 : check user presence (this implementation does not insist on verification currently)
384
         * §7.2 STEP 13 + 14 : check user presence (this implementation does not insist on verification currently)
385
         */
386
        if (((4 & ord($bitfield)) > 0) || ((1 & ord($bitfield)) > 0)) {
387
            $this->pass("UV and/or UP indicated: User has token in his hands.");
388
        } else {
389
            $this->fail("Neither UV nor UP asserted: user is possibly not present at computer.");
390
        }
391
        $counterBin = substr($authData, 33, 4);
392
393
        $counterDec = intval(bin2hex($counterBin), 16);
394
        $this->debugBuffer .= "Signature Counter: $counterDec<br/>";
395
        return $counterDec;
396
    }
397
398
    /**
399
     * this function takes a binary CBOR blob and decodes it into an associative PHP array.
400
     *
401
     * @param string $rawData the binary CBOR blob
402
     * @return array the decoded CBOR data
403
     */
404
    protected function cborDecode(string $rawData): array
405
    {
406
        $otherObjectManager = new OtherObject\OtherObjectManager();
407
        $otherObjectManager->add(OtherObject\SimpleObject::class);
408
        $otherObjectManager->add(OtherObject\FalseObject::class);
409
        $otherObjectManager->add(OtherObject\TrueObject::class);
410
        $otherObjectManager->add(OtherObject\NullObject::class);
411
        $otherObjectManager->add(OtherObject\UndefinedObject::class);
412
        $otherObjectManager->add(OtherObject\HalfPrecisionFloatObject::class);
413
        $otherObjectManager->add(OtherObject\SinglePrecisionFloatObject::class);
414
        $otherObjectManager->add(OtherObject\DoublePrecisionFloatObject::class);
415
416
        $tagManager = new Tag\TagObjectManager();
417
        $tagManager->add(Tag\EpochTag::class);
418
        $tagManager->add(Tag\TimestampTag::class);
419
        $tagManager->add(Tag\PositiveBigIntegerTag::class);
420
        $tagManager->add(Tag\NegativeBigIntegerTag::class);
421
        $tagManager->add(Tag\DecimalFractionTag::class);
422
        $tagManager->add(Tag\BigFloatTag::class);
423
        $tagManager->add(Tag\Base64UrlEncodingTag::class);
424
        $tagManager->add(Tag\Base64EncodingTag::class);
425
        $tagManager->add(Tag\Base16EncodingTag::class);
426
427
        $decoder = new Decoder($tagManager, $otherObjectManager);
428
        $stream = new StringStream($rawData);
429
        $object = $decoder->decode($stream);
430
        $finalData = $object->getNormalizedData(true);
431
        if ($finalData === null) {
432
            $this->fail("CBOR data decoding failed.");
433
        }
434
        return $finalData;
435
    }
436
437
    /**
438
     * @param string $text
439
     * @return void
440
     */
441
    protected function warn(string $text): void
442
    {
443
        $this->validateBuffer .= "<span style='background-color:yellow;'>WARN: $text</span><br/>";
444
    }
445
446
    /**
447
     * @param string $text
448
     * @throws \Exception
449
     * @return void
450
     */
451
    protected function fail(string $text): void
452
    {
453
        $this->validateBuffer .= "<span style='background-color:red;'>FAIL: $text</span><br/>";
454
        if ($this->debugMode === true) {
455
            echo $this->debugBuffer;
456
            echo $this->validateBuffer;
457
        }
458
        throw new \Exception($text);
459
    }
460
461
    /**
462
     * @param string $text
463
     * @return void
464
     */
465
    protected function pass(string $text): void
466
    {
467
        $this->validateBuffer .= "<span style='background-color:green; color:white;'>PASS: $text</span><br/>";
468
    }
469
470
    /**
471
     * @param string $text
472
     * @return void
473
     */
474
    protected function ignore(string $text): void
475
    {
476
        $this->validateBuffer .= "<span style='background-color:blue; color:white;'>IGNORE: $text</span><br/>";
477
    }
478
}
479