Push

Encryption

For a device that registered its own key, honkio encrypts every push to that key, so Apple and Google receive the content encrypted. Apple sees a placeholder instead of the content, and the badge, sound, thread id and collapse key stay readable; for Google, only the collapse key stays readable. Only that device can decrypt it.

How encryption works

Each install of your app makes its own encryption key pair. The HonkIO Swift and Android packages make it the first time your app asks for the public key, and keep the private key on the device, which it never leaves. Your app sends the public key to your backend with its push token, and your backend registers it with the device (encryption_public_key on PUT /v1/push/devices).

honkio then encrypts each push to the key of the device it goes to, and sends Apple a placeholder and the encrypted envelope, or Google the envelope alone. Of the content, Apple can read only the placeholder, which is per app (placeholder_title and placeholder_body, default title New notification). On iOS, the badge, sound and thread id are outside the envelope too, readable by Apple, because iOS applies them before your extension decrypts anything; on Android they are inside it. The collapse key is outside it on both, because Apple and Google use it to replace an older push.

What it protects: Apple, Google and anything between honkio and the device cannot read the title, body or data. Each device can decrypt only the pushes sent to its own key, so a compromised device reads its own pushes and no one else's.

What it does not: this is not end-to-end encryption. honkio encrypts the content, so it sees it, as it sees the text of a message it sends for you, and your backend sees it because it sends it. For sensitive content, register a key for every device and require encryption.

A device registered without a key gets its pushes in plain text, unless the app requires encryption (below).

Register each device's key

encryption_public_key is the device's public key as the packages return it: the 65 byte uncompressed P-256 point, base64url without padding. honkio checks that it is a point on the curve and refuses anything else with 422 VALIDATION_ERROR. The key is never returned: a device carries encryption_key_id instead, the first 16 characters of base64url(SHA-256(key)), which is also the kid of every envelope sent to it.

Register it on every launch with the token, as the package samples below do. Registering the same token again without encryption_public_key keeps its stored key, but a new token, after an APNs or FCM token refresh, is registered as a new device with no key unless you send the key with it. The packages give your app the public key together with the token, so have your backend forward both every time. Only an explicit encryption_public_key of null removes it; on an app that requires encryption, that device then gets no pushes (ENCRYPTION_KEY_MISSING) until it registers a key again.

// Call this from your backend when the app reports its token.
const { data: device, error } = await honkio.push.devices.register({
  appId: 'APP_ID',
  token: 'DEVICE_TOKEN',
  platform: 'ios', // or 'android'
  userId: 'user_1042', // your own identifier for this person
  apnsEnvironment: 'production', // iOS only: 'sandbox' for development builds
  encryptionPublicKey: 'DEVICE_PUBLIC_KEY', // the key the HonkIO push package made on the device
})
if (error) throw new Error(error.message)
console.log(device.id, device.active, device.encryption_key_id)

To rotate a device's key, make a new one on the device (HonkioPush.rotateEncryptionKey in both packages) and register it: the new key replaces the old one at once. The packages keep the previous key too, so pushes sent to the old key before your backend registered the new one still open.

Require encryption

require_encryption is off by default and is set per push app, on POST /v1/push/apps, on PATCH /v1/push/apps/:id or in the dashboard. Turning it off from the dashboard asks you to confirm it is you first, because devices without a key then get pushes in plain text. Off, a device with a key gets each push encrypted and a device without one gets it in plain text.

On, every delivery is encrypted or not made. A device without a key is refused for that push with the delivery error_code ENCRYPTION_KEY_MISSING: it is never sent the push in plain text, never charged and not counted in device_count. If every targeted device is refused, the push fails with ENCRYPTION_KEY_MISSING, or sends its fallback text when it has one. Turning it on also covers pushes already scheduled or queued: a delivery to a device without a key is refused when it is sent, and refunded.

A push's encrypted is the app's require_encryption when it was sent, and each delivery's encrypted says whether that device got it encrypted.

require_encryption is also the guard against a device's key being removed. While it is off, anyone who can register devices (a key with the push_devices permission's w, or an OAuth push:devices grant) can remove a device's key with null, and later pushes to that device go in plain text; no webhook fires. While it is on, that device is refused instead. Turn it on once every install of your app sends its key.

