oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package apns
2
3import (
4 "context"
5 "crypto/ecdsa"
6 "crypto/elliptic"
7 "crypto/rand"
8 "crypto/sha256"
9 "crypto/x509"
10 "encoding/base64"
11 "encoding/json"
12 "encoding/pem"
13 "math/big"
14 "net/http"
15 "net/http/httptest"
16 "strings"
17 "sync"
18 "sync/atomic"
19 "testing"
20 "time"
21)
22
23func testKey(t *testing.T) (*ecdsa.PrivateKey, []byte) {
24 t.Helper()
25 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
26 if err != nil {
27 t.Fatal(err)
28 }
29 der, err := x509.MarshalPKCS8PrivateKey(key)
30 if err != nil {
31 t.Fatal(err)
32 }
33 return key, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
34}
35
36func client(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) {
37 t.Helper()
38 key, _ := testKey(t)
39 server := httptest.NewServer(handler)
40 t.Cleanup(server.Close)
41 c, err := New(Config{
42 KeyID: "ABC123DEFG", TeamID: "TEAM123456", Key: key,
43 Topics: map[string]string{"ios": "works.oficina.calls", "macos": "works.oficina.calls.mac"},
44 Host: server.URL, HTTP: server.Client(),
45 })
46 if err != nil {
47 t.Fatal(err)
48 }
49 return c, server
50}
51
52func TestParseKeyAcceptsAP8AndRefusesAnythingElse(t *testing.T) {
53 _, pemBytes := testKey(t)
54 if _, err := ParseKey(pemBytes); err != nil {
55 t.Fatal(err)
56 }
57 for name, input := range map[string]string{
58 "not pem": "hello",
59 "empty": "",
60 "pem but not a key": "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
61 } {
62 if _, err := ParseKey([]byte(input)); err == nil {
63 t.Errorf("accepted %s", name)
64 }
65 }
66}
67
68// The provider token is the part Apple rejects most opaquely — a wrong
69// signature encoding comes back as "InvalidProviderToken" and nothing
70// else — so it is verified here against the public key rather than
71// merely produced.
72func TestTheProviderTokenIsAValidES256JWTApplesWay(t *testing.T) {
73 var seen atomic.Value
74 c, _ := client(t, func(w http.ResponseWriter, r *http.Request) {
75 seen.Store(r.Header.Get("authorization"))
76 w.WriteHeader(http.StatusOK)
77 })
78 if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil {
79 t.Fatal(err)
80 }
81 header, ok := seen.Load().(string)
82 if !ok || !strings.HasPrefix(header, "bearer ") {
83 t.Fatal("no bearer token", header)
84 }
85 parts := strings.Split(strings.TrimPrefix(header, "bearer "), ".")
86 if len(parts) != 3 {
87 t.Fatal("not three JWT segments")
88 }
89 var head map[string]any
90 raw, err := base64.RawURLEncoding.DecodeString(parts[0])
91 if err != nil || json.Unmarshal(raw, &head) != nil {
92 t.Fatal("header is not base64url JSON")
93 }
94 if head["alg"] != "ES256" || head["kid"] != "ABC123DEFG" {
95 t.Fatal("wrong header", head)
96 }
97 var claims map[string]any
98 raw, err = base64.RawURLEncoding.DecodeString(parts[1])
99 if err != nil || json.Unmarshal(raw, &claims) != nil {
100 t.Fatal("claims are not base64url JSON")
101 }
102 if claims["iss"] != "TEAM123456" {
103 t.Fatal("wrong issuer", claims)
104 }
105 // The signature must be the raw r||s pair, each padded to 32 bytes.
106 // ASN.1 — what SignASN1 produces — is the mistake this asserts against.
107 signature, err := base64.RawURLEncoding.DecodeString(parts[2])
108 if err != nil || len(signature) != 64 {
109 t.Fatalf("signature is %d bytes, want 64 raw r||s", len(signature))
110 }
111 digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
112 r := new(big.Int).SetBytes(signature[:32])
113 s := new(big.Int).SetBytes(signature[32:])
114 if !ecdsa.Verify(&c.cfg.Key.PublicKey, digest[:], r, s) {
115 t.Fatal("signature does not verify against the key that made it")
116 }
117}
118
119func TestOneTokenIsReusedAndThenReminted(t *testing.T) {
120 var tokens sync.Map
121 var count atomic.Int32
122 c, _ := client(t, func(w http.ResponseWriter, r *http.Request) {
123 tokens.Store(r.Header.Get("authorization"), true)
124 count.Add(1)
125 w.WriteHeader(http.StatusOK)
126 })
127 now := time.Now()
128 c.cfg.Now = func() time.Time { return now }
129 for range 3 {
130 if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil {
131 t.Fatal(err)
132 }
133 }
134 distinct := 0
135 tokens.Range(func(_, _ any) bool { distinct++; return true })
136 if distinct != 1 {
137 t.Fatalf("minted %d tokens for three pushes; Apple rate-limits that", distinct)
138 }
139 // Past the life, a new one. Apple refuses a token older than an hour.
140 now = now.Add(providerTokenLife + time.Minute)
141 if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil {
142 t.Fatal(err)
143 }
144 distinct = 0
145 tokens.Range(func(_, _ any) bool { distinct++; return true })
146 if distinct != 2 {
147 t.Fatal("an expired provider token was reused")
148 }
149}
150
151func TestTheRequestCarriesWhatApplesRequires(t *testing.T) {
152 var path, topic, pushType, priority atomic.Value
153 var payload atomic.Value
154 c, _ := client(t, func(w http.ResponseWriter, r *http.Request) {
155 path.Store(r.URL.Path)
156 topic.Store(r.Header.Get("apns-topic"))
157 pushType.Store(r.Header.Get("apns-push-type"))
158 priority.Store(r.Header.Get("apns-priority"))
159 var body map[string]any
160 json.NewDecoder(r.Body).Decode(&body)
161 payload.Store(body)
162 w.WriteHeader(http.StatusOK)
163 })
164 if _, err := c.Push(t.Context(), "devicetoken123", "macos", "Bea O'Neill"); err != nil {
165 t.Fatal(err)
166 }
167 if path.Load() != "/3/device/devicetoken123" {
168 t.Fatal("wrong path", path.Load())
169 }
170 // The topic follows the platform: the two apps have different bundle
171 // identifiers, and a token sent under the wrong one is refused with
172 // DeviceTokenNotForTopic.
173 if topic.Load() != "works.oficina.calls.mac" {
174 t.Fatal("wrong topic for macos", topic.Load())
175 }
176 if pushType.Load() != "alert" || priority.Load() != "10" {
177 t.Fatal("wrong push type or priority", pushType.Load(), priority.Load())
178 }
179 body := payload.Load().(map[string]any)
180 aps := body["aps"].(map[string]any)
181 alert := aps["alert"].(map[string]any)
182 // The name is someone else's text and rides as a JSON value, never
183 // built into one.
184 if alert["body"] != "Bea O'Neill" {
185 t.Fatal("name not carried verbatim", alert["body"])
186 }
187}
188
189func TestAnUnknownPlatformIsRefusedWithoutCallingApple(t *testing.T) {
190 var called atomic.Int32
191 c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { called.Add(1) })
192 verdict, err := c.Push(t.Context(), "abc", "android", "Bea")
193 if err == nil || verdict != Retry {
194 t.Fatal("accepted an unknown platform")
195 }
196 if called.Load() != 0 {
197 t.Fatal("called Apple for a platform with no topic")
198 }
199}
200
201// The table that decides whether a token lives or dies. Pure, so every
202// reason Apple documents can be covered without a fake acting each out.
203func TestApplesRefusalsAreSortedIntoKeepAndDelete(t *testing.T) {
204 for _, c := range []struct {
205 status int
206 reason string
207 want Verdict
208 }{
209 {410, "Unregistered", Gone},
210 {400, "BadDeviceToken", Gone},
211 {400, "DeviceTokenNotForTopic", Gone},
212 {410, "", Gone},
213 {403, "ExpiredProviderToken", Retry},
214 {403, "InvalidProviderToken", Retry},
215 {429, "TooManyRequests", Retry},
216 {503, "", Retry},
217 {500, "InternalServerError", Retry},
218 } {
219 got, err := classify(c.status, c.reason)
220 if got != c.want {
221 t.Errorf("status %d reason %q: got %v, want %v", c.status, c.reason, got, c.want)
222 }
223 if err == nil {
224 t.Errorf("status %d reason %q: refusal with no error", c.status, c.reason)
225 }
226 }
227}
228
229func TestApplesGoneVerdictReachesTheCaller(t *testing.T) {
230 c, _ := client(t, func(w http.ResponseWriter, r *http.Request) {
231 w.WriteHeader(http.StatusGone)
232 w.Write([]byte(`{"reason":"Unregistered"}`))
233 })
234 verdict, err := c.Push(t.Context(), "dead", "ios", "Bea")
235 if verdict != Gone || err == nil {
236 t.Fatal("a dead token must be reported as Gone", verdict, err)
237 }
238}
239
240func TestAConfigMissingItsCredentialsIsRefusedAtConstruction(t *testing.T) {
241 key, _ := testKey(t)
242 full := Config{KeyID: "K", TeamID: "T", Key: key, Topics: map[string]string{"ios": "a"}}
243 if _, err := New(full); err != nil {
244 t.Fatal(err)
245 }
246 for name, broken := range map[string]Config{
247 "no key id": {TeamID: "T", Key: key, Topics: full.Topics},
248 "no team": {KeyID: "K", Key: key, Topics: full.Topics},
249 "no key": {KeyID: "K", TeamID: "T", Topics: full.Topics},
250 "no topics": {KeyID: "K", TeamID: "T", Key: key},
251 } {
252 if _, err := New(broken); err == nil {
253 t.Errorf("accepted a config with %s", name)
254 }
255 }
256}
257
258func TestUnreachableAppleIsRetryAndNotGone(t *testing.T) {
259 // The distinction that matters: a network that is down must never be
260 // read as "this device is gone", or an outage deletes every token.
261 key, _ := testKey(t)
262 c, err := New(Config{
263 KeyID: "K", TeamID: "T", Key: key,
264 Topics: map[string]string{"ios": "a"},
265 Host: "http://127.0.0.1:1",
266 })
267 if err != nil {
268 t.Fatal(err)
269 }
270 ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
271 defer cancel()
272 verdict, err := c.Push(ctx, "abc", "ios", "Bea")
273 if verdict != Retry || err == nil {
274 t.Fatal("an unreachable Apple must be Retry", verdict, err)
275 }
276}
277