ID, base64_encode( $challenge ), self::CHALLENGE_TTL ); // Exclude already-registered credential IDs. $exclude = array_map( fn( $c ) => array( 'type' => 'public-key', 'id' => $c['id'], ), self::get_passkeys( $user->ID ) ); wp_send_json_success( array( 'challenge' => self::b64u( $challenge ), 'rp' => array( 'name' => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), 'id' => self::rp_id(), ), 'user' => array( 'id' => self::b64u( (string) $user->ID ), 'name' => $user->user_login, 'displayName' => $user->display_name, ), 'pubKeyCredParams' => array( array( 'type' => 'public-key', 'alg' => -7, ), // ES256 / P-256 array( 'type' => 'public-key', 'alg' => -257, ), // RS256 ), 'timeout' => 60000, 'attestation' => 'none', 'excludeCredentials' => $exclude, 'authenticatorSelection' => array( 'residentKey' => 'preferred', 'userVerification' => 'required', ), ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } // ── AJAX: Registration finish ──────────────────────────────────────────── public static function ajax_register_finish(): void { check_ajax_referer( self::NONCE_ACTION, 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( 'Unauthorized.', 401 ); } try { $user_id = get_current_user_id(); $stored_b64 = get_transient( self::CHALLENGE_PREFIX . $user_id ); if ( ! $stored_b64 ) { wp_send_json_error( __( 'Registration timed out, please try again.', 'cloudscale-cyber-devtools' ) ); } delete_transient( self::CHALLENGE_PREFIX . $user_id ); $expected_ch = base64_decode( $stored_b64 ); $cred_name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : 'My Passkey'; $cdj_b64u = isset( $_POST['clientDataJSON'] ) ? sanitize_text_field( wp_unslash( $_POST['clientDataJSON'] ) ) : ''; $atto_b64u = isset( $_POST['attestationObject'] ) ? sanitize_text_field( wp_unslash( $_POST['attestationObject'] ) ) : ''; $cred_id_b64u = isset( $_POST['credentialId'] ) ? sanitize_text_field( wp_unslash( $_POST['credentialId'] ) ) : ''; if ( ! $cdj_b64u || ! $atto_b64u || ! $cred_id_b64u ) { wp_send_json_error( __( 'Incomplete registration data.', 'cloudscale-cyber-devtools' ) ); } // Validate clientDataJSON. $cdj = json_decode( self::b64u_decode( $cdj_b64u ), true ); $err = self::check_client_data( $cdj, 'webauthn.create', $expected_ch ); if ( $err ) { wp_send_json_error( $err ); } // Parse attestationObject. try { $att = CSDT_DevTools_CBOR::decode( self::b64u_decode( $atto_b64u ) ); } catch ( \Exception $e ) { wp_send_json_error( __( 'Attestation parse error.', 'cloudscale-cyber-devtools' ) ); } $auth_data = $att['authData'] ?? ''; if ( strlen( $auth_data ) < 37 ) { wp_send_json_error( __( 'AuthData too short.', 'cloudscale-cyber-devtools' ) ); } $parsed = self::parse_auth_data( $auth_data ); if ( ! hash_equals( hash( 'sha256', self::rp_id(), true ), $parsed['rp_id_hash'] ) ) { wp_send_json_error( __( 'RP ID mismatch.', 'cloudscale-cyber-devtools' ) ); } // UV (0x04) as well as UP (0x01). Requesting userVerification is only a request; // this bit is the authenticator's answer, and until it is read the request is // decoration, the passkey then proves possession of the device and nothing more. // Enforced at registration too, so a credential that cannot verify its owner never // gets stored and then fails confusingly at every later login. if ( ! ( $parsed['flags'] & 0x04 ) ) { wp_send_json_error( __( 'That passkey did not verify you. Use one with Face ID, Touch ID, Windows Hello or a PIN.', 'cloudscale-cyber-devtools' ) ); } if ( ! ( $parsed['flags'] & 0x01 ) ) { wp_send_json_error( __( 'User presence not confirmed.', 'cloudscale-cyber-devtools' ) ); } if ( ! ( $parsed['flags'] & 0x40 ) || empty( $parsed['cred_id'] ) || empty( $parsed['cose_key'] ) ) { wp_send_json_error( __( 'Missing credential data in attestation.', 'cloudscale-cyber-devtools' ) ); } $pem = self::cose_to_pem( $parsed['cose_key'] ); if ( ! $pem ) { wp_send_json_error( __( 'Unsupported key type. Use a P-256 or RSA passkey.', 'cloudscale-cyber-devtools' ) ); } $cred_id = self::b64u( $parsed['cred_id'] ); $credentials = self::get_passkeys( $user_id ); foreach ( $credentials as $c ) { if ( $c['id'] === $cred_id ) { wp_send_json_error( __( 'This passkey is already registered.', 'cloudscale-cyber-devtools' ) ); } } $user = get_userdata( $user_id ); $alg = (int) ( $parsed['cose_key'][3] ?? -7 ); $credentials[] = array( 'id' => $cred_id, 'pem' => $pem, 'alg' => $alg, 'sign_count' => $parsed['sign_count'], 'name' => $cred_name ?: 'Passkey', 'wp_user_login' => $user ? $user->user_login : '', 'created' => time(), ); self::save_passkeys( $user_id, $credentials ); wp_send_json_success( array( 'message' => __( 'Passkey registered successfully!', 'cloudscale-cyber-devtools' ), 'id' => $cred_id, 'name' => $cred_name ?: 'Passkey', ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } // ── AJAX: Delete ───────────────────────────────────────────────────────── public static function ajax_delete(): void { check_ajax_referer( self::NONCE_ACTION, 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( 'Unauthorized.', 401 ); } try { $cred_id = isset( $_POST['cred_id'] ) ? sanitize_text_field( wp_unslash( $_POST['cred_id'] ) ) : ''; if ( ! $cred_id ) { wp_send_json_error( 'Missing credential ID.' ); } $user_id = get_current_user_id(); $credentials = array_values( array_filter( self::get_passkeys( $user_id ), fn( $c ) => $c['id'] !== $cred_id ) ); self::save_passkeys( $user_id, $credentials ); wp_send_json_success( array( 'message' => 'Removed.' ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } // ── AJAX: Test passkey (admin panel) ──────────────────────────────────────── /** Returns a WebAuthn challenge so the admin can test a stored passkey without logging out. */ public static function ajax_test_start(): void { check_ajax_referer( self::NONCE_ACTION, 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( 'Unauthorized.', 401 ); } try { $user_id = get_current_user_id(); $credentials = self::get_passkeys( $user_id ); if ( empty( $credentials ) ) { wp_send_json_error( 'No passkeys registered.' ); } $challenge = random_bytes( 32 ); set_transient( self::CHALLENGE_PREFIX . 'test_' . $user_id, base64_encode( $challenge ), self::CHALLENGE_TTL ); wp_send_json_success( array( 'challenge' => self::b64u( $challenge ), 'rpId' => self::rp_id(), 'timeout' => 60000, 'userVerification' => 'required', 'allowCredentials' => array(), ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } /** Verifies the assertion from the test flow. Returns success/error message. */ public static function ajax_test_finish(): void { check_ajax_referer( self::NONCE_ACTION, 'nonce' ); if ( ! is_user_logged_in() ) { wp_send_json_error( 'Unauthorized.', 401 ); } try { $user_id = get_current_user_id(); $suffix = 'test_' . $user_id; $stored = get_transient( self::CHALLENGE_PREFIX . $suffix ); if ( ! $stored ) { wp_send_json_error( 'Challenge expired, try again.' ); } $assertion = array( 'csdt_devtools_pk_cred_id' => isset( $_POST['csdt_devtools_pk_cred_id'] ) ? sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_cred_id'] ) ) : '', 'csdt_devtools_pk_client_data' => isset( $_POST['csdt_devtools_pk_client_data'] ) ? sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_client_data'] ) ) : '', 'csdt_devtools_pk_auth_data' => isset( $_POST['csdt_devtools_pk_auth_data'] ) ? sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_auth_data'] ) ) : '', 'csdt_devtools_pk_signature' => isset( $_POST['csdt_devtools_pk_signature'] ) ? sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_signature'] ) ) : '', ); $result = self::do_verify_assertion( $user_id, $suffix, $assertion ); if ( $result === true ) { wp_send_json_success( 'Passkey verified successfully! ✅ Your passkey is working correctly.' ); } wp_send_json_error( CSDT_Error_Text::in_seconds( $result->get_error_message() ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } // ── Step-up verification ───────────────────────────────────────────────── // // Same ceremony as the passkey test, with a different purpose and its own challenge, so a // "test my passkey" click can never be replayed as authorisation to weaken a login control. // A success is stamped against the CURRENT session for a few minutes; see // CSDT_Login::mark_stepup() and gate_login_security_write(). /** Issues a step-up challenge. User verification is required, not merely preferred. */ public static function ajax_stepup_start(): void { check_ajax_referer( self::NONCE_ACTION, 'nonce' ); if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized.', 401 ); } try { $user_id = get_current_user_id(); $credentials = self::get_passkeys( $user_id ); if ( empty( $credentials ) ) { wp_send_json_error( 'No passkeys registered for this account.' ); } $challenge = random_bytes( 32 ); set_transient( self::CHALLENGE_PREFIX . 'stepup_' . $user_id, base64_encode( $challenge ), self::CHALLENGE_TTL ); wp_send_json_success( array( 'challenge' => self::b64u( $challenge ), 'rpId' => self::rp_id(), 'timeout' => 60000, // Required, not preferred: the point is to prove the person is present, so a // silent assertion from a stolen resident credential is not enough. 'userVerification' => 'required', 'allowCredentials' => array(), ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } /** Verifies the step-up assertion and stamps the session. */ public static function ajax_stepup_finish(): void { check_ajax_referer( self::NONCE_ACTION, 'nonce' ); if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized.', 401 ); } try { $user_id = get_current_user_id(); $suffix = 'stepup_' . $user_id; if ( ! get_transient( self::CHALLENGE_PREFIX . $suffix ) ) { wp_send_json_error( 'Challenge expired, try again.' ); } $assertion = array( 'csdt_devtools_pk_cred_id' => isset( $_POST['csdt_devtools_pk_cred_id'] ) ? sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_cred_id'] ) ) : '', 'csdt_devtools_pk_client_data' => isset( $_POST['csdt_devtools_pk_client_data'] ) ? sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_client_data'] ) ) : '', 'csdt_devtools_pk_auth_data' => isset( $_POST['csdt_devtools_pk_auth_data'] ) ? sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_auth_data'] ) ) : '', 'csdt_devtools_pk_signature' => isset( $_POST['csdt_devtools_pk_signature'] ) ? sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_signature'] ) ) : '', ); $result = self::do_verify_assertion( $user_id, $suffix, $assertion ); if ( true === $result ) { CSDT_Login::mark_stepup( $user_id ); wp_send_json_success( array( 'verified' => true, 'window' => CSDT_Login::STEPUP_WINDOW, ) ); } wp_send_json_error( CSDT_Error_Text::in_seconds( $result->get_error_message() ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } // ── Ceremony API for other plugins ─────────────────────────────────────── // // The CI access token used to be minted here directly, with its own challenge suffix and // its own ajax_ci_start/ajax_ci_finish pair. That code moved to the cloudscale-test-accounts // companion plugin (see docs/wporg-justifications.md, WordPress.org flagged the session- // minting endpoint twice, and removing it from the distributed plugin is the only change // that removes the finding rather than arguing it). Login and account-creation stay out of // the distributed plugin; PASSKEY VERIFICATION does not have to, and duplicating the CBOR/ // COSE/signature code in a second plugin would be a second copy to keep in sync with every // WebAuthn spec nuance this file already handles. So this is the seam: any plugin that has // its own reason to run a WebAuthn ceremony gets a challenge and a verifier, not a copy of // the crypto. // // Each purpose gets its own challenge namespace, for the same reason step-up and the old // CI ceremony each had their own: a captured assertion for one purpose must never be // replayable as authorisation for another. /** * Issues a challenge for a ceremony this class does not know the purpose of. * * @param string $purpose Short, caller-chosen string unique to what the ceremony authorises, * e.g. 'ci_token_mint'. Namespaces the challenge so it cannot be * redeemed for anything else. * @return array{ok:true,challenge:array}|array{ok:false,error:string} */ public static function issue_ceremony_challenge( string $purpose ): array { if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) { return array( 'ok' => false, 'error' => 'Unauthorized.', ); } $user_id = get_current_user_id(); if ( empty( self::get_passkeys( $user_id ) ) ) { return array( 'ok' => false, 'error' => 'No passkeys registered for this account.', ); } $challenge = random_bytes( 32 ); set_transient( self::ceremony_key( $purpose, $user_id ), base64_encode( $challenge ), self::CHALLENGE_TTL ); return array( 'ok' => true, 'challenge' => array( 'challenge' => self::b64u( $challenge ), 'rpId' => self::rp_id(), 'timeout' => 60000, // Required, not preferred, for every ceremony this class issues, the point is // to prove the person is present, not merely that a device was tapped. 'userVerification' => 'required', 'allowCredentials' => array(), ), ); } /** * Verifies the assertion for a challenge issued by issue_ceremony_challenge(), reading the * same four WebAuthn POST fields every ceremony in this file expects * (csdt_devtools_pk_cred_id / _client_data / _auth_data / _signature). The caller must have * already run check_ajax_referer(), this does not repeat that check, only the passkey one. * * @param string $purpose Must match what was passed to issue_ceremony_challenge(). * @return true|\WP_Error */ public static function verify_ceremony( string $purpose, array $post ) { if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'unauthorized', 'Unauthorized.' ); } $user_id = get_current_user_id(); if ( ! get_transient( self::ceremony_key( $purpose, $user_id ) ) ) { return new \WP_Error( 'expired', __( 'Challenge expired, please try again.', 'cloudscale-cyber-devtools' ) ); } return self::do_verify_assertion( $user_id, self::ceremony_suffix( $purpose, $user_id ), $post ); } /** * The credential id (base64url, matching get_passkeys()' 'id' field) from the current * ceremony's POST fields, or '' if absent. Exposed so a caller can bind whatever it issues * to the specific passkey that authorised it, the way mint_ci_token() does. */ public static function ceremony_credential_id( array $post ): string { $raw = self::assertion_field( $post, 'csdt_devtools_pk_cred_id' ); return '' !== $raw ? self::b64u( self::b64u_decode( $raw ) ) : ''; } /** The transient suffix do_verify_assertion() expects, without the CHALLENGE_PREFIX. */ private static function ceremony_suffix( string $purpose, int $user_id ): string { return 'ext_' . sanitize_key( $purpose ) . '_' . $user_id; } /** The full transient key for a ceremony challenge. */ private static function ceremony_key( string $purpose, int $user_id ): string { return self::CHALLENGE_PREFIX . self::ceremony_suffix( $purpose, $user_id ); } // ── Login challenge page ───────────────────────────────────────────────── /** * Renders the passkey authentication page at login. * Auto-triggers navigator.credentials.get() and submits a form on success. * * @param string $token Pending 2FA token (shared with email/TOTP flow). * @param int $user_id User to authenticate. * @return void Always exits. */ public static function render_login_challenge( string $token, int $user_id, string $error = '', array $available = array() ): void { $credentials = self::get_passkeys( $user_id ); if ( empty( $credentials ) ) { wp_safe_redirect( wp_login_url() ); exit; } $challenge = random_bytes( 32 ); set_transient( self::CHALLENGE_PREFIX . 'login_' . $token, base64_encode( $challenge ), self::CHALLENGE_TTL ); // Use discoverable credentials (empty allowCredentials) so any synced passkey // for this rpId is accepted, avoids credential ID mismatch across devices. $opts = array( 'challenge' => self::b64u( $challenge ), 'rpId' => self::rp_id(), 'timeout' => 60000, 'userVerification' => 'required', 'allowCredentials' => array(), ); $nonce = wp_create_nonce( 'csdt_devtools_pk_login_' . $token ); $has_picker = count( $available ) > 1; $has_totp = in_array( 'totp', $available, true ); $fallback_url = add_query_arg( array( 'action' => 'csdt_devtools_2fa', 'csdt_devtools_token' => rawurlencode( $token ), ), wp_login_url() ); $picker_url = $has_picker ? add_query_arg( array( 'action' => 'csdt_devtools_2fa', 'csdt_devtools_token' => rawurlencode( $token ), 'csdt_devtools_back_to_picker' => '1', ), wp_login_url() ) : ''; login_header( __( 'Passkey Authentication', 'cloudscale-cyber-devtools' ), '', null ); ?>