// Every push is encrypted to each device that registered a key. With
// requireEncryption, a device without one is skipped (ENCRYPTION_KEY_MISSING)
// instead of getting plaintext, and is not charged.
const { data: pushApp, error } = await honkio.push.apps.update('APP_ID', { requireEncryption: true })
if (error) throw new Error(error.message)
console.log(pushApp.require_encryption) // true

The envelope

The envelope is one JSON object: v (2), kid (the device key's ID), enc (the encapsulated key, a 65 byte P-256 point) and ct (the sealed payload followed by the 16 byte tag). enc and ct are base64url without padding.

It is HPKE (RFC 9180) in base mode with DHKEM(P-256, HKDF-SHA256), HKDF-SHA256 and AES-128-GCM, with info the UTF-8 text honkio-push-v2, no additional data and one message per envelope. Opened, it is the push as JSON: title, body, data, badge, sound and thread_id, each present when you sent it. Any HPKE library that supports this suite can open it; check yours against the test vector below.

On iOS, the aps block carries the placeholder and mutable-content 1, with hk beside it. Your Notification Service Extension decrypts hk and replaces the title and body. If the extension fails or times out, iOS shows the placeholder.

On Android, the message is data-only: data.hk holds the envelope as a JSON string, with no notification block. Your FirebaseMessagingService decrypts it in onMessageReceived and posts the notification itself, with the decrypted thread_id, when present, as the notification's tag, as FCM does for a plain-text push.

iOS: Notification Service Extension

Add the HonkioPush Swift package to your app and to your Notification Service Extension target (iOS 17 or later), and give both targets the same Keychain access group. The app calls HonkioPush.encryptionPublicKey(accessGroup:), which makes the key pair the first time, and sends publicKey to your backend with the token. The extension passes its content and the same access group to decryptNotificationContent, which replaces the title and body with the decrypted ones; if anything fails (no envelope, an unknown key ID, tampering, a phone not unlocked since it restarted), iOS shows the placeholder. Decrypted data is placed under userInfo["data"]. The private key stays in the Secure Enclave where the device has one, or otherwise in the Keychain, readable after first unlock and never copied to another device by a backup.

swift
.package(url: "https://github.com/somecorp-dev/honkio-swift", from: "0.1.0")
swift
import HonkioPush

func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = HonkioDeviceToken.hexString(from: deviceToken)
    #if DEBUG
    let environment = "sandbox"     // a build you run from Xcode
    #else
    let environment = "production"  // TestFlight, App Store and ad hoc builds
    #endif
    // Created on first use, then the same key on every launch.
    guard let key = try? HonkioPush.encryptionPublicKey(accessGroup: "group.com.example.app") else { return }
    // sendTokenToYourBackend is your app's own function; your backend registers all three with HonkIO
    // (key.publicKey as encryption_public_key).
    Task { await sendTokenToYourBackend(token, apnsEnvironment: environment, encryptionPublicKey: key.publicKey) }
}
swift
import UserNotifications
import HonkioPush

final class NotificationService: UNNotificationServiceExtension {
    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
            contentHandler(request.content)
            return
        }
        // The same access group the app passes to HonkioPush.encryptionPublicKey.
        contentHandler(HonkioPush.decryptNotificationContent(content, accessGroup: "group.com.example.app"))
    }
}
Without the package

On iOS 17 and later, CryptoKit has HPKE. Without the package, you make the key pair yourself (P256.KeyAgreement.PrivateKey), keep it in a Keychain group the extension shares, and send its public key's x963Representation, base64url encoded, as encryption_public_key. The extension reads hk, finds its private key by kid and opens ct with an HPKE.Recipient made from enc.

swift
import UserNotifications
import CryptoKit

enum PushDecryptError: Error { case badEnvelope, unknownKey }

