| 1 | // Package apns holds the one authoritative APNs provider client: it parses the |
| 2 | // Apple .p8 auth key, signs the ES256 provider JWT, builds the alert envelope, |
| 3 | // and posts to Apple. It exists as its own importable package (not package main) |
| 4 | // so BOTH the messenger (direct mode, apns.go) and the push relay (push/) share |
| 5 | // exactly one copy. |
| 6 | // |
| 7 | // The aps envelope BuildPayload produces is a load-bearing E2E contract: the |
| 8 | // iOS Notification Service Extension mirrors it field-for-field to decrypt the |
| 9 | // ciphertext blob on-device. Apple only ever relays that ciphertext — the p8 is |
| 10 | // Apple *auth*, never a message-decryption key — so routing it through a relay |
| 11 | // does not weaken end-to-end encryption. |
| 12 | package apns |
| 13 | |
| 14 | import ( |
| 15 | "bytes" |
| 16 | "crypto/ecdsa" |
| 17 | "crypto/rand" |
| 18 | "crypto/sha256" |
| 19 | "crypto/x509" |
| 20 | "encoding/base64" |
| 21 | "encoding/json" |
| 22 | "encoding/pem" |
| 23 | "errors" |
| 24 | "fmt" |
| 25 | "io" |
| 26 | "net/http" |
| 27 | "strings" |
| 28 | "sync" |
| 29 | "time" |
| 30 | ) |
| 31 | |
| 32 | // ErrGone means Apple reported the device token is dead (HTTP 410 or a |
| 33 | // BadDeviceToken reason). Callers should prune the token. |
| 34 | var ErrGone = errors.New("apns: token gone") |
| 35 | |
| 36 | // ParseKey turns the .p8 auth key — PEM, or base64 of the PEM (easier to pass |
| 37 | // as an env var) — into an ECDSA private key. |
| 38 | func ParseKey(raw string) (*ecdsa.PrivateKey, error) { |
| 39 | if !strings.Contains(raw, "BEGIN") { // base64-packed PEM |
| 40 | if dec, err := base64.StdEncoding.DecodeString(raw); err == nil { |
| 41 | raw = string(dec) |
| 42 | } |
| 43 | } |
| 44 | block, _ := pem.Decode([]byte(raw)) |
| 45 | if block == nil { |
| 46 | return nil, errors.New("apns: key is not PEM") |
| 47 | } |
| 48 | parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) |
| 49 | if err != nil { |
| 50 | return nil, fmt.Errorf("apns: bad key: %w", err) |
| 51 | } |
| 52 | ec, ok := parsed.(*ecdsa.PrivateKey) |
| 53 | if !ok { |
| 54 | return nil, errors.New("apns: key is not ECDSA") |
| 55 | } |
| 56 | return ec, nil |
| 57 | } |
| 58 | |
| 59 | // ValidDeviceToken reports whether t looks like an APNs device token: hex, |
| 60 | // 1..200 chars. Rejecting non-hex is load-bearing, not cosmetic: the token is |
| 61 | // interpolated into the request URL path to Apple (/3/device/<token>), so a |
| 62 | // '/', '.', '?', or CR/LF could rewrite that path or smuggle a header. APNs |
| 63 | // tokens are hex, so this is lossless. Send enforces it; callers should too, at |
| 64 | // the point they accept a token, for a clearer error. |
| 65 | func ValidDeviceToken(t string) bool { |
| 66 | if t == "" || len(t) > 200 { |
| 67 | return false |
| 68 | } |
| 69 | for i := 0; i < len(t); i++ { |
| 70 | c := t[i] |
| 71 | if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { |
| 72 | return false |
| 73 | } |
| 74 | } |
| 75 | return true |
| 76 | } |
| 77 | |
| 78 | // Client signs and sends. Safe for concurrent use. |
| 79 | type Client struct { |
| 80 | key *ecdsa.PrivateKey |
| 81 | keyID string |
| 82 | teamID string |
| 83 | topic string |
| 84 | http *http.Client |
| 85 | |
| 86 | // APNs gateways, overridable in tests so a stub can stand in for Apple. |
| 87 | prodHost string |
| 88 | sandboxHost string |
| 89 | |
| 90 | mu sync.Mutex |
| 91 | jwt string |
| 92 | jwtAge time.Time |
| 93 | } |
| 94 | |
| 95 | // New builds a Client from the raw key material + identifiers. It returns an |
| 96 | // error (never a silent nil) so each caller decides whether APNs is optional |
| 97 | // (the messenger falls back to Web Push) or fatal (the relay exits). |
| 98 | func New(rawKey, keyID, teamID, topic string) (*Client, error) { |
| 99 | if rawKey == "" || keyID == "" || teamID == "" || topic == "" { |
| 100 | return nil, errors.New("apns: incomplete config (need key, key id, team id, topic)") |
| 101 | } |
| 102 | key, err := ParseKey(rawKey) |
| 103 | if err != nil { |
| 104 | return nil, err |
| 105 | } |
| 106 | return &Client{ |
| 107 | key: key, keyID: keyID, teamID: teamID, topic: topic, |
| 108 | http: &http.Client{Timeout: 15 * time.Second}, |
| 109 | prodHost: "https://api.push.apple.com", |
| 110 | sandboxHost: "https://api.sandbox.push.apple.com", |
| 111 | }, nil |
| 112 | } |
| 113 | |
| 114 | // Topic is the app bundle id this client signs for. |
| 115 | func (c *Client) Topic() string { return c.topic } |
| 116 | |
| 117 | // SetHosts overrides the APNs gateways. Tests only — it lets an httptest server |
| 118 | // stand in for Apple. |
| 119 | func (c *Client) SetHosts(prod, sandbox string) { c.prodHost, c.sandboxHost = prod, sandbox } |
| 120 | |
| 121 | // bearer returns a cached provider JWT, re-signed when older than ~50 minutes |
| 122 | // (Apple accepts 20–60; a stale token gets ExpiredProviderToken). |
| 123 | func (c *Client) bearer() (string, error) { |
| 124 | c.mu.Lock() |
| 125 | defer c.mu.Unlock() |
| 126 | if c.jwt != "" && time.Since(c.jwtAge) < 50*time.Minute { |
| 127 | return c.jwt, nil |
| 128 | } |
| 129 | b64 := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } |
| 130 | header, _ := json.Marshal(map[string]string{"alg": "ES256", "kid": c.keyID}) |
| 131 | claims, _ := json.Marshal(map[string]any{"iss": c.teamID, "iat": time.Now().Unix()}) |
| 132 | signing := b64(header) + "." + b64(claims) |
| 133 | digest := sha256.Sum256([]byte(signing)) |
| 134 | r, s, err := ecdsa.Sign(rand.Reader, c.key, digest[:]) |
| 135 | if err != nil { |
| 136 | return "", err |
| 137 | } |
| 138 | // JOSE wants the raw fixed-width r||s, not ASN.1. |
| 139 | sig := make([]byte, 64) |
| 140 | r.FillBytes(sig[:32]) |
| 141 | s.FillBytes(sig[32:]) |
| 142 | c.jwt = signing + "." + b64(sig) |
| 143 | c.jwtAge = time.Now() |
| 144 | return c.jwt, nil |
| 145 | } |
| 146 | |
| 147 | // BuildPayload builds the aps envelope. Exported so it can be pinned by a test |
| 148 | // (the NSE mirrors these exact keys — mutable-content lets the app decrypt and |
| 149 | // rewrite the placeholder title; thread/blob mirror the Web Push payload). |
| 150 | // |
| 151 | // id/at are the message's server id + created_at (seconds) — routing metadata |
| 152 | // the server already stores beside the ciphertext, added so the NSE can ingest |
| 153 | // the decrypted message into the device's local store at push time (the chat is |
| 154 | // already caught up when the notification is tapped). Still no content: Apple |
| 155 | // sees an integer sequence number and a timestamp it could infer from delivery |
| 156 | // anyway. Zero values are omitted (the test push has no message). |
| 157 | func BuildPayload(threadID, blob string, id, at int64) []byte { |
| 158 | m := map[string]any{ |
| 159 | "aps": map[string]any{ |
| 160 | "alert": map[string]string{"title": "New message"}, |
| 161 | "mutable-content": 1, |
| 162 | "sound": "default", |
| 163 | "thread-id": threadID, |
| 164 | }, |
| 165 | "thread": threadID, |
| 166 | "blob": blob, |
| 167 | } |
| 168 | if id > 0 { |
| 169 | m["id"] = id |
| 170 | m["at"] = at |
| 171 | } |
| 172 | payload, _ := json.Marshal(m) |
| 173 | return payload |
| 174 | } |
| 175 | |
| 176 | // BuildAlertPayload builds the aps envelope for a PLAINTEXT banner — the |
| 177 | // changelog announcement to admins (announce.go in the messenger). Unlike |
| 178 | // BuildPayload there is no mutable-content and no ciphertext: the words are |
| 179 | // branding-class plaintext (the changelog ships in the binary and is announced |
| 180 | // publicly, never user content), so the system shows title/body as-is and the |
| 181 | // NSE never runs — which is also why the banner works on app builds that |
| 182 | // predate it. The `changelog` marker lets a tap route to the Admin app. |
| 183 | // Exported so the messenger and the relay can't drift (same rule as |
| 184 | // BuildPayload). |
| 185 | func BuildAlertPayload(title, body string) []byte { |
| 186 | m := map[string]any{ |
| 187 | "aps": map[string]any{ |
| 188 | "alert": map[string]string{"title": title, "body": body}, |
| 189 | "sound": "default", |
| 190 | }, |
| 191 | "changelog": true, |
| 192 | } |
| 193 | payload, _ := json.Marshal(m) |
| 194 | return payload |
| 195 | } |
| 196 | |
| 197 | // Send posts one alert to one device, returning ErrGone when Apple says the |
| 198 | // token is dead so the caller can prune it. |
| 199 | func (c *Client) Send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error { |
| 200 | return c.post(deviceToken, sandbox, BuildPayload(threadID, blob, id, at)) |
| 201 | } |
| 202 | |
| 203 | // SendAlert posts a plaintext banner (BuildAlertPayload) to one device. Same |
| 204 | // ErrGone/prune contract as Send. |
| 205 | func (c *Client) SendAlert(deviceToken string, sandbox bool, title, body string) error { |
| 206 | return c.post(deviceToken, sandbox, BuildAlertPayload(title, body)) |
| 207 | } |
| 208 | |
| 209 | func (c *Client) post(deviceToken string, sandbox bool, payload []byte) error { |
| 210 | if !ValidDeviceToken(deviceToken) { |
| 211 | return errors.New("apns: invalid device token") // never ErrGone — not a prune signal |
| 212 | } |
| 213 | host := c.prodHost |
| 214 | if sandbox { |
| 215 | host = c.sandboxHost |
| 216 | } |
| 217 | req, err := http.NewRequest("POST", host+"/3/device/"+deviceToken, bytes.NewReader(payload)) |
| 218 | if err != nil { |
| 219 | return err |
| 220 | } |
| 221 | token, err := c.bearer() |
| 222 | if err != nil { |
| 223 | return err |
| 224 | } |
| 225 | req.Header.Set("authorization", "bearer "+token) |
| 226 | req.Header.Set("apns-topic", c.topic) |
| 227 | req.Header.Set("apns-push-type", "alert") |
| 228 | req.Header.Set("apns-priority", "10") |
| 229 | resp, err := c.http.Do(req) |
| 230 | if err != nil { |
| 231 | return err |
| 232 | } |
| 233 | defer resp.Body.Close() |
| 234 | if resp.StatusCode >= 200 && resp.StatusCode < 300 { |
| 235 | return nil |
| 236 | } |
| 237 | body, _ := io.ReadAll(io.LimitReader(resp.Body, 400)) |
| 238 | if resp.StatusCode == http.StatusGone || bytes.Contains(body, []byte("BadDeviceToken")) { |
| 239 | return ErrGone |
| 240 | } |
| 241 | return fmt.Errorf("apns: status %d body=%q", resp.StatusCode, body) |
| 242 | } |
| 243 | |