Skip to main content

open_chess_server/
push.rs

1//! Apple push notifications for the iOS app: "your move" in correspondence
2//! games, a result to confirm. Sent over APNs' HTTP/2 API with a token
3//! signed by the team's `.p8` key (ES256), which is kept for 50 minutes as
4//! Apple asks. Off unless CHESS_APNS_KEY, _KEY_ID and _TEAM_ID are set.
5//! A device APNs reports gone is forgotten.
6
7use std::sync::{Arc, Mutex};
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result};
11use base64::Engine as _;
12use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
13use p256::ecdsa::signature::Signer;
14use p256::ecdsa::{Signature, SigningKey};
15use p256::pkcs8::DecodePrivateKey;
16use serde_json::{Value, json};
17use tracing::{debug, warn};
18
19use crate::app::Ctx;
20use crate::config::Config;
21
22/// How long a signed provider token is used (Apple: between 20 and 60 minutes).
23const TOKEN_LIFE: Duration = Duration::from_mins(50);
24
25/// The signing key and the token made with it.
26pub struct Apns {
27    key: SigningKey,
28    key_id: String,
29    team_id: String,
30    topic: String,
31    http: reqwest::Client,
32    token: Mutex<Option<(String, Instant)>>,
33}
34
35impl Apns {
36    /// From the config, when it names a key.
37    pub fn from_config(cfg: &Config) -> Result<Option<Arc<Apns>>> {
38        let (Some(key), Some(key_id), Some(team_id)) = (&cfg.apns_key, &cfg.apns_key_id, &cfg.apns_team_id)
39        else {
40            return Ok(None);
41        };
42        let pem = if key.contains("BEGIN PRIVATE KEY") {
43            key.clone()
44        } else {
45            std::fs::read_to_string(key).with_context(|| format!("reading CHESS_APNS_KEY {key}"))?
46        };
47        let key = SigningKey::from_pkcs8_pem(&pem).map_err(|e| anyhow::anyhow!("CHESS_APNS_KEY: {e}"))?;
48        let http = reqwest::Client::builder().timeout(Duration::from_secs(10)).build()?;
49        Ok(Some(Arc::new(Apns {
50            key,
51            key_id: key_id.clone(),
52            team_id: team_id.clone(),
53            topic: cfg.apns_topic.clone(),
54            http,
55            token: Mutex::new(None),
56        })))
57    }
58
59    fn token(&self) -> String {
60        let mut t = self.token.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
61        if let Some((tok, at)) = t.as_ref()
62            && at.elapsed() < TOKEN_LIFE
63        {
64            return tok.clone();
65        }
66        let tok = provider_token(&self.key, &self.key_id, &self.team_id, chrono::Utc::now().timestamp());
67        *t = Some((tok.clone(), Instant::now()));
68        tok
69    }
70
71    /// Send one alert. `Ok(false)`: the device is gone (forget it).
72    async fn send(
73        &self,
74        device: &str,
75        sandbox: bool,
76        payload: &Value,
77        collapse: Option<&str>,
78    ) -> Result<bool> {
79        let host = if sandbox { "api.sandbox.push.apple.com" } else { "api.push.apple.com" };
80        let mut req = self
81            .http
82            .post(format!("https://{host}/3/device/{device}"))
83            .bearer_auth(self.token())
84            .header("apns-topic", &self.topic)
85            .header("apns-push-type", "alert")
86            .header("apns-priority", "10")
87            .json(payload);
88        if let Some(c) = collapse {
89            req = req.header("apns-collapse-id", c);
90        }
91        let r = req.send().await?;
92        let status = r.status();
93        if status.is_success() {
94            return Ok(true);
95        }
96        let body: Value = r.json().await.unwrap_or(Value::Null);
97        let reason = body["reason"].as_str().unwrap_or_default();
98        if status.as_u16() == 410
99            || matches!(reason, "BadDeviceToken" | "Unregistered" | "DeviceTokenNotForTopic")
100        {
101            return Ok(false);
102        }
103        anyhow::bail!("APNs answered {status} {reason}")
104    }
105}
106
107/// The JWT APNs wants: ES256 over `{alg, kid}.{iss, iat}`.
108fn provider_token(key: &SigningKey, key_id: &str, team_id: &str, now: i64) -> String {
109    let header = B64.encode(json!({"alg": "ES256", "kid": key_id}).to_string());
110    let claims = B64.encode(json!({"iss": team_id, "iat": now}).to_string());
111    let input = format!("{header}.{claims}");
112    let sig: Signature = key.sign(input.as_bytes());
113    format!("{input}.{}", B64.encode(sig.to_bytes()))
114}
115
116/// Tell `player`'s devices something, in the background (never fails the
117/// caller). `collapse`: a later alert with the same id replaces this one.
118pub fn notify(
119    ctx: &Ctx,
120    player: i64,
121    title: String,
122    body: String,
123    game: Option<String>,
124    collapse: Option<String>,
125) {
126    let Some(apns) = ctx.apns.clone() else { return };
127    let ctx = ctx.clone();
128    tokio::spawn(async move {
129        if let Err(e) =
130            deliver(&ctx, &apns, player, &title, &body, game.as_deref(), collapse.as_deref()).await
131        {
132            warn!(player, "push: {e:#}");
133        }
134    });
135}
136
137async fn deliver(
138    ctx: &Ctx,
139    apns: &Apns,
140    player: i64,
141    title: &str,
142    body: &str,
143    game: Option<&str>,
144    collapse: Option<&str>,
145) -> Result<()> {
146    let devices: Vec<(String, bool)> = {
147        let c = ctx.db.conn().await?;
148        c.query(&ctx.db.sql("SELECT token, sandbox FROM chess.push_devices WHERE player_id = $1"), &[&player])
149            .await?
150            .iter()
151            .map(|r| (r.get(0), r.get(1)))
152            .collect()
153    };
154    let payload = json!({
155        "aps": {"alert": {"title": title, "body": body}, "sound": "default", "thread-id": game.unwrap_or("open-chess")},
156        "gameId": game,
157    });
158    for (token, sandbox) in devices {
159        match apns.send(&token, sandbox, &payload, collapse).await {
160            Ok(true) => debug!(player, "push sent"),
161            Ok(false) => {
162                let c = ctx.db.conn().await?;
163                c.execute(&ctx.db.sql("DELETE FROM chess.push_devices WHERE token = $1"), &[&token]).await?;
164            }
165            Err(e) => warn!(player, "push: {e:#}"),
166        }
167    }
168    Ok(())
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use p256::ecdsa::VerifyingKey;
175    use p256::ecdsa::signature::Verifier;
176
177    #[test]
178    fn signs_a_verifiable_provider_token() {
179        let key = SigningKey::from_slice(&[7u8; 32]).unwrap();
180        let t = provider_token(&key, "ABC123DEFG", "TEAM456789", 1_700_000_000);
181        let parts: Vec<&str> = t.split('.').collect();
182        assert_eq!(parts.len(), 3);
183        let header: Value = serde_json::from_slice(&B64.decode(parts[0]).unwrap()).unwrap();
184        assert_eq!((header["alg"].as_str(), header["kid"].as_str()), (Some("ES256"), Some("ABC123DEFG")));
185        let claims: Value = serde_json::from_slice(&B64.decode(parts[1]).unwrap()).unwrap();
186        assert_eq!(
187            (claims["iss"].as_str(), claims["iat"].as_i64()),
188            (Some("TEAM456789"), Some(1_700_000_000))
189        );
190        let sig = Signature::from_slice(&B64.decode(parts[2]).unwrap()).unwrap();
191        let input = format!("{}.{}", parts[0], parts[1]);
192        VerifyingKey::from(&key).verify(input.as_bytes(), &sig).unwrap();
193    }
194}