final class NotificationService: UNNotificationServiceExtension {
    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        // If anything below fails, iOS shows the placeholder that came with the push.
        guard let content = request.content.mutableCopy() as? UNMutableNotificationContent,
              let envelope = request.content.userInfo["hk"] as? [String: Any],
              let plain = try? Self.decrypt(envelope, keys: loadPrivateKeys()) else {
            contentHandler(request.content)
            return
        }
        if let title = plain["title"] as? String { content.title = title }
        if let body = plain["body"] as? String { content.body = body }
        if let data = plain["data"] as? [String: Any] { content.userInfo["data"] = data }
        contentHandler(content)
    }

    override func serviceExtensionTimeWillExpire() {
        // Nothing to flush: the placeholder is shown if we run out of time.
    }

    /// kid -> this device's private key. The kid is the first 16 characters of
    /// base64url(SHA-256(the 65 byte public key)), as the API reports it in encryption_key_id.
    /// Keep the keys in a Keychain item in the shared group, saved with
    /// kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Sample: returns nothing here.
    private func loadPrivateKeys() -> [String: P256.KeyAgreement.PrivateKey] { [:] }

    static func decrypt(_ envelope: [String: Any], keys: [String: P256.KeyAgreement.PrivateKey]) throws -> [String: Any] {
        guard envelope["v"] as? Int == 2,
              let kid = envelope["kid"] as? String,
              let enc = (envelope["enc"] as? String).flatMap(base64URL),
              let ct = (envelope["ct"] as? String).flatMap(base64URL) else { throw PushDecryptError.badEnvelope }
        guard let key = keys[kid] else { throw PushDecryptError.unknownKey }

        // HPKE base mode: DHKEM(P-256, HKDF-SHA256), HKDF-SHA256, AES-128-GCM, info "honkio-push-v2", no aad.
        let suite = HPKE.Ciphersuite(kem: .P256_HKDF_SHA256, kdf: .HKDF_SHA256, aead: .AES_GCM_128)
        var recipient = try HPKE.Recipient(privateKey: key, ciphersuite: suite,
                                           info: Data("honkio-push-v2".utf8), encapsulatedKey: enc)
        let plain = try recipient.open(ct)
        guard let json = try JSONSerialization.jsonObject(with: plain) as? [String: Any] else {
            throw PushDecryptError.badEnvelope
        }
        return json
    }

    static func base64URL(_ s: String) -> Data? {
        var b64 = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
        b64 += String(repeating: "=", count: (4 - b64.count % 4) % 4)
        return Data(base64Encoded: b64)
    }
}

Android: FirebaseMessagingService

Add the honkio-push library to your app module. Send HonkioPush.encryptionPublicKey(context).publicKey to your backend with the FCM token, at launch and from onNewToken: the key pair is made the first time and kept encrypted under an Android Keystore key. In onMessageReceived, a message without an hk key is not an encrypted honkio push, so the service leaves it alone. With hk present, HonkioPush.decryptData opens it; if it is damaged or was sent to a key the device no longer has, show your own generic placeholder. Both calls read the Keystore, so make them off the main thread (onMessageReceived already runs off it).

kotlin
dependencies {
    implementation(platform("com.google.firebase:firebase-bom:34.5.0"))
    implementation("com.google.firebase:firebase-messaging")
    implementation("ca.honkio:honkio-push:0.1.0")
}
kotlin
// sendTokenToBackend is your app's own function. onToken runs on the main thread, so move off it.
HonkioTokens.current(
    onToken = { token -> thread { sendTokenToBackend(token, HonkioPush.encryptionPublicKey(applicationContext)) } },
    onError = { e -> Log.w("push", "no FCM token", e) },
)

class PushService : FirebaseMessagingService() {
    // onNewToken already runs off the main thread.
    override fun onNewToken(token: String) = sendTokenToBackend(token, HonkioPush.encryptionPublicKey(applicationContext))
}
kotlin
class PushService : FirebaseMessagingService() {
    // sendTokenToBackend is your app's own function.
    override fun onNewToken(token: String) = sendTokenToBackend(token, HonkioPush.encryptionPublicKey(applicationContext))

