Completed
Push — master ( e1d87d...70b691 )
by Tim
13s queued 10s
created

WebAuthnAbstractEvent::getValidateBuffer()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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