La validación HMAC de DiscourseConnect falla con WordPress a pesar de que los parámetros de callback coinciden

Hola,

Estoy configurando DiscourseConnect entre un sitio de WordPress y una instancia de Discourse alojada y me he encontrado con un fallo persistente en la validación de la firma HMAC.

Configuración

  • WordPress es el lado de autenticación/proveedor.
  • Discourse está utilizando DiscourseConnect.
  • WordPress recibe la solicitud de DiscourseConnect y devuelve el callback que contiene sso y sig.
  • El callback llega a Discourse, pero Discourse lo rechaza porque la firma HMAC no se valida.

Hemos realizado una depuración bastante exhaustiva y hemos acotado el problema considerablemente.

Lo que hemos confirmado

  • wp_unslash() no altera los valores del callback.
  • Las entradas de validación de EA/WordPress coinciden con los parámetros reconstruidos a partir de la cadena de consulta observada en WordPress.
  • El código del callback esperado definitivamente se está ejecutando.
  • Los archivos de fuente desplegados coinciden con nuestro manifiesto de compilación.
  • Hemos revisado problemas de archivos incorrectos y definiciones duplicadas.
  • PHP-FPM se ha reiniciado tras las últimas correcciones, por lo que no se trata de un proceso PHP antiguo ni de un estado de opcache obsoleto.
  • Una solicitud de DiscourseConnect completamente nueva después de esos cambios sigue fallando en la validación HMAC.

En otras palabras, el fallo actual es reproducible en una solicitud nueva.

Lo que aún no hemos podido demostrar

  1. Que el secreto efectivo utilizado por Discourse en tiempo de ejecución sea idéntico, byte a byte, al secreto utilizado por WordPress.
  2. Que el payload sso no esté siendo modificado en algún punto antes de que WordPress observe la solicitud.

En esta etapa no queremos seguir cambiando configuraciones o código a ciegas.

Pregunta

Para la versión actual de Discourse/DiscourseConnect, ¿cuál es la mejor manera de determinar exactamente qué payload y secreto está utilizando Discourse cuando calcula el HMAC esperado?

¿Existe algún método de depuración/registro recomendado que nos permita comparar la entrada HMAC del lado de Discourse con la entrada del lado de WordPress sin exponer públicamente el secreto real?

Si hay algún problema conocido que involucre el manejo de WordPress/PHP, la codificación de URL, los payloads Base64, los proxies inversos o Discourse alojado que pueda producir esta situación, también agradecería cualquier orientación.

Puedo proporcionar valores de solicitud/callback saneados, el código relevante de WordPress y registros si es necesario.

Gracias.

¿Qué estás usando entonces en la parte de WordPress? ¿El plugin WP-Discourse? Si no, ¿puedes compartir tu código?

Hola, Richard. WP-Discourse está instalado, pero su proveedor DiscourseConnect y las funciones de sincronización de inicio de sesión están desactivados.

Estamos utilizando un pequeño plugin personalizado de WordPress como proveedor de DiscourseConnect. Recibe los parámetros sso y sig de Discourse, verifica el HMAC entrante utilizando el secreto compartido y luego construye/firma la respuesta para devolverla a Discourse.

El fallo se está produciendo actualmente en la solicitud entrante de Discourse a WordPress: nuestro nuevo diagnóstico muestra que la recálculo del HMAC no coincide con el sig recibido de Discourse.

Estoy dispuesto a publicar el código PHP relevante de la callback/validación. Eliminaré los valores de configuración/secretos antes de publicarlo.

Entonces, es al revés, ¿verdad?

La vía obvia sería usar el plugin WP-Discourse, pero probablemente tengas buenas razones para no hacerlo.
Sí, por favor publica el código.

Sí, tienes razón. Mi redacción en la publicación original estaba al revés.

El fallo actual es Discourse → WordPress.

Discourse genera la solicitud DiscourseConnect que contiene sso y sig. WordPress la recibe y nuestro proveedor EA personalizado intenta verificar la firma. Esa verificación HMAC entrante falla, por lo que WordPress se detiene ahí. No llega tan lejos como para devolver el payload del usuario autenticado a Discourse.

