| 1 | package apns |
| 2 | |
| 3 | import ( |
| 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 | |
| 23 | func 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 | |
| 36 | func 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 | |
| 52 | func 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. |
| 72 | func 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 | |
| 119 | func 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 | |
| 151 | func 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 | |
| 189 | func 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. |
| 203 | func 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 | |
| 229 | func 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 | |
| 240 | func 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 | |
| 258 | func 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 | |