Commit a7d44dc543

a7d44dc5434dbd45ebec304c150a9675a36054d7

parent: 10c1e6efa5

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 09:31 UTC

push: APNs provider tokens

ES256 over iss and iat, cached fifty minutes. The signature is raw
r||s rather than DER, which is the difference between a token APNs
accepts and one it rejects.

Ref #89
internal/push/token.go added +77
@@ -0,0 +1,77 @@
1// Package push delivers activity notices to Apple devices over APNs: the
2// third delivery route beside the inbox row and the activity mail, with
3// the bounded-retry discipline the mail queue and webhook deliverer use.
4package push
5
6import (
7 "crypto/ecdsa"
8 "crypto/rand"
9 "crypto/sha256"
10 "encoding/base64"
11 "encoding/json"
12 "sync"
13 "time"
14)
15
16// tokenLifetime is how long a provider token is reused. APNs accepts one
17// for an hour and answers TooManyProviderTokenUpdates if they are minted
18// faster than roughly once every twenty minutes, so the useful window is
19// between the two.
20const tokenLifetime = 50 * time.Minute
21
22type tokenSource struct {
23 key *ecdsa.PrivateKey
24 keyID string
25 teamID string
26 now func() time.Time
27
28 mu sync.Mutex
29 cached string
30 issued time.Time
31}
32
33func newTokenSource(key *ecdsa.PrivateKey, keyID, teamID string) *tokenSource {
34 return &tokenSource{key: key, keyID: keyID, teamID: teamID, now: time.Now}
35}
36
37// token returns the cached provider token, minting a new one when the old
38// one is near its end.
39func (t *tokenSource) token() (string, error) {
40 t.mu.Lock()
41 defer t.mu.Unlock()
42 now := t.now()
43 if t.cached != "" && now.Sub(t.issued) < tokenLifetime {
44 return t.cached, nil
45 }
46 tok, err := t.sign(now)
47 if err != nil {
48 return "", err
49 }
50 t.cached, t.issued = tok, now
51 return tok, nil
52}
53
54func (t *tokenSource) sign(now time.Time) (string, error) {
55 header, err := json.Marshal(map[string]string{"alg": "ES256", "kid": t.keyID})
56 if err != nil {
57 return "", err
58 }
59 claims, err := json.Marshal(map[string]any{"iss": t.teamID, "iat": now.Unix()})
60 if err != nil {
61 return "", err
62 }
63 enc := base64.RawURLEncoding
64 signing := enc.EncodeToString(header) + "." + enc.EncodeToString(claims)
65 sum := sha256.Sum256([]byte(signing))
66 r, s, err := ecdsa.Sign(rand.Reader, t.key, sum[:])
67 if err != nil {
68 return "", err
69 }
70 // JWS wants the raw pair, each left-padded to the curve's byte size —
71 // not ecdsa.SignASN1's DER. A DER signature is well-formed ECDSA and
72 // is rejected by every JWT verifier, APNs included.
73 sig := make([]byte, 64)
74 r.FillBytes(sig[:32])
75 s.FillBytes(sig[32:])
76 return signing + "." + enc.EncodeToString(sig), nil
77}
internal/push/token_test.go added +97
@@ -0,0 +1,97 @@
1package push
2
3import (
4 "crypto/ecdsa"
5 "crypto/elliptic"
6 "crypto/rand"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/json"
10 "math/big"
11 "strings"
12 "testing"
13 "time"
14)
15
16func testKey(t *testing.T) *ecdsa.PrivateKey {
17 t.Helper()
18 k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
19 if err != nil {
20 t.Fatal(err)
21 }
22 return k
23}
24
25func TestTokenShapeAndSignature(t *testing.T) {
26 key := testKey(t)
27 ts := newTokenSource(key, "KEYID123", "TEAMID456")
28 tok, err := ts.token()
29 if err != nil {
30 t.Fatalf("token: %v", err)
31 }
32 parts := strings.Split(tok, ".")
33 if len(parts) != 3 {
34 t.Fatalf("want three dot-separated parts, got %d", len(parts))
35 }
36
37 var hdr struct{ Alg, Kid string }
38 raw, _ := base64.RawURLEncoding.DecodeString(parts[0])
39 if err := json.Unmarshal(raw, &hdr); err != nil {
40 t.Fatalf("header: %v", err)
41 }
42 if hdr.Alg != "ES256" || hdr.Kid != "KEYID123" {
43 t.Fatalf("header = %+v", hdr)
44 }
45
46 // APNs provider tokens carry iss (team id) and iat, and nothing else.
47 var claims map[string]any
48 raw, _ = base64.RawURLEncoding.DecodeString(parts[1])
49 if err := json.Unmarshal(raw, &claims); err != nil {
50 t.Fatalf("claims: %v", err)
51 }
52 if claims["iss"] != "TEAMID456" {
53 t.Fatalf("iss = %v", claims["iss"])
54 }
55 if _, ok := claims["iat"]; !ok {
56 t.Fatal("no iat")
57 }
58 if len(claims) != 2 {
59 t.Fatalf("unexpected claims: %v", claims)
60 }
61
62 // The signature is raw r||s, 64 bytes — not the ASN.1 DER that
63 // ecdsa.SignASN1 returns. Sending DER gets every push rejected.
64 sig, err := base64.RawURLEncoding.DecodeString(parts[2])
65 if err != nil {
66 t.Fatalf("signature not base64url: %v", err)
67 }
68 if len(sig) != 64 {
69 t.Fatalf("signature is %d bytes, want 64 (raw r||s)", len(sig))
70 }
71 sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
72 r := new(big.Int).SetBytes(sig[:32])
73 s := new(big.Int).SetBytes(sig[32:])
74 if !ecdsa.Verify(&key.PublicKey, sum[:], r, s) {
75 t.Fatal("signature does not verify")
76 }
77}
78
79func TestTokenCachedThenReminted(t *testing.T) {
80 ts := newTokenSource(testKey(t), "K", "T")
81 base := time.Now()
82 ts.now = func() time.Time { return base }
83
84 first, _ := ts.token()
85 second, _ := ts.token()
86 if first != second {
87 t.Fatal("token reminted inside the cache window; APNs answers TooManyProviderTokenUpdates")
88 }
89
90 // Valid for an hour, not to be reminted faster than every twenty
91 // minutes: refresh at fifty.
92 ts.now = func() time.Time { return base.Add(51 * time.Minute) }
93 third, _ := ts.token()
94 if third == first {
95 t.Fatal("token not reminted after fifty minutes")
96 }
97}