diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 49bc2e8d..22cbbc6b 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -117,6 +117,7 @@ public static function get_providers() { 'Two_Factor_Email' => TWO_FACTOR_DIR . 'providers/class-two-factor-email.php', 'Two_Factor_Totp' => TWO_FACTOR_DIR . 'providers/class-two-factor-totp.php', 'Two_Factor_FIDO_U2F' => TWO_FACTOR_DIR . 'providers/class-two-factor-fido-u2f.php', + 'Two_Factor_WebAuthn' => TWO_FACTOR_DIR . 'providers/class-two-factor-webauthn.php', 'Two_Factor_Backup_Codes' => TWO_FACTOR_DIR . 'providers/class-two-factor-backup-codes.php', 'Two_Factor_Dummy' => TWO_FACTOR_DIR . 'providers/class-two-factor-dummy.php', ); @@ -144,6 +145,26 @@ public static function get_providers() { ); } + // WebAuthn is PHP 7.2+. + if ( isset( $providers['Two_Factor_WebAuthn'] ) ) { + if ( version_compare( PHP_VERSION, '7.2.0', '<' ) ) { + unset( $providers['Two_Factor_WebAuthn'] ); + trigger_error( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + sprintf( + /* translators: %s: version number */ + __( 'WebAuthn is not available because you are using PHP %s. (Requires 7.2 or greater)', 'two-factor' ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + PHP_VERSION + ) + ); + } + if ( ! function_exists( 'openssl_verify' ) ) { + unset( $providers['Two_Factor_WebAuthn'] ); + trigger_error( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + __( 'WebAuthn requires the OpenSSL PHP-Extension', 'two-factor' ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ); + } + } + /** * For each filtered provider, */ diff --git a/includes/WebAuthn/class-cbor-decoder.php b/includes/WebAuthn/class-cbor-decoder.php new file mode 100644 index 00000000..de663fed --- /dev/null +++ b/includes/WebAuthn/class-cbor-decoder.php @@ -0,0 +1,296 @@ + "C", + self::ADDITIONAL_TYPE_INT_UINT16 => "n", + self::ADDITIONAL_TYPE_INT_UINT32 => "N", + self::ADDITIONAL_TYPE_INT_UINT64 => null, + ); + + private static $float_pack_type = array( + self::ADDITIONAL_TYPE_FLOAT32 => "f", + self::ADDITIONAL_TYPE_FLOAT64 => "d", + ); + + private static $byte_length = array( + self::ADDITIONAL_TYPE_INT_UINT8 => 1, + self::ADDITIONAL_TYPE_INT_UINT16 => 2, + self::ADDITIONAL_TYPE_INT_UINT32 => 4, + self::ADDITIONAL_TYPE_INT_UINT64 => 8, + ); + + /** + * Decode CBOR byte string + * @param mixed $var + * @throws \Exception + * @return mixed + */ + public static function decode(&$var){ + $out = null; + + //get initial byte + $unpacked = unpack("C*", substr($var, 0, 1)); + $header_byte = array_shift($unpacked); + + if ($header_byte == self::MAJOR_TYPE_INFINITE_CLOSE) { + $major_type = $header_byte; + $additional_info = 0; + } else { + //unpack major type + $major_type = $header_byte & self::ADDITIONAL_WIPE; + //get additional_info + $additional_info = self::unpack_additional_info($header_byte); + } + + $byte_data_offset = 1; + if(array_key_exists($additional_info, self::$byte_length)){ + $byte_data_offset += self::$byte_length[$additional_info]; + } + + switch($major_type) { + case self::MAJOR_TYPE_UNSIGNED_INT: + case self::MAJOR_TYPE_INT: + //decode int + $out = self::decode_int($additional_info, $var); + + if($major_type == self::MAJOR_TYPE_INT){ + $out = -($out+1); + } + + break; + case self::MAJOR_TYPE_BYTE_STRING: + case self::MAJOR_TYPE_UTF8_STRING: + $string_length = self::decode_int($additional_info, $var); + + $out = substr($var, $byte_data_offset, $string_length); + + if($major_type == self::MAJOR_TYPE_BYTE_STRING) { + $out = new CBORByteString($out); + } + + $byte_data_offset += $string_length; + break; + case self::MAJOR_TYPE_ARRAY: + case self::MAJOR_TYPE_MAP: + $out = array(); + + $elem_count = $additional_info != self::ADDITIONAL_TYPE_INFINITE ? + self::decode_int($additional_info, $var) : PHP_INT_MAX; + $var = substr($var, $byte_data_offset); + + while($elem_count > count($out)) + { + $primitive = self::decode($var); + if (is_null($primitive)) { + break; + } + if($major_type == self::MAJOR_TYPE_MAP) { + $out[$primitive] = self::decode($var); + } else { + $out[] = $primitive; + } + } + + break; + case self::MAJOR_TYPE_TAGS: + throw new \Exception("Not implemented. Sorry"); + break; + case self::MAJOR_TYPE_SIMPLE_AND_FLOAT: + $out = self::decode_simple_float($additional_info, $var); + break; + case self::MAJOR_TYPE_INFINITE_CLOSE: + $out = null; + } + + if(!in_array($major_type, array(self::MAJOR_TYPE_ARRAY, self::MAJOR_TYPE_MAP))){ + $var = substr($var, $byte_data_offset); + } + + return $out; + } + + /** + * Unpack data length/int + * @param $length_capacity + * @param $byte_string + * @throws Exception + * @internal param $length + * @return int|null + */ + private static function decode_int($length_capacity, &$byte_string){ + + if($length_capacity <= self::ADDITIONAL_MAX) return $length_capacity; + $decoding_byte_string = substr($byte_string, 1, self::$byte_length[$length_capacity]); + switch(true) + { + case $length_capacity == self::ADDITIONAL_TYPE_INT_UINT64: + return self::bigint_unpack($decoding_byte_string); + break; + case array_key_exists($length_capacity, self::$length_pack_type): + $typed_int = unpack(self::$length_pack_type[$length_capacity], $decoding_byte_string); + return array_shift($typed_int); + break; + default: + throw new Exception("CBOR Incorrect additional info"); + break; + } + + return null; + } + + /** + * Unpack double/bool/null + * @param $length_capacity + * @param $byte_string + * @return null|string + */ + private static function decode_simple_float($length_capacity, &$byte_string){ + $simple_association = array( + self::ADDITIONAL_TYPE_INT_FALSE => false, + self::ADDITIONAL_TYPE_INT_TRUE => true, + self::ADDITIONAL_TYPE_INT_NULL => null, + self::ADDITIONAL_TYPE_INT_UNDEFINED => NAN, + ); + + if(array_key_exists($length_capacity, $simple_association)) + { + return $simple_association[$length_capacity]; + } + $typed_float = unpack(self::$float_pack_type[$length_capacity], strrev(substr($byte_string, 1, self::$byte_length[$length_capacity]))); + return array_shift($typed_float); + } + + /** + * Unpack additional info + * @param $byte + * @return int + */ + private static function unpack_additional_info($byte) + { + return $byte & self::HEADER_WIPE; + } + + /** + * Pack initial byte NOT IN USE + * @param $major_type + * @param $additional_info + * @return string + */ + private static function pack_init_byte($major_type, $additional_info) + { + return pack("c", $major_type | $additional_info); + } + + /** + * Get length of int NOT IN USE + * @param $int + * @return int|null + */ + private static function get_length($int) + { + switch(true) + { + case $int < 256: + return self::ADDITIONAL_TYPE_INT_UINT8; + break; + case $int < 65536: + return self::ADDITIONAL_TYPE_INT_UINT16; + break; + case $int < 4294967296: + return self::ADDITIONAL_TYPE_INT_UINT32; + break; + //are you seriously? + case $int < 9223372036854775807: + return null; + break; + } + return null; + } + + /** + * Array is associative or not + * + * @param $arr + * @return bool + */ + private static function is_assoc(&$arr) + { + return array_keys($arr) !== range(0, count($arr) -1); + } + + /** + * Split big int in two 32 bit parts and pack + * @param $big_int + * @return string + */ + private static function bigint_unpack($big_int) + { + list($higher, $lower) = array_values(unpack("N2", $big_int)); + return $higher << 32 | $lower; + } + + private static function bigint_pack($big_int) + { + return pack("NN", ($big_int & 0xffffffff00000000) >> 32, ($big_int & 0x00000000ffffffff)); + } +} + + +class CBORByteString { + private $byte_string = null; + + public function __construct($byte_string) + { + $this->byte_string = $byte_string; + } + + /** + * @return null + */ + public function get_byte_string() + { + return $this->byte_string; + } + + /** + * @param null $byte_string + */ + public function set_byte_string($byte_string) + { + $this->byte_string = $byte_string; + } +} diff --git a/includes/WebAuthn/class-webauthn-handler.php b/includes/WebAuthn/class-webauthn-handler.php new file mode 100644 index 00000000..67f016c7 --- /dev/null +++ b/includes/WebAuthn/class-webauthn-handler.php @@ -0,0 +1,750 @@ + false, + 'prepareAuthenticate' => false, + 'register' => false, + 'prepareRegister' => false, + ); + + const ES256 = -7; + const RS256 = -257; // Windows Hello support + + /** + * construct object on which to operate + * + * @param string $appid a string identifying your app, typically the domain of your website which people + * are using the key to log in to. If you have the URL (ie including the + * https:// on the front) to hand, give that; + * if it's not https, well what are you doing using this code? + */ + public function __construct($appid) + { + if (! is_string($appid)) { + throw new Exception('appid must be a string'); + } + $this->appid = $appid; + if (strpos($this->appid, 'https://') === 0) { + $this->appid = substr($this->appid, 8); /* drop the https:// */ + } + } + + /** + * Return last error depending on request + */ + public function getLastError( string $realm = NULL ) { + if ( is_null( $realm ) ) { + $realm = $this->last_call; + } + if ( is_null( $realm ) ) { + return false; + } + if ( ! isset( $this->last_error[ $realm ] ) ) { + return false; + } + return $this->last_error[ $realm ]; + } + + /** + * generate a challenge ready for registering a hardware key, fingerprint or whatever: + * @param $username string by which the user is known potentially displayed on the hardware key + * @param $userid string by which the user can be uniquely identified. Don't use email address as this can change, + * user perhaps the database record id + * @param $crossPlatform bool default=FALSE, whether to link the identity to the key (TRUE, so it + * can be used cross-platofrm, on different computers) or the platform (FALSE, only on + * this computer, but with any available authentication device, e.g. known to Windows Hello) + * @return string pass this JSON string back to the browser + */ + public function prepareRegister($username, $userid, $crossPlatform = FALSE) + { + $result = (object) array(); + $rbchallenge = self::randomBytes(16); + $result->challenge = self::stringToArray($rbchallenge); + $result->user = (object) array(); + $result->user->name = $result->user->displayName = $username; + $result->user->id = self::stringToArray($userid); + + $result->rp = (object) array(); + $result->rp->name = $result->rp->id = $this->appid; + + $result->pubKeyCredParams = array( + array( + 'alg' => self::ES256, + 'type' => 'public-key' + ), + array( + 'alg' => self::RS256, + 'type' => 'public-key' + ) + ); + + $result->authenticatorSelection = (object) array(); + if ( $crossPlatform ) { + $result->authenticatorSelection->authenticatorAttachment = 'cross-platform'; + } + + $result->authenticatorSelection->requireResidentKey = false; + $result->authenticatorSelection->userVerification = 'discouraged'; + + $result->attestation = null; + $result->timeout = 60000; + $result->excludeCredentials = array(); // No excludeList + $result->extensions = (object) array(); + $result->extensions->exts = true; + + return array( + 'publicKey' => $result, + 'b64challenge' => rtrim( strtr( base64_encode( $rbchallenge ), '+/', '-_'), '=') + ); + } + + /** + * registers a new key for a user + * requires info from the hardware via javascript given below + * @param object $info supplied to the PHP script via a POST, constructed by the Javascript given below, ultimately + * provided by the key + * @param string $userwebauthn the exisitng webauthn field for the user from your + * database (it's actaully a JSON string, but that's entirely internal to + * this code) + * @return boolean|object user key + */ + public function register( object $info ) { + + $this->last_call = __FUNCTION__; + + $this->last_error[ $this->last_call ] = false; + + // check info + if ( false === $this->validateRegisterInfo( $info ) ) { + // error generated in validateRegisterInfo() + return false; + } + + /* check response from key and store as new identity. This is a hex string representing the raw CBOR + attestation object received from the key */ + + $attData = $this->parseAttestationObject( $info->response->attestationObject ); + + // check info + if ( false === $attData ) { + // error generated in parseAttestationObject() + return false; + } + + if ( $attData->credId !== self::arrayToString( $info->rawId ) ) { + $this->last_error[ $this->last_call ] = 'ao-id-mismatch'; + return false; + } + + return (object) array( + 'key' => $attData->keyBytes, + 'id' => $info->rawId, + ); + + } + + /** + * generates a new key string for the physical key, fingerprint + * reader or whatever to respond to on login + * @param array $userKeys the existing webauthn field for the user from your database + * @return boolean|object Object to pass to javascript webauthnAuthenticate or false on faliue + */ + public function prepareAuthenticate( array $userKeys = array() ) + { + $allowKeyDefaults = array( + 'transports' => array( 'usb','nfc','ble','internal' ), + 'type' => 'public-key', + ); + $allows = array(); + foreach ( $userKeys as $key) { + if ( $this->isValidKey( $key ) ) { + $allows[] = (object) ( array( + 'id' => $key->id, + ) + $allowKeyDefaults ); + } + } + + if ( ! count( $allows ) ) { + /* including empty user, so they can't tell whether the user exists or not (need same result each + time for each user) */ + $rb = md5( (string) time() ); + $allows[] = (object) (array( + 'id' => self::stringToArray( $rb ), + ) + $allowKeyDefaults); + } + + /* generate key request */ + $publickey = (object) array(); + $publickey->challenge = self::stringToArray( self::randomBytes(16) ); + $publickey->timeout = 60000; + $publickey->allowCredentials = $allows; + $publickey->userVerification = 'discouraged'; + $publickey->extensions = (object) array(); + // $publickey->extensions->txAuthSimple = 'Execute order 66'; + $publickey->rpId = str_replace('https://', '', $this->appid ); + + return $publickey; + } + + /** + * validates a response for login or 2fa + * requires info from the hardware via javascript given below + * @param object $info supplied to the PHP script via POST, constructed by the Javascript given below, ultimately + * provided by the key + * @param array $userKeys the exisiting webauthn field for the user from your + * database + * @return object|null the matching key object from $userKeys for a valid authentication, null otherwise + */ + public function authenticate( object $info, array $userKeys ) + { + + $this->last_call = __FUNCTION__; + + $this->last_error[ $this->last_call ] = false; + + // check info + if ( ! $this->validateAuthenticateInfo( $info ) ) { + return false; + } + + $key = $this->findKeyById( $info->rawId, $userKeys ); + + if ( false === $key ) { + $this->last_error[ $this->last_call ] = 'no-matching-key'; + return false; + } + + + $bs = self::arrayToString( $info->response->authenticatorData ); + $ao = (object)array(); + + $ao->rpIdHash = substr( $bs, 0, 32 ); + $ao->flags = ord( substr( $bs, 32, 1 ) ); + $ao->counter = substr( $bs, 33, 4 ); + + $hashId = hash( 'sha256', $this->appid, true ); + + if ( $hashId !== $ao->rpIdHash ) { + $this->last_error[ $this->last_call ] = 'key-response-decode-hash-mismatch'; + return false; + } + + /* experience shows that at least one device (OnePlus 6T/Pie (Android phone)) doesn't set this, + so this test would fail. This is not correct according to the spec, so pragmatically it may + have to be removed */ + if ( ( $ao->flags & 0x1 ) != 0x1 ) { + $this->last_error[ $this->last_call ] = 'key-response-decode-flags-mismatch'; + return false; + } /* only TUP must be set */ + + /* assemble signed data */ + $clientdata = self::arrayToString( $info->response->clientDataJSONarray ); + $signeddata = $hashId . chr( $ao->flags ) . $ao->counter . hash( 'sha256', $clientdata, true ); + + if (count( $info->response->signature ) < 70) { + $this->last_error[ $this->last_call ] = 'key-response-decode-signature-invalid'; + return false; + } + + $signature = self::arrayToString($info->response->signature); + + $verify_result = openssl_verify( $signeddata, $signature, $key->key, OPENSSL_ALGO_SHA256 ); + + if ( 1 === $verify_result ) { + $this->last_error[ $this->last_call ] = false; + return $key; + } else if ( 0 === $verify_result ) { + $this->last_error[ $this->last_call ] = 'key-not-verfied'; + return false; + } + + $this->last_error[ $this->last_call ] = openssl_error_string(); + + return false; + + } + + /** + * Parse and validate Attestation object + * + * @param array $ao_arr Attestation Object byte array + * @return boolean|object attestedCredentialData false on failure + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData + */ + private function parseAttestationObject( array $ao_arr ) { + + // + $ao_cbor = self::arrayToString( $ao_arr ); + /** + * Fires before an attestiation object is parsed + * + * @param String $ao_cbor Byte string + */ + do_action( 'two_factor_webauthn_parse_attestation_object', $ao_cbor ); + $ao = (object)( CBORDecoder::decode( $ao_cbor ) ); + + // begin validation + if ( ! is_object( $ao ) ) { + $this->last_error[ $this->last_call ] = 'ao-not-object'; + return false; + } + + if ( empty( $ao ) ) { + $this->last_error[ $this->last_call ] = 'ao-empty'; + return false; + } + + if ( ! isset( $ao->fmt, $ao->authData ) ) { + $this->last_error[ $this->last_call ] = 'ao-missing-property'; + return false; + } + + if ( ! is_string( $ao->fmt ) ) { + $this->last_error[ $this->last_call ] = 'ao-fmt-invalid'; + return false; + } + if ( ! ( $ao->authData instanceof CBORByteString ) ) { + $this->last_error[ $this->last_call ] = 'ao-authdata-invalid'; + return false; + } + + if ( ! in_array( $ao->fmt, array( 'none', 'packed' ) ) ) { + $this->last_error[ $this->last_call ] = 'ao-fmt-unsupported'; + return false; + } + + $bs = $ao->authData->get_byte_string(); + /** + * Fires before an attestiation object is parsed + * + * @param String $ao_cbor Byte string + */ + do_action( 'two_factor_webauthn_parse_auth_data', $bs ); + + if ( empty( $bs ) ) { + $this->last_error[ $this->last_call ] = 'ao-authdata-empty'; + return false; + } + + // + $authData = (object) array( + 'rpIdHash' => substr($bs, 0, 32), + 'flags' => ord(substr($bs, 32, 1)), + 'signCount' => substr($bs, 33, 4), + ); + + if ( ! ( $authData->flags & 0x41 ) ) { + $this->last_error[ $this->last_call ] = 'ao-flags-unsupported'; + return false; + } + + $hashId = hash('sha256', $this->appid, true); + + if ( $hashId != $authData->rpIdHash ) { + $this->last_error[ $this->last_call ] = 'ao-appid-mismatch'; + return false; + } + + $attData = (object) array( + 'aaguid' => substr($bs, 37, 16), + 'credIdLen' => ( ord( $bs[53] ) << 8 ) + ord( $bs[54] ), + ); + + $attData->credId = substr( $bs, 55, $attData->credIdLen ); + $attData->keyBytes = self::COSEECDHAtoPKCS( + substr( $bs, 55 + $attData->credIdLen ) + ); + + return $attData; + + } + + /** + * Validates First argument of authenticate. + * @param object $info + * @return boolean + */ + private function validateRegisterInfo( object $info ) { + /* + $info + ->rawId Uint8Array + ->response + ->attestationObject Uint8Array : CBOR + + */ + if ( ! isset( $info->rawId, $info->response ) ) { + $this->last_error[ $this->last_call ] = 'info-missing-property'; + return false; + } + if ( ! is_array( $info->rawId ) || ! is_object( $info->response ) ) { + $this->last_error[ $this->last_call ] = 'info-malformed-property'; + return false; + } + if ( ! isset( $info->response->attestationObject ) ) { + $this->last_error[ $this->last_call ] = 'info-response-missing-property'; + return false; + } + if ( ! is_array( $info->response->attestationObject ) ) { + $this->last_error[ $this->last_call ] = 'info-response-malformed-property'; + return false; + } + + return true; + + } + + + + + /** + * Validates First argument of authenticate. + * @param object $info + * @return boolean + */ + private function validateAuthenticateInfo( object $info ) { + /* + $info + ->rawId array Uint8Array + ->originalChallenge Uint8Array + ->response + ->clientData + ->challenge base64string + ->origin string URL + ->type string 'webauthn.get' + ->clientDataJSONarray Uint8Array + ->authenticatorData Uint8Array + ->signature Uint8Array + */ + // check existence 1st level + if ( ! isset( $info->rawId, $info->originalChallenge, $info->response ) ) { + $this->last_error[ $this->last_call ] = 'info-missing-property'; + return false; + } + // check types 1st level + if ( ! is_array( $info->rawId ) || ! is_array( $info->originalChallenge ) || ! is_object( $info->response ) ) { + $this->last_error[ $this->last_call ] = 'info-malformed-value'; + return false; + } + + // check existence 2nd level + if ( ! isset( $info->response->clientData, $info->response->clientDataJSONarray, $info->response->authenticatorData, $info->response->signature ) ) { + $this->last_error[ $this->last_call ] = 'info-response-missing-property'; + return false; + } + // check types 2nd level + if ( ! is_object( $info->response->clientData ) || ! is_array( $info->response->clientDataJSONarray ) || ! is_array( $info->response->authenticatorData ) || ! is_array( $info->response->signature ) ) { + $this->last_error[ $this->last_call ] = 'info-response-malformed-value'; + return false; + } + + // check existence 3rd level + if ( ! isset( + $info->response->clientData->challenge, + $info->response->clientData->origin, + $info->response->clientData->type + ) + ) { + $this->last_error[ $this->last_call ] = 'info-clientdata-missing-property'; + return false; + } + + // check types 3rd level + if ( + ! is_string( $info->response->clientData->challenge ) || + ! is_string( $info->response->clientData->origin ) || + ! is_string( $info->response->clientData->type ) + ) { + $this->last_error[ $this->last_call ] = 'info-clientdata-malformed-value'; + return false; + } + + if ( $info->response->clientData->type != 'webauthn.get') { + $this->last_error[ $this->last_call ] = "info-wrong-type"; + return false; + } + + + /* cross-check challenge */ + if ( $info->response->clientData->challenge + !== + rtrim( strtr( base64_encode( self::arrayToString( $info->originalChallenge ) ), '+/', '-_'), '=') + ) { + $this->last_error[ $this->last_call ] = 'info-challenge-mismatch'; + return false; + } + + /* cross check origin */ + $origin = parse_url( $info->response->clientData->origin ); + + if ( strpos( $origin['host'], $this->appid ) !== ( strlen( $origin['host'] ) - strlen( $this->appid ) ) ) { + + $this->last_error[ $this->last_call ] = 'info-origin-mismatch'; + return false; + } + + + return true; + + + } + + + /** + * Find key by ID + * @param array $keyId + * @param array $keys Contains key objects (object) [ 'id' => [ int, int, ...], 'key' => '-----BEGIN PUBLIC KEY--...' ] + */ + private function findKeyById( array $keyId, array $keys ) { + + $keyIdString = implode( ',', $keyId ); + + foreach ( $keys as $key ) { + // check for key format + if ( ! $this->isValidKey( $key ) ) { + continue; + } + if ( implode(',', $key->id ) === $keyIdString ) { + return $key; + } + } + return false; + } + + /** + * @param object $key + * @return boolean + */ + private function isValidKey( $key ) { + return is_object( $key ) && isset( $key->id ) && is_array( $key->id ) && isset( $key->key ) && is_string( $key->key ); + } + + + /** + * convert an array of uint8's to a binary string + * @param array $a to be converted (array of unsigned 8 bit integers) + * @return string converted to bytes + */ + private static function arrayToString($a) + { + $s = ''; + foreach ($a as $c) { + $s .= chr($c); + } + return $s; + } + + /** + * convert a binary string to an array of uint8's + * @param string $s to be converted + * @return array converted to array of unsigned integers + */ + private static function stringToArray($s) + { + /* convert binary string to array of uint8 */ + $a = array(); + for ($idx = 0; $idx < strlen($s); $idx++) { + $a[] = ord($s[$idx]); + } + return $a; + } + + /** + * convert a public key from the hardware to PEM format + * @param string $key to be converted to PEM format + * @return string converted to PEM format + */ + private function pubkeyToPem($key) + { + /* see https://github.com/Yubico/php-u2flib-server/blob/master/src/u2flib_server/U2F.php */ + if (strlen($key) !== 65 || $key[0] !== "\x04") { + return null; + } + /* + * Convert the public key to binary DER format first + * Using the ECC SubjectPublicKeyInfo OIDs from RFC 5480 + * + * SEQUENCE(2 elem) 30 59 + * SEQUENCE(2 elem) 30 13 + * OID1.2.840.10045.2.1 (id-ecPublicKey) 06 07 2a 86 48 ce 3d 02 01 + * OID1.2.840.10045.3.1.7 (secp256r1) 06 08 2a 86 48 ce 3d 03 01 07 + * BIT STRING(520 bit) 03 42 ..key.. + */ + $der = "\x30\x59\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01"; + $der .= "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07\x03\x42"; + $der .= "\x00".$key; + $pem = "-----BEGIN PUBLIC KEY-----\x0A"; + $pem .= chunk_split(base64_encode($der), 64, "\x0A"); + $pem .= "-----END PUBLIC KEY-----\x0A"; + return $pem; + } + + /** + * Convert COSE ECDHA to PKCS + * @param string binary string to be converted + * @return string converted public key + */ + private function COSEECDHAtoPKCS($binary) + { + $cosePubKey = CBORDecoder::decode($binary); + + if (! isset($cosePubKey[3] /* cose_alg */)) { + throw new Exception('cannot decode key response (8)'); + } + + switch ($cosePubKey[3]) { + case self::ES256: + /* COSE Alg: ECDSA w/ SHA-256 */ + if (! isset($cosePubKey[-1] /* cose_crv */)) { + throw new Exception('cannot decode key response (9)'); + } + + if (! isset($cosePubKey[-2] /* cose_crv_x */)) { + throw new Exception('cannot decode key response (10)'); + } + + if ($cosePubKey[-1] != 1 /* cose_crv_P256 */) { + throw new Exception('cannot decode key response (14)'); + } + + if (!isset($cosePubKey[-2] /* cose_crv_x */)) { + throw new Exception('x coordinate for curve missing'); + } + + if (! isset($cosePubKey[1] /* cose_kty */)) { + throw new Exception('cannot decode key response (7)'); + } + + if (! isset($cosePubKey[-3] /* cose_crv_y */)) { + throw new Exception('cannot decode key response (11)'); + } + + if (!isset($cosePubKey[-3] /* cose_crv_y */)) { + throw new Exception('y coordinate for curve missing'); + } + + if ($cosePubKey[1] != 2 /* cose_kty_ec2 */) { + throw new Exception('cannot decode key response (12)'); + } + + $x = $cosePubKey[-2]->get_byte_string(); + $y = $cosePubKey[-3]->get_byte_string(); + if (strlen($x) != 32 || strlen($y) != 32) { + throw new Exception('cannot decode key response (15)'); + } + + $tag = "\x04"; + + $pem = $this->pubkeyToPem($tag.$x.$y); + + return $pem; + + case self::RS256: + /* COSE Alg: RSASSA-PKCS1-v1_5 w/ SHA-256 */ + if (!isset($cosePubKey[-2])) { + throw new Exception('RSA Exponent missing'); + } + if (!isset($cosePubKey[-1])) { + throw new Exception('RSA Modulus missing'); + } + + $pubkey = $this->getRSAPubkey( + $cosePubKey[-2]->get_byte_string(), + $cosePubKey[-1]->get_byte_string() + ); + + return $pubkey; + //*/ + default: + throw new Exception('cannot decode key response (13)'); + } + } + + /** + * + */ + private function getRSAPubkey( $publicExponent, $modulus ) { + // derived from + $components = array( + 'modulus' => pack('Ca*a*', 2, $this->derEncodeLength(strlen($modulus)), $modulus), + 'publicExponent' => pack('Ca*a*', 2, $this->derEncodeLength(strlen($publicExponent)), $publicExponent) + ); + $RSAPublicKey = pack( + 'Ca*a*a*', + 48, // ASN1 Sequence + $this->derEncodeLength(strlen($components['modulus']) + strlen($components['publicExponent'])), + $components['modulus'], + $components['publicExponent'] + ); + + // sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption. + $rsaOID = pack('H*', '300d06092a864886f70d0101010500'); // hex version of MA0GCSqGSIb3DQEBAQUA + $RSAPublicKey = chr(0) . $RSAPublicKey; + $RSAPublicKey = chr(3) . $this->derEncodeLength(strlen($RSAPublicKey)) . $RSAPublicKey; + + $RSAPublicKey = pack( + 'Ca*a*', + 48, + $this->derEncodeLength(strlen($rsaOID . $RSAPublicKey)), + $rsaOID . $RSAPublicKey + ); + + $RSAPublicKey = "-----BEGIN PUBLIC KEY-----\r\n" . + chunk_split(base64_encode($RSAPublicKey), 64) . + '-----END PUBLIC KEY-----'; + + return $RSAPublicKey; + + } + + /** + * DER-encode length + * {@link http://itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf#p=13 X.690 paragraph 8.1.3} + * + * @param Integer $length + * @param String DES Encoded $length + */ + private function derEncodeLength($length) { + if ($length <= 0x7F) { + return chr($length); + } + + $temp = ltrim(pack('N', $length), chr(0)); + return pack('Ca*', 0x80 | strlen($temp), $temp); + + } + + + /** + * shim for random_bytes which doesn't exist pre php7 + * @param int $length the number of bytes required + * @return string cryptographically random bytes + */ + private static function randomBytes($length) + { + if (function_exists('random_bytes')) { + return random_bytes($length); + } else if (function_exists('openssl_random_pseudo_bytes')) { + $bytes = openssl_random_pseudo_bytes($length, $crypto_strong); + if (! $crypto_strong) { + throw new Exception("openssl_random_pseudo_bytes did not return a cryptographically strong result", 1); + } + return $bytes; + } else { + throw new Exception("Neither random_bytes not openssl_random_pseudo_bytes exists. PHP too old? openssl PHP extension not installed?", 1); + } + } + + +} diff --git a/includes/WebAuthn/class-webauthn-keystore.php b/includes/WebAuthn/class-webauthn-keystore.php new file mode 100644 index 00000000..6f7f2ca0 --- /dev/null +++ b/includes/WebAuthn/class-webauthn-keystore.php @@ -0,0 +1,141 @@ +get_results( $wpdb->prepare( + "SELECT * FROM $wpdb->usermeta WHERE user_id=%d AND meta_key=%s AND meta_value LIKE %s", + $user_id, + self::PUBKEY_USERMETA_KEY, + '%' . $wpdb->esc_like( $keyLike ) . '%' + ) ); + foreach ( $found as $key ) { + return maybe_unserialize( $key->meta_value ); + } + return false; + } + + /** + * Check whether a key exists + * + * @param string $keyLike + * @return bool + */ + public function key_exists( $keyLike ) { + + global $wpdb; + + $num_keys = $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->usermeta WHERE meta_key=%s AND meta_value LIKE %s", + self::PUBKEY_USERMETA_KEY, + '%' . $wpdb->esc_like( serialize( $keyLike ) ) . '%' + ) ); + + return intval( $num_keys ) !== 0; + + } + + /** + * Add key to user + * + * @param int $user_id + * @param string $key + * @return bool + */ + public function create_key( $user_id, $key ) { + if ( $this->find_key( $user_id, $key->md5id ) ) { + return false; + } + return add_user_meta( $user_id, self::PUBKEY_USERMETA_KEY, $key ); + } + + /** + * Add or update key for user + * + * @param int $user_id + * @param string $key The new Key + * @param string $keyLike The old Key to be updated + * @return bool + */ + public function save_key( $user_id, $key, $keyLike ) { + $oldKey = $this->find_key( $user_id, $keyLike ); + return update_user_meta( $user_id, self::PUBKEY_USERMETA_KEY, $key, $oldKey ); + } + + /** + * Delete key for user + * + * @param int $user_id + * @param string $keyLike The Key to be deleted + * @return bool + */ + public function delete_key( $user_id, $keyLike ) { + global $wpdb; + + if ( $key = $this->find_key( $user_id, $keyLike ) ) { + return delete_user_meta( $user_id, self::PUBKEY_USERMETA_KEY, $key ); + } + + return false; + } + + +} diff --git a/providers/class-two-factor-webauthn.php b/providers/class-two-factor-webauthn.php new file mode 100644 index 00000000..bcc8cf55 --- /dev/null +++ b/providers/class-two-factor-webauthn.php @@ -0,0 +1,646 @@ +webauthn = new WebAuthnHandler( $this->get_app_id() ); + + $this->key_store = WebAuthnKeyStore::instance(); + + wp_register_script( + 'webauthn-login', + plugins_url( 'providers/js/webauthn-login.js', dirname( __FILE__ ) ), + array( 'jquery' ), + TWO_FACTOR_VERSION, + true + ); + + wp_register_script( + 'webauthn-admin', + plugins_url( 'providers/js/webauthn-admin.js', dirname( __FILE__ ) ), + array( 'jquery' ), + TWO_FACTOR_VERSION, + true + ); + + wp_register_style( + 'webauthn-admin', + plugins_url( 'providers/css/webauthn-admin.css', dirname( __FILE__ ) ), + array(), + TWO_FACTOR_VERSION + ); + + wp_register_style( + 'webauthn-login', + plugins_url( 'providers/css/webauthn-login.css', dirname( __FILE__ ) ), + array(), + TWO_FACTOR_VERSION + ); + + add_action( 'wp_ajax_webauthn-register', array( $this, 'ajax_register' ) ); + add_action( 'wp_ajax_webauthn-edit-key', array( $this, 'ajax_edit_key' ) ); + add_action( 'wp_ajax_webauthn-delete-key', array( $this, 'ajax_delete_key' ) ); + add_action( 'wp_ajax_webauthn-test-key', array( $this, 'ajax_test_key' ) ); + + add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_options' ) ); + + parent::__construct(); + + } + + + /** + * Enqueue assets for login form. + */ + public function login_enqueue_assets() { + wp_enqueue_script( 'webauthn-login' ); + wp_enqueue_style( 'webauthn-login' ); + } + + /** + * Return the U2F AppId. WebAuthn requires the AppID + * to be the current domain or a suffix of it. + * + * @return string AppID FQDN + */ + public function get_app_id() { + + $url_parts = wp_parse_url( network_site_url() ); + + $app_id = $url_parts['host']; + + if ( ! empty( $url_parts['port'] ) ) { + $app_id = sprintf( '%s:%d', $app_id, $url_parts['port'] ); + } + + /** + * Filter the WebAuthn App ID. + * + * In order for this to work, the App-ID has to be either the current + * (sub-)domain or a suffix of it. + * + * @param string $app_id Domain name acting as relying party ID. + */ + return apply_filters( 'two_factor_webauthn_app_id', $app_id ); + + } + + + /** + * Returns the name of the provider. + * + * @return string + */ + public function get_label() { + return _x( 'Web Authentication (FIDO2)', 'Provider Label', 'two-factor' ); + } + + /** + * Prints the form that prompts the user to authenticate. + * + * @param WP_User $user WP_User object of the logged-in user. + * @return null + */ + public function authentication_page( $user ) { + + wp_enqueue_style( 'webauthn-login' ); + + require_once ABSPATH . '/wp-admin/includes/template.php'; + + // WebAuthn doesn't work without HTTPS. + if ( ! is_ssl() ) { + ?> +
+ key_store->get_keys( $user->ID ); + + $auth_opts = $this->webauthn->prepareAuthenticate( $keys ); + + update_user_meta( $user->ID, self::LOGIN_USERMETA, 1 ); + } catch ( Exception $e ) { + ?> + + 'webauthn-login', + 'payload' => $auth_opts, + '_wpnonce' => wp_create_nonce( 'webauthn-login' ), + ) + ); + + wp_enqueue_script( 'webauthn-login' ); + + ?> + + + ++ + + +
++ + + +
++ +
+ +