    override fun onMessageReceived(message: RemoteMessage) {
        // No `hk` key: not an encrypted HonkIO push, so leave it alone.
        if (!message.data.containsKey(PushEnvelope.DATA_KEY)) return
        // `hk` present but unparseable or undecryptable: show the placeholder.
        val plain = try {
            HonkioPush.decryptData(applicationContext, message.data)
        } catch (e: HonkioPushException) {
            null // e.reason: UNSUPPORTED_VERSION, INVALID_ENVELOPE, UNKNOWN_KID, DECRYPTION_FAILED, INVALID_PAYLOAD, KEY_STORAGE
        }
        val title = plain?.optString("title")?.takeIf { it.isNotEmpty() }
            ?: getString(R.string.push_placeholder_title)
        val body = plain?.optString("body")?.takeIf { it.isNotEmpty() }
            ?: getString(R.string.push_placeholder_body)
        // Build and post the notification with NotificationCompat, as usual.
        // plain?.optJSONObject("data") holds your custom data.
    }
}
Without the package

Android has no HPKE of its own. Without the package, use an HPKE library that supports the suite above, such as Google's Tink, keep the private key encrypted under an Android Keystore key, and check your code against the test vector below.

Test vector

Use this to unit-test your decrypt code: opening the envelope with the recipient's private key gives the plaintext exactly. honkio generates it with the server's own encryption code, and honkio's tests check it against that code, so it cannot drift. Keys are base64url without padding; the private key is the raw 32 byte scalar.

Recipient private key_nrSUmsffUCgaGb5gmqkRLXCc_-ls5Dh6sm2Bu6OA3M
Recipient public keyBFGHw33ou72Yj1eGoA53xqL29aB_tk5aVfF2PiySBv06hugrUUHAOoXyzC6xpLBnjBfu8DS3Phd3ascQSTTrYPc
Key ID (kid)cHRL-UfvREIeEx3Q

Envelope

json
{
  "v": 2,
  "kid": "cHRL-UfvREIeEx3Q",
  "enc": "BPDblkoWAGabKxtOrs52tL2JaFkPPoqNVP8104Q3C7SwOj8mbh1bw7iLBpr7tWEPglEt4X_DX9Y8BVJnrHo1-wg",
  "ct": "SK86q5n7JJTP_HjhHm_7WGk0g2mgJSjE4UyRTnjlQqObn8ntlLw8IJgQ5UasGWOTNuWwmlhl6o-wbXOB9lse8pkvaOlV7SNKs4p54P5nVzLtMoJsp8t8gD1t_m0Y3jSxhoCsRMV-pg"
}

Plaintext

json
{"title":"Your code","body":"123456","data":{"order":"42"},"badge":1,"sound":"default"}

The whole vector, with the ephemeral key and the APNs and FCM payloads

