APNs を利用したプッシュ通知でのエラー

プログラミング

iPhone などのスマートフォンへのプッシュ通知は、APNs【Apple Push Notification Service】を利用して行います。
サーバ側での処理の実装はそれほど難しくはありませんが、プッシュ通知に必要な証明書を用意するのが結構大変です。

プッシュ通知に必要な2つの証明書


ルート証明書 (entrust_root_certification_authority.pem)
Entrust Datacard よりダウンロード

Apple Push Notification Service 証明書(サーバ用証明書)(server_certificates_bundle.pem)
Apple Developer Program で作成。

ルート証明書の方は基本的にダウンロードだけなので特に引っかかることはありませんが、サーバ用証明書の方は Apple Developer Program に登録し、なにやら色々なことをしないといけないので、証明書の不備によるエラーを経験されている方も結構多いのではないでしょうか。

ERROR: Unable to connect to 'tls://gateway.sandbox.push.apple.com:2195':  (0)

エラーログを見ると通知サーバへの connect に失敗している旨のエラーが発生しました。
さて、ここからどのように調査をしたら良いでしょうか。

openssl コマンドで接続確認

openssl コマンドを利用して、用意した証明書で通知サーバへ正しく接続できるかどうか確認してみましょう。

> openssl s_client -connect gateway.sandbox.push.apple.com:2195 -cert server_certificates_bundle_sandbox.pem -key server_certificates_bundle_sandbox.pem -CApath Entrust_Root_Certification_Authority.pem
CONNECTED(00000003)
・・・

上記は接続に成功した例です。
接続に失敗すると、以下のようなエラーメッセージが表示されます。

139996056893328:error:14094438:SSL routines:ssl3_read_bytes:tlsv1 alert internal error:s3_pkt.c:1493:SSL alert number 80

エラーメッセージ中の「internal error」や「alert number 80」というのがエラー原因を表していそうですね。
このエラーメッセージやエラーコードのようなものは、TLS(トランスポート層セキュリティ)【Transport Layer Security】におけるエラーアラートで、最新バージョンの TLS1.3 は RFC8446 で規定されています。
(ちなみに TLS とは暗号化通信プロトコル規約のことで、概ね SSL のことだと思ってください)

internal_error (80)
ピアに関係のない内部エラーまたはプロトコルの正確性(メモリ割り当ての失敗など)により、続行できません。

RFC8446

アラート番号が 80 の場合は証明書の作成に失敗していると思われるので、もう1度最初からやり直してみてください。

140259273422736:error:14094415:SSL routines:ssl3_read_bytes:sslv3 alert certificate expired:s3_pkt.c:1493:SSL alert number 45

アラート番号が 45 の場合は証明書の期限切れですね。
openssl コマンドで証明書の期限を確認してみましょう。

> openssl x509 -noout -dates -in server_certificates_bundle_sandbox.pem
notBefore=May 21 01:14:12 2019 GMT
notAfter=May 21 01:14:12 2020 GMT

この証明書は、2020/05/21 で期限が切れてしまっているようです。
証明書の作り直しが必要ですね。

エラーアラートの一覧
参考までに RFC で規定されているエラーアラートの一覧をまとめました。

表1 TLS 1.3 RFC8446

