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  
   4  namespace lbuchs\WebAuthn\Attestation\Format;
   5  use lbuchs\WebAuthn\Attestation\AuthenticatorData;
   6  use lbuchs\WebAuthn\WebAuthnException;
   7  use lbuchs\WebAuthn\Binary\ByteBuffer;
   8  
   9  class AndroidSafetyNet extends FormatBase {
  10      private $_signature;
  11      private $_signedValue;
  12      private $_x5c;
  13      private $_payload;
  14  
  15      public function __construct($AttestionObject, AuthenticatorData $authenticatorData) {
  16          parent::__construct($AttestionObject, $authenticatorData);
  17  
  18          // check data
  19          $attStmt = $this->_attestationObject['attStmt'];
  20  
  21          if (!\array_key_exists('ver', $attStmt) || !$attStmt['ver']) {
  22              throw new WebAuthnException('invalid Android Safety Net Format', WebAuthnException::INVALID_DATA);
  23          }
  24  
  25          if (!\array_key_exists('response', $attStmt) || !($attStmt['response'] instanceof ByteBuffer)) {
  26              throw new WebAuthnException('invalid Android Safety Net Format', WebAuthnException::INVALID_DATA);
  27          }
  28  
  29          $response = $attStmt['response']->getBinaryString();
  30  
  31          // Response is a JWS [RFC7515] object in Compact Serialization.
  32          // JWSs have three segments separated by two period ('.') characters
  33          $parts = \explode('.', $response);
  34          unset ($response);
  35          if (\count($parts) !== 3) {
  36              throw new WebAuthnException('invalid JWS data', WebAuthnException::INVALID_DATA);
  37          }
  38  
  39          $header = $this->_base64url_decode($parts[0]);
  40          $payload = $this->_base64url_decode($parts[1]);
  41          $this->_signature = $this->_base64url_decode($parts[2]);
  42          $this->_signedValue = $parts[0] . '.' . $parts[1];
  43          unset ($parts);
  44  
  45          $header = \json_decode($header);
  46          $payload = \json_decode($payload);
  47  
  48          if (!($header instanceof \stdClass)) {
  49              throw new WebAuthnException('invalid JWS header', WebAuthnException::INVALID_DATA);
  50          }
  51          if (!($payload instanceof \stdClass)) {
  52              throw new WebAuthnException('invalid JWS payload', WebAuthnException::INVALID_DATA);
  53          }
  54  
  55          if (!isset($header->x5c) || !is_array($header->x5c) || count($header->x5c) === 0) {
  56              throw new WebAuthnException('No X.509 signature in JWS Header', WebAuthnException::INVALID_DATA);
  57          }
  58  
  59          // algorithm
  60          if (!\in_array($header->alg, array('RS256', 'ES256'))) {
  61              throw new WebAuthnException('invalid JWS algorithm ' . $header->alg, WebAuthnException::INVALID_DATA);
  62          }
  63  
  64          $this->_x5c = \base64_decode($header->x5c[0]);
  65          $this->_payload = $payload;
  66  
  67          if (count($header->x5c) > 1) {
  68              for ($i=1; $i<count($header->x5c); $i++) {
  69                  $this->_x5c_chain[] = \base64_decode($header->x5c[$i]);
  70              }
  71              unset ($i);
  72          }
  73      }
  74  
  75      /**
  76       * ctsProfileMatch: A stricter verdict of device integrity.
  77       * If the value of ctsProfileMatch is true, then the profile of the device running your app matches
  78       * the profile of a device that has passed Android compatibility testing and
  79       * has been approved as a Google-certified Android device.
  80       * @return bool
  81       */
  82      public function ctsProfileMatch() {
  83          return isset($this->_payload->ctsProfileMatch) ? !!$this->_payload->ctsProfileMatch : false;
  84      }
  85  
  86  
  87      /*
  88       * returns the key certificate in PEM format
  89       * @return string
  90       */
  91      public function getCertificatePem() {
  92          return $this->_createCertificatePem($this->_x5c);
  93      }
  94  
  95      /**
  96       * @param string $clientDataHash
  97       */
  98      public function validateAttestation($clientDataHash) {
  99          $publicKey = \openssl_pkey_get_public($this->getCertificatePem());
 100  
 101          // Verify that the nonce in the response is identical to the Base64 encoding
 102          // of the SHA-256 hash of the concatenation of authenticatorData and clientDataHash.
 103          if (empty($this->_payload->nonce) || $this->_payload->nonce !== \base64_encode(\hash('SHA256', $this->_authenticatorData->getBinary() . $clientDataHash, true))) {
 104              throw new WebAuthnException('invalid nonce in JWS payload', WebAuthnException::INVALID_DATA);
 105          }
 106  
 107          // Verify that attestationCert is issued to the hostname "attest.android.com"
 108          $certInfo = \openssl_x509_parse($this->getCertificatePem());
 109          if (!\is_array($certInfo) || ($certInfo['subject']['CN'] ?? '') !== 'attest.android.com') {
 110              throw new WebAuthnException('invalid certificate CN in JWS (' . ($certInfo['subject']['CN'] ?? '-'). ')', WebAuthnException::INVALID_DATA);
 111          }
 112  
 113          // Verify that the basicIntegrity attribute in the payload of response is true.
 114          if (empty($this->_payload->basicIntegrity)) {
 115              throw new WebAuthnException('invalid basicIntegrity in payload', WebAuthnException::INVALID_DATA);
 116          }
 117  
 118          // check certificate
 119          return \openssl_verify($this->_signedValue, $this->_signature, $publicKey, OPENSSL_ALGO_SHA256) === 1;
 120      }
 121  
 122  
 123      /**
 124       * validates the certificate against root certificates
 125       * @param array $rootCas
 126       * @return boolean
 127       * @throws WebAuthnException
 128       */
 129      public function validateRootCertificate($rootCas) {
 130          $chainC = $this->_createX5cChainFile();
 131          if ($chainC) {
 132              $rootCas[] = $chainC;
 133          }
 134  
 135          $v = \openssl_x509_checkpurpose($this->getCertificatePem(), -1, $rootCas);
 136          if ($v === -1) {
 137              throw new WebAuthnException('error on validating root certificate: ' . \openssl_error_string(), WebAuthnException::CERTIFICATE_NOT_TRUSTED);
 138          }
 139          return $v;
 140      }
 141  
 142  
 143      /**
 144       * decode base64 url
 145       * @param string $data
 146       * @return string
 147       */
 148      private function _base64url_decode($data) {
 149          return \base64_decode(\strtr($data, '-_', '+/') . \str_repeat('=', 3 - (3 + \strlen($data)) % 4));
 150      }
 151  }
 152