つばくろぐ @takamii228

知は力なり

iOSのPush通知でAPNsとの連携を証明書と認証キーでそれぞれやってみた

iOSアプリのPush通知を実現するには、APNs(Apple Push Notification Service)と連携する必要があります。この連携方式について調べてみたので備忘録としてまとめておきます。

APNsとの連携方式

APNsとの連携はPush通知を受け取るアプリケーションとの連携と、アプリケーションにPush通知を送るようにAPNsへ依頼するアプリケーションとの連携 の2種類行う必要があります。

https://docs-assets.developer.apple.com/published/1fe29f6177/4ebaf4d8-031d-4eb5-b975-07373dfa6eb6.png

Apple Developer Documentation

前者のPush通知を受け取る側の設定についてはiOSのアプリケーションのApp IDをApple Developer Console上で発行するときにPush Notificationsを有効にし、APNsの証明書と紐付けることで実現できます。証明書の発行の手順はやや複雑ですが、ググればいろんな記事がヒットします。

qiita.com

一方で後者のPush通知を発行する側の連携方式については、実際はAPNsと直接やりとりするケースはあまりなく、Firebase Cloud MessagingやニフティクラウドなどMBaaS製品を経由することが多いと思います。今回はこちらの連携方式について詳しく見ていきます。

Push通知をAPNsへ依頼するアプリケーションとの連携方式

Push通知をAPNsへ依頼するアプリケーションとの連携方式は、調べてみると現在は認証キー(p8ファイル)での認証方式と証明書(p12ファイル)での認証方式の2種類があるようです。

認証キーでの認証方式の方が新しく、証明書での認証方式は以前からある方式のようです。それぞれのメリット・デメリットを比較すると以下のようになります。

連携方式 メリット デメリット
認証キー(p8ファイル)での認証 キーの有効期限がなく更新の運用がいらない Apple Developer Programに対して2つまでしか認証キーが発行できない
・App ID単位で管理ができない
・開発環境・本番環境と区別ができない
証明書(p12ファイル)での認証 ・App ID単位で管理ができる
・開発環境・本番環境と区別ができる
1年単位で更新が必要となる

メリット・デメリットはそれぞれの裏返しになっています。

認証キーの方が便利そうに見えますが、キーが2つしか発行できないので、同一アカウントで複数のアプリを開発・配信している場合は鍵の原本管理に注意が必要です。とはいえ証明書のケースでも管理は大変なので、管理対象の数が減って更新運用が不要になるのはだいぶ楽ですね。 今後はどうなっていくかについてはAppleのご都合次第ですが、恐らく認証キーの方に流れていくのではないかと予想しています。

Legacy Binary Protocolでの連携は2021年3月以降サポートされない

ちなみにもっと昔にはLegacy Binary Protocolというものもあったようです。こちらは2021年の3月以降はサポートしないとAppleから発表されています。以前のアナウンスの11月からさらに延長されました。

少し混乱しますが、ここでサポートが切れるのはLegacy Binary Protocolの連携方式であって、証明書ベースの認証方式自体は2021年3月以降も利用できると考えられます(証明書でもhttp2で通信できるますし)(でも違ったらごめんなさい...)。

各種MBaaSにおけるAPNsとの連携方式

ここで各種MBaaSのツールのAPNsとの認証方式が現在どうなっているか見てみましょう。

Firebase

Firebaseの設定手順を見ると、authentication keyとあるので認証キーでの設定が推奨されているようです。

firebase.google.com

FCMの設定画面ではAuthentication Keyが推奨と出ていますが、APNs CertificatesのアップロードUIも用意されているので今現在は両方利用できるようです。

f:id:takamii228:20200713164341p:plain

ニフティクラウド

ニフティクラウドはp12ファイルのアップロードとあるので証明書での認証方式のようです。

mbaas.nifcloud.com

f:id:takamii228:20200713165100p:plain

AWS Amplify

AWSのBaaSツールであるAmplifyはp12ファイルのアップロードとあるので証明書での認証方式のようです。

docs.amplify.aws

Salesforce

セールスフォースはp12ファイルの利用をやめてp8ファイルへの以降に舵を切ったようです。

org62.my.salesforce.com

証明書での認証と認証キーでの認証は共存できる?

