simplesamlphp /
simplesamlphp-module-webauthn
| 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
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 |