PluginProbe ʕ •ᴥ•ʔ
WP 2FA – Two-factor authentication for WordPress / 4.0.0
WP 2FA – Two-factor authentication for WordPress v4.0.0
4.0.0 1.7.1 2.0.0 2.0.1 2.1.0 2.2.0 2.2.1 2.3.0 2.4.0 2.4.1 2.4.2 2.5.0 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.7.0 2.8.0 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0 3.0.1 3.1.0 3.1.1 3.1.1.2 trunk 1.2.0 1.3.0 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.2 1.6.0 1.6.1 1.6.2 1.7.0
wp-2fa / includes / classes / Admin / Methods / passkeys / class-source-repository.php
wp-2fa / includes / classes / Admin / Methods / passkeys Last commit date
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