From f357743680b9f05f98bf95bd71f3e30b6cdc603f Mon Sep 17 00:00:00 2001 From: mcguffin Date: Fri, 21 Jan 2022 20:02:49 +0100 Subject: [PATCH 01/17] Chew and swallow mcguffin/two-factor-webauthn --- class-two-factor-core.php | 13 + includes/WebAuthn/class-cbor-decoder.php | 300 +++++++ includes/WebAuthn/class-webauthn-handler.php | 750 ++++++++++++++++++ includes/WebAuthn/class-webauthn-keystore.php | 128 +++ providers/class-two-factor-webauthn.php | 642 +++++++++++++++ providers/css/webauthn-admin.css | 1 + providers/css/webauthn-login.css | 1 + providers/js/webauthn-admin.js | 364 +++++++++ providers/js/webauthn-login.js | 134 ++++ 9 files changed, 2333 insertions(+) create mode 100644 includes/WebAuthn/class-cbor-decoder.php create mode 100644 includes/WebAuthn/class-webauthn-handler.php create mode 100644 includes/WebAuthn/class-webauthn-keystore.php create mode 100644 providers/class-two-factor-webauthn.php create mode 100644 providers/css/webauthn-admin.css create mode 100644 providers/css/webauthn-login.css create mode 100644 providers/js/webauthn-admin.js create mode 100644 providers/js/webauthn-login.js diff --git a/class-two-factor-core.php b/class-two-factor-core.php index d9a85af7..fc1e9ffe 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,18 @@ public static function get_providers() { ); } + // WebAuthn is PHP 7.2+. + if ( isset( $providers['Two_Factor_WebAuthn'] ) && 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 + ) + ); + } + /** * 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..3dc16465 --- /dev/null +++ b/includes/WebAuthn/class-cbor-decoder.php @@ -0,0 +1,300 @@ + "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..6a18e979 --- /dev/null +++ b/includes/WebAuthn/class-webauthn-keystore.php @@ -0,0 +1,128 @@ +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; + + } + + /** + * Add key to user + * + * @param int $user_id + * @param string $key + * @return bool + */ + private function create_key( $user_id, $key ) { + 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 = null ) { + $oldKey = $this->find_key( $user_id, $keyLike ); + if ( false === $oldKey ) { + return $this->create_key( $user_id, $key ); + } + return update_user_meta( $user_id, self::PUBKEY_USERMETA_KEY, $key, $oldKey ); + } + + /** + * Delete key for user + * + * @param int $user_id + * @param string $keyLike The old Key to be updated + * @return bool + */ + public function delete_key( $user_id, $keyLike ) { + global $wpdb; + return $wpdb->query( $wpdb->prepare( + "DELETE FROM $wpdb->usermeta WHERE user_id=%d AND meta_key=%s AND meta_value LIKE %s", + $user_id, + self::PUBKEY_USERMETA_KEY, + '%' . $keyLike . '%' + ) ) !== 0; + } + + +} diff --git a/providers/class-two-factor-webauthn.php b/providers/class-two-factor-webauthn.php new file mode 100644 index 00000000..ca59b7f5 --- /dev/null +++ b/providers/class-two-factor-webauthn.php @@ -0,0 +1,642 @@ +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() { + + $fqdn = wp_parse_url( network_site_url(), PHP_URL_HOST ); + + /** + * 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 $fqdn Domain name acting as relying party ID. + */ + return apply_filters( 'two_factor_webauthn_app_id', $fqdn ); + + } + + + /** + * 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' ); + + ?> +

+ + +
+

+ + + +

+
+
+

+ + + +