←

←

__( 'Verified, signing you in…', 'cloudscale-cyber-devtools' ), 'cancelled' => __( 'Passkey cancelled, choose another way to sign in.', 'cloudscale-cyber-devtools' ), 'failed' => __( 'Passkey authentication failed.', 'cloudscale-cyber-devtools' ), ); wp_enqueue_script( 'csdt-passkey-challenge', plugins_url( 'assets/cs-passkey-challenge.js', dirname( __DIR__ ) . '/cloudscale-cyber-devtools.php' ), array(), CSDT_DevTools::VERSION, true ); wp_add_inline_script( 'csdt-passkey-challenge', 'window.csdtPkChallenge = ' . wp_json_encode( $pk_payload ) . ';', 'before' ); login_footer(); exit; } // ── Login assertion verification ───────────────────────────────────────── /** * Verifies a passkey assertion submitted via login form POST. * Called from CSDT_DevTools::login_2fa_handle(). * * @param string $token Pending 2FA token. * @param int $user_id Expected user ID. * @return true|\WP_Error */ public static function verify_login_assertion( string $token, int $user_id ) { // The sanitiser is on the wp_verify_nonce() line itself. It was already applied, one // line below the call, and round 23 reported it anyway: wp_verify_nonce() is pluggable, // so WordPress.org asks for the value to be unslashed and sanitised where it is passed. if ( ! isset( $_POST['csdt_devtools_pk_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_nonce'] ) ), 'csdt_devtools_pk_login_' . $token ) ) { return new \WP_Error( 'nonce', __( 'Security check failed.', 'cloudscale-cyber-devtools' ) ); } $assertion = array( 'csdt_devtools_pk_cred_id' => isset( $_POST['csdt_devtools_pk_cred_id'] ) ? sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_cred_id'] ) ) : '', 'csdt_devtools_pk_client_data' => isset( $_POST['csdt_devtools_pk_client_data'] ) ? sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_client_data'] ) ) : '', 'csdt_devtools_pk_auth_data' => isset( $_POST['csdt_devtools_pk_auth_data'] ) ? sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_auth_data'] ) ) : '', 'csdt_devtools_pk_signature' => isset( $_POST['csdt_devtools_pk_signature'] ) ? sanitize_text_field( wp_unslash( $_POST['csdt_devtools_pk_signature'] ) ) : '', ); return self::do_verify_assertion( $user_id, 'login_' . $token, $assertion ); } // ── Admin section HTML ─────────────────────────────────────────────────── /** * Renders the Passkeys section for the Login Security admin tab. * * @param int $user_id Logged-in admin user. */ public static function render_section( int $user_id ): void { $passkeys = self::get_passkeys( $user_id ); $count = count( $passkeys ); $owner = get_userdata( $user_id ); $owner_login = $owner ? $owner->user_login : ''; ?>
🔑
0 ) : ?>
0 ) : ?>
🔑
substr( $b, 0, 32 ), 'flags' => ord( $b[32] ), 'sign_count' => unpack( 'N', substr( $b, 33, 4 ) )[1], ); if ( ( $r['flags'] & 0x40 ) && strlen( $b ) > 55 ) { $cid_len = unpack( 'n', substr( $b, 53, 2 ) )[1]; $r['cred_id'] = substr( $b, 55, $cid_len ); $cose_raw = substr( $b, 55 + $cid_len ); try { $r['cose_key'] = CSDT_DevTools_CBOR::decode( $cose_raw ); } catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- intentional: leave cose_key unset if CBOR decode fails /* leave unset */ } } return $r; } /** * Converts a CBOR-decoded COSE public key map to a base64-encoded DER string. * Stored without PEM headers/newlines to avoid WordPress meta storage corruption. * Use pem_from_stored() to convert back to a PEM before passing to OpenSSL. */ private static function cose_to_pem( array $cose ): ?string { $kty = $cose[1] ?? null; if ( $kty === 2 ) { $x = $cose[-2] ?? null; $y = $cose[-3] ?? null; if ( ! $x || ! $y || strlen( $x ) !== 32 || strlen( $y ) !== 32 ) { return null; } return base64_encode( self::ec_p256_spki( "\x04" . $x . $y ) ); } if ( $kty === 3 ) { $n = $cose[-1] ?? null; $e = $cose[-2] ?? null; if ( ! $n || ! $e ) { return null; } return base64_encode( self::rsa_spki( $n, $e ) ); } return null; } /** * Converts a stored key value to a valid PEM string for OpenSSL. * * Handles three formats: * - New: plain base64-encoded DER (no PEM headers), safe, wrap and return. * - Old/good: PEM with real newlines, return as-is. * - Old/corrupted: PEM where newlines were replaced by the letter 'n' due to * WordPress stripslashes processing of JSON-encoded meta values. Repairs by * removing the spurious 'n' characters at known 64-char base64 boundaries. */ private static function pem_from_stored( string $stored ): string { // New format: plain base64 DER (no PEM markers). if ( strncmp( $stored, '-----', 5 ) !== 0 ) { $der = base64_decode( $stored, true ); if ( ! $der ) { return $stored; } return "-----BEGIN PUBLIC KEY-----\n" . chunk_split( base64_encode( $der ), 64, "\n" ) . "-----END PUBLIC KEY-----\n"; } // Old format with real newlines, fine as-is. if ( strpos( $stored, "\n" ) !== false ) { return $stored; } // Old format with corrupted newlines ('n' instead of 0x0A). // Find body between first '-----n' and last 'n-----END'. $body_start = strpos( $stored, '-----n' ); if ( $body_start === false ) { return $stored; } $body_start += 6; $footer_pos = strrpos( $stored, 'n-----END' ); if ( $footer_pos === false ) { return $stored; } $body = substr( $stored, $body_start, $footer_pos - $body_start ); // Remove the corrupted 'n' characters that appear at 64-char line breaks. $b64 = ''; $offset = 0; $blen = strlen( $body ); while ( $offset < $blen ) { $chunk = substr( $body, $offset, 64 ); $b64 .= $chunk; $offset += 64; // Skip exactly one 'n' if it sits at the chunk boundary (corrupted newline). if ( $offset < $blen && $body[ $offset ] === 'n' ) { ++$offset; } } $der = base64_decode( $b64, true ); if ( ! $der ) { return $stored; } return "-----BEGIN PUBLIC KEY-----\n" . chunk_split( base64_encode( $der ), 64, "\n" ) . "-----END PUBLIC KEY-----\n"; } /** * SubjectPublicKeyInfo DER for EC P-256. * OIDs: id-ecPublicKey (1.2.840.10045.2.1) + prime256v1 (1.2.840.10045.3.1.7). */ private static function ec_p256_spki( string $point ): string { // AlgorithmIdentifier SEQUENCE containing both OIDs. $alg = "\x30\x13" . "\x06\x07\x2a\x86\x48\xce\x3d\x02\x01" // ecPublicKey OID . "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07"; // P-256 OID $bs = "\x03" . self::dl( strlen( $point ) + 1 ) . "\x00" . $point; // BIT STRING return "\x30" . self::dl( strlen( $alg ) + strlen( $bs ) ) . $alg . $bs; } /** SubjectPublicKeyInfo DER for RSA (rsaEncryption OID + PKCS#1 key). */ private static function rsa_spki( string $n, string $e ): string { $ni = self::der_int( $n ); $ei = self::der_int( $e ); $seq = "\x30" . self::dl( strlen( $ni ) + strlen( $ei ) ) . $ni . $ei; $bs = "\x03" . self::dl( strlen( $seq ) + 1 ) . "\x00" . $seq; $alg = "\x30\x0d\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00"; // rsaEncryption OID + NULL return "\x30" . self::dl( strlen( $alg ) + strlen( $bs ) ) . $alg . $bs; } /** Encodes bytes as a DER INTEGER (prepends 0x00 if high bit set). */ private static function der_int( string $v ): string { $v = ltrim( $v, "\x00" ) ?: "\x00"; if ( ord( $v[0] ) > 0x7f ) { $v = "\x00" . $v; } return "\x02" . self::dl( strlen( $v ) ) . $v; } /** DER length encoding. */ private static function dl( int $len ): string { if ( $len < 0x80 ) { return chr( $len ); } if ( $len < 0x100 ) { return "\x81" . chr( $len ); } if ( $len < 0x10000 ) { return "\x82" . chr( $len >> 8 ) . chr( $len & 0xff ); } throw new \RuntimeException( 'DER: length overflow' ); } /** Verifies an OpenSSL signature. alg: -7 = ES256, -257 = RS256. */ private static function verify_sig( string $stored_key, int $alg, string $data, string $sig ): bool { if ( $alg !== -7 && $alg !== -257 ) { return false; } $pem = self::pem_from_stored( $stored_key ); $key = openssl_pkey_get_public( $pem ); if ( ! $key ) { return false; } return openssl_verify( $data, $sig, $key, OPENSSL_ALGO_SHA256 ) === 1; } // ── Utility ────────────────────────────────────────────────────────────── /** * The Relying Party ID a passkey is bound to. * * Defaults to this site's own host, which is the safest scope: a credential * registered here works here and nowhere else. * * IT CAN BE WIDENED TO A PARENT DOMAIN, and there is one situation that needs * it. A production site and its standby or QA copy are the SAME site to the * person signing in, but different origins to WebAuthn, so a passkey enrolled * on example.com is rejected by qa.example.com and * standby.example.com at the rpIdHash check. The operator is then left with * TOTP or email on exactly the machines they reach least often, and the * database restore that refreshes a standby makes it worse, not better: the * standby inherits the primary's credential IDs, which are bound to an RP ID * it does not use, so the slot is not empty but filled with something that can * never work. * * Setting this to example.com on all three fixes both halves at once. * The spec allows an RP ID that is a registrable domain suffix of the origin, * the primary's host IS that suffix so its existing passkeys keep working * untouched, the subdomains accept the same credential, and the inherited * credential IDs become correct rather than useless. One passkey, three sites. * * THE COST, stated plainly: a passkey scoped to a parent domain can be asked * for by ANY subdomain of it. That is a real widening of who may prompt for * the credential, and it is why this is opt-in, off by default, and never * derived by chopping a label off the host -- that would compute "co.uk" for * a .co.uk site and quietly hand every site on the registry a prompt. * * The value is deliberately the SAME on the primary and its copies, which is * what makes the option safe to store in the database: a restore propagating * it from the primary is the correct outcome, not a loss. The constant wins * where an install would rather not depend on that. * * A value that does not cover this host is IGNORED in favour of the host. * A wrong RP ID makes the browser refuse every passkey operation, so falling * back is the difference between a misconfiguration that does nothing and one * that locks the owner out. * * @return string */ public static function rp_id(): string { $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $configured = ''; if ( defined( 'CSDT_PASSKEY_RP_ID' ) ) { $configured = (string) constant( 'CSDT_PASSKEY_RP_ID' ); } if ( '' === trim( $configured ) ) { $configured = (string) CSDT_Options::get( self::OPTION_RP_ID ); } $configured = strtolower( trim( $configured ) ); return self::rp_id_covers( $configured, $host ) ? $configured : $host; } /** * Is $rp_id a registrable domain suffix of $host, as WebAuthn means it? * * True when the two are equal, or when the host is a subdomain of it. The * label boundary is the point: a bare string comparison would accept * "example.com" for "notexample.com", which is a different registration * entirely. * * This does NOT consult the public suffix list, so it cannot tell that "co.uk" * is not a registrable domain. It does not need to: the value is explicit * operator configuration rather than anything derived, and the browser applies * the real rule, refusing the operation outright. The check here is the one * this code can make correctly -- that the operator has not pointed a site at * a domain it is not part of. * * Public so the settings screen validates with THIS rule rather than its own * copy of it. Two implementations of "is this RP ID allowed here" would drift, * and the half that drifts is the half that saves a value the other half then * silently ignores -- a setting that reads as applied and does nothing. * * @param string $rp_id Candidate RP ID, already lowercased and trimmed. * @param string $host This site's host, lowercased. * @return bool */ public static function rp_id_covers( string $rp_id, string $host ): bool { if ( '' === $rp_id || '' === $host ) { return false; } if ( $rp_id === $host ) { return true; } // A single label ("ninja", "localhost") is never a registrable domain we // would widen to, and is the shape a truncation bug produces. if ( false === strpos( $rp_id, '.' ) ) { return false; } return substr( $host, - ( strlen( $rp_id ) + 1 ) ) === '.' . $rp_id; } /** * This site's own host, as rp_id() derives it. For the settings screen. * * @return string Lowercased. */ public static function site_host(): string { return strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); } private static function origin(): string { $p = wp_parse_url( home_url() ); $scheme = $p['scheme'] ?? 'https'; $host = $p['host'] ?? ''; $port = ''; if ( isset( $p['port'] ) ) { if ( ! ( ( $scheme === 'https' && $p['port'] === 443 ) || ( $scheme === 'http' && $p['port'] === 80 ) ) ) { $port = ':' . $p['port']; } } return $scheme . '://' . $host . $port; } private static function check_origin( string $origin ): bool { return $origin === self::origin(); } public static function b64u( string $data ): string { return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); } public static function b64u_decode( string $data ): string { return base64_decode( strtr( $data, '-_', '+/' ) . str_repeat( '=', ( 4 - strlen( $data ) % 4 ) % 4 ) ); } public static function get_passkeys( int $user_id ): array { $raw = get_user_meta( $user_id, self::META_KEY, true ); if ( ! $raw ) { return array(); } $arr = json_decode( $raw, true ); if ( ! is_array( $arr ) ) { return array(); } // The account a credential belongs to is the user whose meta row this IS. // Until 1.10.849 this pruned, AND SAVED the pruning of, any credential whose // recorded wp_user_login named no existing user, so any rename of a login, // by any plugin or tool, deleted that user's passkeys on the next read. The // owner's own passkeys survived the 2026-09-17 rename only because they // predate the field. A login string is a label; the user ID is the identity. // The label is refreshed to the current login so the list never shows a // stale name, and nothing is ever dropped here. $user = get_user_by( 'id', $user_id ); $login = $user && ! empty( $user->user_login ) ? (string) $user->user_login : ''; $dirty = false; foreach ( $arr as $i => $c ) { if ( ! is_array( $c ) ) { continue; } if ( '' !== $login && ( $c['wp_user_login'] ?? '' ) !== $login ) { $arr[ $i ]['wp_user_login'] = $login; $dirty = true; } } $arr = array_values( $arr ); if ( $dirty ) { update_user_meta( $user_id, self::META_KEY, wp_json_encode( $arr ) ); } return $arr; } private static function save_passkeys( int $user_id, array $creds ): void { update_user_meta( $user_id, self::META_KEY, wp_json_encode( $creds ) ); } }