証明書での連携方式を今後どこかのタイミングで認証キーへ移行していくケースを考えてみます。証明書(p12ファイル)と認証キー(p8ファイル)は、いずれもPush通知の発行を依頼するサービスがAPNsへリクエストを投げる時の認証に使うものです。そのため、基本的にはAPNsの受け口が空いている限り共存できるのでは思いましたが、少し不安になったので同一アプリに対してp12ファイルで認証したPushのリクエストとp8ファイルで認証したPushのリクエストがいずれも受信できるか実際に検証してみました。

証明書一式を準備する

検証用のアプリを作るにあたっての一式をApple Developer Program上で準備します。

  • 開発用証明書
  • AppID(Push Notificationsをオンにする)
  • provision profile(検証につかう端末を含む)
  • APNs証明書(P12ファイル)
  • AuthKey(p8ファイル)

iOSのサンプルプロジェクトを作る

次にPush通知を受け取るサンプルアプリを作ります。今回はPush通知が受け取れればいいので、デフォルトのSingle Page Viewアプリプロジェクトを作り、AppDelegate.swiftに最低限の設定のみを記述します。PushのCapabilitiesをオンにするのを忘れないようにします。

import UIKit
import UserNotifications

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Push通知の許可ダイアログを出す
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
            guard granted else { return }
            DispatchQueue.main.async {
                UIApplication.shared.registerForRemoteNotifications()
            }
        }
        UNUserNotificationCenter.current().delegate = self
        
        return true
    }
   
   ...
    // Push通知の用のデバイストークンをログに吐き出す
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let token = deviceToken.map { String(format: "%.2hhx", $0) }.joined()
        print("Device token: \(token)")
    }
    
    // Push通知のペイロードの受信を確認する
    func application(_ application: UIApplication, didReceiveRemoteNotification payload: [AnyHashable: Any]) {
        print(payload)
    }
            
    // フォワグラウンドで通知バナーを出す
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
         completionHandler([.alert, .sound, .badge])
    }
}

この状態でアプリを実機でビルドし、Push通知の許可ダイアログを許可するとログからデバイストークンが取得できます。このデバイストークンを使ってPush通知のリクエストを組みます。

証明書を使ってAPNsへPush通知のリクエストを投げる

p12ファイルを使ったAPNsへのリクエストはcurlコマンドを使って組むことができます。ググってみたらちょうどcurlコマンドがあったのでこれを参考にして組んでみます。

https://gist.github.com/greencoder/16d1f8d7b0fed5b49cf64312ce2b72cc

#!/bin/sh

set -x

P12_CERT_FILE_PATH="xxxxx.p12"
APP_BUNDLE_INDENTIFER="com.takamiii.push-test"
DEVICE_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
API_ENDPOINT="https://api.development.push.apple.com/3/device/"

curl -v \
-d '{"aps":{"alert":{"title":"Test","body":"Hello from request with P12 certificates"}}}' \
-H "Content-Type: application/json" \
-H "apns-topic: ${APP_BUNDLE_INDENTIFER}" \
-H "apns-priority: 10" \
--http2 \
--cert-type P12 --cert ${P12_CERT_FILE_PATH} \
${API_ENDPOINT}${DEVICE_TOKEN}

curlコマンドを実行すると無事200で成功し、Push通知を受信できました。ログからPushが受け取れていることがわかります。

< HTTP/2 200 
< apns-id: 66AF09FA-154B-F68F-CAB3-5CBD49104122
< 
* Connection #0 to host api.development.push.apple.com left intact
* Closing connection 0
[AnyHashable("aps"): {
    alert =     {
        body = "Hello from request with P12 certificates";
        title = Test;
    };
}]

通知バナーも出ています。

f:id:takamii228:20200713174627p:plain

認証キーを使ってAPNsへPush通知のリクエストを投げる

p12ファイルの設定はそのままに、p8ファイルの認証キーを使ったPush通知のリクエストを投げてみます。こちらはjwtの文字列を組む必要があって少し複雑なのですが、こちらのqiita記事を参考を参考にリクエストを組んでみます。

qiita.com

#!/bin/bash

set -x

AUTH_KEY_FILE_PATH="AuthKey_xxxxxx.p8"
AUTH_KEY_ID="xxxxxxxx"
TEAM_ID="xxxxxxxxx"

APP_BUNDLE_INDENTIFER="com.takamiii.push-test"
DEVICE_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
API_ENDPOINT="https://api.development.push.apple.com/3/device/"