No.AlertDescription
0
close_notify
このアラートは、送信者がこの接続でこれ以上メッセージを送信しないことを受信者に通知します。閉鎖アラートが受信された後に受信されたデータは無視されなければなりません(MUST)。
10unexpected_message不適切なメッセージ(たとえば、誤ったハンドシェイクメッセージ、時期尚早のアプリケーションデータなど)が受信されました。このアラートは、適切な実装間の通信で監視されるべきではありません。
20bad_record_macこのアラートは、保護解除できないレコードを受信した場合に返されます。AEADアルゴリズムは復号化と検証を組み合わせており、サイドチャネル攻撃を回避するためにも、このアラートはすべての保護解除の失敗に使用されます。このアラートは、メッセージがネットワークで破損した場合を除いて、適切な実装間の通信では絶対に観察されません。
22record_overflow2 ^ 14 + 256バイトを超える長さのTLSCiphertextレコード、または2 ^ 14バイト(またはその他のネゴシエートされた制限)を超えるTLSPlaintextレコードに復号化されたレコードを受信しました。このアラートは、メッセージがネットワークで破損した場合を除いて、適切な実装間の通信では絶対に観察されません。
40handshake_failure「handshake_failure」アラートメッセージの受信は、送信者が、利用可能なオプションを前提として、許容可能なセキュリティパラメータのセットをネゴシエートできなかったことを示します。
42bad_certificate証明書が破損しているか、正しく検証されない署名が含まれているなど。
43unsupported_certificate証明書のタイプはサポートされていません。
44certificate_revoked証明書が署名者によって取り消されました。
45certificate_expired証明書の有効期限が切れているか、現在無効です。
46certificate_unknown証明書の処理中に他の(不特定の)問題が発生し、受け入れられなくなりました。
47illegal_parameter(1.3)
invalid_parameter(1.2)
ハンドシェイクのフィールドが正しくないか、他のフィールドと矛盾しています。このアラートは、正式なプロトコル構文に準拠しているが、それ以外は正しくないエラーに使用されます。
48unknown_ca有効な証明書チェーンまたは部分チェーンを受け取りましたが、CA証明書が見つからなかったか、既知のトラストアンカーと一致しなかったため、証明書は受け入れられませんでした。
49access_denied有効な証明書またはPSKを受信しましたが、アクセス制御が適用されたときに、送信者はネゴシエーションを続行しないことを決定しました。
50decode_error一部のフィールドが指定された範囲外だったか、メッセージの長さが正しくなかったため、メッセージをデコードできませんでした。このアラートは、メッセージが正式なプロトコル構文に準拠していないエラーに使用されます。このアラートは、メッセージがネットワークで破損した場合を除いて、適切な実装間の通信では絶対に観察されません。
51decrypt_error署名を正しく検証できない、または完了したメッセージまたはPSKバインダーを検証できないなど、ハンドシェイク(レコードレイヤーではない)暗号化操作が失敗しました。
70protocol_versionピアがネゴシエートしようとしたプロトコルバージョンは認識されていますが、サポートされていません(付録Dを参照)。
71insufficient_securityサーバーがクライアントでサポートされているパラメーターよりも安全なパラメーターを必要とするため、ネゴシエーションが失敗したときに「handshake_failure」の代わりに返されます。
80internal_errorピアに関係のない内部エラーまたはプロトコルの正確性(メモリ割り当ての失敗など)により、続行できません。
86inappropriate_fallbackクライアントからの無効な接続再試行に応答してサーバーから送信されます([ RFC7507 ]を参照)。
90user_canceledこのアラートは、プロトコル障害とは無関係の何らかの理由で送信者がハンドシェイクをキャンセルしていることを受信者に通知します。ハンドシェイクの完了後にユーザーが操作をキャンセルした場合は、「close_notify」を送信して接続を閉じるだけの方が適切です。このアラートの後には、「close_notify」が続く必要があります(SHOULD)。
109missing_extension提供されたTLSバージョンまたはその他のネゴシエートされたパラメーターに送信するために必須である拡張を含まないハンドシェイクメッセージを受信するエンドポイントによって送信されます。
110unsupported_extension特定のハンドシェイクメッセージに含めることが禁止されていることがわかっている拡張機能を含む、または対応するClientHelloまたはCertificateRequestで最初に提供されなかったServerHelloまたは証明書に拡張機能を含む、ハンドシェイクメッセージを受信するエンドポイントによって送信されます。
112unrecognized_name「server_name」拡張機能を介してクライアントから提供された名前で識別されるサーバーが存在しない場合にサーバーから送信されます([RFC6066]を参照)。
113bad_certificate_status_response「status_request」拡張機能を介してサーバーから無効または許容できないOCSP応答が提供されたときにクライアントから送信されます([RFC6066]を参照)。
115unknown_psk_identityPSKキーの確立が必要であるが、クライアントから受け入れ可能なPSK IDが提供されない場合にサーバーから送信されます。このアラートの送信はオプションです。代わりに、サーバーは「decrypt_error」アラートを送信して、無効なPSK IDを単に示すことを選択する場合があります。
116certificate_requiredクライアント証明書が必要であるがクライアントから提供されていない場合にサーバーから送信されます。
120
255
no_application_protocolクライアントの「application_layer_protocol_negotiation」拡張がサーバーがサポートしていないプロトコルのみをアドバタイズするときにサーバーによって送信されます([RFC7301]を参照)。


一応、TLS 1.2 の方も(1.3 との差分です)。

表1 TLS 1.2 RFC5246

No.AlertDescription
21
decryption_failed_RESERVED
このアラートは、TLSの以前のバージョンの一部で使用されており、CBCモード[CBCATT]に対する特定の攻撃を許可している可能性があります。準拠した実装から送信してはいけません。
30decompression_failure解凍関数が不適切な入力(たとえば、過度に長くなるデータ)を受け取りました。このメッセージは常に致命的であり、適切な実装間の通信で決して観察されるべきではありません。
41no_certificate_RESERVEDこのアラートはSSLv3 で使用されましたが、TLSのどのバージョンでも使用されていません。準拠した実装から送信してはいけません。
60export_restriction_RESERVEDこのアラートは、一部の以前のバージョンのTLSで使用されていました。準拠した実装から送信してはいけません。
100no_renegotiation最初のハンドシェイク後、helloリクエストに応答してクライアントから送信されるか、クライアントhelloに応答してサーバーから送信されます。これらのいずれかは通常、再交渉につながります。それが適切でない場合、受信者はこのアラートで応答する必要があります。その時点で、元のリクエスターは接続を続行するかどうかを決定できます。これが適切なケースの1つは、サーバーが要求を満たすためにプロセスを生成した場合です。プロセスは起動時にセキュリティパラメータ(キーの長さ、認証など)を受け取り、それ以降にこれらのパラメータへの変更を伝達することが困難になる場合があります。このメッセージは常に警告です。

