// Package apns holds the one authoritative APNs provider client: it parses the // Apple .p8 auth key, signs the ES256 provider JWT, builds the alert envelope, // and posts to Apple. It exists as its own importable package (not package main) // so BOTH the messenger (direct mode, apns.go) and the push relay (push/) share // exactly one copy. // // The aps envelope BuildPayload produces is a load-bearing E2E contract: the // iOS Notification Service Extension mirrors it field-for-field to decrypt the // ciphertext blob on-device. Apple only ever relays that ciphertext — the p8 is // Apple *auth*, never a message-decryption key — so routing it through a relay // does not weaken end-to-end encryption. package apns import ( "bytes" "crypto/ecdsa" "crypto/rand" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" "io" "net/http" "strings" "sync" "time" ) // ErrGone means Apple reported the device token is dead (HTTP 410 or a // BadDeviceToken reason). Callers should prune the token. var ErrGone = errors.New("apns: token gone") // ParseKey turns the .p8 auth key — PEM, or base64 of the PEM (easier to pass // as an env var) — into an ECDSA private key. func ParseKey(raw string) (*ecdsa.PrivateKey, error) { if !strings.Contains(raw, "BEGIN") { // base64-packed PEM if dec, err := base64.StdEncoding.DecodeString(raw); err == nil { raw = string(dec) } } block, _ := pem.Decode([]byte(raw)) if block == nil { return nil, errors.New("apns: key is not PEM") } parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("apns: bad key: %w", err) } ec, ok := parsed.(*ecdsa.PrivateKey) if !ok { return nil, errors.New("apns: key is not ECDSA") } return ec, nil } // ValidDeviceToken reports whether t looks like an APNs device token: hex, // 1..200 chars. Rejecting non-hex is load-bearing, not cosmetic: the token is // interpolated into the request URL path to Apple (/3/device/), so a // '/', '.', '?', or CR/LF could rewrite that path or smuggle a header. APNs // tokens are hex, so this is lossless. Send enforces it; callers should too, at // the point they accept a token, for a clearer error. func ValidDeviceToken(t string) bool { if t == "" || len(t) > 200 { return false } for i := 0; i < len(t); i++ { c := t[i] if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { return false } } return true } // Client signs and sends. Safe for concurrent use. type Client struct { key *ecdsa.PrivateKey keyID string teamID string topic string http *http.Client // APNs gateways, overridable in tests so a stub can stand in for Apple. prodHost string sandboxHost string mu sync.Mutex jwt string jwtAge time.Time } // New builds a Client from the raw key material + identifiers. It returns an // error (never a silent nil) so each caller decides whether APNs is optional // (the messenger falls back to Web Push) or fatal (the relay exits). func New(rawKey, keyID, teamID, topic string) (*Client, error) { if rawKey == "" || keyID == "" || teamID == "" || topic == "" { return nil, errors.New("apns: incomplete config (need key, key id, team id, topic)") } key, err := ParseKey(rawKey) if err != nil { return nil, err } return &Client{ key: key, keyID: keyID, teamID: teamID, topic: topic, http: &http.Client{Timeout: 15 * time.Second}, prodHost: "https://api.push.apple.com", sandboxHost: "https://api.sandbox.push.apple.com", }, nil } // Topic is the app bundle id this client signs for. func (c *Client) Topic() string { return c.topic } // SetHosts overrides the APNs gateways. Tests only — it lets an httptest server // stand in for Apple. func (c *Client) SetHosts(prod, sandbox string) { c.prodHost, c.sandboxHost = prod, sandbox } // bearer returns a cached provider JWT, re-signed when older than ~50 minutes // (Apple accepts 20–60; a stale token gets ExpiredProviderToken). func (c *Client) bearer() (string, error) { c.mu.Lock() defer c.mu.Unlock() if c.jwt != "" && time.Since(c.jwtAge) < 50*time.Minute { return c.jwt, nil } b64 := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } header, _ := json.Marshal(map[string]string{"alg": "ES256", "kid": c.keyID}) claims, _ := json.Marshal(map[string]any{"iss": c.teamID, "iat": time.Now().Unix()}) signing := b64(header) + "." + b64(claims) digest := sha256.Sum256([]byte(signing)) r, s, err := ecdsa.Sign(rand.Reader, c.key, digest[:]) if err != nil { return "", err } // JOSE wants the raw fixed-width r||s, not ASN.1. sig := make([]byte, 64) r.FillBytes(sig[:32]) s.FillBytes(sig[32:]) c.jwt = signing + "." + b64(sig) c.jwtAge = time.Now() return c.jwt, nil } // BuildPayload builds the aps envelope. Exported so it can be pinned by a test // (the NSE mirrors these exact keys — mutable-content lets the app decrypt and // rewrite the placeholder title; thread/blob mirror the Web Push payload). // // id/at are the message's server id + created_at (seconds) — routing metadata // the server already stores beside the ciphertext, added so the NSE can ingest // the decrypted message into the device's local store at push time (the chat is // already caught up when the notification is tapped). Still no content: Apple // sees an integer sequence number and a timestamp it could infer from delivery // anyway. Zero values are omitted (the test push has no message). func BuildPayload(threadID, blob string, id, at int64) []byte { m := map[string]any{ "aps": map[string]any{ "alert": map[string]string{"title": "New message"}, "mutable-content": 1, "sound": "default", "thread-id": threadID, }, "thread": threadID, "blob": blob, } if id > 0 { m["id"] = id m["at"] = at } payload, _ := json.Marshal(m) return payload } // BuildAlertPayload builds the aps envelope for a PLAINTEXT banner — the // changelog announcement to admins (announce.go in the messenger). Unlike // BuildPayload there is no mutable-content and no ciphertext: the words are // branding-class plaintext (the changelog ships in the binary and is announced // publicly, never user content), so the system shows title/body as-is and the // NSE never runs — which is also why the banner works on app builds that // predate it. The `changelog` marker lets a tap route to the Admin app. // Exported so the messenger and the relay can't drift (same rule as // BuildPayload). func BuildAlertPayload(title, body string) []byte { m := map[string]any{ "aps": map[string]any{ "alert": map[string]string{"title": title, "body": body}, "sound": "default", }, "changelog": true, } payload, _ := json.Marshal(m) return payload } // Send posts one alert to one device, returning ErrGone when Apple says the // token is dead so the caller can prune it. func (c *Client) Send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error { return c.post(deviceToken, sandbox, BuildPayload(threadID, blob, id, at)) } // SendAlert posts a plaintext banner (BuildAlertPayload) to one device. Same // ErrGone/prune contract as Send. func (c *Client) SendAlert(deviceToken string, sandbox bool, title, body string) error { return c.post(deviceToken, sandbox, BuildAlertPayload(title, body)) } func (c *Client) post(deviceToken string, sandbox bool, payload []byte) error { if !ValidDeviceToken(deviceToken) { return errors.New("apns: invalid device token") // never ErrGone — not a prune signal } host := c.prodHost if sandbox { host = c.sandboxHost } req, err := http.NewRequest("POST", host+"/3/device/"+deviceToken, bytes.NewReader(payload)) if err != nil { return err } token, err := c.bearer() if err != nil { return err } req.Header.Set("authorization", "bearer "+token) req.Header.Set("apns-topic", c.topic) req.Header.Set("apns-push-type", "alert") req.Header.Set("apns-priority", "10") resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } body, _ := io.ReadAll(io.LimitReader(resp.Body, 400)) if resp.StatusCode == http.StatusGone || bytes.Contains(body, []byte("BadDeviceToken")) { return ErrGone } return fmt.Errorf("apns: status %d body=%q", resp.StatusCode, body) }