Skip to content

Web Push (std/webpush) ​

milo
from "std/webpush" import { WebPush, VapidKeys, WebPushError }

Everything an application server needs to send a browser push message except the HTTP request itself: payload encryption (RFC 8291, the aes128gcm content coding of RFC 8188) and the VAPID Authorization header (RFC 8292). P-256 ECDH and ECDSA come from OpenSSL's libcrypto; HKDF and AES-128-GCM from std/hkdf and std/crypto.

Sending a message ​

A browser's PushSubscription.toJSON() gives an endpoint and two keys, p256dh and auth, all strings. Encrypt the payload to those keys, sign a VAPID header for the endpoint, and POST:

milo
from "std/webpush" import { WebPush, VapidKeys }
from "std/fetch" import { fetchWith, FetchOptions }
from "std/time" import { epochSecs }

fn push(endpoint: &string, p256dh: &string, auth: &string, keys: &VapidKeys, text: &string): Result<i32> {
    let body = WebPush.encrypt(text, p256dh, auth).mapErr((e) => e.message())?
    let vapid = WebPush.vapid(endpoint, "mailto:ops@example.com", keys, epochSecs() + 12 * 3600).mapErr((e) => e.message())?
    let headers = $"Authorization: {vapid}\r\nContent-Encoding: aes128gcm\r\nContent-Type: application/octet-stream\r\nTTL: 86400"
    let r = fetchWith(endpoint, FetchOptions { method: "POST", headers: headers, body: body }).mapErr((e) => e.message())?
    // 201: accepted. 404 or 410: the subscription is gone, delete it.
    return Ok(r.status)
}

Generate the server's key pair once with VapidKeys.generate() and store privateKey; the browser subscribes with publicKey as its applicationServerKey, and every subscription is bound to it. VapidKeys.fromPrivate rebuilds the pair from the stored private key.

A message is a single record of at most 3993 bytes of payload, which is what keeps it within the 4096 bytes every push service must accept. Longer is WebPushError.PayloadTooLarge; a subscription whose keys are not a P-256 point and a 16-byte secret is WebPushError.BadSubscription, and is worth deleting.

WebPush.encryptWith takes the sender key and salt instead of drawing them at random. It exists for known-answer tests (the RFC 8291 example is reproduced byte for byte in the test suite); reusing a key and salt across messages breaks the encryption.

API reference ​

VapidKeys ​

milo
pub struct VapidKeys

VapidKeys: an application server's P-256 key pair, both halves base64url as VAPID and the browser's applicationServerKey expect them: publicKey is the 65-byte uncompressed point, privateKey the 32-byte scalar. Keep privateKey secret.

Fields: publicKey: string, privateKey: string.

VapidKeys.fromPrivate ​

milo
fn VapidKeys.fromPrivate(privateKey: &string): Result<VapidKeys, WebPushError>

The key pair for a stored base64url privateKey, its public half derived.

VapidKeys.generate ​

milo
fn VapidKeys.generate(): Result<VapidKeys, WebPushError>

A fresh key pair. Generate once and store it: every subscription a browser made is bound to the public key it was given.

WebPush ​

milo
pub struct WebPush

WebPush: message encryption and VAPID authorization.

WebPush.encrypt ​

milo
fn WebPush.encrypt(payload: &string, p256dh: &string, auth: &string): Result<string, WebPushError>

Encrypt payload for a subscription whose keys are p256dh and auth, base64url as PushSubscription.toJSON().keys gives them. Returns the request body: one aes128gcm record (RFC 8188) under a fresh sender key and salt (RFC 8291). Send it with Content-Encoding: aes128gcm.

WebPush.encryptWith ​

milo
fn WebPush.encryptWith(payload: &string, p256dh: &string, auth: &string, senderPrivate: &string, salt: &string): Result<string, WebPushError>

encrypt with the sender's private key (32 raw bytes) and the 16-byte salt chosen by the caller instead of at random, so a known-answer test (RFC 8291 Appendix A) can reproduce a message exactly. Reusing either across messages breaks the encryption: production code calls encrypt.

WebPush.vapid ​

milo
fn WebPush.vapid(endpoint: &string, subject: &string, keys: &VapidKeys, expiresAt: i64): Result<string, WebPushError>

The Authorization header value for a push to endpoint (RFC 8292): vapid t=<JWT>, k=<public key>. The JWT is ES256-signed with keys, its aud is the endpoint's origin, sub is subject (a mailto: or https: contact for the push service operator) and exp is expiresAt in epoch seconds, which push services require to be within 24 hours.

WebPushError ​

milo
pub enum WebPushError

WebPushError: why a message or a VAPID header could not be made.

Variants:

  • BadSubscription(string): The subscription's p256dh key is not a P-256 point, or its auth secret is not 16 bytes. Payload: what was wrong. The subscription is unusable: delete it.
  • PayloadTooLarge(i64): The payload is longer than the 3993 bytes one 4096-byte push message holds. Payload: the payload's length.
  • BadVapidKey(string): The VAPID private key is not a 32-byte P-256 scalar (base64url), or the endpoint is not an absolute https URL. Payload: what was wrong.
  • CryptoFailure(string): libcrypto failed where valid inputs should not fail. Payload: the step.

WebPushError.message ​

milo
fn WebPushError.message(self: &WebPushError): string

One-line description.