+
+ key_store->get_keys( $user->ID ); + + $auth = $this->webauthn->authenticate( $credential, $keys ); + + if ( false === $auth ) { + return false; + } + $auth->last_used = time(); + $this->key_store->save_key( $user->ID, $auth, $auth->md5id ); + delete_user_meta( $user->ID, self::LOGIN_USERMETA ); + + return true; + } + + /** + * Whether this Two Factor provider is configured and available for the user specified. + * + * @param WP_User $user WP_User object of the logged-in user. + * @return boolean + */ + public function is_available_for_user( $user ) { + // only works for currently logged in user. + return function_exists( 'openssl_verify' ) && count( $this->key_store->get_keys( $user->ID ) ); + } + + /** + * Inserts markup at the end of the user profile field for this provider. + * + * @param WP_User $user WP_User object of the logged-in user. + */ + public function user_options( $user ) { + + wp_enqueue_script( 'webauthn-admin' ); + wp_enqueue_style( 'webauthn-admin' ); + + $challenge = $this->webauthn->prepareRegister( $user->display_name, $user->user_login ); + + $create_data = array( + 'action' => 'webauthn-register', + 'payload' => $challenge, + 'user_id' => $user->ID, + '_wpnonce' => wp_create_nonce( 'webauthn-register' ), + ); + + $keys = $this->key_store->get_keys( $user->ID ); + + ?> +

+ +

+ +
+ +
+ + + + + key_store->get_keys( $user_id ); + + try { + $key = $this->webauthn->register( $credential, '' ); + + if ( false === $key ) { + wp_send_json_error( new WP_Error( 'webauthn', $this->webauthn->getLastError() ) ); + } + /* translators: %s webauthn app id (domain) */ + $key->label = sprintf( esc_html__( 'New Device - %s', 'two-factor' ), $this->get_app_id() ); + $key->md5id = md5( implode( '', array_map( 'chr', $key->id ) ) ); + $key->created = time(); + $key->last_used = false; + $key->tested = false; + + if ( false !== $this->key_store->find_key( $user_id, $key->md5id ) ) { + wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'Device already Exists', 'two-factor' ) ) ); + exit(); + } + + $this->key_store->save_key( $user_id, $key ); + + } catch ( Exception $err ) { + wp_send_json( + array( + 'success' => false, + 'error' => $err->getMessage(), + ) + ); + return; + } + + wp_send_json( + array( + 'success' => true, + 'html' => $this->get_key_item( $key, $user_id ), + ) + ); + } + + /** + * Edit Key Ajax Callback. + */ + public function ajax_edit_key() { + + check_ajax_referer( 'webauthn-edit-key' ); + + if ( ! isset( $_REQUEST['payload'] ) ) { + // Error couldn't decode. + wp_send_json_error( new WP_Error( 'webauthn', __( 'Invalid request', 'two-factor' ) ) ); + } + + $current_user_id = get_current_user_id(); + + if ( isset( $_REQUEST['user_id'] ) ) { + $user_id = intval( wp_unslash( $_REQUEST['user_id'] ) ); + } else { + wp_send_json_error( new WP_Error( 'webauthn', __( 'Invalid request data', 'two-factor' ) ) ); + } + // Not permitted. + if ( ! current_user_can( 'edit_users' ) && $user_id !== $current_user_id ) { + wp_send_json_error( new WP_Error( 'webauthn', __( 'Operation not permitted', 'two-factor' ) ) ); + } + + $payload = wp_unslash( $_REQUEST['payload'] ); + + if ( ! isset( $payload['md5id'], $payload['label'] ) ) { + wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'Invalid request', 'two-factor' ) ) ); + } + $new_label = sanitize_text_field( $payload['label'] ); + + if ( empty( $new_label ) ) { + wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'Invalid label', 'two-factor' ) ) ); + } + + $key = $this->key_store->find_key( $user_id, $payload['md5id'] ); + if ( ! $key ) { + wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'No such key', 'two-factor' ) ) ); + } + + $key->label = $new_label; + + if ( $this->key_store->save_key( $user_id, $key, $payload['md5id'] ) ) { + wp_send_json( + array( + 'success' => true, + ) + ); + } + + wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'Could not edit key', 'two-factor' ) ) ); + } + + /** + * Delete Key Ajax Callback. + */ + public function ajax_delete_key() { + + check_ajax_referer( 'webauthn-delete-key' ); + + if ( ! isset( $_REQUEST['payload'] ) ) { + + // Error couldn't decode. + wp_send_json_error( new WP_Error( 'webauthn', __( 'Invalid request', 'two-factor' ) ) ); + } + + $key_id = wp_unslash( $_REQUEST['payload'] ); + + $current_user_id = get_current_user_id(); + + if ( isset( $_REQUEST['user_id'] ) ) { + $user_id = intval( wp_unslash( $_REQUEST['user_id'] ) ); + } else { + $user_id = $current_user_id; + } + + // Not permitted. + if ( ! current_user_can( 'edit_users' ) && $user_id !== $current_user_id ) { + wp_send_json_error( new WP_Error( 'webauthn', __( 'Operation not permitted', 'two-factor' ) ) ); + } + + if ( $this->key_store->delete_key( $user_id, $key_id ) ) { + wp_send_json( + array( + 'success' => true, + ) + ); + } + + wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'Could not delete key', 'two-factor' ) ) ); + } + + /** + * Test Key Ajax Callback. + */ + public function ajax_test_key() { + + check_ajax_referer( 'webauthn-test-key' ); + + if ( ! isset( $_REQUEST['payload'] ) ) { + // error couldn't decode. + wp_send_json_error( new WP_Error( 'webauthn', __( 'Invalid request', 'two-factor' ) ) ); + } + + $credential = wp_unslash( $_REQUEST['payload'] ); + + $current_user_id = get_current_user_id(); + + if ( isset( $_REQUEST['user_id'] ) ) { + $user_id = intval( wp_unslash( $_REQUEST['user_id'] ) ); + } else { + wp_send_json_error( new WP_Error( 'webauthn', __( 'Invalid request data', 'two-factor' ) ) ); + } + + // Not permitted. + if ( ! current_user_can( 'edit_users' ) && $user_id !== $current_user_id ) { + wp_send_json_error( new WP_Error( 'webauthn', __( 'Operation not permitted', 'two-factor' ) ) ); + } + + $keys = $this->key_store->get_keys( $user_id ); + + $key = $this->webauthn->authenticate( json_decode( $credential ), $keys ); + + if ( false !== $key ) { + // store key tested state. + $key->tested = true; + $this->key_store->save_key( $user_id, $key, $key->md5id ); + } + + wp_send_json( + array( + 'success' => false !== $key, + 'message' => $this->webauthn->getLastError(), + ) + ); + } + + /** + * Key Row HTML. + * + * @param object $pub_key Public key as generated by $this->webauthn->register(). + * @param int $user_id User ID. + * @return string HTML. + */ + private function get_key_item( $pub_key, $user_id ) { + + $out = '
  • '; + + // Info. + $out .= sprintf( + '%2$s', + esc_attr( + wp_json_encode( + array( + 'action' => 'webauthn-edit-key', + 'payload' => $pub_key->md5id, + 'user_id' => $user_id, + '_wpnonce' => wp_create_nonce( 'webauthn-edit-key' ), + ) + ) + ), + esc_html( $pub_key->label ) + ); + + $date_format = _x( 'm/d/Y', 'Short date format', 'two-factor' ); + + $out .= sprintf( + '%s
    %s
    ', + __( 'Created:', 'two-factor' ), + date_i18n( $date_format, $pub_key->created ) + ); + $out .= sprintf( + '%s
    %s
    ', + __( 'Last used:', 'two-factor' ), + $pub_key->last_used ? date_i18n( $date_format, $pub_key->last_used ) : esc_html__( '- Never -', 'two-factor' ) + ); + + // Actions. + $out .= sprintf( + ' + %1$s + + ', + esc_html__( 'Test', 'two-factor' ), + esc_attr( + wp_json_encode( + array( + 'action' => 'webauthn-test-key', + 'payload' => $this->webauthn->prepareAuthenticate( array( $pub_key ) ), + 'user_id' => $user_id, + '_wpnonce' => wp_create_nonce( 'webauthn-test-key' ), + ) + ) + ), + $pub_key->tested ? 'tested' : 'untested' + ); + $out .= sprintf( + ' + + %1$s + ', + esc_html__( 'Delete', 'two-factor' ), + esc_attr( + wp_json_encode( + array( + 'action' => 'webauthn-delete-key', + 'payload' => $pub_key->md5id, + 'user_id' => $user_id, + '_wpnonce' => wp_create_nonce( 'webauthn-delete-key' ), + ) + ) + ) + ); + $out .= '
  • '; + + return $out; + } + +} diff --git a/providers/css/webauthn-admin.css b/providers/css/webauthn-admin.css new file mode 100644 index 00000000..fa943ea4 --- /dev/null +++ b/providers/css/webauthn-admin.css @@ -0,0 +1 @@ +.webauth-register{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.webauth-register .webauthn-error{margin-left:13px}#webauthn-keys .busy,.webauth-register .busy{color:#7f8284;pointer-events:none;-webkit-transition:opacity .3s ease;-o-transition:opacity .3s ease;transition:opacity .3s ease;background-size:30px 30px;background-image:-o-linear-gradient(45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent);-webkit-animation:barberpole .5s linear infinite;animation:barberpole .5s linear infinite}@-webkit-keyframes barberpole{from{background-position:0 0}to{background-position:60px 30px}}@keyframes barberpole{from{background-position:0 0}to{background-position:60px 30px}}.webauthn-key{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:last baseline;-ms-flex-align:last baseline;align-items:last baseline;border-top:1px solid #ccc}@media screen and (max-width: 600px){.webauthn-key{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;text-align:center}.webauthn-key>*{min-width:100%;margin-bottom:6px}}.webauthn-key,.webauthn-key *{-webkit-box-sizing:border-box;box-sizing:border-box}.webauthn-key:last-of-type{border-bottom:1px solid #ccc}.webauthn-key .webauthn-label{-webkit-box-flex:1;-ms-flex:1;flex:1;white-space:normal;padding:3px}@media screen and (max-width: 600px){.webauthn-key .webauthn-label{font-size:1.5em;padding:6px}}.webauthn-key .webauthn-label~*{-webkit-box-flex:0;-ms-flex:0;flex:0}.webauthn-key .webauthn-created,.webauthn-key .webauthn-used{display:inline-block;white-space:nowrap;padding:0 .5em}@media screen and (min-width: 600px){.webauthn-key .webauthn-created,.webauthn-key .webauthn-used{min-width:100px}}.webauthn-key [data-tested=tested]{color:#0085ba}.webauthn-key [data-tested=fail]{color:#dc3232}.webauthn-key [data-tested=fail]::before{content:""}.webauthn-key [data-tested=untested]{color:#ccc}.webauthn-key [data-tested=untested]::before{content:"";border-radius:50%;border:1px solid #ccc;font-size:16px}.webauthn-key .webauthn-action{padding:3px;border:1px solid rgba(0,0,0,0);text-decoration:none}.webauthn-key .webauthn-action-link{-webkit-box-flex:0;-ms-flex:0;flex:0}.webauthn-key .webauthn-action-link.-test,.webauthn-key .webauthn-action-link.-delete{white-space:nowrap}@media screen and (max-width: 600px){.webauthn-key .webauthn-action-link.-test,.webauthn-key .webauthn-action-link.-delete{text-align:center}.webauthn-key .webauthn-action-link.-test,.webauthn-key .webauthn-action-link.-test ::before,.webauthn-key .webauthn-action-link.-delete,.webauthn-key .webauthn-action-link.-delete ::before{font-size:1.5em}}.webauthn-key .webauthn-action-link.-delete:hover{color:#dc3232}.webauthn-key>.webauthn-label{word-break:break-word}.webauthn-key>.webauthn-label:focus{outline:none}.webauthn-key>.webauthn-label[contenteditable=true]{background-color:#fff;border-color:#0085ba}.webauthn-key>.webauthn-label.busy{border-color:#ccc}.webauthn-key .notice{-ms-flex-preferred-size:100%;flex-basis:100%} diff --git a/providers/css/webauthn-login.css b/providers/css/webauthn-login.css new file mode 100644 index 00000000..e89fa05d --- /dev/null +++ b/providers/css/webauthn-login.css @@ -0,0 +1 @@ +.webauthn-retry,.webauthn-unsupported{display:none;margin-top:13px}.webauthn-retry.visible,.webauthn-unsupported.visible{display:block}.webauthn-retry p,.webauthn-unsupported p{font-style:italic}.webauthn-retry .button{float:right} diff --git a/providers/js/webauthn-admin.js b/providers/js/webauthn-admin.js new file mode 100644 index 00000000..1a89c055 --- /dev/null +++ b/providers/js/webauthn-admin.js @@ -0,0 +1,364 @@ +( function( $ ) { + + /** + * Borrowed from https://github.com/davidearl/webauthn + */ + function webauthnRegister( key, callback ) { + + let publicKey = Object.assign( {}, key.publicKey ); + + publicKey.attestation = undefined; + publicKey.challenge = new Uint8Array( publicKey.challenge ); + publicKey.user.id = new Uint8Array( publicKey.user.id ); + + navigator.credentials.create( { publicKey } ) + .then( function( aNewCredentialInfo ) { + let cd, ao, rawId, info; + + cd = JSON.parse( String.fromCharCode.apply( null, new Uint8Array( aNewCredentialInfo.response.clientDataJSON ) ) ); + if ( key.b64challenge !== cd.challenge ) { + callback( false, 'key returned something unexpected (1)' ); + } + if ( ! ( 'type' in cd ) ) { + return callback( false, 'key returned something unexpected (3)' ); + } + if ( 'webauthn.create' != cd.type ) { + return callback( false, 'key returned something unexpected (4)' ); + } + + ao = []; + ( new Uint8Array( aNewCredentialInfo.response.attestationObject ) ).forEach( function( v ) { + ao.push( v ); + }); + rawId = []; + ( new Uint8Array( aNewCredentialInfo.rawId ) ).forEach( function( v ) { + rawId.push( v ); + }); + info = { + rawId: rawId, + id: aNewCredentialInfo.id, + type: aNewCredentialInfo.type, + response: { + attestationObject: ao, + clientDataJSON: + JSON.parse( String.fromCharCode.apply( null, new Uint8Array( aNewCredentialInfo.response.clientDataJSON ) ) ) + } + }; + callback( true, JSON.stringify( info ) ); + }) + .catch( err => { + if ( 'name' in err ) { + callback( false, err.name + ': ' + err.message ); + } else { + callback( false, err.toString() ); + } + }); + } + + /** + * Borrowed from https://github.com/davidearl/webauthn + */ + function webauthnAuthenticate( pubKeyAuth, callback ) { + + const originalChallenge = pubKeyAuth.challenge; + const pk = Object.assign( {}, pubKeyAuth ); + + pk.challenge = new Uint8Array( pubKeyAuth.challenge ); + pk.allowCredentials = pk.allowCredentials.map( k => { + let ret = Object.assign( {}, k ); + ret.id = new Uint8Array( k.id ); + return ret; + } ); + + /* Ask the browser to prompt the user */ + navigator.credentials.get( { publicKey: pk } ) + .then( aAssertion => { + let ida, cd, cda, ad, sig, info; + + ida = [] + ( new Uint8Array( aAssertion.rawId ) ).forEach( function( v ) { + ida.push( v ); + } ); + + cd = JSON.parse( String.fromCharCode.apply( null, + new Uint8Array( aAssertion.response.clientDataJSON ) ) ); + + cda = []; + ( new Uint8Array( aAssertion.response.clientDataJSON ) ).forEach( function( v ) { + cda.push( v ); + } ); + + ad = []; + ( new Uint8Array( aAssertion.response.authenticatorData ) ).forEach( function( v ) { + ad.push( v ); + } ); + + sig = []; + ( new Uint8Array( aAssertion.response.signature ) ).forEach( function( v ) { + sig.push( v ); + } ); + + info = { + type: aAssertion.type, + originalChallenge: originalChallenge, + rawId: ida, + response: { + authenticatorData: ad, + clientData: cd, + clientDataJSONarray: cda, + signature: sig + } + }; + + callback( true, JSON.stringify( info ) ); + }) + .catch( err => { + /* + FF mac: + InvalidStateError: key not found + AbortError: user aborted or denied + NotAllowedError: ? + The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission. + + Chrome mac: + NotAllowedError: user aborted or denied + + Safari mac: + NotAllowedError: user aborted or denied + + Edge win10: + UnknownError: wrong key...? + NotAllowedError: user aborted or denied + + FF win: + NotAllowedError: user aborted or denied + DOMException: "The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission." + */ + if ( 'name' in err ) { + callback( false, err.name + ': ' + err.message ); + } else { + callback( false, err.toString() ); + } + }); + }; + + /** + * @param ArrayBuffer arrayBuf + * @return Array + */ + const buffer2Array = arrayBuf => [ ... ( new Uint8Array( arrayBuf ) ) ]; + + const register = ( opts, callback ) => { + + const { action, userId, payload, _wpnonce } = opts; + + webauthnRegister( payload, ( success, info ) => { + if ( success ) { + $.ajax({ + url: wp.ajax.settings.url, + method: 'post', + data: { + action, + payload: info, + userId, + _wpnonce + }, + success: callback + }); + } else { + callback( { success: false, message: info } ); + } + } ); + }; + + const login = ( opts, callback ) => { + + const { action, payload, _wpnonce } = opts; + + webauthnAuthenticate( payload, ( success, info ) => { + if ( success ) { + callback( { success:true, result: info } ); + } else { + callback( { success:false, message: info } ); + } + }); + }; + + const sendRequest = ( opts, callback ) => { + + $.ajax( { + url: wp.ajax.settings.url, + method: 'post', + data: opts, + success:callback + } ); + }; + + const editKey = ( editLabel, opts ) => { + + const { + action, + payload, + _wpnonce, + userId + } = opts; + + const stopEditing = ( save = false ) => { + const newLabel = $( editLabel ).text(); + $( editLabel ).text( newLabel ); + $( editLabel ).prop( 'contenteditable', false ); + $( document ).off( 'keydown' ); + $( editLabel ).off( 'blur' ); + if ( save && prevLabel !== newLabel ) { + $( editLabel ).addClass( 'busy' ); + + sendRequest( + { + action, + payload: { + md5id: payload, + label: newLabel + }, + userId, + _wpnonce + }, + response => { + $( editLabel ).removeClass( 'busy' ); + } + ); + } else if ( ! save ) { + $( editLabel ).text( prevLabel ); + } + }; + + const prevLabel = $( editLabel ).text(); + + $( editLabel ).prop( 'contenteditable', true ); + + $( document ).on( 'keydown', e => { + if ( 13 === e.which ) { + stopEditing( true ); + e.preventDefault(); + } else if ( 27 === e.which ) { + stopEditing( true ); + } + } ); + + // Focus and select + $( editLabel ) + .on( 'blur', e => stopEditing( true ) ) + .on( 'paste', e => { + e.preventDefault(); + let text = ( e.originalEvent || e ).clipboardData.getData( 'text/plain' ); + document.execCommand( 'insertHTML', false, text ); + } ); + + $( editLabel ).focus(); + + document.execCommand( 'selectAll', false, null ); + }; + + $( document ).on( 'click', '#webauthn-register-key', e => { + + e.preventDefault(); + + $( e.target ).next( '.webauthn-error' ).remove(); + + let $btn = $( e.target ).addClass( 'busy' ); + + const opts = JSON.parse( $( e.target ).attr( 'data-create-options' ) ); + + register( opts, response => { + $btn.removeClass( 'busy' ); + if ( response.success ) { + let $keyItem = $( response.html ).appendTo( '#webauthn-keys' ); + let $keyLabel = $keyItem.find( '.webauthn-label' ); + + editKey( + $keyLabel.get( 0 ), + JSON.parse( $keyLabel.attr( 'data-action' ) ) + ); + } else { + let msg; + if ( !! response.message ) { + msg = response.message; + } else if ( !! response.data && response.data[0] && response.data[0].message ) { + msg = response.data[0].message; + } else { + msg = JSON.stringify( response ); + } + $( `${msg}` ).insertAfter( '#webauthn-register-key' ); + } + }); + + }); + + if ( 'credentials' in navigator ) { + $( document ).on( 'click', '.webauthn-action', e => { + e.preventDefault(); + const $btn = $( e.target ).closest( '.webauthn-action' ); + const opts = JSON.parse( $btn.attr( 'data-action' ) ); + const $keyEl = $( e.target ).closest( '.webauthn-key' ); + const { + action, + userId, + payload, + _wpnonce + } = opts; + + if ( 'webauthn-test-key' === action ) { + e.preventDefault(); + $keyEl.find( '.notice' ).remove(); + $btn.addClass( 'busy' ); + login( opts, result => { + if ( ! result.success ) { + $keyEl.append( `
    ${result.message}
    ` ); + $btn.removeClass( 'busy' ); + return; + } + + // Send to server + sendRequest( { + action, + userId, + payload: result.result, + _wpnonce + }, response => { + if ( response.success ) { + $btn.find( '[data-tested]' ).attr( 'data-tested', 'tested' ); + } else { + $btn.find( '[data-tested]' ).attr( 'data-tested', 'fail' ); + $keyEl.append( `
    ${response.message}
    ` ); + } + $btn.removeClass( 'busy' ); + } ); + } ); + } else if ( 'webauthn-delete-key' === action ) { + $keyEl.addClass( 'busy' ); + e.preventDefault(); + sendRequest( opts, function( response ) { + $keyEl.removeClass( 'busy' ); + + // Remove key from list + if ( response.success ) { + $keyEl.remove(); + } else { + + // Error from server + $keyEl.append( `
    ${response.data[0].message}
    ` ); + } + } ); + } + if ( 'webauthn-edit-key' === opts.action ) { + if ( 'true' !== $( e.currentTarget ).prop( 'contenteditable' ) ) { + e.preventDefault(); + editKey( e.currentTarget, opts ); + } + } + } ); + } else { + $( '.webauthn-unsupported' ).removeClass( 'hidden' ); + $( '.webauthn-supported' ).addClass( 'hidden' ); + } + +} )( jQuery ); diff --git a/providers/js/webauthn-login.js b/providers/js/webauthn-login.js new file mode 100644 index 00000000..961c2fc9 --- /dev/null +++ b/providers/js/webauthn-login.js @@ -0,0 +1,134 @@ +( function( $ ) { + /** + * Derived from https://github.com/davidearl/webauthn + */ + function webauthnAuthenticate( pubKeyAuth, callback ) { + + const originalChallenge = pubKeyAuth.challenge; + const pk = Object.assign( {}, pubKeyAuth ); + + pk.challenge = new Uint8Array( pubKeyAuth.challenge ); + pk.allowCredentials = pk.allowCredentials.map( k => { + let ret = Object.assign( {}, k ); + ret.id = new Uint8Array( k.id ); + return ret; + } ); + + /* Ask the browser to prompt the user */ + navigator.credentials.get( { publicKey: pk } ) + .then( aAssertion => { + var ida, cd, cda, ad, sig, info; + + ida = [] + ( new Uint8Array( aAssertion.rawId ) ).forEach( function( v ) { + ida.push( v ); + } ); + + cd = JSON.parse( String.fromCharCode.apply( null, + new Uint8Array( aAssertion.response.clientDataJSON ) ) ); + + cda = []; + ( new Uint8Array( aAssertion.response.clientDataJSON ) ).forEach( function( v ) { + cda.push( v ); + } ); + + ad = []; + ( new Uint8Array( aAssertion.response.authenticatorData ) ).forEach( function( v ) { + ad.push( v ); + } ); + + sig = []; + ( new Uint8Array( aAssertion.response.signature ) ).forEach( function( v ) { + sig.push( v ); + } ); + + info = { + type: aAssertion.type, + originalChallenge: originalChallenge, + rawId: ida, + response: { + authenticatorData: ad, + clientData: cd, + clientDataJSONarray: cda, + signature: sig + } + }; + callback( true, JSON.stringify( info ) ); + }) + .catch( err => { + /* + FF mac: + InvalidStateError: key not found + AbortError: user aborted or denied + NotAllowedError: ? + The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission. + + Chrome mac: + NotAllowedError: user aborted or denied + + Safari mac: + NotAllowedError: user aborted or denied + + Edge win10: + UnknownError: wrong key...? + NotAllowedError: user aborted or denied + + FF win: + NotAllowedError: user aborted or denied + DOMException: "The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission." + */ + if ( 'name' in err ) { + callback( false, err.name + ': ' + err.message ); + } else { + callback( false, err.toString() ); + } + }); + }; + + const login = ( opts, callback ) => { + + const { action, payload, _wpnonce } = opts; + + webauthnAuthenticate( payload, ( success, info ) => { + if ( success ) { + callback( { success:true, result: info } ); + } else { + callback( { success:false, message: info } ); + } + }); + }; + + /** + * Some Password Managers (like nextcloud passwords) seem to abort the + * key browser dialog. + * We have to retry a couple of times to + */ + const auth = () => { + $( '.webauthn-retry' ).removeClass( 'visible' ); + login( window.webauthnL10n, response => { + if ( response.success ) { + $( '#webauthn_response' ).val( response.result ); + $( '#loginform' ).submit(); + } else { + + // Show retry-button + $( '.webauthn-retry' ).addClass( 'visible' ); + } + } ); + }; + + if ( ! window.webauthnL10n ) { + console.error( 'webauthL10n is not defined' ); + }; + + if ( 'credentials' in navigator ) { + $( document ) + .ready( auth ) + .on( 'click', '.webauthn-retry-link', auth ); + } else { + + // Show unsupported message + $( '.webauthn-unsupported' ).addClass( 'visible' ); + } + +} )( jQuery ); From be4602eab4f98267a384ddcdd3270378274de0e6 Mon Sep 17 00:00:00 2001 From: mcguffin Date: Fri, 21 Jan 2022 20:05:45 +0100 Subject: [PATCH 02/17] unminify css --- providers/css/webauthn-admin.css | 167 ++++++++++++++++++++++++++++++- providers/css/webauthn-login.css | 17 +++- 2 files changed, 182 insertions(+), 2 deletions(-) diff --git a/providers/css/webauthn-admin.css b/providers/css/webauthn-admin.css index fa943ea4..775d4170 100644 --- a/providers/css/webauthn-admin.css +++ b/providers/css/webauthn-admin.css @@ -1 +1,166 @@ -.webauth-register{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.webauth-register .webauthn-error{margin-left:13px}#webauthn-keys .busy,.webauth-register .busy{color:#7f8284;pointer-events:none;-webkit-transition:opacity .3s ease;-o-transition:opacity .3s ease;transition:opacity .3s ease;background-size:30px 30px;background-image:-o-linear-gradient(45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent);-webkit-animation:barberpole .5s linear infinite;animation:barberpole .5s linear infinite}@-webkit-keyframes barberpole{from{background-position:0 0}to{background-position:60px 30px}}@keyframes barberpole{from{background-position:0 0}to{background-position:60px 30px}}.webauthn-key{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:last baseline;-ms-flex-align:last baseline;align-items:last baseline;border-top:1px solid #ccc}@media screen and (max-width: 600px){.webauthn-key{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;text-align:center}.webauthn-key>*{min-width:100%;margin-bottom:6px}}.webauthn-key,.webauthn-key *{-webkit-box-sizing:border-box;box-sizing:border-box}.webauthn-key:last-of-type{border-bottom:1px solid #ccc}.webauthn-key .webauthn-label{-webkit-box-flex:1;-ms-flex:1;flex:1;white-space:normal;padding:3px}@media screen and (max-width: 600px){.webauthn-key .webauthn-label{font-size:1.5em;padding:6px}}.webauthn-key .webauthn-label~*{-webkit-box-flex:0;-ms-flex:0;flex:0}.webauthn-key .webauthn-created,.webauthn-key .webauthn-used{display:inline-block;white-space:nowrap;padding:0 .5em}@media screen and (min-width: 600px){.webauthn-key .webauthn-created,.webauthn-key .webauthn-used{min-width:100px}}.webauthn-key [data-tested=tested]{color:#0085ba}.webauthn-key [data-tested=fail]{color:#dc3232}.webauthn-key [data-tested=fail]::before{content:""}.webauthn-key [data-tested=untested]{color:#ccc}.webauthn-key [data-tested=untested]::before{content:"";border-radius:50%;border:1px solid #ccc;font-size:16px}.webauthn-key .webauthn-action{padding:3px;border:1px solid rgba(0,0,0,0);text-decoration:none}.webauthn-key .webauthn-action-link{-webkit-box-flex:0;-ms-flex:0;flex:0}.webauthn-key .webauthn-action-link.-test,.webauthn-key .webauthn-action-link.-delete{white-space:nowrap}@media screen and (max-width: 600px){.webauthn-key .webauthn-action-link.-test,.webauthn-key .webauthn-action-link.-delete{text-align:center}.webauthn-key .webauthn-action-link.-test,.webauthn-key .webauthn-action-link.-test ::before,.webauthn-key .webauthn-action-link.-delete,.webauthn-key .webauthn-action-link.-delete ::before{font-size:1.5em}}.webauthn-key .webauthn-action-link.-delete:hover{color:#dc3232}.webauthn-key>.webauthn-label{word-break:break-word}.webauthn-key>.webauthn-label:focus{outline:none}.webauthn-key>.webauthn-label[contenteditable=true]{background-color:#fff;border-color:#0085ba}.webauthn-key>.webauthn-label.busy{border-color:#ccc}.webauthn-key .notice{-ms-flex-preferred-size:100%;flex-basis:100%} +.webauth-register { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} +.webauth-register .webauthn-error { + margin-left: 13px; +} +#webauthn-keys .busy, +.webauth-register .busy { + color: #7f8284; + pointer-events: none; + -webkit-transition: opacity 0.3s ease; + -o-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; + background-size: 30px 30px; + background-image: -o-linear-gradient(45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent); + -webkit-animation: barberpole 0.5s linear infinite; + animation: barberpole 0.5s linear infinite; +} +@-webkit-keyframes barberpole { + from { + background-position: 0 0; + } + to { + background-position: 60px 30px; + } +} +@keyframes barberpole { + from { + background-position: 0 0; + } + to { + background-position: 60px 30px; + } +} +.webauthn-key { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-box-align: last baseline; + -ms-flex-align: last baseline; + align-items: last baseline; + border-top: 1px solid #ccc; +} +@media screen and (max-width: 600px) { + .webauthn-key { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + text-align: center; + } + .webauthn-key > * { + min-width: 100%; + margin-bottom: 6px; + } +} +.webauthn-key, +.webauthn-key * { + -webkit-box-sizing: border-box; + box-sizing: border-box; +} +.webauthn-key:last-of-type { + border-bottom: 1px solid #ccc; +} +.webauthn-key .webauthn-label { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + white-space: normal; + padding: 3px; +} +@media screen and (max-width: 600px) { + .webauthn-key .webauthn-label { + font-size: 1.5em; + padding: 6px; + } +} +.webauthn-key .webauthn-label ~ * { + -webkit-box-flex: 0; + -ms-flex: 0; + flex: 0; +} +.webauthn-key .webauthn-created, +.webauthn-key .webauthn-used { + display: inline-block; + white-space: nowrap; + padding: 0 0.5em; +} +@media screen and (min-width: 600px) { + .webauthn-key .webauthn-created, + .webauthn-key .webauthn-used { + min-width: 100px; + } +} +.webauthn-key [data-tested="tested"] { + color: #0085ba; +} +.webauthn-key [data-tested="fail"] { + color: #dc3232; +} +.webauthn-key [data-tested="fail"]::before { + content: ""; +} +.webauthn-key [data-tested="untested"] { + color: #ccc; +} +.webauthn-key [data-tested="untested"]::before { + content: ""; + border-radius: 50%; + border: 1px solid #ccc; + font-size: 16px; +} +.webauthn-key .webauthn-action { + padding: 3px; + border: 1px solid rgba(0, 0, 0, 0); + text-decoration: none; +} +.webauthn-key .webauthn-action-link { + -webkit-box-flex: 0; + -ms-flex: 0; + flex: 0; +} +.webauthn-key .webauthn-action-link.-test, +.webauthn-key .webauthn-action-link.-delete { + white-space: nowrap; +} +@media screen and (max-width: 600px) { + .webauthn-key .webauthn-action-link.-test, + .webauthn-key .webauthn-action-link.-delete { + text-align: center; + } + .webauthn-key .webauthn-action-link.-test, + .webauthn-key .webauthn-action-link.-test ::before, + .webauthn-key .webauthn-action-link.-delete, + .webauthn-key .webauthn-action-link.-delete ::before { + font-size: 1.5em; + } +} +.webauthn-key .webauthn-action-link.-delete:hover { + color: #dc3232; +} +.webauthn-key > .webauthn-label { + word-break: break-word; +} +.webauthn-key > .webauthn-label:focus { + outline: none; +} +.webauthn-key > .webauthn-label[contenteditable="true"] { + background-color: #fff; + border-color: #0085ba; +} +.webauthn-key > .webauthn-label.busy { + border-color: #ccc; +} +.webauthn-key .notice { + -ms-flex-preferred-size: 100%; + flex-basis: 100%; +} diff --git a/providers/css/webauthn-login.css b/providers/css/webauthn-login.css index e89fa05d..452f10a9 100644 --- a/providers/css/webauthn-login.css +++ b/providers/css/webauthn-login.css @@ -1 +1,16 @@ -.webauthn-retry,.webauthn-unsupported{display:none;margin-top:13px}.webauthn-retry.visible,.webauthn-unsupported.visible{display:block}.webauthn-retry p,.webauthn-unsupported p{font-style:italic}.webauthn-retry .button{float:right} +.webauthn-retry, +.webauthn-unsupported { + display:none; + margin-top:13px; +} +.webauthn-retry.visible, +.webauthn-unsupported.visible{ + display:block; +} +.webauthn-retry p, +.webauthn-unsupported p { + font-style:italic; +} +.webauthn-retry .button{ + float:right; +} From bad7b0d318341bd3120a0ba6de7a5364de8662f0 Mon Sep 17 00:00:00 2001 From: mcguffin Date: Fri, 21 Jan 2022 20:59:38 +0100 Subject: [PATCH 03/17] Test and debug --- includes/WebAuthn/class-webauthn-keystore.php | 2 +- providers/class-two-factor-webauthn.php | 10 +++---- providers/js/webauthn-admin.js | 20 ++++++++----- providers/js/webauthn-login.js | 28 +++---------------- 4 files changed, 23 insertions(+), 37 deletions(-) diff --git a/includes/WebAuthn/class-webauthn-keystore.php b/includes/WebAuthn/class-webauthn-keystore.php index 6a18e979..41417fb2 100644 --- a/includes/WebAuthn/class-webauthn-keystore.php +++ b/includes/WebAuthn/class-webauthn-keystore.php @@ -71,7 +71,7 @@ public function find_key( $user_id, $keyLike = null ) { "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 . '%' ) + '%' . $wpdb->esc_like( $keyLike ) . '%' ) ); foreach ( $found as $key ) { return maybe_unserialize( $key->meta_value ); diff --git a/providers/class-two-factor-webauthn.php b/providers/class-two-factor-webauthn.php index ca59b7f5..51f69c8a 100644 --- a/providers/class-two-factor-webauthn.php +++ b/providers/class-two-factor-webauthn.php @@ -94,7 +94,7 @@ protected function __construct() { 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' ) ); + add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_options' ) ); parent::__construct(); @@ -265,7 +265,7 @@ public function user_options( $user ) { $create_data = array( 'action' => 'webauthn-register', 'payload' => $challenge, - 'user_id' => $user->ID, + 'userId' => $user->ID, '_wpnonce' => wp_create_nonce( 'webauthn-register' ), ); @@ -577,7 +577,7 @@ private function get_key_item( $pub_key, $user_id ) { array( 'action' => 'webauthn-edit-key', 'payload' => $pub_key->md5id, - 'user_id' => $user_id, + 'userId' => $user_id, '_wpnonce' => wp_create_nonce( 'webauthn-edit-key' ), ) ) @@ -610,7 +610,7 @@ private function get_key_item( $pub_key, $user_id ) { array( 'action' => 'webauthn-test-key', 'payload' => $this->webauthn->prepareAuthenticate( array( $pub_key ) ), - 'user_id' => $user_id, + 'userId' => $user_id, '_wpnonce' => wp_create_nonce( 'webauthn-test-key' ), ) ) @@ -628,7 +628,7 @@ private function get_key_item( $pub_key, $user_id ) { array( 'action' => 'webauthn-delete-key', 'payload' => $pub_key->md5id, - 'user_id' => $user_id, + 'userId' => $user_id, '_wpnonce' => wp_create_nonce( 'webauthn-delete-key' ), ) ) diff --git a/providers/js/webauthn-admin.js b/providers/js/webauthn-admin.js index 1a89c055..a27fa40f 100644 --- a/providers/js/webauthn-admin.js +++ b/providers/js/webauthn-admin.js @@ -75,7 +75,7 @@ .then( aAssertion => { let ida, cd, cda, ad, sig, info; - ida = [] + ida = []; ( new Uint8Array( aAssertion.rawId ) ).forEach( function( v ) { ida.push( v ); } ); @@ -160,7 +160,7 @@ data: { action, payload: info, - userId, + user_id: userId, _wpnonce }, success: callback @@ -194,7 +194,7 @@ } ); }; - const editKey = ( editLabel, opts ) => { + const editKey = ( editLabel, opts, callback = () => {} ) => { const { action, @@ -219,11 +219,12 @@ md5id: payload, label: newLabel }, - userId, + user_id: userId, _wpnonce }, response => { $( editLabel ).removeClass( 'busy' ); + callback( response ); } ); } else if ( ! save ) { @@ -306,6 +307,7 @@ _wpnonce } = opts; + if ( 'webauthn-test-key' === action ) { e.preventDefault(); $keyEl.find( '.notice' ).remove(); @@ -320,7 +322,7 @@ // Send to server sendRequest( { action, - userId, + user_id: userId, payload: result.result, _wpnonce }, response => { @@ -328,7 +330,7 @@ $btn.find( '[data-tested]' ).attr( 'data-tested', 'tested' ); } else { $btn.find( '[data-tested]' ).attr( 'data-tested', 'fail' ); - $keyEl.append( `
    ${response.message}
    ` ); + $keyEl.append( `
    ${response.data[0].message}
    ` ); } $btn.removeClass( 'busy' ); } ); @@ -352,7 +354,11 @@ if ( 'webauthn-edit-key' === opts.action ) { if ( 'true' !== $( e.currentTarget ).prop( 'contenteditable' ) ) { e.preventDefault(); - editKey( e.currentTarget, opts ); + editKey( e.currentTarget, opts, response => { + if ( ! response.success ) { + $keyEl.append( `
    ${response.data[0].message}
    ` ); + } + } ); } } } ); diff --git a/providers/js/webauthn-login.js b/providers/js/webauthn-login.js index 961c2fc9..16c07f5b 100644 --- a/providers/js/webauthn-login.js +++ b/providers/js/webauthn-login.js @@ -1,6 +1,6 @@ ( function( $ ) { /** - * Derived from https://github.com/davidearl/webauthn + * Borrowed from https://github.com/davidearl/webauthn */ function webauthnAuthenticate( pubKeyAuth, callback ) { @@ -17,9 +17,9 @@ /* Ask the browser to prompt the user */ navigator.credentials.get( { publicKey: pk } ) .then( aAssertion => { - var ida, cd, cda, ad, sig, info; + let ida, cd, cda, ad, sig, info; - ida = [] + ida = []; ( new Uint8Array( aAssertion.rawId ) ).forEach( function( v ) { ida.push( v ); } ); @@ -53,30 +53,10 @@ signature: sig } }; + callback( true, JSON.stringify( info ) ); }) .catch( err => { - /* - FF mac: - InvalidStateError: key not found - AbortError: user aborted or denied - NotAllowedError: ? - The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission. - - Chrome mac: - NotAllowedError: user aborted or denied - - Safari mac: - NotAllowedError: user aborted or denied - - Edge win10: - UnknownError: wrong key...? - NotAllowedError: user aborted or denied - - FF win: - NotAllowedError: user aborted or denied - DOMException: "The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission." - */ if ( 'name' in err ) { callback( false, err.name + ': ' + err.message ); } else { From 735bb930434935ad2d2c58926512ddec52ea885b Mon Sep 17 00:00:00 2001 From: mcguffin Date: Mon, 24 Jan 2022 11:35:41 +0100 Subject: [PATCH 04/17] change WebAuthnKeyStore::find_key() function args --- includes/WebAuthn/class-webauthn-keystore.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/includes/WebAuthn/class-webauthn-keystore.php b/includes/WebAuthn/class-webauthn-keystore.php index 41417fb2..aaa3246f 100644 --- a/includes/WebAuthn/class-webauthn-keystore.php +++ b/includes/WebAuthn/class-webauthn-keystore.php @@ -59,10 +59,12 @@ public function get_keys( $user_id ) { * Find specific key for user by * * @param int $user_id - * @return string|bool + * @param string $keyLike + * @return array|bool */ - public function find_key( $user_id, $keyLike = null ) { + public function find_key( $user_id, $keyLike ) { global $wpdb; + if ( is_null( $keyLike ) ) { return false; } @@ -111,7 +113,7 @@ public function save_key( $user_id, $key, $keyLike = null ) { * Delete key for user * * @param int $user_id - * @param string $keyLike The old Key to be updated + * @param string $keyLike The Key to be deleted * @return bool */ public function delete_key( $user_id, $keyLike ) { From edbd8ffc85a33ef6eec49b77ae278c5bdf7f0d19 Mon Sep 17 00:00:00 2001 From: mcguffin Date: Mon, 24 Jan 2022 11:38:03 +0100 Subject: [PATCH 05/17] WebAuthnKeyStore: check if key exists before delete --- includes/WebAuthn/class-webauthn-keystore.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/includes/WebAuthn/class-webauthn-keystore.php b/includes/WebAuthn/class-webauthn-keystore.php index aaa3246f..c605abf0 100644 --- a/includes/WebAuthn/class-webauthn-keystore.php +++ b/includes/WebAuthn/class-webauthn-keystore.php @@ -118,12 +118,12 @@ public function save_key( $user_id, $key, $keyLike = null ) { */ public function delete_key( $user_id, $keyLike ) { global $wpdb; - return $wpdb->query( $wpdb->prepare( - "DELETE FROM $wpdb->usermeta WHERE user_id=%d AND meta_key=%s AND meta_value LIKE %s", - $user_id, - self::PUBKEY_USERMETA_KEY, - '%' . $keyLike . '%' - ) ) !== 0; + + if ( $key = $this->find_key( $user_id, $keyLike ) ) { + return delete_user_meta( $user_id, self::PUBKEY_USERMETA_KEY, $key ); + } + + return false; } From a65f70602694a7910a521f2f40cd2191329c99ed Mon Sep 17 00:00:00 2001 From: mcguffin Date: Mon, 24 Jan 2022 11:42:18 +0100 Subject: [PATCH 06/17] allow non-standard ports in Two_Factor_WebAuthn::get_app_id() --- providers/class-two-factor-webauthn.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/providers/class-two-factor-webauthn.php b/providers/class-two-factor-webauthn.php index 51f69c8a..56f9559f 100644 --- a/providers/class-two-factor-webauthn.php +++ b/providers/class-two-factor-webauthn.php @@ -117,7 +117,13 @@ public function login_enqueue_assets() { */ public function get_app_id() { - $fqdn = wp_parse_url( network_site_url(), PHP_URL_HOST ); + $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. @@ -125,9 +131,9 @@ public function get_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 $fqdn Domain name acting as relying party ID. + * @param string $app_id Domain name acting as relying party ID. */ - return apply_filters( 'two_factor_webauthn_app_id', $fqdn ); + return apply_filters( 'two_factor_webauthn_app_id', $app_id ); } From 9974a5dc9476db4bf0321699d52ab06f3f2a73e8 Mon Sep 17 00:00:00 2001 From: mcguffin Date: Mon, 24 Jan 2022 11:44:04 +0100 Subject: [PATCH 07/17] Two_Factor_WebAuthn: make sure key is not registered for another user --- includes/WebAuthn/class-webauthn-keystore.php | 19 +++++++++++++++++++ providers/class-two-factor-webauthn.php | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/includes/WebAuthn/class-webauthn-keystore.php b/includes/WebAuthn/class-webauthn-keystore.php index c605abf0..1aa77a61 100644 --- a/includes/WebAuthn/class-webauthn-keystore.php +++ b/includes/WebAuthn/class-webauthn-keystore.php @@ -79,6 +79,25 @@ public function find_key( $user_id, $keyLike ) { 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( $keyLike ) . '%' + ) ); + + return intval( $num_keys ) !== 0; } diff --git a/providers/class-two-factor-webauthn.php b/providers/class-two-factor-webauthn.php index 56f9559f..3e5f2328 100644 --- a/providers/class-two-factor-webauthn.php +++ b/providers/class-two-factor-webauthn.php @@ -402,7 +402,7 @@ public function ajax_register() { $key->last_used = false; $key->tested = false; - if ( false !== $this->key_store->find_key( $user_id, $key->md5id ) ) { + if ( false !== $this->key_store->key_exists( $key->md5id ) ) { wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'Device already Exists', 'two-factor' ) ) ); exit(); } From 1161fcb25ced295b704ff8fa81130e879293dd98 Mon Sep 17 00:00:00 2001 From: mcguffin Date: Wed, 28 Sep 2022 18:54:36 +0200 Subject: [PATCH 08/17] incorporate suggestions by @sjinks --- includes/WebAuthn/class-cbor-decoder.php | 4 ---- includes/WebAuthn/class-webauthn-keystore.php | 4 ---- providers/js/webauthn-admin.js | 10 +++++----- providers/js/webauthn-login.js | 2 +- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/includes/WebAuthn/class-cbor-decoder.php b/includes/WebAuthn/class-cbor-decoder.php index 3dc16465..de663fed 100644 --- a/includes/WebAuthn/class-cbor-decoder.php +++ b/includes/WebAuthn/class-cbor-decoder.php @@ -1,9 +1,5 @@ { - let ret = Object.assign( {}, k ); + const ret = Object.assign( {}, k ); ret.id = new Uint8Array( k.id ); return ret; } ); @@ -265,15 +265,15 @@ $( e.target ).next( '.webauthn-error' ).remove(); - let $btn = $( e.target ).addClass( 'busy' ); + const $btn = $( e.target ).addClass( 'busy' ); const opts = JSON.parse( $( e.target ).attr( 'data-create-options' ) ); register( opts, response => { $btn.removeClass( 'busy' ); if ( response.success ) { - let $keyItem = $( response.html ).appendTo( '#webauthn-keys' ); - let $keyLabel = $keyItem.find( '.webauthn-label' ); + const $keyItem = $( response.html ).appendTo( '#webauthn-keys' ); + const $keyLabel = $keyItem.find( '.webauthn-label' ); editKey( $keyLabel.get( 0 ), diff --git a/providers/js/webauthn-login.js b/providers/js/webauthn-login.js index 16c07f5b..b2daf63d 100644 --- a/providers/js/webauthn-login.js +++ b/providers/js/webauthn-login.js @@ -9,7 +9,7 @@ pk.challenge = new Uint8Array( pubKeyAuth.challenge ); pk.allowCredentials = pk.allowCredentials.map( k => { - let ret = Object.assign( {}, k ); + const ret = Object.assign( {}, k ); ret.id = new Uint8Array( k.id ); return ret; } ); From 70cabf0e48906d6f761af18dd8f62ed5c58b03dd Mon Sep 17 00:00:00 2001 From: mcguffin Date: Thu, 3 Nov 2022 12:21:14 +0100 Subject: [PATCH 09/17] WebAuthn: check oppenssl php-lib in core class --- class-two-factor-core.php | 26 ++++++++++++++++--------- providers/class-two-factor-webauthn.php | 6 +++--- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index e585c15b..22cbbc6b 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -146,15 +146,23 @@ public static function get_providers() { } // WebAuthn is PHP 7.2+. - if ( isset( $providers['Two_Factor_WebAuthn'] ) && 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 ( 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 + ); + } } /** diff --git a/providers/class-two-factor-webauthn.php b/providers/class-two-factor-webauthn.php index 3e5f2328..aee9e9b9 100644 --- a/providers/class-two-factor-webauthn.php +++ b/providers/class-two-factor-webauthn.php @@ -253,7 +253,7 @@ public function validate_authentication( $user ) { */ public function is_available_for_user( $user ) { // only works for currently logged in user. - return function_exists( 'openssl_verify' ) && count( $this->key_store->get_keys( $user->ID ) ); + return (bool) count( $this->key_store->get_keys( $user->ID ) ); } /** @@ -279,7 +279,7 @@ public function user_options( $user ) { ?>

    - +

    @@ -624,7 +624,7 @@ private function get_key_item( $pub_key, $user_id ) { $pub_key->tested ? 'tested' : 'untested' ); $out .= sprintf( - ' + ' %1$s ', From 7888979b8c02c4bd78dade325b96f89186224e0e Mon Sep 17 00:00:00 2001 From: mcguffin Date: Thu, 3 Nov 2022 12:21:47 +0100 Subject: [PATCH 10/17] WebAuthn: remove unnecessary function call --- providers/class-two-factor-webauthn.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/providers/class-two-factor-webauthn.php b/providers/class-two-factor-webauthn.php index aee9e9b9..93f334dc 100644 --- a/providers/class-two-factor-webauthn.php +++ b/providers/class-two-factor-webauthn.php @@ -387,8 +387,6 @@ public function ajax_register() { wp_send_json_error( new WP_Error( 'webauthn', __( 'Not allowed to add key', 'two-factor' ) ) ); } - $keys = $this->key_store->get_keys( $user_id ); - try { $key = $this->webauthn->register( $credential, '' ); From 67e77253b6d20c3d3ad3f031518061d5b5be952b Mon Sep 17 00:00:00 2001 From: mcguffin Date: Thu, 3 Nov 2022 13:28:53 +0100 Subject: [PATCH 11/17] webauthn keystore: distinct key lookup in db --- includes/WebAuthn/class-webauthn-keystore.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/WebAuthn/class-webauthn-keystore.php b/includes/WebAuthn/class-webauthn-keystore.php index 84575f4a..be0120e9 100644 --- a/includes/WebAuthn/class-webauthn-keystore.php +++ b/includes/WebAuthn/class-webauthn-keystore.php @@ -90,7 +90,7 @@ public function key_exists( $keyLike ) { $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( $keyLike ) . '%' + '%' . $wpdb->esc_like( serialize( $keyLike ) ) . '%' ) ); return intval( $num_keys ) !== 0; From bcfcfc70fca50403eec13f7031bb0e508146921e Mon Sep 17 00:00:00 2001 From: mcguffin Date: Thu, 3 Nov 2022 14:50:15 +0100 Subject: [PATCH 12/17] WebAuthn keystore: make create_key public --- includes/WebAuthn/class-webauthn-keystore.php | 14 +++++--------- providers/class-two-factor-webauthn.php | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/includes/WebAuthn/class-webauthn-keystore.php b/includes/WebAuthn/class-webauthn-keystore.php index be0120e9..6f7f2ca0 100644 --- a/includes/WebAuthn/class-webauthn-keystore.php +++ b/includes/WebAuthn/class-webauthn-keystore.php @@ -61,10 +61,6 @@ public function get_keys( $user_id ) { public function find_key( $user_id, $keyLike ) { global $wpdb; - if ( is_null( $keyLike ) ) { - return false; - } - $found = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $wpdb->usermeta WHERE user_id=%d AND meta_key=%s AND meta_value LIKE %s", $user_id, @@ -104,7 +100,10 @@ public function key_exists( $keyLike ) { * @param string $key * @return bool */ - private function create_key( $user_id, $key ) { + 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 ); } @@ -116,11 +115,8 @@ private function create_key( $user_id, $key ) { * @param string $keyLike The old Key to be updated * @return bool */ - public function save_key( $user_id, $key, $keyLike = null ) { + public function save_key( $user_id, $key, $keyLike ) { $oldKey = $this->find_key( $user_id, $keyLike ); - if ( false === $oldKey ) { - return $this->create_key( $user_id, $key ); - } return update_user_meta( $user_id, self::PUBKEY_USERMETA_KEY, $key, $oldKey ); } diff --git a/providers/class-two-factor-webauthn.php b/providers/class-two-factor-webauthn.php index 93f334dc..bcc8cf55 100644 --- a/providers/class-two-factor-webauthn.php +++ b/providers/class-two-factor-webauthn.php @@ -405,7 +405,7 @@ public function ajax_register() { exit(); } - $this->key_store->save_key( $user_id, $key ); + $this->key_store->create_key( $user_id, $key ); } catch ( Exception $err ) { wp_send_json( From 126ab18b19895eb58281a818d142c419e30fdebd Mon Sep 17 00:00:00 2001 From: mcguffin Date: Thu, 3 Nov 2022 14:50:30 +0100 Subject: [PATCH 13/17] add webauthn test --- tests/providers/class-two-factor-webauthn.php | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 tests/providers/class-two-factor-webauthn.php diff --git a/tests/providers/class-two-factor-webauthn.php b/tests/providers/class-two-factor-webauthn.php new file mode 100644 index 00000000..c24b009e --- /dev/null +++ b/tests/providers/class-two-factor-webauthn.php @@ -0,0 +1,206 @@ +provider = Two_Factor_WebAuthn::get_instance(); + } + + /** + * Clean up after tests. + * + * @see WP_UnitTestCase::tearDown() + */ + public function tearDown(): void { + unset( $this->provider ); + + parent::tearDown(); + } + + /** + * Verify an instance exists. + * + * @covers Two_Factor_Totp::get_instance + */ + public function test_get_instance() { + $this->assertNotNull( $this->provider->get_instance() ); + } + + /** + * Verify the label value. + * + * @covers Two_Factor_WebAuthn::test_get_label + */ + public function test_get_label() { + $this->assertStringContainsString( 'Web Authentication (FIDO2)', $this->provider->get_label() ); + } + + /** + * Verify appi id is a valid hostname + * + * @covers Two_Factor_WebAuthn::get_app_id + */ + public function test_get_app_id() { + + $app_id = $this->provider->get_app_id(); + + // whether this is a valid hostname + $this->assertIsString( filter_var( $app_id, FILTER_VALIDATE_DOMAIN, FILTER_NULL_ON_FAILURE ) ); + + // the key is part of the current wp hostname + $this->assertStringContainsString( $app_id, get_option('home') ); + + } + + /** + * @covers Two_Factor_WebAuthn::validate_authentication + */ + public function test_validate_authentication() { + + $user_id = $this->factory->user->create(); + $user = new WP_User( $user_id ); + + $key = unserialize( $this->serialized_key ); + + $key_store = WebAuthnKeyStore::instance(); + + add_user_meta( $user_id, '_two_factor_enabled_providers', array( 'Two_Factor_WebAuthn' ) ); + add_user_meta( $user_id, '_two_factor_provider', 'Two_Factor_WebAuthn' ); + + $key_store->save_key( $user_id, $key, $key->md5id ); + + // test non-json response + $_POST['webauthn_response'] = '-- garbage --'; + + $result = $this->provider->validate_authentication( $user ); + $this->assertFalse( $result ); + + + // test successful authentication + // keys are domain specific. We are testing actual keys, so we can't simply use a dummy host here + $webauthn = new WebAuthnHandler( 'mu.wordpress.local' ); + $result = $webauthn->authenticate( json_decode( $this->authentication_payload ), $key_store->get_keys( $user_id ) ); + // craft a request, try to verify + $this->assertIsObject( $result ); // dummy + + + // test key deletion + $key_store->delete_key( $user_id, $key->md5id ); + $result = $key_store->find_key( $user_id, $key->md5id ); + $this->assertFalse( $result ); + + $result = $webauthn->authenticate( json_decode( $this->authentication_payload ), $key_store->get_keys( $user_id ) ); + // craft a request, try to verify + $this->assertFalse( $result ); // dummy + + } + + /** + * @covers Two_Factor_WebAuthn::ajax_register + */ + public function test_register() { + add_filter( 'wp_die_ajax_handler', function( $handler ) { return '__return_false'; } ); + add_filter( 'wp_ajax_handler', function() { return '__return_false'; } ); + + $user_id = $this->factory->user->create(); + $user = new WP_User( $user_id ); + + $webauthn = new WebAuthnHandler( 'mu.wordpress.local' ); + $key_store = WebAuthnKeyStore::instance(); + + $credential = json_decode( $this->registration_payload ); + + $key = $webauthn->register( $credential, '' ); + + $this->assertIsObject( $key ); + + /* translators: %s webauthn app id (domain) */ + $key->label = sprintf( esc_html__( 'New Device - %s', 'two-factor' ), $this->provider->get_app_id() ); + $key->md5id = md5( implode( '', array_map( 'chr', $key->id ) ) ); + $key->created = time(); + $key->last_used = false; + $key->tested = false; + + $meta_id = $key_store->save_key( $user_id, $key, $key->md5id ); + + $this->assertIsInt( $meta_id ); + + // save the same key again + $key->label = 'name was changed'; + $alternative_meta_id = $key_store->save_key( $user_id, $key, $key->md5id ); + + $this->assertEquals( $meta_id, $alternative_meta_id ); + + + // try to save the same key again + $new_meta_id = $key_store->create_key( $user_id, $key ); + + $this->assertFalse( $new_meta_id ); + + $keys = $key_store->get_keys( $user_id ); + $this->assertEquals( count( $keys ), 1 ); + + } + +} From e2a161d360130f48f52b5ad663cba67e15979a30 Mon Sep 17 00:00:00 2001 From: mcguffin Date: Thu, 3 Nov 2022 23:05:54 +0100 Subject: [PATCH 14/17] u2f migration --- includes/WebAuthn/class-webauthn-handler.php | 53 +++++--- .../WebAuthn/class-webauthn-key-migrator.php | 124 ++++++++++++++++++ ...s-two-factor-fido-u2f-admin-list-table.php | 1 + providers/class-two-factor-fido-u2f-admin.php | 55 ++++++++ providers/class-two-factor-webauthn.php | 27 ++-- providers/js/webauthn-admin.js | 3 +- providers/js/webauthn-login.js | 28 ++-- 7 files changed, 250 insertions(+), 41 deletions(-) create mode 100644 includes/WebAuthn/class-webauthn-key-migrator.php diff --git a/includes/WebAuthn/class-webauthn-handler.php b/includes/WebAuthn/class-webauthn-handler.php index 67f016c7..d301e60e 100644 --- a/includes/WebAuthn/class-webauthn-handler.php +++ b/includes/WebAuthn/class-webauthn-handler.php @@ -22,13 +22,13 @@ class WebAuthnHandler { 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? - */ + * 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)) { @@ -66,8 +66,7 @@ public function getLastError( string $realm = NULL ) { * 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) - { + public function prepareRegister($username, $userid, $crossPlatform = false) { $result = (object) array(); $rbchallenge = self::randomBytes(16); $result->challenge = self::stringToArray($rbchallenge); @@ -158,15 +157,23 @@ public function register( object $info ) { * 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 + * @param string $appid * @return boolean|object Object to pass to javascript webauthnAuthenticate or false on faliue */ - public function prepareAuthenticate( array $userKeys = array() ) + public function prepareAuthenticate( array $userKeys = array(), $appid = null ) { + if ( is_null( $appid ) ) { + $appid = $this->appid; + } $allowKeyDefaults = array( 'transports' => array( 'usb','nfc','ble','internal' ), 'type' => 'public-key', ); $allows = array(); + $userKeys = array_filter( $userKeys, function( $userKey ) use( $appid ) { + return $userKey->app_id !== $appid; + } ); + foreach ( $userKeys as $key) { if ( $this->isValidKey( $key ) ) { $allows[] = (object) ( array( @@ -233,7 +240,7 @@ public function authenticate( object $info, array $userKeys ) $ao->flags = ord( substr( $bs, 32, 1 ) ); $ao->counter = substr( $bs, 33, 4 ); - $hashId = hash( 'sha256', $this->appid, true ); + $hashId = hash( 'sha256', $key->app_id, true ); if ( $hashId !== $ao->rpIdHash ) { $this->last_error[ $this->last_call ] = 'key-response-decode-hash-mismatch'; @@ -266,14 +273,13 @@ public function authenticate( object $info, array $userKeys ) return $key; } else if ( 0 === $verify_result ) { $this->last_error[ $this->last_call ] = 'key-not-verfied'; - return false; + } else if ( false === $verify_result ) { + $this->last_error[ $this->last_call ] = openssl_error_string(); } - $this->last_error[ $this->last_call ] = openssl_error_string(); - return false; - } + } /** * Parse and validate Attestation object @@ -473,26 +479,31 @@ private function validateAuthenticateInfo( object $info ) { 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 + 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 ); + $origin_host = parse_url( $info->response->clientData->origin, PHP_URL_HOST ); + if ( strpos( $this->appid, 'https://' ) !== false ) { + $app_host = parse_url( $this->appid, PHP_URL_HOST ); + } else { + $app_host = $this->appid; + } - if ( strpos( $origin['host'], $this->appid ) !== ( strlen( $origin['host'] ) - strlen( $this->appid ) ) ) { + if ( strpos( $origin_host, $app_host ) !== ( strlen( $origin_host ) - strlen( $app_host ) ) ) { $this->last_error[ $this->last_call ] = 'info-origin-mismatch'; return false; - } + } return true; diff --git a/includes/WebAuthn/class-webauthn-key-migrator.php b/includes/WebAuthn/class-webauthn-key-migrator.php new file mode 100644 index 00000000..6f67fdbe --- /dev/null +++ b/includes/WebAuthn/class-webauthn-key-migrator.php @@ -0,0 +1,124 @@ + self::u2fKeyToCOSE( $u2f_key['publicKey'] ), + 'id' => array_map( 'ord', str_split( $id_str ) ), + 'label' => $u2f_key['name'], + 'md5id' => md5( $id_str ), + 'created' => $u2f_key['added'], + 'last_used' => $u2f_key['last_used'], + 'tested' => false, + 'app_id' => Two_Factor_FIDO_U2F::get_u2f_app_id(), // has trailing https:// + ); + + $keystore = WebAuthnKeyStore::instance(); + + return $keystore->create_key( $user_id, $webauthn_key ); + } + + /** + * @param int $user_id + * @param string $key_handle + * @return array + */ + private static function get_u2k_key_by_handle( $user_id, $key_handle ) { + + global $wpdb; + + $key_handle = wp_unslash( $key_handle ); + $key_handle = maybe_serialize( $key_handle ); + + $query = $wpdb->prepare( "SELECT umeta_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND user_id = %d", Two_Factor_FIDO_U2F::REGISTERED_KEY_USER_META_KEY, $user_id ); + + $key_handle_lookup = sprintf( ':"%s";s:', $key_handle ); + + $query .= $wpdb->prepare( + ' AND meta_value LIKE %s', + '%' . $wpdb->esc_like( $key_handle_lookup ) . '%' + ); + + $meta_id = $wpdb->get_var( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + if ( ! $meta_id ) { + return false; + } + $meta = get_metadata_by_mid( 'user', $meta_id ); + if ( false !== $meta ) { + return $meta->meta_value; + } + return false; + } + + /** + * @param string $key base64 encoded pubkey + * @return string|null COSE Key + */ + private static function u2fKeyToCOSE( $key ) { + + $binary = base64_decode(strtr($key, '-_', '+/'), true); + $x = substr( $binary, 1, 32 ); + $y = substr( $binary, 33, 32 ); + + $der = self::sequence( + self::sequence( + self::oid( "\x2A\x86\x48\xCE\x3D\x02\x01" ) . // OID 1.2.840.10045.2.1 ecPublicKey + self::oid( "\x2A\x86\x48\xCE\x3D\x03\x01\x07" ) + ) . + self::bitString( base64_decode(strtr($key, '-_', '+/'), true) ) + ); + + return '-----BEGIN PUBLIC KEY-----' . "\n" + . chunk_split(base64_encode($der), 64, "\n") + . '-----END PUBLIC KEY-----' . "\n"; + } + + private static function length(int $len): string + { + if ($len < 128) { + return \chr($len); + } + + $lenBytes = ''; + while ($len > 0) { + $lenBytes = \chr($len % 256) . $lenBytes; + $len = \intdiv($len, 256); + } + return \chr(0x80 | \strlen($lenBytes)) . $lenBytes; + } + + public static function sequence(string $contents): string + { + return "\x30" . self::length(\strlen($contents)) . $contents; + } + + public static function oid(string $encoded): string + { + return "\x06" . self::length(\strlen($encoded)) . $encoded; + } + + + public static function bitString(string $bytes): string + { + $len = \strlen($bytes) + 1; + + return "\x03" . self::length($len) . "\x00" . $bytes; + } + + +} diff --git a/providers/class-two-factor-fido-u2f-admin-list-table.php b/providers/class-two-factor-fido-u2f-admin-list-table.php index deb220cf..3734b3d4 100644 --- a/providers/class-two-factor-fido-u2f-admin-list-table.php +++ b/providers/class-two-factor-fido-u2f-admin-list-table.php @@ -67,6 +67,7 @@ protected function column_default( $item, $column_name ) { $actions = array( 'rename hide-if-no-js' => Two_Factor_FIDO_U2F_Admin::rename_link( $item ), + 'migrate' => Two_Factor_FIDO_U2F_Admin::migrate_link( $item ), 'delete' => Two_Factor_FIDO_U2F_Admin::delete_link( $item ), ); diff --git a/providers/class-two-factor-fido-u2f-admin.php b/providers/class-two-factor-fido-u2f-admin.php index 65ffddc1..1700ad1f 100644 --- a/providers/class-two-factor-fido-u2f-admin.php +++ b/providers/class-two-factor-fido-u2f-admin.php @@ -36,6 +36,8 @@ public static function add_hooks() { add_action( 'edit_user_profile_update', array( __CLASS__, 'catch_submission' ), 0 ); add_action( 'load-profile.php', array( __CLASS__, 'catch_delete_security_key' ) ); add_action( 'load-user-edit.php', array( __CLASS__, 'catch_delete_security_key' ) ); + add_action( 'load-profile.php', array( __CLASS__, 'catch_migrate_security_key' ) ); + add_action( 'load-user-edit.php', array( __CLASS__, 'catch_migrate_security_key' ) ); add_action( 'wp_ajax_inline-save-key', array( __CLASS__, 'wp_ajax_inline_save' ) ); } @@ -285,6 +287,39 @@ public static function catch_delete_security_key() { } } + + /** + * Catch the delete security key request. + * + * This executes during the `load-profile.php` & `load-user-edit.php` actions. + * + * @since 0.1-dev + * + * @access public + * @static + */ + public static function catch_migrate_security_key() { + $user_id = Two_Factor_Core::current_user_being_edited(); + + if ( ! empty( $user_id ) && ! empty( $_REQUEST['migrate_security_key'] ) ) { + + $u2f_key_slug = $_REQUEST['migrate_security_key']; + + check_admin_referer( "migrate_security_key-{$u2f_key_slug}", '_nonce_migrate_security_key' ); + + require_once TWO_FACTOR_DIR . 'includes/WebAuthn/class-webauthn-key-migrator.php'; + + $redirect = remove_query_arg( ['migrate_security_key', '_nonce_migrate_security_key' ], wp_get_referer() ); + + if ( false === $webauthn_key = WebAuthnKeyMigrator::migrate_key_for_user( $user_id, $u2f_key_slug ) ) { + $redirect = add_query_arg( 'u2f_migrate_error', '1', $redirect ); + } + wp_safe_redirect( $redirect ); + exit; + } + } + + /** * Generate a link to rename a specified security key. * @@ -317,6 +352,26 @@ public static function delete_link( $item ) { return sprintf( '%2$s', esc_url( $delete_link ), esc_html__( 'Delete', 'two-factor' ) ); } + /** + * Generate a link to delete a migrate security key. + * + * @since 0.8 + * + * @access public + * @static + * + * @param array $item The current item. + * @return string + */ + public static function migrate_link( $item ) { + $migrate_link = add_query_arg( 'migrate_security_key', $item->keyHandle ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $migrate_link = remove_query_arg( 'u2f_migrate_error', $migrate_link ); + $migrate_link = wp_nonce_url( $migrate_link, "migrate_security_key-{$item->keyHandle}", '_nonce_migrate_security_key' ); + return sprintf( '%2$s', esc_url( $migrate_link ), esc_html__( 'Migrate', 'two-factor' ) ); + } + + + /** * Ajax handler for quick edit saving for a security key. * diff --git a/providers/class-two-factor-webauthn.php b/providers/class-two-factor-webauthn.php index bcc8cf55..0c9045b5 100644 --- a/providers/class-two-factor-webauthn.php +++ b/providers/class-two-factor-webauthn.php @@ -105,8 +105,6 @@ protected function __construct() { * Enqueue assets for login form. */ public function login_enqueue_assets() { - wp_enqueue_script( 'webauthn-login' ); - wp_enqueue_style( 'webauthn-login' ); } /** @@ -119,11 +117,11 @@ public function get_app_id() { $url_parts = wp_parse_url( network_site_url() ); + // No port, no protocol, just the hostname. + // Everthing else will fail at the regsistration + // @see https://stackoverflow.com/a/70487895/1983694 $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. @@ -156,6 +154,7 @@ public function get_label() { public function authentication_page( $user ) { wp_enqueue_style( 'webauthn-login' ); + wp_enqueue_script( 'webauthn-login' ); require_once ABSPATH . '/wp-admin/includes/template.php'; @@ -172,7 +171,17 @@ public function authentication_page( $user ) { $keys = $this->key_store->get_keys( $user->ID ); - $auth_opts = $this->webauthn->prepareAuthenticate( $keys ); + $app_ids = array(); + + foreach ( $keys as $key ) { + $app_ids[] = $key->app_id; + } + $app_ids = array_unique( $app_ids ); + $auth_opts = array(); + + foreach ( $app_ids as $app_id ) { + $auth_opts[] = $this->webauthn->prepareAuthenticate( $keys, $app_id ); + } update_user_meta( $user->ID, self::LOGIN_USERMETA, 1 ); } catch ( Exception $e ) { @@ -187,7 +196,7 @@ public function authentication_page( $user ) { 'webauthnL10n', array( 'action' => 'webauthn-login', - 'payload' => $auth_opts, + 'apps' => $auth_opts, '_wpnonce' => wp_create_nonce( 'webauthn-login' ), ) ); @@ -399,6 +408,7 @@ public function ajax_register() { $key->created = time(); $key->last_used = false; $key->tested = false; + $key->app_id = $this->get_app_id(); if ( false !== $this->key_store->key_exists( $key->md5id ) ) { wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'Device already Exists', 'two-factor' ) ) ); @@ -412,6 +422,7 @@ public function ajax_register() { array( 'success' => false, 'error' => $err->getMessage(), + 'trace' => $err->getTraceAsString(), ) ); return; @@ -613,7 +624,7 @@ private function get_key_item( $pub_key, $user_id ) { wp_json_encode( array( 'action' => 'webauthn-test-key', - 'payload' => $this->webauthn->prepareAuthenticate( array( $pub_key ) ), + 'payload' => $this->webauthn->prepareAuthenticate( array( $pub_key ), $pub_key->app_id ), 'userId' => $user_id, '_wpnonce' => wp_create_nonce( 'webauthn-test-key' ), ) diff --git a/providers/js/webauthn-admin.js b/providers/js/webauthn-admin.js index b798fd88..27f2cfdb 100644 --- a/providers/js/webauthn-admin.js +++ b/providers/js/webauthn-admin.js @@ -80,8 +80,7 @@ ida.push( v ); } ); - cd = JSON.parse( String.fromCharCode.apply( null, - new Uint8Array( aAssertion.response.clientDataJSON ) ) ); + cd = JSON.parse( String.fromCharCode.apply( null, new Uint8Array( aAssertion.response.clientDataJSON ) ) ); cda = []; ( new Uint8Array( aAssertion.response.clientDataJSON ) ).forEach( function( v ) { diff --git a/providers/js/webauthn-login.js b/providers/js/webauthn-login.js index b2daf63d..01ae7385 100644 --- a/providers/js/webauthn-login.js +++ b/providers/js/webauthn-login.js @@ -67,23 +67,31 @@ const login = ( opts, callback ) => { - const { action, payload, _wpnonce } = opts; - - webauthnAuthenticate( payload, ( success, info ) => { - if ( success ) { - callback( { success:true, result: info } ); - } else { - callback( { success:false, message: info } ); - } - }); + const { action, apps, _wpnonce } = opts; + let app + + while ( apps.length ) { + app = apps.unshift() + webauthnAuthenticate( app, ( success, info ) => { + if ( success ) { + callback( { success:true, result: info } ); + } else { + callback( { success:false, message: info } ); + } + }); + } }; /** * Some Password Managers (like nextcloud passwords) seem to abort the * key browser dialog. - * We have to retry a couple of times to + * We have to retry a few times */ const auth = () => { + // return if we are not + if ( ! $( '.webauthn-retry' ).length ) { + return; + } $( '.webauthn-retry' ).removeClass( 'visible' ); login( window.webauthnL10n, response => { if ( response.success ) { From 213a6888ea94cfdb8ae8f7e0d5c7cfd630e82ead Mon Sep 17 00:00:00 2001 From: mcguffin Date: Thu, 3 Nov 2022 23:26:40 +0100 Subject: [PATCH 15/17] fix legacy + modern test --- includes/WebAuthn/class-webauthn-handler.php | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/includes/WebAuthn/class-webauthn-handler.php b/includes/WebAuthn/class-webauthn-handler.php index d301e60e..14ac3684 100644 --- a/includes/WebAuthn/class-webauthn-handler.php +++ b/includes/WebAuthn/class-webauthn-handler.php @@ -171,7 +171,7 @@ public function prepareAuthenticate( array $userKeys = array(), $appid = null ) ); $allows = array(); $userKeys = array_filter( $userKeys, function( $userKey ) use( $appid ) { - return $userKey->app_id !== $appid; + return $userKey->app_id === $appid; } ); foreach ( $userKeys as $key) { @@ -197,9 +197,18 @@ public function prepareAuthenticate( array $userKeys = array(), $appid = null ) $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 ); + + if ( false !== strpos($appid,'https://') ) { + // legacy AppIDs with full URL + $publickey->extensions = (object) array( + 'appid' => $appid, + ); + } else { + // propper AppID + $publickey->extensions = (object) array(); + // $publickey->extensions->txAuthSimple = 'Execute order 66'; + $publickey->rpId = str_replace('https://', '', $appid ); + } return $publickey; } From 938d6129881a3d0a25e20fe345d47e3d00a8efb2 Mon Sep 17 00:00:00 2001 From: mcguffin Date: Thu, 3 Nov 2022 23:45:03 +0100 Subject: [PATCH 16/17] fix login --- providers/js/webauthn-login.js | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/providers/js/webauthn-login.js b/providers/js/webauthn-login.js index 01ae7385..4909ae1e 100644 --- a/providers/js/webauthn-login.js +++ b/providers/js/webauthn-login.js @@ -1,4 +1,5 @@ ( function( $ ) { + let counter = 0 /** * Borrowed from https://github.com/davidearl/webauthn */ @@ -67,19 +68,15 @@ const login = ( opts, callback ) => { - const { action, apps, _wpnonce } = opts; - let app + const { action, payload, _wpnonce } = opts; - while ( apps.length ) { - app = apps.unshift() - webauthnAuthenticate( app, ( success, info ) => { - if ( success ) { - callback( { success:true, result: info } ); - } else { - callback( { success:false, message: info } ); - } - }); - } + webauthnAuthenticate( payload, ( success, info ) => { + if ( success ) { + callback( { success:true, result: info } ); + } else { + callback( { success:false, message: info } ); + } + }); }; /** @@ -93,12 +90,16 @@ return; } $( '.webauthn-retry' ).removeClass( 'visible' ); - login( window.webauthnL10n, response => { + + const { action, apps, _wpnonce } = window.webauthnL10n; + const payload = apps[ counter % apps.length ] + + login( { action, payload, _wpnonce }, response => { if ( response.success ) { $( '#webauthn_response' ).val( response.result ); $( '#loginform' ).submit(); } else { - + counter++ // Show retry-button $( '.webauthn-retry' ).addClass( 'visible' ); } From 3a2645ce71e0a90390b897eba17a30503103dabc Mon Sep 17 00:00:00 2001 From: mcguffin Date: Thu, 3 Nov 2022 23:58:06 +0100 Subject: [PATCH 17/17] formatting --- .../WebAuthn/class-webauthn-key-migrator.php | 65 +++++++++---------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/includes/WebAuthn/class-webauthn-key-migrator.php b/includes/WebAuthn/class-webauthn-key-migrator.php index 6f67fdbe..f2fe02ab 100644 --- a/includes/WebAuthn/class-webauthn-key-migrator.php +++ b/includes/WebAuthn/class-webauthn-key-migrator.php @@ -25,7 +25,7 @@ public static function migrate_key_for_user( $user_id, $u2f_key_handle ) { 'created' => $u2f_key['added'], 'last_used' => $u2f_key['last_used'], 'tested' => false, - 'app_id' => Two_Factor_FIDO_U2F::get_u2f_app_id(), // has trailing https:// + 'app_id' => Two_Factor_FIDO_U2F::get_u2f_app_id(), // legacy IDs have trailing https:// ); $keystore = WebAuthnKeyStore::instance(); @@ -76,49 +76,46 @@ private static function u2fKeyToCOSE( $key ) { $y = substr( $binary, 33, 32 ); $der = self::sequence( - self::sequence( - self::oid( "\x2A\x86\x48\xCE\x3D\x02\x01" ) . // OID 1.2.840.10045.2.1 ecPublicKey - self::oid( "\x2A\x86\x48\xCE\x3D\x03\x01\x07" ) - ) . - self::bitString( base64_decode(strtr($key, '-_', '+/'), true) ) - ); + self::sequence( + self::oid( "\x2A\x86\x48\xCE\x3D\x02\x01" ) . // OID 1.2.840.10045.2.1 ecPublicKey + self::oid( "\x2A\x86\x48\xCE\x3D\x03\x01\x07" ) + ) . + self::bitString( base64_decode(strtr($key, '-_', '+/'), true) ) + ); return '-----BEGIN PUBLIC KEY-----' . "\n" . chunk_split(base64_encode($der), 64, "\n") . '-----END PUBLIC KEY-----' . "\n"; } - private static function length(int $len): string - { - if ($len < 128) { - return \chr($len); - } - - $lenBytes = ''; - while ($len > 0) { - $lenBytes = \chr($len % 256) . $lenBytes; - $len = \intdiv($len, 256); - } - return \chr(0x80 | \strlen($lenBytes)) . $lenBytes; - } - - public static function sequence(string $contents): string - { - return "\x30" . self::length(\strlen($contents)) . $contents; - } + /** + * Adapted from https://github.com/madwizard-org/webauthn-server/blob/master/src/Crypto/Der.php + */ + private static function length(int $len): string { + if ($len < 128) { + return \chr($len); + } - public static function oid(string $encoded): string - { - return "\x06" . self::length(\strlen($encoded)) . $encoded; - } + $lenBytes = ''; + while ($len > 0) { + $lenBytes = \chr($len % 256) . $lenBytes; + $len = \intdiv($len, 256); + } + return \chr(0x80 | \strlen($lenBytes)) . $lenBytes; + } + public static function sequence(string $contents): string { + return "\x30" . self::length(\strlen($contents)) . $contents; + } - public static function bitString(string $bytes): string - { - $len = \strlen($bytes) + 1; + public static function oid(string $encoded): string { + return "\x06" . self::length(\strlen($encoded)) . $encoded; + } - return "\x03" . self::length($len) . "\x00" . $bytes; - } + public static function bitString(string $bytes): string { + $len = \strlen($bytes) + 1; + return "\x03" . self::length($len) . "\x00" . $bytes; + } }