Échec de la validation HMAC de DiscourseConnect avec WordPress malgré des paramètres de rappel identiques

Bonjour,

Je configure DiscourseConnect entre un site WordPress et une instance Discourse hébergée, et je rencontre un échec persistant de la validation de la signature HMAC.

Configuration

  • WordPress est le côté authentification/fournisseur.
  • Discourse utilise DiscourseConnect.
  • WordPress reçoit la requête DiscourseConnect et renvoie le callback contenant sso et sig.
  • Le callback atteint Discourse, mais Discourse le rejette car la signature HMAC ne se valide pas.

Nous avons effectué un débogage assez approfondi et avons considérablement réduit le champ des possibles.

Ce que nous avons confirmé

  • wp_unslash() ne modifie pas les valeurs du callback.
  • Les entrées de validation EA/WordPress correspondent aux paramètres reconstitués à partir de la chaîne de requête observée par WordPress.
  • Le code de callback attendu s’exécute bien.
  • Les fichiers sources déployés correspondent à notre manifeste de build.
  • Nous avons vérifié l’absence de problèmes liés à de mauvais fichiers ou à des définitions en double.
  • PHP-FPM a été redémarré après les dernières corrections, il ne s’agit donc pas d’un processus PHP obsolète ou d’un état d’opcache.
  • Une requête DiscourseConnect entièrement nouvelle après ces modifications échoue toujours à la validation HMAC.

En d’autres termes, l’échec actuel est reproductible sur une requête nouvelle.

Ce que nous n’avons pas encore pu prouver

  1. Que le secret effectif utilisé par Discourse à l’exécution est identique, octet par octet, au secret utilisé par WordPress.
  2. Que la charge utile sso n’est pas modifiée quelque part avant le point où WordPress observe la requête.

À ce stade, nous ne voulons pas continuer à modifier les paramètres ou le code à l’aveugle.

Question

Pour les versions actuelles de Discourse/DiscourseConnect, quelle est la meilleure façon de déterminer exactement quelle charge utile et quel secret Discourse utilise lorsqu’il calcule le HMAC attendu ?

Y a-t-il une méthode de débogage/journalisation recommandée qui nous permettrait de comparer l’entrée HMAC côté Discourse avec l’entrée côté WordPress sans exposer publiquement le secret réel ?

S’il existe des problèmes connus impliquant le traitement WordPress/PHP, l’encodage d’URL, les charges utiles Base64, les proxys inverses ou Discourse hébergé qui pourraient provoquer cette situation, je serais également reconnaissant pour des indications.

Je peux fournir des valeurs de requête/callback anonymisées, le code WordPress pertinent et des journaux si nécessaire.

Merci.

Donc, que utilisez-vous côté WordPress ? Le plugin WP-Discourse ? Si ce n’est pas le cas, pourriez-vous partager votre code ?

Bonjour Richard. WP-Discourse est installé, mais son fournisseur DiscourseConnect et ses fonctions de synchronisation de connexion sont désactivés.

Nous utilisons une petite extension WordPress personnalisée en tant que fournisseur DiscourseConnect. Elle reçoit les paramètres sso et sig de Discourse, vérifie le HMAC entrant à l’aide de la clé secrète partagée, puis construit et signe la réponse à renvoyer à Discourse.

L’échec se produit actuellement sur la requête entrante de Discourse vers WordPress — notre nouveau diagnostic montre que le recalcul du HMAC ne correspond pas au sig reçu de Discourse.

Je suis prêt à publier le code PHP pertinent du callback/la validation. Je retirerai les valeurs de configuration et les secrets avant de le publier.

C’est donc l’inverse, n’est-ce pas ?

La voie évidente serait d’utiliser le plugin WP-Discourse, mais vous avez probablement de bonnes raisons de ne pas le faire.
Oui, merci de publier le code.

Oui — vous avez raison. Ma formulation dans le message d’origine était inversée.

L’échec actuel se produit dans le sens Discourse → WordPress.

Discourse génère la requête DiscourseConnect contenant sso et sig. WordPress la reçoit, et notre fournisseur EA personnalisé tente de vérifier la signature. La vérification HMAC entrante échoue, si bien que WordPress s’arrête là. Il n’atteint pas l’étape où il renvoie la charge utile de l’utilisateur authentifié à Discourse.

WP-Discourse est installé, mais son fournisseur DiscourseConnect et ses fonctions de synchronisation de connexion utilisateur sont désactivés. Nous utilisons actuellement le fournisseur EA personnalisé car nous souhaitons que la couche de compte/profil EA reste sous notre contrôle.

Je posterai ci-dessous le code pertinent du callback/de la configuration/de la validation HMAC, avec tous les secrets et la configuration privée retirés.

Voici le chemin de validation normal et assaini de DiscourseConnect en entrée (Discourse → WordPress) dans le fournisseur WordPress personnalisé de EA.

<?php
// Les constantes de configuration sont définies ailleurs ; leurs valeurs privées sont omises.

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 ),
			'Connexion à la communauté 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() {
		// Configuration des en-têtes de réponse omise.

		if ( ! self::enabled() ) {
			$this->fail( 'La connexion à la communauté est désactivée.', 503 );
		}

		if ( ! self::ready() ) {
			$this->fail( 'La connexion à la communauté n’est pas configurée.', 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(
				'Demande de connexion à la communauté invalide. Recommencez depuis la communauté.'
			);
		}

		// Connexion WordPress ultérieure et code de réponse authentifié omis.
	}
}

// Instantié par le bootstrap du plugin :
new EA_Discourse_Connect();

Vous devriez utiliser urldecode() pour les paramètres, et non wp_unslash()

Merci Richard. J’ai vérifié ce point par rapport au traitement des requêtes PHP/WordPress et au flux de signature actuel de Discourse.

Dans notre callback, nous lisons depuis $_GET, ce qui signifie que PHP a déjà décodé l’URL des paramètres de requête avant qu’EA ne les reçoive. wp_unslash() ne fait que rétablir le traitement des barres obliques de WordPress ; il n’effectue pas de décodage d’URL.

Appliquer urldecode() à nouveau à ce stade provoquerait un double décodage de la valeur et pourrait transformer un + en Base64 en espace, ce qui, en soi, casserait le HMAC.

J’ai également confirmé que notre nouveau diagnostic échoue toujours au HMAC, même après que les secrets WordPress et Discourse ont été corrigés pour correspondre.

Je ne pense donc pas que remplacer wp_unslash() par urldecode() soit la bonne correction pour ce chemin de callback particulier. Je continue de tracer l’endroit où la charge signée peut diverger avant d’atteindre PHP.