Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.
   1  <?php
   2  
   3  namespace lbuchs\WebAuthn\Attestation;
   4  use lbuchs\WebAuthn\WebAuthnException;
   5  use lbuchs\WebAuthn\CBOR\CborDecoder;
   6  use lbuchs\WebAuthn\Binary\ByteBuffer;
   7  
   8  /**
   9   * @author Lukas Buchs
  10   * @license https://github.com/lbuchs/WebAuthn/blob/master/LICENSE MIT
  11   */
  12  class AuthenticatorData {
  13      protected $_binary;
  14      protected $_rpIdHash;
  15      protected $_flags;
  16      protected $_signCount;
  17      protected $_attestedCredentialData;
  18      protected $_extensionData;
  19  
  20  
  21  
  22      // Cose encoded keys
  23      private static $_COSE_KTY = 1;
  24      private static $_COSE_ALG = 3;
  25  
  26      // Cose EC2 ES256 P-256 curve
  27      private static $_COSE_CRV = -1;
  28      private static $_COSE_X = -2;
  29      private static $_COSE_Y = -3;
  30  
  31      // Cose RSA PS256
  32      private static $_COSE_N = -1;
  33      private static $_COSE_E = -2;
  34  
  35      private static $_EC2_TYPE = 2;
  36      private static $_EC2_ES256 = -7;
  37      private static $_EC2_P256 = 1;
  38  
  39      private static $_RSA_TYPE = 3;
  40      private static $_RSA_RS256 = -257;
  41  
  42      /**
  43       * Parsing the authenticatorData binary.
  44       * @param string $binary
  45       * @throws WebAuthnException
  46       */
  47      public function __construct($binary) {
  48          if (!\is_string($binary) || \strlen($binary) < 37) {
  49              throw new WebAuthnException('Invalid authenticatorData input', WebAuthnException::INVALID_DATA);
  50          }
  51          $this->_binary = $binary;
  52  
  53          // Read infos from binary
  54          // https://www.w3.org/TR/webauthn/#sec-authenticator-data
  55  
  56          // RP ID
  57          $this->_rpIdHash = \substr($binary, 0, 32);
  58  
  59          // flags (1 byte)
  60          $flags = \unpack('Cflags', \substr($binary, 32, 1))['flags'];
  61          $this->_flags = $this->_readFlags($flags);
  62  
  63          // signature counter: 32-bit unsigned big-endian integer.
  64          $this->_signCount = \unpack('Nsigncount', \substr($binary, 33, 4))['signcount'];
  65  
  66          $offset = 37;
  67          // https://www.w3.org/TR/webauthn/#sec-attested-credential-data
  68          if ($this->_flags->attestedDataIncluded) {
  69              $this->_attestedCredentialData = $this->_readAttestData($binary, $offset);
  70          }
  71  
  72          if ($this->_flags->extensionDataIncluded) {
  73              $this->_readExtensionData(\substr($binary, $offset));
  74          }
  75      }
  76  
  77      /**
  78       * Authenticator Attestation Globally Unique Identifier, a unique number
  79       * that identifies the model of the authenticator (not the specific instance
  80       * of the authenticator)
  81       * The aaguid may be 0 if the user is using a old u2f device and/or if
  82       * the browser is using the fido-u2f format.
  83       * @return string
  84       * @throws WebAuthnException
  85       */
  86      public function getAAGUID() {
  87          if (!($this->_attestedCredentialData instanceof \stdClass)) {
  88              throw  new WebAuthnException('credential data not included in authenticator data', WebAuthnException::INVALID_DATA);
  89          }
  90          return $this->_attestedCredentialData->aaguid;
  91      }
  92  
  93      /**
  94       * returns the authenticatorData as binary
  95       * @return string
  96       */
  97      public function getBinary() {
  98          return $this->_binary;
  99      }
 100  
 101      /**
 102       * returns the credentialId
 103       * @return string
 104       * @throws WebAuthnException
 105       */
 106      public function getCredentialId() {
 107          if (!($this->_attestedCredentialData instanceof \stdClass)) {
 108              throw  new WebAuthnException('credential id not included in authenticator data', WebAuthnException::INVALID_DATA);
 109          }
 110          return $this->_attestedCredentialData->credentialId;
 111      }
 112  
 113      /**
 114       * returns the public key in PEM format
 115       * @return string
 116       */
 117      public function getPublicKeyPem() {
 118          $der = null;
 119          switch ($this->_attestedCredentialData->credentialPublicKey->kty) {
 120              case self::$_EC2_TYPE: $der = $this->_getEc2Der(); break;
 121              case self::$_RSA_TYPE: $der = $this->_getRsaDer(); break;
 122              default: throw new WebAuthnException('invalid key type', WebAuthnException::INVALID_DATA);
 123          }
 124  
 125          $pem = '-----BEGIN PUBLIC KEY-----' . "\n";
 126          $pem .= \chunk_split(\base64_encode($der), 64, "\n");
 127          $pem .= '-----END PUBLIC KEY-----' . "\n";
 128          return $pem;
 129      }
 130  
 131      /**
 132       * returns the public key in U2F format
 133       * @return string
 134       * @throws WebAuthnException
 135       */
 136      public function getPublicKeyU2F() {
 137          if (!($this->_attestedCredentialData instanceof \stdClass)) {
 138              throw  new WebAuthnException('credential data not included in authenticator data', WebAuthnException::INVALID_DATA);
 139          }
 140          return "\x04" . // ECC uncompressed
 141                  $this->_attestedCredentialData->credentialPublicKey->x .
 142                  $this->_attestedCredentialData->credentialPublicKey->y;
 143      }
 144  
 145      /**
 146       * returns the SHA256 hash of the relying party id (=hostname)
 147       * @return string
 148       */
 149      public function getRpIdHash() {
 150          return $this->_rpIdHash;
 151      }
 152  
 153      /**
 154       * returns the sign counter
 155       * @return int
 156       */
 157      public function getSignCount() {
 158          return $this->_signCount;
 159      }
 160  
 161      /**
 162       * returns true if the user is present
 163       * @return boolean
 164       */
 165      public function getUserPresent() {
 166          return $this->_flags->userPresent;
 167      }
 168  
 169      /**
 170       * returns true if the user is verified
 171       * @return boolean
 172       */
 173      public function getUserVerified() {
 174          return $this->_flags->userVerified;
 175      }
 176  
 177      // -----------------------------------------------
 178      // PRIVATE
 179      // -----------------------------------------------
 180  
 181      /**
 182       * Returns DER encoded EC2 key
 183       * @return string
 184       */
 185      private function _getEc2Der() {
 186          return $this->_der_sequence(
 187              $this->_der_sequence(
 188                  $this->_der_oid("\x2A\x86\x48\xCE\x3D\x02\x01") . // OID 1.2.840.10045.2.1 ecPublicKey
 189                  $this->_der_oid("\x2A\x86\x48\xCE\x3D\x03\x01\x07")  // 1.2.840.10045.3.1.7 prime256v1
 190              ) .
 191              $this->_der_bitString($this->getPublicKeyU2F())
 192          );
 193      }
 194  
 195      /**
 196       * Returns DER encoded RSA key
 197       * @return string
 198       */
 199      private function _getRsaDer() {
 200          return $this->_der_sequence(
 201              $this->_der_sequence(
 202                  $this->_der_oid("\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01") . // OID 1.2.840.113549.1.1.1 rsaEncryption
 203                  $this->_der_nullValue()
 204              ) .
 205              $this->_der_bitString(
 206                  $this->_der_sequence(
 207                      $this->_der_unsignedInteger($this->_attestedCredentialData->credentialPublicKey->n) .
 208                      $this->_der_unsignedInteger($this->_attestedCredentialData->credentialPublicKey->e)
 209                  )
 210              )
 211          );
 212      }
 213  
 214      /**
 215       * reads the flags from flag byte
 216       * @param string $binFlag
 217       * @return \stdClass
 218       */
 219      private function _readFlags($binFlag) {
 220          $flags = new \stdClass();
 221  
 222          $flags->bit_0 = !!($binFlag & 1);
 223          $flags->bit_1 = !!($binFlag & 2);
 224          $flags->bit_2 = !!($binFlag & 4);
 225          $flags->bit_3 = !!($binFlag & 8);
 226          $flags->bit_4 = !!($binFlag & 16);
 227          $flags->bit_5 = !!($binFlag & 32);
 228          $flags->bit_6 = !!($binFlag & 64);
 229          $flags->bit_7 = !!($binFlag & 128);
 230  
 231          // named flags
 232          $flags->userPresent = $flags->bit_0;
 233          $flags->userVerified = $flags->bit_2;
 234          $flags->attestedDataIncluded = $flags->bit_6;
 235          $flags->extensionDataIncluded = $flags->bit_7;
 236          return $flags;
 237      }
 238  
 239      /**
 240       * read attested data
 241       * @param string $binary
 242       * @param int $endOffset
 243       * @return \stdClass
 244       * @throws WebAuthnException
 245       */
 246      private function _readAttestData($binary, &$endOffset) {
 247          $attestedCData = new \stdClass();
 248          if (\strlen($binary) <= 55) {
 249              throw new WebAuthnException('Attested data should be present but is missing', WebAuthnException::INVALID_DATA);
 250          }
 251  
 252          // The AAGUID of the authenticator
 253          $attestedCData->aaguid = \substr($binary, 37, 16);
 254  
 255          //Byte length L of Credential ID, 16-bit unsigned big-endian integer.
 256          $length = \unpack('nlength', \substr($binary, 53, 2))['length'];
 257          $attestedCData->credentialId = \substr($binary, 55, $length);
 258  
 259          // set end offset
 260          $endOffset = 55 + $length;
 261  
 262          // extract public key
 263          $attestedCData->credentialPublicKey = $this->_readCredentialPublicKey($binary, 55 + $length, $endOffset);
 264  
 265          return $attestedCData;
 266      }
 267  
 268      /**
 269       * reads COSE key-encoded elliptic curve public key in EC2 format
 270       * @param string $binary
 271       * @param int $endOffset
 272       * @return \stdClass
 273       * @throws WebAuthnException
 274       */
 275      private function _readCredentialPublicKey($binary, $offset, &$endOffset) {
 276          $enc = CborDecoder::decodeInPlace($binary, $offset, $endOffset);
 277  
 278          // COSE key-encoded elliptic curve public key in EC2 format
 279          $credPKey = new \stdClass();
 280          $credPKey->kty = $enc[self::$_COSE_KTY];
 281          $credPKey->alg = $enc[self::$_COSE_ALG];
 282  
 283          switch ($credPKey->alg) {
 284              case self::$_EC2_ES256: $this->_readCredentialPublicKeyES256($credPKey, $enc); break;
 285              case self::$_RSA_RS256: $this->_readCredentialPublicKeyRS256($credPKey, $enc); break;
 286          }
 287  
 288          return $credPKey;
 289      }
 290  
 291      /**
 292       * extract ES256 informations from cose
 293       * @param \stdClass $credPKey
 294       * @param \stdClass $enc
 295       * @throws WebAuthnException
 296       */
 297      private function _readCredentialPublicKeyES256(&$credPKey, $enc) {
 298          $credPKey->crv = $enc[self::$_COSE_CRV];
 299          $credPKey->x   = $enc[self::$_COSE_X] instanceof ByteBuffer ? $enc[self::$_COSE_X]->getBinaryString() : null;
 300          $credPKey->y   = $enc[self::$_COSE_Y] instanceof ByteBuffer ? $enc[self::$_COSE_Y]->getBinaryString() : null;
 301          unset ($enc);
 302  
 303          // Validation
 304          if ($credPKey->kty !== self::$_EC2_TYPE) {
 305              throw new WebAuthnException('public key not in EC2 format', WebAuthnException::INVALID_PUBLIC_KEY);
 306          }
 307  
 308          if ($credPKey->alg !== self::$_EC2_ES256) {
 309              throw new WebAuthnException('signature algorithm not ES256', WebAuthnException::INVALID_PUBLIC_KEY);
 310          }
 311  
 312          if ($credPKey->crv !== self::$_EC2_P256) {
 313              throw new WebAuthnException('curve not P-256', WebAuthnException::INVALID_PUBLIC_KEY);
 314          }
 315  
 316          if (\strlen($credPKey->x) !== 32) {
 317              throw new WebAuthnException('Invalid X-coordinate', WebAuthnException::INVALID_PUBLIC_KEY);
 318          }
 319  
 320          if (\strlen($credPKey->y) !== 32) {
 321              throw new WebAuthnException('Invalid Y-coordinate', WebAuthnException::INVALID_PUBLIC_KEY);
 322          }
 323      }
 324  
 325      /**
 326       * extract RS256 informations from COSE
 327       * @param \stdClass $credPKey
 328       * @param \stdClass $enc
 329       * @throws WebAuthnException
 330       */
 331      private function _readCredentialPublicKeyRS256(&$credPKey, $enc) {
 332          $credPKey->n = $enc[self::$_COSE_N] instanceof ByteBuffer ? $enc[self::$_COSE_N]->getBinaryString() : null;
 333          $credPKey->e = $enc[self::$_COSE_E] instanceof ByteBuffer ? $enc[self::$_COSE_E]->getBinaryString() : null;
 334          unset ($enc);
 335  
 336          // Validation
 337          if ($credPKey->kty !== self::$_RSA_TYPE) {
 338              throw new WebAuthnException('public key not in RSA format', WebAuthnException::INVALID_PUBLIC_KEY);
 339          }
 340  
 341          if ($credPKey->alg !== self::$_RSA_RS256) {
 342              throw new WebAuthnException('signature algorithm not ES256', WebAuthnException::INVALID_PUBLIC_KEY);
 343          }
 344  
 345          if (\strlen($credPKey->n) !== 256) {
 346              throw new WebAuthnException('Invalid RSA modulus', WebAuthnException::INVALID_PUBLIC_KEY);
 347          }
 348  
 349          if (\strlen($credPKey->e) !== 3) {
 350              throw new WebAuthnException('Invalid RSA public exponent', WebAuthnException::INVALID_PUBLIC_KEY);
 351          }
 352  
 353      }
 354  
 355      /**
 356       * reads cbor encoded extension data.
 357       * @param string $binary
 358       * @return array
 359       * @throws WebAuthnException
 360       */
 361      private function _readExtensionData($binary) {
 362          $ext = CborDecoder::decode($binary);
 363          if (!\is_array($ext)) {
 364              throw new WebAuthnException('invalid extension data', WebAuthnException::INVALID_DATA);
 365          }
 366  
 367          return $ext;
 368      }
 369  
 370  
 371      // ---------------
 372      // DER functions
 373      // ---------------
 374  
 375      private function _der_length($len) {
 376          if ($len < 128) {
 377              return \chr($len);
 378          }
 379          $lenBytes = '';
 380          while ($len > 0) {
 381              $lenBytes = \chr($len % 256) . $lenBytes;
 382              $len = \intdiv($len, 256);
 383          }
 384          return \chr(0x80 | \strlen($lenBytes)) . $lenBytes;
 385      }
 386  
 387      private function _der_sequence($contents) {
 388          return "\x30" . $this->_der_length(\strlen($contents)) . $contents;
 389      }
 390  
 391      private function _der_oid($encoded) {
 392          return "\x06" . $this->_der_length(\strlen($encoded)) . $encoded;
 393      }
 394  
 395      private function _der_bitString($bytes) {
 396          return "\x03" . $this->_der_length(\strlen($bytes) + 1) . "\x00" . $bytes;
 397      }
 398  
 399      private function _der_nullValue() {
 400          return "\x05\x00";
 401      }
 402  
 403      private function _der_unsignedInteger($bytes) {
 404          $len = \strlen($bytes);
 405  
 406          // Remove leading zero bytes
 407          for ($i = 0; $i < ($len - 1); $i++) {
 408              if (\ord($bytes[$i]) !== 0) {
 409                  break;
 410              }
 411          }
 412          if ($i !== 0) {
 413              $bytes = \substr($bytes, $i);
 414          }
 415  
 416          // If most significant bit is set, prefix with another zero to prevent it being seen as negative number
 417          if ((\ord($bytes[0]) & 0x80) !== 0) {
 418              $bytes = "\x00" . $bytes;
 419          }
 420  
 421          return "\x02" . $this->_der_length(\strlen($bytes)) . $bytes;
 422      }
 423  }