DiscourseConnect の HMAC 検証が、コールバックパラメータが一致しているにもかかわらず WordPress で失敗する

こんにちは、

WordPressサイトとホストされたDiscourseインスタンス間でDiscourseConnectを設定しているところ、HMAC署名の検証が繰り返し失敗しています。

構成

  • WordPressは認証/プロバイダー側です。
  • DiscourseはDiscourseConnectを使用しています。
  • WordPressはDiscourseConnectのリクエストを受け取り、ssosig を含むコールバックを返します。
  • コールバックはDiscourseに到達しますが、HMAC署名が検証できないため、Discourseによって拒否されます。

かなり広範囲にわたるデバッグを行い、問題をかなり絞り込むことができました。

確認済みの事項

  • wp_unslash() はコールバックの値を変更しません。
  • EA/WordPressの検証入力値は、WordPressが観察したクエリ文字列から再構築されたパラメータと一致しています。
  • 想定されるコールバックコードが確実に実行されています。
  • デプロイされたソースファイルはビルドマニフェストと一致しています。
  • 誤ったファイルや重複定義の問題を確認しました。
  • 最新の修正後にPHP-FPMを再起動したため、古いPHPプロセス/opcacheの状態が原因ではありません。
  • これらの変更後の完全に新しいDiscourseConnectリクエストでも、HMAC検証は依然として失敗します。

つまり、現在の失敗は新しいリクエストで再現可能です。

まだ証明できていない事項

  1. Discourseが実行時に使用している実効シークレットが、WordPressで使用されているシークレットとバイト単位で完全に一致していること。
  2. sso ペイロードが、WordPressがリクエストを観察する時点以前にどこかで変更されていないこと。

この段階では、設定やコードを盲目的に変更し続けることは望んでいません。

質問

現在のDiscourse/DiscourseConnectにおいて、Discourseが期待されるHMACを計算する際に、まさにどのペイロードとシークレットを使用しているかを正確に決定する最善の方法は何ですか?

実際のシークレットを公開せずに、Discourse側のHMAC入力値とWordPress側の入力値を比較できるようにする推奨されるデバッグ/ロギング手法はありますか?

WordPress/PHPの処理、URLエンコーディング、Base64ペイロード、リバースプロキシ、またはホストされたDiscourseに関わる既知の問題で、この状況を引き起こしうるものがある場合、指針をいただければ幸いです。

必要に応じて、サニタイズ済みのリクエスト/コールバック値、関連するWordPressコード、およびログを提供できます。

よろしくお願いいたします。

WordPress側では何を使っていますか?WP-Discourseプラグインでしょうか?そうではない場合、コードを共有してもらえますか?

リチャードさん、こんにちは。WP-Discourse はインストールされていますが、DiscourseConnect プロバイダーおよびログイン同期機能は無効化されています。

DiscourseConnect プロバイダーとしては、小型のカスタム WordPress プラグインを使用しています。このプラグインは Discourse から ssosig パラメータを受け取り、共有シークレットを使用して受信した HMAC を検証した上で、Discourse への応答を構築・署名します。

現在の失敗は、Discourse から WordPress への受信リクエストで発生しています。新しい診断の結果、Discourse から受信した sig と HMAC の再計算結果が一致しないことが判明しました。

関連する PHP のコールバック/検証コードを投稿することも可能です。投稿前に設定値やシークレットを削除します。

つまり、逆の方向のことですね?

最も簡単な方法はWP-Discourseプラグインを使用することですが、おそらくそれを避けるには良い理由があるのでしょう。
はい、コードを投稿してください。

はい、その通りです。元の投稿での私の表現が逆になっていました。

現在の失敗は Discourse → WordPress の方向です。

Discourse は ssosig を含む DiscourseConnect リクエストを生成します。WordPress がそれを受信し、私たちのカスタム EA プロバイダーが署名を検証しようとします。その受信 HMAC 検証が失敗するため、WordPress はそこで停止します。認証済みユーザーのペイロードを Discourse に返す段階には至りません。

WP-Discourse はインストールされていますが、その DiscourseConnect プロバイダーとユーザーログイン同期機能は無効化されています。EA アカウント/プロファイル層を私たちの管理下に保ちたいと考えているため、現在カスタム EA プロバイダーを使用しています。

関連するコールバック/設定/HMAC 検証コードを、すべての秘密情報やプライベートな設定を削除した上で以下に投稿します。

これは、EAのカスタムWordPressプロバイダーにおける、サニタイズ済みの通常の入力DiscourseConnect検証パス(Discourse → WordPress)です。

<?php
// 設定定数は他の場所で定義されています。その非公開の値は省略されています。

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 ),
			'EA community sign-in',
			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() {
		// レスポンスヘッダーの設定は省略されています。

		if ( ! self::enabled() ) {
			$this->fail( 'Community sign-in is disabled.', 503 );
		}

		if ( ! self::ready() ) {
			$this->fail( 'Community sign-in is not configured.', 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(
				'Invalid community sign-in request. Start again from the community.'
			);
		}

		// 以降のWordPressログインと認証済みレスポンスコードは省略されています。
	}
}

// プラグインのブートストラップによってインスタンス化されます:
new EA_Discourse_Connect();

パラメータは wp_unslash() ではなく urldecode() するべきです

Richard、ありがとうございます。PHP/WordPressのリクエスト処理と、現在のDiscourseの署名フローと照らして、その点を確認しました。

私たちのコールバックでは$_GETから読み取っているため、PHPはEAがそれらを受信する前に、クエリパラメータをURLデコード済みです。wp_unslash()はWordPressのリクエストスラッシングを逆転させるだけであり、URLデコードを行っているわけではありません。

その時点でurldecode()を再度適用すると、値が二重にデコードされ、Base64の+がスペースに変わってしまう可能性があります。これ自体がHMACを壊してしまいます。

また、WordPressとDiscourseのシークレットが一致するよう修正された後でも、新しい診断が依然としてHMACに失敗することを確認しました。

したがって、この特定のコールバックパスにおいて、wp_unslash()urldecode()に置き換えることは、適切な修正ではないと考えます。署名付きペイロードがPHPに到達する前にどこで分岐しているのか、引き続き追跡しています。