assets
1 day ago
format
1 day ago
helpers
1 day ago
class-ajax-passkeys.php
1 day ago
class-api-register.php
1 day ago
class-api-signin.php
1 day ago
class-attestation-object.php
1 day ago
class-authenticate-server.php
1 day ago
class-authenticator-data.php
1 day ago
class-byte-buffer.php
1 day ago
class-chor-decoder.php
1 day ago
class-passkeys-endpoints.php
1 day ago
class-passkeys-user-profile.php
1 day ago
class-passkeys-wizard-steps.php
1 day ago
class-passkeys.php
1 day ago
class-pending-2fa-helper.php
1 day ago
class-source-repository.php
1 day ago
class-web-authn-exception.php
1 day ago
class-web-authn.php
1 day ago
index.php
1 day ago
class-source-repository.php
293 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Responsible for the register API endpoints |
| 4 | * |
| 5 | * @package wp-2fa |
| 6 | * @since 3.0.0 |
| 7 | * @copyright 2026 Melapress |
| 8 | * @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0 |
| 9 | * @link https://wordpress.org/plugins/wp-2fa/ |
| 10 | */ |
| 11 | |
| 12 | declare(strict_types=1); |
| 13 | |
| 14 | namespace WP2FA\Passkeys; |
| 15 | |
| 16 | use WP2FA\Methods\Passkeys\Web_Authn; |
| 17 | |
| 18 | defined( 'ABSPATH' ) || exit; // Exit if accessed directly. |
| 19 | |
| 20 | /** |
| 21 | * Public Key Credential Source Repository. |
| 22 | */ |
| 23 | class Source_Repository { |
| 24 | |
| 25 | /** |
| 26 | * Meta key prefix. |
| 27 | */ |
| 28 | public const PASSKEYS_META = WP_2FA_PREFIX . 'passkey_'; |
| 29 | |
| 30 | /** |
| 31 | * Find a credential source by its credential ID. |
| 32 | * |
| 33 | * @param string $public_key_credential_id The credential ID to find. |
| 34 | * |
| 35 | * @return null|bool The credential source, if found. |
| 36 | * |
| 37 | * @since 3.0.0 |
| 38 | */ |
| 39 | public static function find_one_by_credential_id( string $public_key_credential_id ) { |
| 40 | global $wpdb; |
| 41 | |
| 42 | // Sanitize incoming id and build meta key. |
| 43 | $public_key_credential_id = sanitize_text_field( $public_key_credential_id ); |
| 44 | $meta_key = self::PASSKEYS_META . $public_key_credential_id; |
| 45 | $row = $wpdb->get_row( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s", $meta_key ) ); |
| 46 | |
| 47 | if ( ! $row || empty( $row->meta_value ) ) { |
| 48 | return null; |
| 49 | } |
| 50 | |
| 51 | try { |
| 52 | $data = json_decode( (string) $row->meta_value, true, 512, JSON_THROW_ON_ERROR ); |
| 53 | } catch ( \JsonException $e ) { |
| 54 | return null; |
| 55 | } |
| 56 | |
| 57 | return $data; |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Find all credential sources for a given user entity. |
| 62 | * |
| 63 | * @param \WP_User $user - The user entity to find credential sources for. |
| 64 | * |
| 65 | * @return PublicKeyCredentialSource[] The credential sources, if found. |
| 66 | * |
| 67 | * @throws \Exception If the user is not found. |
| 68 | * |
| 69 | * @since 3.0.0 |
| 70 | */ |
| 71 | public static function find_all_for_user( \WP_User $user ): array { |
| 72 | |
| 73 | global $wpdb; |
| 74 | |
| 75 | // @free:start |
| 76 | $limit = ' LIMIT 1'; |
| 77 | // @free:end |
| 78 | |
| 79 | |
| 80 | // Use esc_like to build a safe LIKE pattern for meta_key search. |
| 81 | $like_pattern = $wpdb->esc_like( self::PASSKEYS_META ) . '%'; |
| 82 | $public_keys = $wpdb->get_results( |
| 83 | $wpdb->prepare( |
| 84 | "SELECT meta_value FROM {$wpdb->usermeta} WHERE meta_key LIKE %s AND user_id = %d ", |
| 85 | $like_pattern, |
| 86 | $user->ID, |
| 87 | ) . $limit |
| 88 | ); |
| 89 | |
| 90 | if ( ! $public_keys ) { |
| 91 | return array(); |
| 92 | } |
| 93 | |
| 94 | $public_keys = array_map( |
| 95 | function ( $public_key ) { |
| 96 | return json_decode( $public_key->meta_value, true ); |
| 97 | }, |
| 98 | $public_keys |
| 99 | ); |
| 100 | |
| 101 | // Removes null values. |
| 102 | $public_keys = array_filter( $public_keys ); |
| 103 | |
| 104 | return array_map( |
| 105 | function ( $public_key ) { |
| 106 | return self::create_from_array( $public_key ); |
| 107 | }, |
| 108 | $public_keys |
| 109 | ); |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * Prepares the raw array from DB for displaying. |
| 114 | * |
| 115 | * @param array $array - The array with raw values, must contain 'extra' key. |
| 116 | * |
| 117 | * @return array |
| 118 | * |
| 119 | * @since 3.0.0 |
| 120 | */ |
| 121 | private static function create_from_array( array $array ): array { |
| 122 | $ret_arr = array(); |
| 123 | if ( isset( $array['extra'] ) ) { |
| 124 | |
| 125 | $ret_arr['name'] = $array['extra']['name'] ?? ''; |
| 126 | $ret_arr['aaguid'] = $array['extra']['aaguid'] ?? ''; |
| 127 | $ret_arr['created'] = $array['extra']['created'] ?? ''; |
| 128 | $ret_arr['last_used'] = $array['extra']['last_used'] ?? ''; |
| 129 | $ret_arr['enabled'] = $array['extra']['enabled'] ?? ''; |
| 130 | $ret_arr['revoked'] = $array['extra']['revoked'] ?? false; |
| 131 | $ret_arr['credential_id'] = $array['extra']['credential_id'] ?? ''; |
| 132 | |
| 133 | } |
| 134 | |
| 135 | return $ret_arr; |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * Save a new credential source. |
| 140 | * |
| 141 | * @param \WP_USer $user - The WP user to store the data to. |
| 142 | * @param string[] $extra_data -Extra data to store. |
| 143 | * |
| 144 | * @return void |
| 145 | * |
| 146 | * @throws \Exception If the user is not found. |
| 147 | * |
| 148 | * @since 3.0.0 |
| 149 | */ |
| 150 | public static function save_credential_source( $user, array $extra_data = array() ): void { |
| 151 | |
| 152 | if ( ! $user instanceof \WP_User ) { |
| 153 | throw new \Exception( 'User not found.', 400 ); |
| 154 | } |
| 155 | |
| 156 | // Extra data to store. Sanitize known scalar values; allow binary/public_key fields untouched. |
| 157 | $public_key = array( 'extra' => array() ); |
| 158 | foreach ( $extra_data as $key => $value ) { |
| 159 | if ( in_array( $key, array( 'public_key', 'credential_id', 'transports', 'aaguid' ), true ) ) { |
| 160 | $public_key['extra'][ $key ] = $value; |
| 161 | } else { |
| 162 | $public_key['extra'][ $key ] = is_scalar( $value ) ? sanitize_text_field( (string) $value ) : $value; |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // Store the public key credential source. And need to add extra slashes to escape the slashes in the JSON. |
| 167 | $public_key_json = addcslashes( \wp_json_encode( $public_key, JSON_UNESCAPED_SLASHES ), '\\' ); |
| 168 | |
| 169 | if ( ! class_exists( 'ParagonIE_Sodium_Core_Base64_UrlSafe', false ) ) { |
| 170 | require_once ABSPATH . WPINC . '/sodium_compat/src/Core/Base64/UrlSafe.php'; |
| 171 | require_once ABSPATH . WPINC . '/sodium_compat/src/Core/Util.php'; |
| 172 | } |
| 173 | |
| 174 | // Credential id should exist; sanitize minimally (it's used for encoded meta key). |
| 175 | $cred_id = isset( $extra_data['credential_id'] ) ? sanitize_text_field( (string) $extra_data['credential_id'] ) : ''; |
| 176 | $meta_key = self::PASSKEYS_META . \ParagonIE_Sodium_Core_Base64_UrlSafe::encodeUnpadded( $cred_id ); |
| 177 | |
| 178 | \update_user_meta( $user->ID, $meta_key, $public_key_json ); |
| 179 | } |
| 180 | |
| 181 | /** |
| 182 | * Remove not safe characters from a given string. |
| 183 | * |
| 184 | * @param string $encoded_string - The string to sanitize. |
| 185 | * |
| 186 | * @return string |
| 187 | * |
| 188 | * @throws \InvalidArgumentException - Exception thrown if an argument is not of the expected type. |
| 189 | * |
| 190 | * @since 3.0.0 |
| 191 | */ |
| 192 | private static function decode_no_padding( string $encoded_string ): string { |
| 193 | $src_len = self::safe_str_len( $encoded_string ); |
| 194 | if ( 0 === $src_len ) { |
| 195 | return ''; |
| 196 | } |
| 197 | if ( ( $src_len & 3 ) === 0 ) { |
| 198 | if ( '=' === $encoded_string[ $src_len - 1 ] ) { |
| 199 | throw new \InvalidArgumentException( |
| 200 | "decodeNoPadding() doesn't tolerate padding" |
| 201 | ); |
| 202 | } |
| 203 | if ( ( $src_len & 3 ) > 1 ) { |
| 204 | if ( '=' === $encoded_string[ $src_len - 2 ] ) { |
| 205 | throw new \InvalidArgumentException( |
| 206 | "decodeNoPadding() doesn't tolerate padding" |
| 207 | ); |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | if ( ! class_exists( 'ParagonIE_Sodium_Core_Base64_UrlSafe', false ) ) { |
| 213 | require_once ABSPATH . WPINC . '/sodium_compat/src/Core/Base64/UrlSafe.php'; |
| 214 | require_once ABSPATH . WPINC . '/sodium_compat/src/Core/Util.php'; |
| 215 | } |
| 216 | |
| 217 | return \ParagonIE_Sodium_Core_Base64_UrlSafe::decode( |
| 218 | $encoded_string, |
| 219 | true |
| 220 | ); |
| 221 | } |
| 222 | |
| 223 | /** |
| 224 | * Decode from base64 into binary |
| 225 | * |
| 226 | * Base64 character set "./[A-Z][a-z][0-9]" |
| 227 | * |
| 228 | * @param string $src - The source string. |
| 229 | * @param bool $strict_padding - Should use strict padding or not?. |
| 230 | * |
| 231 | * @return string |
| 232 | * |
| 233 | * @since 3.0.0 |
| 234 | */ |
| 235 | public static function base64_url_safe( $src, ?bool $strict_padding = true ) { |
| 236 | return \ParagonIE_Sodium_Core_Base64_UrlSafe::decode( |
| 237 | $src, |
| 238 | $strict_padding |
| 239 | ); |
| 240 | } |
| 241 | |
| 242 | /** |
| 243 | * Safe string length |
| 244 | * |
| 245 | * @ref mbstring.func_overload |
| 246 | * |
| 247 | * @param string $str - String to be encoded and prepared. |
| 248 | * |
| 249 | * @return int |
| 250 | */ |
| 251 | private static function safe_str_len( string $str ): int { |
| 252 | if ( \function_exists( 'mb_strlen' ) ) { |
| 253 | // mb_strlen in PHP 7.x can return false. |
| 254 | |
| 255 | return (int) \mb_strlen( $str, '8bit' ); |
| 256 | } else { |
| 257 | return \strlen( $str ); |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * Delete a credential source. |
| 263 | * |
| 264 | * @param string $public_key_credential_source - The credential source to delete. |
| 265 | * @param \WP_user $user - The user which credential needs to be removed. |
| 266 | * |
| 267 | * @return void |
| 268 | * |
| 269 | * @throws \Exception If the user is not found. |
| 270 | * |
| 271 | * @since 3.0.0 |
| 272 | */ |
| 273 | public static function delete_credential_source( $public_key_credential_source, $user ) { |
| 274 | |
| 275 | if ( ! $user instanceof \WP_User ) { |
| 276 | throw new \Exception( 'User not found.', 404 ); |
| 277 | } |
| 278 | |
| 279 | // Only the passkey owner can revoke their own passkey. |
| 280 | $current = \get_current_user_id(); |
| 281 | if ( $current !== $user->ID && ! current_user_can( 'edit_user', $user->ID ) ) { |
| 282 | throw new \Exception( 'User not found or insufficient permissions.', 404 ); |
| 283 | } |
| 284 | |
| 285 | $meta_key = self::PASSKEYS_META . $public_key_credential_source; |
| 286 | $is_success = \delete_user_meta( $user->ID, $meta_key ); |
| 287 | |
| 288 | if ( ! $is_success ) { |
| 289 | throw new \Exception( 'Unable to delete credential source.', 500 ); |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 |