base64() {
   openssl base64 -e -A | tr -- '+/' '-_' | tr -d =
}

sign() {
   printf "$1"| openssl dgst -binary -sha256 -sign "${AUTH_KEY_FILE_PATH}" | base64
}

TIME=$(date +%s)
HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "${AUTH_KEY_ID}" | base64)
CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "${TEAM_ID}" "${TIME}" | base64)
JWT="${HEADER}.${CLAIMS}.$(sign ${HEADER}.${CLAIMS})"

curl -v \
   -d '{"aps":{"alert":{"title":"Test","body":"Hello from request with P8 certificates"}}}' \
   -H "Content-Type: application/json" \
   -H "Authorization: Bearer ${JWT}" \
   -H "apns-topic: ${APP_BUNDLE_INDENTIFER}" \
   --http2 \
   ${API_ENDPOINT}${DEVICE_TOKEN}

こちらも無事200で成功し、Push通知を受信できました。ログからPushが受け取れていることがわかります。

< HTTP/2 200 
< apns-id: 09393B36-E1CE-4F9A-5A29-2FC7FF73D424
< 
* Connection #0 to host api.development.push.apple.com left intact
* Closing connection 0
[AnyHashable("aps"): {
    alert =     {
        body = "Hello from request with P8 certificates";
        title = Test;
    };
}]

通知バナーも出ています。

f:id:takamii228:20200713175350p:plain

以上から、p12ファイルとp8ファイルを使ったリクエストは併用できているので、証明書と認証キーの設定は共存できていると考えられます。

なのでもともとニフティクラウドでp12ファイルでPushを送っていたところからp8ファイルを使ったFirebaseでのPushに移行する、なんてことは問題なくできそうということですね。

まとめ

iOSのPush通知についてAPNsとの連携方式ついて調べた結果をまとめてみました。Push通知の実現にはアプリの実装に加えて証明書連携が必要で、その手順は複雑ですが一つ一つ紐解いていけば理解できるものだと思いました。

またAPNsの証明書と認証キーの移行については今のところどちらでも利用できているので、Pushを実際に発行するアプリケーションの連携方式に合わせて利用していけばよいでしょう。認証キーでの連携は今後は増えていくと思われるので、鍵ファイルの管理をきちんとしていく必要があると感じました。

おまけ

Push通知のリクエストのエンドポイントを調査している中でapi.sandbox.push.apple.comapi.development.push.apple.comの2つがあって混乱したので、digで掘ってみました。

$ dig api.sandbox.push.apple.com

; <<>> DiG 9.10.6 <<>> api.sandbox.push.apple.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 32012
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;api.sandbox.push.apple.com.  IN  A

;; ANSWER SECTION:
api.sandbox.push.apple.com. 269   IN  CNAME   api.sandbox.push-apple.com.akadns.net.
api.sandbox.push-apple.com.akadns.net. 185 IN A   17.188.138.73

;; Query time: 14 msec
;; SERVER: 2404:1a8:7f01:b::3#53(2404:1a8:7f01:b::3)
;; WHEN: Mon Jul 13 18:40:18 JST 2020
;; MSG SIZE  rcvd: 122
$ dig api.development.push.apple.com

; <<>> DiG 9.10.6 <<>> api.development.push.apple.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 45266
;; flags: qr rd ra; QUERY: 1, ANSWER: 5, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;api.development.push.apple.com.  IN  A

;; ANSWER SECTION:
api.development.push.apple.com.   242 IN  CNAME   api.sandbox.push-apple.com.akadns.net.
api.sandbox.push-apple.com.akadns.net. 554 IN A   17.188.165.218
api.sandbox.push-apple.com.akadns.net. 554 IN A   17.188.166.29
api.sandbox.push-apple.com.akadns.net. 554 IN A   17.188.165.219
api.sandbox.push-apple.com.akadns.net. 554 IN A   17.188.166.27

;; Query time: 17 msec
;; SERVER: 2404:1a8:7f01:b::3#53(2404:1a8:7f01:b::3)
;; WHEN: Mon Jul 13 18:40:36 JST 2020
;; MSG SIZE  rcvd: 174

どっちも最終的には同じapi.sandbox.push-apple.com.akadns.netを向いてるようなので、どちらでもよさそうです。