\d+)', array( 'methods' => 'GET', 'callback' => array( self::class, 'endpoint' ), 'permission_callback' => '__return_true', // Anonymous visitors need a challenge; it grants no privileges. 'args' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1, ), ), ) ); } public static function endpoint( $request ) { if ( 'altcha' !== Spam::provider() || ! Schema::get( $request['id'] ) ) { return new \WP_Error( 'accessible_forms_challenge_unavailable', __( 'Verification is unavailable for this form.', 'accessible-forms-by-accessible-org' ), array( 'status' => 404 ) ); } // A shared lease makes simultaneous requests consume the same budget. // Refuse a challenge if the counter cannot be safely persisted. if ( ! Locks::limit( 'challenge:' . Spam::ip_hash(), 30, MINUTE_IN_SECONDS ) ) { return new \WP_Error( 'accessible_forms_challenge_rate', __( 'Please wait a minute before trying verification again.', 'accessible-forms-by-accessible-org' ), array( 'status' => 429 ) ); } try { $data = self::challenge( (int) $request['id'] ); } catch ( \Throwable $error ) { return new \WP_Error( 'accessible_forms_challenge_unavailable', __( 'Verification is temporarily unavailable.', 'accessible-forms-by-accessible-org' ), array( 'status' => 503 ) ); } $response = new \WP_REST_Response( $data ); $response->header( 'Cache-Control', 'no-store, private, max-age=0' ); return $response; } private static function verifier() { return new Altcha( hash_hmac( 'sha256', 'accessible-forms-altcha-v3', wp_salt( 'auth' ) ) ); } public static function challenge( $form_id ) { return self::verifier() ->createChallenge( new CreateChallengeOptions( algorithm: new Pbkdf2(), cost: self::COST, keyPrefix: '00', expiresAt: time() + self::LIFETIME, data: array( 'form' => (int) $form_id, 'site' => get_current_blog_id(), ) ) ) ->toArray(); } /** Verify only our bounded algorithm and claim its signature atomically. */ public static function verify( $token, $form_id ) { if ( ! is_string( $token ) || '' === $token ) { return 'altcha_missing'; } if ( strlen( $token ) > 8192 ) { return 'altcha_invalid'; } try { $decoded = base64_decode( $token, true ); $data = false === $decoded ? null : json_decode( $decoded, true, 12 ); $params = $data['challenge']['parameters'] ?? null; $signature = $data['challenge']['signature'] ?? null; $solution = $data['solution'] ?? null; if ( ! is_array( $params ) || ! is_array( $solution ) || ! is_string( $signature ) || ! preg_match( '/^[a-f0-9]{64}$/D', $signature ) ) { return 'altcha_invalid'; } if ( 'PBKDF2/SHA-256' !== ( $params['algorithm'] ?? '' ) || self::COST !== ( $params['cost'] ?? null ) || 32 !== ( $params['keyLength'] ?? null ) || '00' !== ( $params['keyPrefix'] ?? '' ) ) { return 'altcha_invalid'; } foreach ( array( 'nonce', 'salt' ) as $key ) { if ( ! isset( $params[ $key ] ) || ! is_string( $params[ $key ] ) || ! preg_match( '/^[a-f0-9]{32}$/D', $params[ $key ] ) ) { return 'altcha_invalid'; } } if ( ! is_int( $params['expiresAt'] ?? null ) || $params['expiresAt'] > time() + self::LIFETIME || (int) $form_id !== ( $params['data']['form'] ?? null ) || get_current_blog_id() !== ( $params['data']['site'] ?? null ) ) { return 'altcha_invalid'; } if ( $params['expiresAt'] < time() ) { return 'altcha_expired'; } if ( ! is_int( $solution['counter'] ?? null ) || $solution['counter'] < 0 || $solution['counter'] > 4294967295 || ! is_string( $solution['derivedKey'] ?? null ) || ! preg_match( '/^[a-f0-9]{64}$/D', $solution['derivedKey'] ) ) { return 'altcha_invalid'; } $result = self::verifier()->verifySolution( new VerifySolutionOptions( payload: $data, algorithm: new Pbkdf2() ) ); if ( ! $result->verified ) { return 'altcha_invalid'; } // The options table's unique key prevents concurrent reuse. Never autoload tokens. if ( ! add_option( self::USED_PREFIX . $signature, $params['expiresAt'], '', false ) ) { return 'altcha_reused'; } return ''; } catch ( \Throwable $error ) { return 'altcha_invalid'; } } /** Bounded cleanup; keys contain signatures and expiry times, never answers or IPs. */ public static function purge() { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded maintenance of our non-autoloaded, expiring registry. $names = $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s AND CAST(option_value AS UNSIGNED) < %d LIMIT 1000', $wpdb->options, $wpdb->esc_like( self::USED_PREFIX ) . '%', time() ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching foreach ( $names as $name ) { delete_option( $name ); } if ( count( $names ) === 1000 && ! wp_next_scheduled( 'accessible_forms_purge_proofs' ) ) { wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'accessible_forms_purge_proofs' ); } } public static function widget( $form_id ) { wp_enqueue_script( 'accessible-forms-altcha', ACCESSIBLE_FORMS_URL . 'assets/vendor/altcha/altcha.js', array(), '3.2.3', true ); wp_enqueue_script( 'accessible-forms-altcha-i18n', ACCESSIBLE_FORMS_URL . 'assets/vendor/altcha/i18n.js', array( 'accessible-forms-altcha' ), '3.2.3', true ); wp_enqueue_script( 'accessible-forms-altcha-setup', ACCESSIBLE_FORMS_URL . 'assets/altcha.js', array( 'accessible-forms-altcha-i18n' ), VERSION, true ); wp_localize_script( 'accessible-forms-altcha-setup', 'AccessibleFormsAltcha', array( 'workerUrl' => ACCESSIBLE_FORMS_URL . 'assets/vendor/altcha/pbkdf2.js', ) ); $config = array( 'humanInteractionSignature' => false, 'disableAutoFocus' => true, 'minDuration' => 0, 'timeout' => 15000, 'hideLogo' => true, // No third-party credit link on public forms (WordPress.org guideline 10). 'hideFooter' => true, ); // Every part sits in its own block element, so wpautop() in the_content // has nothing to wrap in paragraphs or split (see Renderer::noscript()). return '