WP-Discourse está instalado, pero su proveedor DiscourseConnect y sus funciones de sincronización de inicio de sesión de usuario están desactivados. Actualmente estamos usando el proveedor EA personalizado porque queremos que la capa de cuenta/perfil de EA siga bajo nuestro control.

Publicaré a continuación el código relevante del callback/configuración/validación HMAC, con todos los secretos y la configuración privada eliminados.

Este es el camino de validación normal y saneado de DiscourseConnect entrante (Discourse → WordPress) en el proveedor personalizado de WordPress de EA.

<?php
// Las constantes de configuración se definen en otro lugar; sus valores privados se omiten.

final class EA_Discourse_Connect {
	public function __construct() {
		add_action( 'admin_post_ea_discourse_connect', array( $this, 'connect' ) );
		add_action( 'admin_post_nopriv_ea_discourse_connect', array( $this, 'connect' ) );
	}

	public static function enabled() {
		return defined( 'EA_DISCOURSE_CONNECT_ENABLED' ) && true === EA_DISCOURSE_CONNECT_ENABLED;
	}

	public static function config() {
		$url = defined( 'EA_DISCOURSE_URL' ) ? EA_DISCOURSE_URL : '';
		$secret = defined( 'EA_DISCOURSE_CONNECT_SECRET' ) ? EA_DISCOURSE_CONNECT_SECRET : '';

		if ( ! is_string( $url ) || ! preg_match( '~\Ahttps://[a-z0-9.-]+(?::[0-9]+)?(?:/[a-z0-9_-]+)*/?\z~i', $url ) ) {
			$url = '';
		}

		return array(
			'url'    => rtrim( $url, '/' ),
			'secret' => is_string( $secret ) ? $secret : '',
		);
	}

	public static function ready() {
		$c = self::config();

		return self::enabled()
			&& $c['url']
			&& strlen( $c['secret'] ) >= 32
			&& 'https' === wp_parse_url( home_url(), PHP_URL_SCHEME );
	}

	private function fail( $message, $code = 400 ) {
		wp_die(
			esc_html( $message ),
			'Inicio de sesión de la comunidad EA',
			array( 'response' => $code )
		);
	}

	public static function validate( $payload, $signature, $config ) {
		if (
			! is_string( $payload )
			|| ! is_string( $signature )
			|| strlen( $payload ) > 8192
			|| ! preg_match( '/\A[a-f0-9]{64}\z/', $signature )
			|| ! hash_equals(
				hash_hmac( 'sha256', $payload, $config['secret'] ),
				$signature
			)
		) {
			return false;
		}

		$decoded = base64_decode( $payload, true );

		if ( false === $decoded ) {
			return false;
		}

		parse_str( $decoded, $params );

		if (
			empty( $params['nonce'] )
			|| ! is_string( $params['nonce'] )
			|| strlen( $params['nonce'] ) > 256
			|| ! isset( $params['return_sso_url'] )
			|| $config['url'] . '/session/sso_login' !== $params['return_sso_url']
		) {
			return false;
		}

		return $params['nonce'];
	}

	public function connect() {
		// Configuración de encabezados de respuesta omitida.

		if ( ! self::enabled() ) {
			$this->fail( 'El inicio de sesión de la comunidad está desactivado.', 503 );
		}

		if ( ! self::ready() ) {
			$this->fail( 'El inicio de sesión de la comunidad no está configurado.', 503 );
		}

		$c = self::config();

		$payload = isset( $_GET['sso'] )
			? wp_unslash( $_GET['sso'] )
			: null;

		$sig = isset( $_GET['sig'] )
			? wp_unslash( $_GET['sig'] )
			: null;

		$nonce = self::validate( $payload, $sig, $c );

		if ( false === $nonce ) {
			$this->fail(
				'Solicitud de inicio de sesión de la comunidad no válida. Comience de nuevo desde la comunidad.'
			);
		}

		// Inicio de sesión de WordPress posterior y código de respuesta autenticado omitidos.
	}
}

// Instanciado por el arranque del plugin:
new EA_Discourse_Connect();

Deberías usar urldecode() para los parámetros, no wp_unslash()