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 : ''; ?>