Completed
Push — master ( ae0b84...8c5606 )
by Stefan
14s queued 11s
created

WebAuthnAbstractEvent::__construct()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 36
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

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