eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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.
12package apns
13
14import (
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.
34var 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.
38func 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.
65func 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.
79type 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).
98func 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.
115func (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.
119func (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).
123func (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).
157func 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).
185func 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.
199func (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.
205func (c *Client) SendAlert(deviceToken string, sandbox bool, title, body string) error {
206 return c.post(deviceToken, sandbox, BuildAlertPayload(title, body))
207}
208
209func (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