json
{
  "description": "HonkIO push encryption v2 (HPKE, RFC 9180). Generated by api/scripts/push-vectors.ts through the API's own seal; do not edit. A device decrypts envelope with recipient.private_key and gets plaintext_json exactly.",
  "suite": {
    "mode": "base",
    "kem": "DHKEM(P-256, HKDF-SHA256)",
    "kem_id": 16,
    "kdf": "HKDF-SHA256",
    "kdf_id": 1,
    "aead": "AES-128-GCM",
    "aead_id": 1,
    "info": "honkio-push-v2",
    "aad": ""
  },
  "encoding": "Keys, enc and ct are base64url without padding. Public keys are 65-byte uncompressed SEC1 points; private keys are the raw 32-byte scalar.",
  "recipient": {
    "ikm_hex": "499b258aa3aa87a03b95f73ffafc1ca2fec83581239dcb8bcce76d04963f60d0",
    "private_key": "_nrSUmsffUCgaGb5gmqkRLXCc_-ls5Dh6sm2Bu6OA3M",
    "public_key": "BFGHw33ou72Yj1eGoA53xqL29aB_tk5aVfF2PiySBv06hugrUUHAOoXyzC6xpLBnjBfu8DS3Phd3ascQSTTrYPc",
    "kid": "cHRL-UfvREIeEx3Q",
    "kid_rule": "first 16 characters of base64url(SHA-256(raw 65-byte public key))"
  },
  "ephemeral": {
    "ikm_hex": "fa4e2613e4bf02ccd0ec037a5657a7c312c67f0ea9a91c04a634e08f2200922a",
    "private_key": "caYVkHrwUWToc_5RTA3WOsBb5AyhoC3HVD1H955zz8Q",
    "public_key": "BPDblkoWAGabKxtOrs52tL2JaFkPPoqNVP8104Q3C7SwOj8mbh1bw7iLBpr7tWEPglEt4X_DX9Y8BVJnrHo1-wg"
  },
  "plaintext": {
    "title": "Your code",
    "body": "123456",
    "data": {
      "order": "42"
    },
    "badge": 1,
    "sound": "default"
  },
  "plaintext_json": "{\"title\":\"Your code\",\"body\":\"123456\",\"data\":{\"order\":\"42\"},\"badge\":1,\"sound\":\"default\"}",
  "envelope": {
    "v": 2,
    "kid": "cHRL-UfvREIeEx3Q",
    "enc": "BPDblkoWAGabKxtOrs52tL2JaFkPPoqNVP8104Q3C7SwOj8mbh1bw7iLBpr7tWEPglEt4X_DX9Y8BVJnrHo1-wg",
    "ct": "SK86q5n7JJTP_HjhHm_7WGk0g2mgJSjE4UyRTnjlQqObn8ntlLw8IJgQ5UasGWOTNuWwmlhl6o-wbXOB9lse8pkvaOlV7SNKs4p54P5nVzLtMoJsp8t8gD1t_m0Y3jSxhoCsRMV-pg"
  },
  "apns_payload": {
    "aps": {
      "alert": {
        "title": "New notification",
        "body": ""
      },
      "mutable-content": 1,
      "badge": 1,
      "sound": "default"
    },
    "hk": {
      "v": 2,
      "kid": "cHRL-UfvREIeEx3Q",
      "enc": "BPDblkoWAGabKxtOrs52tL2JaFkPPoqNVP8104Q3C7SwOj8mbh1bw7iLBpr7tWEPglEt4X_DX9Y8BVJnrHo1-wg",
      "ct": "SK86q5n7JJTP_HjhHm_7WGk0g2mgJSjE4UyRTnjlQqObn8ntlLw8IJgQ5UasGWOTNuWwmlhl6o-wbXOB9lse8pkvaOlV7SNKs4p54P5nVzLtMoJsp8t8gD1t_m0Y3jSxhoCsRMV-pg"
    }
  },
  "fcm_data": {
    "hk": "{\"v\":2,\"kid\":\"cHRL-UfvREIeEx3Q\",\"enc\":\"BPDblkoWAGabKxtOrs52tL2JaFkPPoqNVP8104Q3C7SwOj8mbh1bw7iLBpr7tWEPglEt4X_DX9Y8BVJnrHo1-wg\",\"ct\":\"SK86q5n7JJTP_HjhHm_7WGk0g2mgJSjE4UyRTnjlQqObn8ntlLw8IJgQ5UasGWOTNuWwmlhl6o-wbXOB9lse8pkvaOlV7SNKs4p54P5nVzLtMoJsp8t8gD1t_m0Y3jSxhoCsRMV-pg\"}"
  }
}

Limits to know

  • Badge, sound and thread ID stay in plain text on iOS even on an encrypted push, because iOS applies them before your extension runs. The collapse key stays in plain text on iOS and Android, because Apple and Google use it to replace an older push. Do not put anything sensitive in any of them.
  • Android data-only messages are sent at high priority by default (<code>priority</code>: high) so the app wakes promptly. Android can deprioritise an app that receives high-priority messages without showing a notification, so always post one.
  • Encryption and base64url add about a third to the size, plus the envelope's encapsulated key and tag. The 4096 byte limit applies to the final payload, and PUSH_PAYLOAD_TOO_LARGE says how many bytes to cut. The size is checked again when a push is sent: if a device's encrypted payload is then over 4096 bytes (for example, it registered a key after a scheduled push was accepted), that delivery is refused with PUSH_PAYLOAD_TOO_LARGE and refunded.
  • Device tokens, your bundle ID or project ID, and send timing are still visible to Apple and Google, and so are the envelope's key ID, its encapsulated key and its length: the sealed payload is 16 bytes longer than the plaintext, so the content's approximate size is not hidden. They may hold a push for an offline phone until it is delivered or expires.
  • A device keeps its current key and the one before it, so a push sent to a key from two rotations ago cannot be opened and shows the placeholder. If the stored key can no longer be read, the package makes a new one with a new ID; registering on every launch keeps honkio up to date.