Skip to main content

ocre/
push.rs

1//! Web push notifications: messages a browser shows even when the app's
2//! page is closed (the Push API, with the service worker of `ocre g pwa`).
3//!
4//! The browser subscribes with the app's public VAPID key and hands the
5//! app a [`Subscription`]: an endpoint at its push service (Google's for
6//! Chrome, Apple's for Safari, Mozilla's for Firefox) and the keys to
7//! encrypt for it. [`send`] encrypts the message (`aes128gcm`, RFC 8291),
8//! signs the request with the app's private VAPID key (RFC 8292) and posts
9//! it to the endpoint: one subrequest per subscription. A subscription the
10//! push service answers 404 or 410 to is gone ([`Sent::Gone`]): delete it.
11//!
12//! Settings: `VAPID_PUBLIC_KEY` and `VAPID_SUBJECT` (`mailto:` or `https:`
13//! contact, required by push services) are variables, `VAPID_PRIVATE_KEY`
14//! a secret; [`VapidKeys::generate`] (or `ocre g push`) makes a pair.
15//!
16//! CPU: each message takes three P-256 operations in WebAssembly (an
17//! ephemeral key, the key agreement, the VAPID signature), the costliest
18//! part of sending (not yet measured on Workers): send to many subscribers
19//! from a job, a batch per run, rather than in a request.
20
21use aes_gcm::{Aes128Gcm, KeyInit as _, aead::Aead as _};
22use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
23use hkdf::Hkdf;
24use p256::{
25    PublicKey, SecretKey,
26    ecdsa::{Signature, SigningKey, signature::Signer as _},
27    elliptic_curve::sec1::ToEncodedPoint as _,
28};
29use serde::{Deserialize, Serialize};
30use sha2::Sha256;
31
32pub use crate::runtime::push::{Sent, send};
33use crate::{Error, Result, token::random_bytes};
34
35/// Worker variable holding the public VAPID key (URL-safe base64), which browsers subscribe with.
36pub const VAPID_PUBLIC_KEY: &str = "VAPID_PUBLIC_KEY";
37/// Worker secret holding the private VAPID key (URL-safe base64, 32 bytes).
38pub const VAPID_PRIVATE_KEY: &str = "VAPID_PRIVATE_KEY";
39/// Worker variable holding the contact push services may write to: `mailto:you@example.com` or an `https:` URL.
40pub const VAPID_SUBJECT: &str = "VAPID_SUBJECT";
41
42/// Largest message, in bytes: one 4,096-byte record less the encryption's overhead.
43pub const MAX_PAYLOAD: usize = 4096 - 16 - 1;
44
45/// What a browser's `PushSubscription.toJSON()` gives: where and how to push to it.
46///
47/// # Examples
48///
49/// ```
50/// let json = r#"{"endpoint":"https://fcm.googleapis.com/fcm/send/abc","expirationTime":null,"keys":{"p256dh":"BCV...","auth":"BTB..."}}"#;
51/// let subscription: ocre::push::Subscription = serde_json::from_str(json).unwrap();
52/// assert_eq!(subscription.keys.auth, "BTB...");
53/// ```
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct Subscription {
56    /// The push service URL for this browser.
57    pub endpoint: String,
58    /// The browser's keys.
59    pub keys: SubscriptionKeys,
60}
61
62/// The browser's public key and authentication secret, URL-safe base64.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct SubscriptionKeys {
65    /// P-256 public key (65 bytes, uncompressed).
66    pub p256dh: String,
67    /// Authentication secret (16 bytes).
68    pub auth: String,
69}
70
71/// A VAPID key pair, URL-safe base64 without padding.
72#[derive(Clone, PartialEq, Eq)]
73pub struct VapidKeys {
74    /// The public key (65 bytes, uncompressed point): `VAPID_PUBLIC_KEY`, given to browsers.
75    pub public_key: String,
76    /// The private key (32 bytes): `VAPID_PRIVATE_KEY`, a secret.
77    pub private_key: String,
78}
79
80impl std::fmt::Debug for VapidKeys {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_struct("VapidKeys").field("public_key", &self.public_key).field("private_key", &"[redacted]").finish()
83    }
84}
85
86impl VapidKeys {
87    /// A new random key pair.
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// let keys = ocre::push::VapidKeys::generate();
93    /// assert_eq!(keys.public_key.len(), 87); // 65 bytes
94    /// assert_eq!(keys.private_key.len(), 43); // 32 bytes
95    /// assert!(!format!("{keys:?}").contains(&keys.private_key));
96    /// ```
97    pub fn generate() -> Self {
98        let secret = random_secret();
99        Self {
100            public_key: URL_SAFE_NO_PAD.encode(secret.public_key().to_encoded_point(false).as_bytes()),
101            private_key: URL_SAFE_NO_PAD.encode(secret.to_bytes()),
102        }
103    }
104}
105
106/// A random P-256 secret key (retried in the astronomically rare case the bytes are not a valid scalar).
107fn random_secret() -> SecretKey {
108    std::iter::repeat_with(|| SecretKey::from_slice(&random_bytes::<32>())).find_map(Result::ok).expect("random bytes")
109}
110
111/// The message the service worker of `ocre g pwa` shows: `{"title", "options": {"body", "data": {"path"}}}`;
112/// clicking the notification opens `path`.
113///
114/// # Examples
115///
116/// ```
117/// let message = ocre::push::message("Ready", "Your video is ready.", "/videos/42");
118/// assert_eq!(message["options"]["data"]["path"], "/videos/42");
119/// ```
120pub fn message(title: &str, body: &str, path: &str) -> serde_json::Value {
121    serde_json::json!({ "title": title, "options": { "body": body, "data": { "path": path } } })
122}
123
124/// Encrypts `payload` for a subscription (`aes128gcm` content coding, RFC 8291), with a new key and salt.
125///
126/// # Errors
127///
128/// [`Error::BadRequest`] when the subscription's keys are not valid
129/// base64 P-256 keys, or the payload is over [`MAX_PAYLOAD`].
130pub fn encrypt(keys: &SubscriptionKeys, payload: &[u8]) -> Result<Vec<u8>> {
131    encrypt_with(keys, payload, &random_secret(), random_bytes::<16>())
132}
133
134/// [`encrypt`] with the sender's key and salt given (RFC 8291's test vector uses fixed ones).
135pub(crate) fn encrypt_with(
136    keys: &SubscriptionKeys,
137    payload: &[u8],
138    sender: &SecretKey,
139    salt: [u8; 16],
140) -> Result<Vec<u8>> {
141    if payload.len() > MAX_PAYLOAD {
142        return Err(Error::bad_request(format!("a push message is at most {MAX_PAYLOAD} bytes")));
143    }
144    let invalid = || Error::bad_request("the push subscription's keys are invalid");
145    let browser_bytes = URL_SAFE_NO_PAD.decode(keys.p256dh.trim_end_matches('=')).map_err(|_| invalid())?;
146    let auth = URL_SAFE_NO_PAD.decode(keys.auth.trim_end_matches('=')).map_err(|_| invalid())?;
147    let browser = PublicKey::from_sec1_bytes(&browser_bytes).map_err(|_| invalid())?;
148    let shared = p256::ecdh::diffie_hellman(sender.to_nonzero_scalar(), browser.as_affine());
149    let sender_public = sender.public_key().to_encoded_point(false);
150    let sender_bytes = sender_public.as_bytes();
151    // IKM = HKDF(auth, ecdh_secret, "WebPush: info" || 0 || ua_public || as_public, 32)
152    let key_info = [b"WebPush: info\0".as_slice(), &browser_bytes, sender_bytes].concat();
153    let mut ikm = [0; 32];
154    Hkdf::<Sha256>::new(Some(&auth), shared.raw_secret_bytes())
155        .expand(&key_info, &mut ikm)
156        .expect("32 bytes is a valid HKDF-SHA256 length");
157    let content = Hkdf::<Sha256>::new(Some(&salt), &ikm);
158    let (mut key, mut nonce) = ([0; 16], [0; 12]);
159    content.expand(b"Content-Encoding: aes128gcm\0", &mut key).expect("valid length");
160    content.expand(b"Content-Encoding: nonce\0", &mut nonce).expect("valid length");
161    // One record: the payload, then the last-record delimiter 0x02, no padding.
162    let plaintext = [payload, &[2]].concat();
163    let ciphertext = Aes128Gcm::new(&key.into())
164        .encrypt(&nonce.into(), plaintext.as_slice())
165        .expect("AES-GCM encrypts any message this short");
166    let record_size: u32 = 4096;
167    let mut body = Vec::with_capacity(16 + 4 + 1 + sender_bytes.len() + ciphertext.len());
168    body.extend_from_slice(&salt);
169    body.extend_from_slice(&record_size.to_be_bytes());
170    body.push(u8::try_from(sender_bytes.len()).expect("65 bytes"));
171    body.extend_from_slice(sender_bytes);
172    body.extend_from_slice(&ciphertext);
173    Ok(body)
174}
175
176/// The `Authorization` header of a push to `endpoint` (VAPID, RFC 8292):
177/// `vapid t=<JWT signed with ES256>, k=<public key>`, valid 12 hours.
178///
179/// # Errors
180///
181/// [`Error::Internal`] when the private key is not 32 bytes of URL-safe
182/// base64, or `endpoint` is not an `https://` (or, for local fakes, `http://`) URL.
183///
184/// # Examples
185///
186/// ```
187/// let keys = ocre::push::VapidKeys::generate();
188/// let header = ocre::push::vapid_authorization("https://push.example/send/1", "mailto:ops@example.com", &keys, 1_790_000_000).unwrap();
189/// assert!(header.starts_with("vapid t=eyJ"));
190/// assert!(header.ends_with(&format!(", k={}", keys.public_key)));
191/// ```
192pub fn vapid_authorization(endpoint: &str, subject: &str, keys: &VapidKeys, now: i64) -> Result<String> {
193    let invalid = || Error::internal("VAPID_PRIVATE_KEY is not a P-256 private key in URL-safe base64");
194    let private = URL_SAFE_NO_PAD.decode(keys.private_key.trim().trim_end_matches('=')).map_err(|_| invalid())?;
195    let signing = SigningKey::from_slice(&private).map_err(|_| invalid())?;
196    // The push service's origin. Real ones are https; http serves local fakes in tests.
197    let audience = ["https://", "http://"]
198        .iter()
199        .find_map(|scheme| {
200            endpoint.strip_prefix(scheme).map(|rest| format!("{scheme}{}", rest.split('/').next().unwrap_or_default()))
201        })
202        .ok_or_else(|| Error::internal(format!("a push endpoint is an https:// URL, not `{endpoint}`")))?;
203    let header = URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"ES256"}"#);
204    let claims = serde_json::json!({ "aud": audience, "exp": now + 12 * 3600, "sub": subject });
205    let unsigned = format!("{header}.{}", URL_SAFE_NO_PAD.encode(claims.to_string()));
206    let signature: Signature = signing.sign(unsigned.as_bytes());
207    Ok(format!("vapid t={unsigned}.{}, k={}", URL_SAFE_NO_PAD.encode(signature.to_bytes()), keys.public_key.trim()))
208}
209
210#[cfg(test)]
211#[path = "../tests/push.rs"]
212mod tests;