1<?php 2/* Copyright (c) 2014 Yubico AB 3 * All rights reserved. 4 * 5 * Redistribution and use in source and binary forms, with or without 6 * modification, are permitted provided that the following conditions are 7 * met: 8 * 9 * * Redistributions of source code must retain the above copyright 10 * notice, this list of conditions and the following disclaimer. 11 * 12 * * Redistributions in binary form must reproduce the above 13 * copyright notice, this list of conditions and the following 14 * disclaimer in the documentation and/or other materials provided 15 * with the distribution. 16 * 17 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 */ 29 30namespace u2flib_server; 31 32/** Constant for the version of the u2f protocol */ 33const U2F_VERSION = "U2F_V2"; 34 35/** Constant for the type value in registration clientData */ 36const REQUEST_TYPE_REGISTER = "navigator.id.finishEnrollment"; 37 38/** Constant for the type value in authentication clientData */ 39const REQUEST_TYPE_AUTHENTICATE = "navigator.id.getAssertion"; 40 41/** Error for the authentication message not matching any outstanding 42 * authentication request */ 43const ERR_NO_MATCHING_REQUEST = 1; 44 45/** Error for the authentication message not matching any registration */ 46const ERR_NO_MATCHING_REGISTRATION = 2; 47 48/** Error for the signature on the authentication message not verifying with 49 * the correct key */ 50const ERR_AUTHENTICATION_FAILURE = 3; 51 52/** Error for the challenge in the registration message not matching the 53 * registration challenge */ 54const ERR_UNMATCHED_CHALLENGE = 4; 55 56/** Error for the attestation signature on the registration message not 57 * verifying */ 58const ERR_ATTESTATION_SIGNATURE = 5; 59 60/** Error for the attestation verification not verifying */ 61const ERR_ATTESTATION_VERIFICATION = 6; 62 63/** Error for not getting good random from the system */ 64const ERR_BAD_RANDOM = 7; 65 66/** Error when the counter is lower than expected */ 67const ERR_COUNTER_TOO_LOW = 8; 68 69/** Error decoding public key */ 70const ERR_PUBKEY_DECODE = 9; 71 72/** Error user-agent returned error */ 73const ERR_BAD_UA_RETURNING = 10; 74 75/** Error old OpenSSL version */ 76const ERR_OLD_OPENSSL = 11; 77 78/** Error for the origin not matching the appId */ 79const ERR_NO_MATCHING_ORIGIN = 12; 80 81/** Error for the type in clientData being invalid */ 82const ERR_BAD_TYPE = 13; 83 84/** Error for bad user presence byte value */ 85const ERR_BAD_USER_PRESENCE = 14; 86 87/** @internal */ 88const PUBKEY_LEN = 65; 89 90class U2F 91{ 92 /** @var string */ 93 private $appId; 94 95 /** @var null|string */ 96 private $attestDir; 97 98 /** @internal */ 99 private $FIXCERTS = array( 100 '349bca1031f8c82c4ceca38b9cebf1a69df9fb3b94eed99eb3fb9aa3822d26e8', 101 'dd574527df608e47ae45fbba75a2afdd5c20fd94a02419381813cd55a2a3398f', 102 '1d8764f0f7cd1352df6150045c8f638e517270e8b5dda1c63ade9c2280240cae', 103 'd0edc9a91a1677435a953390865d208c55b3183c6759c9b5a7ff494c322558eb', 104 '6073c436dcd064a48127ddbf6032ac1a66fd59a0c24434f070d4e564c124c897', 105 'ca993121846c464d666096d35f13bf44c1b05af205f9b4a1e00cf6cc10c5e511' 106 ); 107 108 /** 109 * @param string $appId Application id for the running application 110 * @param string|null $attestDir Directory where trusted attestation roots may be found 111 * @throws Error If OpenSSL older than 1.0.0 is used 112 */ 113 public function __construct($appId, $attestDir = null) 114 { 115 if(OPENSSL_VERSION_NUMBER < 0x10000000) { 116 throw new Error('OpenSSL has to be at least version 1.0.0, this is ' . OPENSSL_VERSION_TEXT, ERR_OLD_OPENSSL); 117 } 118 $this->appId = $appId; 119 $this->attestDir = $attestDir; 120 } 121 122 /** 123 * Called to get a registration request to send to a user. 124 * Returns an array of one registration request and a array of sign requests. 125 * 126 * @param array $registrations List of current registrations for this 127 * user, to prevent the user from registering the same authenticator several 128 * times. 129 * @return array An array of two elements, the first containing a 130 * RegisterRequest the second being an array of SignRequest 131 * @throws Error 132 */ 133 public function getRegisterData(array $registrations = array()) 134 { 135 $challenge = $this->createChallenge(); 136 $request = new RegisterRequest($challenge, $this->appId); 137 $signs = $this->getAuthenticateData($registrations); 138 return array($request, $signs); 139 } 140 141 /** 142 * Called to verify and unpack a registration message. 143 * 144 * @param RegisterRequest $request this is a reply to 145 * @param object $response response from a user 146 * @param bool $includeCert set to true if the attestation certificate should be 147 * included in the returned Registration object 148 * @return Registration 149 * @throws Error 150 */ 151 public function doRegister($request, $response, $includeCert = true) 152 { 153 if( !is_object( $request ) ) { 154 throw new \InvalidArgumentException('$request of doRegister() method only accepts object.'); 155 } 156 157 if( !is_object( $response ) ) { 158 throw new \InvalidArgumentException('$response of doRegister() method only accepts object.'); 159 } 160 161 if( property_exists( $response, 'errorCode') && $response->errorCode !== 0 ) { 162 throw new Error('User-agent returned error. Error code: ' . $response->errorCode, ERR_BAD_UA_RETURNING ); 163 } 164 165 if( !is_bool( $includeCert ) ) { 166 throw new \InvalidArgumentException('$include_cert of doRegister() method only accepts boolean.'); 167 } 168 169 $rawReg = $this->base64u_decode($response->registrationData); 170 $regData = array_values(unpack('C*', $rawReg)); 171 $clientData = $this->base64u_decode($response->clientData); 172 $cli = json_decode($clientData); 173 174 if($cli->challenge !== $request->challenge) { 175 throw new Error('Registration challenge does not match', ERR_UNMATCHED_CHALLENGE ); 176 } 177 178 if(isset($cli->typ) && $cli->typ !== REQUEST_TYPE_REGISTER) { 179 throw new Error('ClientData type is invalid', ERR_BAD_TYPE); 180 } 181 182 if(isset($cli->origin) && $cli->origin !== $request->appId) { 183 throw new Error('App ID does not match the origin', ERR_NO_MATCHING_ORIGIN); 184 } 185 186 $registration = new Registration(); 187 $offs = 1; 188 $pubKey = substr($rawReg, $offs, PUBKEY_LEN); 189 $offs += PUBKEY_LEN; 190 // decode the pubKey to make sure it's good 191 $tmpKey = $this->pubkey_to_pem($pubKey); 192 if($tmpKey === null) { 193 throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE ); 194 } 195 $registration->publicKey = base64_encode($pubKey); 196 $khLen = $regData[$offs++]; 197 $kh = substr($rawReg, $offs, $khLen); 198 $offs += $khLen; 199 $registration->keyHandle = $this->base64u_encode($kh); 200 201 // length of certificate is stored in byte 3 and 4 (excluding the first 4 bytes) 202 $certLen = 4; 203 $certLen += ($regData[$offs + 2] << 8); 204 $certLen += $regData[$offs + 3]; 205 206 $rawCert = $this->fixSignatureUnusedBits(substr($rawReg, $offs, $certLen)); 207 $offs += $certLen; 208 $pemCert = "-----BEGIN CERTIFICATE-----\r\n"; 209 $pemCert .= chunk_split(base64_encode($rawCert), 64); 210 $pemCert .= "-----END CERTIFICATE-----"; 211 if($includeCert) { 212 $registration->certificate = base64_encode($rawCert); 213 } 214 if($this->attestDir) { 215 if(openssl_x509_checkpurpose($pemCert, -1, $this->get_certs()) !== true) { 216 throw new Error('Attestation certificate can not be validated', ERR_ATTESTATION_VERIFICATION ); 217 } 218 } 219 220 if(!openssl_pkey_get_public($pemCert)) { 221 throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE ); 222 } 223 $signature = substr($rawReg, $offs); 224 225 $dataToVerify = pack('C', 0); 226 $dataToVerify .= hash('sha256', $request->appId, true); 227 $dataToVerify .= hash('sha256', $clientData, true); 228 $dataToVerify .= $kh; 229 $dataToVerify .= $pubKey; 230 231 if(openssl_verify($dataToVerify, $signature, $pemCert, 'sha256') === 1) { 232 return $registration; 233 } else { 234 throw new Error('Attestation signature does not match', ERR_ATTESTATION_SIGNATURE ); 235 } 236 } 237 238 /** 239 * Called to get an authentication request. 240 * 241 * @param array $registrations An array of the registrations to create authentication requests for. 242 * @return array An array of SignRequest 243 * @throws Error 244 */ 245 public function getAuthenticateData(array $registrations) 246 { 247 $sigs = array(); 248 $challenge = $this->createChallenge(); 249 foreach ($registrations as $reg) { 250 if( !is_object( $reg ) ) { 251 throw new \InvalidArgumentException('$registrations of getAuthenticateData() method only accepts array of object.'); 252 } 253 /** @var Registration $reg */ 254 255 $sig = new SignRequest(); 256 $sig->appId = $this->appId; 257 $sig->keyHandle = $reg->keyHandle; 258 $sig->challenge = $challenge; 259 $sigs[] = $sig; 260 } 261 return $sigs; 262 } 263 264 /** 265 * Called to verify an authentication response 266 * 267 * @param array $requests An array of outstanding authentication requests 268 * @param array $registrations An array of current registrations 269 * @param object $response A response from the authenticator 270 * @return Registration 271 * @throws Error 272 * 273 * The Registration object returned on success contains an updated counter 274 * that should be saved for future authentications. 275 * If the Error returned is ERR_COUNTER_TOO_LOW this is an indication of 276 * token cloning or similar and appropriate action should be taken. 277 */ 278 public function doAuthenticate(array $requests, array $registrations, $response) 279 { 280 if( !is_object( $response ) ) { 281 throw new \InvalidArgumentException('$response of doAuthenticate() method only accepts object.'); 282 } 283 284 if( property_exists( $response, 'errorCode') && $response->errorCode !== 0 ) { 285 throw new Error('User-agent returned error. Error code: ' . $response->errorCode, ERR_BAD_UA_RETURNING ); 286 } 287 288 /** @var object|null $req */ 289 $req = null; 290 291 /** @var object|null $reg */ 292 $reg = null; 293 294 $clientData = $this->base64u_decode($response->clientData); 295 $decodedClient = json_decode($clientData); 296 297 if(isset($decodedClient->typ) && $decodedClient->typ !== REQUEST_TYPE_AUTHENTICATE) { 298 throw new Error('ClientData type is invalid', ERR_BAD_TYPE); 299 } 300 301 foreach ($requests as $req) { 302 if( !is_object( $req ) ) { 303 throw new \InvalidArgumentException('$requests of doAuthenticate() method only accepts array of object.'); 304 } 305 306 if($req->keyHandle === $response->keyHandle && $req->challenge === $decodedClient->challenge) { 307 break; 308 } 309 310 $req = null; 311 } 312 if($req === null) { 313 throw new Error('No matching request found', ERR_NO_MATCHING_REQUEST ); 314 } 315 if(isset($decodedClient->origin) && $decodedClient->origin !== $req->appId) { 316 throw new Error('App ID does not match the origin', ERR_NO_MATCHING_ORIGIN); 317 } 318 foreach ($registrations as $reg) { 319 if( !is_object( $reg ) ) { 320 throw new \InvalidArgumentException('$registrations of doAuthenticate() method only accepts array of object.'); 321 } 322 323 if($reg->keyHandle === $response->keyHandle) { 324 break; 325 } 326 $reg = null; 327 } 328 if($reg === null) { 329 throw new Error('No matching registration found', ERR_NO_MATCHING_REGISTRATION ); 330 } 331 $pemKey = $this->pubkey_to_pem($this->base64u_decode($reg->publicKey)); 332 if($pemKey === null) { 333 throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE ); 334 } 335 336 $signData = $this->base64u_decode($response->signatureData); 337 $dataToVerify = hash('sha256', $req->appId, true); 338 $dataToVerify .= substr($signData, 0, 5); 339 $dataToVerify .= hash('sha256', $clientData, true); 340 $signature = substr($signData, 5); 341 342 if(openssl_verify($dataToVerify, $signature, $pemKey, 'sha256') === 1) { 343 $upb = unpack("Cupb", substr($signData, 0, 1)); 344 if($upb['upb'] !== 1) { 345 throw new Error('User presence byte value is invalid', ERR_BAD_USER_PRESENCE ); 346 } 347 $ctr = unpack("Nctr", substr($signData, 1, 4)); 348 $counter = $ctr['ctr']; 349 /* TODO: wrap-around should be handled somehow.. */ 350 if($counter > $reg->counter) { 351 $reg->counter = $counter; 352 return self::castObjectToRegistration($reg); 353 } else { 354 throw new Error('Counter too low.', ERR_COUNTER_TOO_LOW ); 355 } 356 } else { 357 throw new Error('Authentication failed', ERR_AUTHENTICATION_FAILURE ); 358 } 359 } 360 361 /** 362 * @param object $object 363 * @return Registration 364 */ 365 protected static function castObjectToRegistration($object) 366 { 367 $reg = new Registration(); 368 if (property_exists($object, 'publicKey')) { 369 $reg->publicKey = $object->publicKey; 370 } 371 if (property_exists($object, 'certificate')) { 372 $reg->certificate = $object->certificate; 373 } 374 if (property_exists($object, 'counter')) { 375 $reg->counter = $object->counter; 376 } 377 if (property_exists($object, 'keyHandle')) { 378 $reg->keyHandle = $object->keyHandle; 379 } 380 return $reg; 381 } 382 383 /** 384 * @return array 385 */ 386 private function get_certs() 387 { 388 $files = array(); 389 $dir = $this->attestDir; 390 if($dir !== null && is_dir($dir) && $handle = opendir($dir)) { 391 while(false !== ($entry = readdir($handle))) { 392 if(is_file("$dir/$entry")) { 393 $files[] = "$dir/$entry"; 394 } 395 } 396 closedir($handle); 397 } elseif (is_file("$dir")) { 398 $files[] = "$dir"; 399 } 400 return $files; 401 } 402 403 /** 404 * @param string $data 405 * @return string 406 */ 407 private function base64u_encode($data) 408 { 409 return trim(strtr(base64_encode($data), '+/', '-_'), '='); 410 } 411 412 /** 413 * @param string $data 414 * @return string 415 */ 416 private function base64u_decode($data) 417 { 418 return base64_decode(strtr($data, '-_', '+/')); 419 } 420 421 /** 422 * @param string $key 423 * @return null|string 424 */ 425 private function pubkey_to_pem($key) 426 { 427 if(strlen($key) !== PUBKEY_LEN || $key[0] !== "\x04") { 428 return null; 429 } 430 431 /* 432 * Convert the public key to binary DER format first 433 * Using the ECC SubjectPublicKeyInfo OIDs from RFC 5480 434 * 435 * SEQUENCE(2 elem) 30 59 436 * SEQUENCE(2 elem) 30 13 437 * OID1.2.840.10045.2.1 (id-ecPublicKey) 06 07 2a 86 48 ce 3d 02 01 438 * OID1.2.840.10045.3.1.7 (secp256r1) 06 08 2a 86 48 ce 3d 03 01 07 439 * BIT STRING(520 bit) 03 42 ..key.. 440 */ 441 $der = "\x30\x59\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01"; 442 $der .= "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07\x03\x42"; 443 $der .= "\0".$key; 444 445 $pem = "-----BEGIN PUBLIC KEY-----\r\n"; 446 $pem .= chunk_split(base64_encode($der), 64); 447 $pem .= "-----END PUBLIC KEY-----"; 448 449 return $pem; 450 } 451 452 /** 453 * @return string 454 * @throws Error 455 */ 456 private function createChallenge() 457 { 458 $challenge = random_bytes(32); 459 $challenge = $this->base64u_encode( $challenge ); 460 461 return $challenge; 462 } 463 464 /** 465 * Fixes a certificate where the signature contains unused bits. 466 * 467 * @param string $cert 468 * @return mixed 469 */ 470 private function fixSignatureUnusedBits($cert) 471 { 472 if(in_array(hash('sha256', $cert), $this->FIXCERTS, true)) { 473 $cert[strlen($cert) - 257] = "\0"; 474 } 475 return $cert; 476 } 477} 478 479/** 480 * Class for building a registration request 481 * 482 * @package u2flib_server 483 */ 484class RegisterRequest 485{ 486 /** @var string Protocol version */ 487 public $version = U2F_VERSION; 488 489 /** @var string Registration challenge */ 490 public $challenge; 491 492 /** @var string Application id */ 493 public $appId; 494 495 /** 496 * @param string $challenge 497 * @param string $appId 498 * @internal 499 */ 500 public function __construct($challenge, $appId) 501 { 502 $this->challenge = $challenge; 503 $this->appId = $appId; 504 } 505} 506 507/** 508 * Class for building up an authentication request 509 * 510 * @package u2flib_server 511 */ 512class SignRequest 513{ 514 /** @var string Protocol version */ 515 public $version = U2F_VERSION; 516 517 /** @var string Authentication challenge */ 518 public $challenge = ''; 519 520 /** @var string Key handle of a registered authenticator */ 521 public $keyHandle = ''; 522 523 /** @var string Application id */ 524 public $appId = ''; 525} 526 527/** 528 * Class returned for successful registrations 529 * 530 * @package u2flib_server 531 */ 532class Registration 533{ 534 /** @var string The key handle of the registered authenticator */ 535 public $keyHandle = ''; 536 537 /** @var string The public key of the registered authenticator */ 538 public $publicKey = ''; 539 540 /** @var string The attestation certificate of the registered authenticator */ 541 public $certificate = ''; 542 543 /** @var int The counter associated with this registration */ 544 public $counter = -1; 545} 546 547/** 548 * Error class, returned on errors 549 * 550 * @package u2flib_server 551 */ 552class Error extends \Exception 553{ 554 /** 555 * Override constructor and make message and code mandatory 556 * @param string $message 557 * @param int $code 558 * @param \Exception|null $previous 559 */ 560 public function __construct($message, $code, \Exception $previous = null) { 561 parent::__construct($message, $code, $previous); 562 } 563} 564