おまけ
PHP でプッシュ通知を実装する場合、ApnsPHP という便利なライブラリがあります。

・GitHub
https://github.com/immobiliare/ApnsPHP

・composer duccio/apns-php
https://packagist.org/packages/duccio/apns-php

ApnsPHP を利用した実装例です。

<?php

class APNs{

	// iOS ルート証明書とサーバ用証明書
	const IOS_ROOT_PEM = '/home/test/entrust_root_certification_authority.pem';
	const IOS_PEM = '/home/test/server_certificates_bundle_sandbox.pem';

	// iOS 接続モード
	const IOS_MODE = ApnsPHP_Abstract::ENVIRONMENT_SANDBOX; // 開発用
	//const IOS_MODE = ApnsPHP_Abstract::ENVIRONMENT_PRODUCTION; // 本番用

	// Android
	const AND_URL = 'https://fcm.googleapis.com/fcm/send';
	const AND_KEY = 'xxxx';

	// タイトル
	private $sTitle_ = null;

	// タイトルの設定
	public function setTitle($sTitle){
		$this->sTitle_ = $sTitle;
	}

	// iOSのプッシュ通知
	public function pushIOS($aTokens,$sMessage,$aCustoms=null){
		if(!$aTokens) return;

		// APNsサーバへ接続
		$push = new ApnsPHP_Push(self::IOS_MODE, self::IOS_PEM);
		$push->setRootCertificationAuthority(self::IOS_ROOT_PEM);
		//$push->setProviderCertificatePassphrase('pass phrase'); // pemにpass phraseがあれば
		$push->setLogger(new ApnsPHP_Log_Custom);
		$push->connect();

		// 通知先を設定
		foreach($aTokens as $token){
			$apns = _pushIOS($token,$sMessage,$aCustoms);
			if($apns) $push->add($apns);
		}

		// プッシュ通知
		try{
			$push->send();
			$errors = $push->getErrors();
			if($errors){
				$errmsg = 'pushIOS() error occurred. error='.var_export($errors,true);
				error_log($errmsg);
			}

		}catch(\Exception $e){
			$errmsg = $e->getCode().' '.$e->getMessage();
			error_log($errmsg);
		}

		$push->disconnect();
	}

	// Androidのプッシュ通知
	public function pushAndroid($aTokens,$sMessage,$aCustoms=null){
		if(!$aTokens) return;

		$ids = array();
		foreach($aTokens as $token){
			$ids[] = $token;
		}

		$data_array = array(
			'registration_ids' => $ids,
			'priority' => 'high',
			'data' => array('message'=>$sMessage),
			'time_to_live' => (60*60*24*7), // 1週間の有効期限
		);

		// カスタマイズ
		if($aCustoms){
			foreach($aCustoms as $name=>$value){
				$data_array['data'][$name] = $value;
			}
		}

		$data = json_encode($data_array);
		$headers = array(
			'Authorization: key='.self::AND_KEY,
			'Content-Type: application/json',
			'Content-Length: '.strlen($data),
		);

		$handler = curl_init(self::AND_URL);
		curl_setopt($handler,CURLOPT_HTTPHEADER, $headers);
		curl_setopt($handler,CURLOPT_CUSTOMREQUEST, 'POST');
		curl_setopt($handler,CURLOPT_POSTFIELDS, $data);
		curl_setopt($handler,CURLOPT_RETURNTRANSFER, true);
		curl_exec($handler);
		curl_close($handler);

		$rc = curl_errno($handler);
		if($rc){
			$errmsg = 'pushAndroid() error occurred. error='.$rc;
			error_log($errmsg);
		}
	}

	// iOSのプッシュ通知先を設定
	private _pushIOS($sToken,$sMessage,$aCustoms=null){
		$apns = null;

		// 他の通知が止まらないよう例外をメソッド内で吸収
		try{
			$apns = new ApnsPHP_Message($sToken);
			$apns->setText($sMessage);
			if($this->sTitle_) $apns->setTitle($this->sTitle_); 
			$apns->setExpiry(60*60*24*7); // 1週間の有効期限
			// カスタマイズ
			if($aCustoms){
				foreach($aCustoms as $name=>$value){
					$apns->setCustomProperty($name,$value);
				}
			}
			$apns->setBadge(1); // バッジに1を設定

		}catch(ApnsPHP_Message_Exception $e){
			$apns = null;
			$errmsg = $e->getCode().' '.$e->getMessage();
			error_log($errmsg);
		}

		return $apns;
	}
}

class ApnsPHP_Log_Custom implements ApnsPHP_Log_Interface{
	public function log($sMessage){
		// ログ受け取り後の処理
	}
}
?>

コメント