From 2a119da1f928b6318fa50cea19575b218f0be539 Mon Sep 17 00:00:00 2001 From: Nathaniel Adams Date: Fri, 24 Apr 2026 22:09:59 -0500 Subject: [PATCH 1/3] fpush-apns: add APNs token-based authentication (p8) Add support for token-based APNs authentication as an alternative to certificate-based (p12) auth. Token-based auth uses a .p8 key file that never expires, eliminating the need for annual certificate renewal. Config now accepts either: - certFilePath + certPassword (existing p12 flow, unchanged) - keyPath + keyId + teamId (new p8 token flow) Both auth methods are fully supported and backward compatible. Existing p12 configs continue to work without modification. --- fpush-apns/src/config.rs | 37 +++++++++++++++++++++------ fpush-apns/src/push.rs | 54 ++++++++++++++++++++++------------------ 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/fpush-apns/src/config.rs b/fpush-apns/src/config.rs index 053ec4e..ef50b42 100644 --- a/fpush-apns/src/config.rs +++ b/fpush-apns/src/config.rs @@ -5,8 +5,6 @@ use std::collections::HashMap; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AppleApnsConfig { - cert_file_path: String, - cert_password: String, topic: String, additional_data: Option>, #[serde(default = "ApnsEndpoint::production")] @@ -15,15 +13,38 @@ pub struct AppleApnsConfig { pool_idle_timeout: u64, #[serde(default = "AppleApnsConfig::default_request_timeout")] request_timeout: u64, + + // Certificate-based auth (p12) + cert_file_path: Option, + cert_password: Option, + + // Token-based auth (p8) + key_path: Option, + key_id: Option, + team_id: Option, } -impl AppleApnsConfig { - pub fn cert_file_path(&self) -> &str { - &self.cert_file_path - } +pub enum ApnsAuth<'a> { + Certificate { path: &'a str, password: &'a str }, + Token { key_path: &'a str, key_id: &'a str, team_id: &'a str }, +} - pub fn cert_password(&self) -> &str { - &self.cert_password +impl AppleApnsConfig { + pub fn auth(&self) -> Option> { + if let (Some(path), Some(password)) = ( + self.cert_file_path.as_deref(), + self.cert_password.as_deref(), + ) { + return Some(ApnsAuth::Certificate { path, password }); + } + if let (Some(key_path), Some(key_id), Some(team_id)) = ( + self.key_path.as_deref(), + self.key_id.as_deref(), + self.team_id.as_deref(), + ) { + return Some(ApnsAuth::Token { key_path, key_id, team_id }); + } + None } pub fn topic(&self) -> &str { diff --git a/fpush-apns/src/push.rs b/fpush-apns/src/push.rs index 5c47b4c..03d7b26 100644 --- a/fpush-apns/src/push.rs +++ b/fpush-apns/src/push.rs @@ -11,7 +11,8 @@ use async_trait::async_trait; use log::{debug, error}; use serde_json::Value; -use crate::AppleApnsConfig; +use crate::config::{ApnsAuth, AppleApnsConfig}; + pub struct FpushApns { apns: a2::client::Client, topic: String, @@ -19,39 +20,44 @@ pub struct FpushApns { } impl FpushApns { - fn open_cert(filename: &str) -> PushResult { - if let Ok(file) = std::fs::File::open(filename) { - Ok(file) - } else { - Err(PushError::CertLoading) - } + fn open_file(filename: &str) -> PushResult { + std::fs::File::open(filename).map_err(|e| { + error!("Could not open file {}: {}", filename, e); + PushError::CertLoading + }) } pub fn init(apns_config: &AppleApnsConfig) -> PushResult { - let mut certificate = FpushApns::open_cert(apns_config.cert_file_path())?; - let mut client_config = ClientConfig::new(apns_config.endpoint()); client_config.pool_idle_timeout_secs = Some(apns_config.pool_idle_timeout()); client_config.request_timeout_secs = Some(apns_config.request_timeout()); - match Client::certificate(&mut certificate, apns_config.cert_password(), client_config) { - Ok(apns_conn) => { - let wrapped_conn = Self { - apns: apns_conn, - topic: apns_config.topic().to_string(), - additional_data: apns_config.additional_data().clone(), - }; - Ok(wrapped_conn) + let apns_conn = match apns_config.auth() { + Some(ApnsAuth::Certificate { path, password }) => { + let mut cert_file = Self::open_file(path)?; + Client::certificate(&mut cert_file, password, client_config).map_err(|e| { + error!("Problem initializing apple certificate config: {}", e); + PushError::PushEndpointTmp + })? } - Err(a2::error::Error::ReadError(e)) => { - error!("Could not read apns: {}", e); - Err(PushError::PushEndpointPersistent) + Some(ApnsAuth::Token { key_path, key_id, team_id }) => { + let key_file = Self::open_file(key_path)?; + Client::token(key_file, key_id, team_id, client_config).map_err(|e| { + error!("Problem initializing apple token config: {}", e); + PushError::PushEndpointTmp + })? } - Err(e) => { - error!("Problem initializing apple config: {}", e); - Err(PushError::PushEndpointTmp) + None => { + error!("APNs config requires either (certFilePath + certPassword) or (keyPath + keyId + teamId)"); + return Err(PushError::CertLoading); } - } + }; + + Ok(Self { + apns: apns_conn, + topic: apns_config.topic().to_string(), + additional_data: apns_config.additional_data().clone(), + }) } } From 1a01510997cbc9458d55cc24cc40ee7d9e5e35ee Mon Sep 17 00:00:00 2001 From: Nathaniel Adams Date: Wed, 27 May 2026 18:32:41 -0500 Subject: [PATCH 2/3] fpush-apns: fix apns-expiration calculation (was set to 1970-01-28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SystemTime::now().elapsed() returns the duration between two consecutive now() calls — effectively zero. Adding 4 weeks gave apns-expiration=2,419,200 (~Jan 28 1970) on every push, which APNs silently drops per Apple's documented behavior for past-timestamp expirations (see Apple Developer Forums thread #708937). Result: every push that did not deliver immediately on first attempt was discarded by APNs with no error feedback to fpush. Devices that were briefly offline / sleeping / handing off cellular missed pushes entirely with no log evidence anywhere in the pipeline. Replace with SystemTime::now().duration_since(UNIX_EPOCH), which returns the current unix timestamp. Adding 4 weeks now gives a correctly-future expiration so APNs holds and retries deliveries per its normal store-and-forward semantics. --- fpush-apns/src/push.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fpush-apns/src/push.rs b/fpush-apns/src/push.rs index 03d7b26..c994e36 100644 --- a/fpush-apns/src/push.rs +++ b/fpush-apns/src/push.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::time::SystemTime; +use std::time::{SystemTime, UNIX_EPOCH}; use a2::{ request::payload::PayloadLike, Client, ClientConfig, DefaultNotificationBuilder, @@ -76,7 +76,7 @@ impl PushTrait for FpushApns { apns_priority: Some(Priority::High), apns_topic: Some(&self.topic), apns_expiration: Some( - SystemTime::now().elapsed().unwrap().as_secs() + 4 * 7 * 24 * 3600, + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 4 * 7 * 24 * 3600, ), apns_push_type: Some(PushType::Alert), ..Default::default() From 46f7e955ba2af411d6fdef672aba48dd9a3ebdc2 Mon Sep 17 00:00:00 2001 From: Nathaniel Adams Date: Wed, 27 May 2026 20:08:44 -0500 Subject: [PATCH 3/3] fpush-apns: match Signal-Server APNSender payload (collapse-id, no sound) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to align the APNs payload with Signal's APN_NSE_NOTIFICATION_PAYLOAD: 1. Add apns-collapse-id "incoming-message". When the NSE fails to post a user-visible notification (the fallback case) and several pushes arrive while the device is locked, the fallback alerts collapse to one entry on the lock screen instead of stacking N copies of "New Message". Per-conversation notifications NSE posts via UNUserNotificationCenter use their own identifiers and are unaffected. 2. Drop the default sound from the APNs payload. NSE-posted notifications carry the user's chosen sound from app settings; when NSE fails and the fallback alert is what surfaces, match Signal's silent-failure UX rather than inconsistent audio cues. Reference: signalapp/Signal-Server APNSender.java — static final String APN_NSE_NOTIFICATION_PAYLOAD = new SimpleApnsPayloadBuilder() .setMutableContent(true) .setLocalizedAlertMessage("APN_Message") .build(); apns-collapse-id: notification.urgent() ? "incoming-message" : null --- fpush-apns/src/push.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/fpush-apns/src/push.rs b/fpush-apns/src/push.rs index c994e36..a77900f 100644 --- a/fpush-apns/src/push.rs +++ b/fpush-apns/src/push.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; use a2::{ - request::payload::PayloadLike, Client, ClientConfig, DefaultNotificationBuilder, - NotificationBuilder, NotificationOptions, Priority, PushType, + request::notification::CollapseId, request::payload::PayloadLike, Client, ClientConfig, + DefaultNotificationBuilder, NotificationBuilder, NotificationOptions, Priority, PushType, }; use fpush_traits::push::{PushError, PushResult, PushTrait}; @@ -65,11 +65,25 @@ impl FpushApns { impl PushTrait for FpushApns { #[inline(always)] async fn send(&self, token: String) -> PushResult<()> { + // APNs payload structure matches Signal-Server APNSender's APN_NSE_NOTIFICATION_PAYLOAD: + // - mutable-content: 1 (wakes the NSE) + // - alert title/body present so iOS shows something if NSE fails to replace it + // - NO sound: the NSE-posted notification carries the user's chosen sound; + // when NSE fails (and the fallback alert is what shows), match Signal's + // silent-failure UX rather than inconsistent audio cues. let notification_builder = DefaultNotificationBuilder::new() .set_title("New Message") .set_body("New Message?") - .set_mutable_content() - .set_sound("default"); + .set_mutable_content(); + // Signal-Server uses apns-collapse-id "incoming-message" on every push. + // Effect: if NSE fails to post a user-visible notification (rare after + // recent NSE minimization work) AND multiple pushes arrive while the + // device is locked, the fallback alerts collapse to one entry on the + // lock screen instead of stacking N copies of "New Message". The + // per-conversation notifications NSE posts via UNUserNotificationCenter + // use their own identifiers and are unaffected. + let collapse_id = CollapseId::new("incoming-message") + .expect("collapse id literal fits the 64-byte limit"); let mut payload = notification_builder.build( &token, NotificationOptions { @@ -79,6 +93,7 @@ impl PushTrait for FpushApns { SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 4 * 7 * 24 * 3600, ), apns_push_type: Some(PushType::Alert), + apns_collapse_id: Some(collapse_id), ..Default::default() }, );