| 1 | package webpush |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/aes" |
| 7 | "crypto/cipher" |
| 8 | "crypto/ecdh" |
| 9 | "crypto/ecdsa" |
| 10 | "crypto/elliptic" |
| 11 | "crypto/hkdf" |
| 12 | "crypto/rand" |
| 13 | "crypto/sha256" |
| 14 | "encoding/base64" |
| 15 | "encoding/binary" |
| 16 | "encoding/json" |
| 17 | "math/big" |
| 18 | "net/http" |
| 19 | "net/http/httptest" |
| 20 | "strings" |
| 21 | "testing" |
| 22 | "time" |
| 23 | ) |
| 24 | |
| 25 | func subscriber(t *testing.T) (Subscription, *ecdh.PrivateKey, []byte) { |
| 26 | t.Helper() |
| 27 | private, err := ecdh.P256().GenerateKey(rand.Reader) |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | auth := make([]byte, 16) |
| 32 | rand.Read(auth) |
| 33 | var subscription Subscription |
| 34 | subscription.Endpoint = "https://push.example/send/abc" |
| 35 | subscription.Keys.P256dh = base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes()) |
| 36 | subscription.Keys.Auth = base64.RawURLEncoding.EncodeToString(auth) |
| 37 | return subscription, private, auth |
| 38 | } |
| 39 | |
| 40 | // decrypt mirrors the browser side of RFC 8291 so the test proves the body |
| 41 | // is readable by a real subscriber. |
| 42 | func decrypt(t *testing.T, body []byte, receiver *ecdh.PrivateKey, auth []byte) []byte { |
| 43 | t.Helper() |
| 44 | salt := body[:16] |
| 45 | recordSize := binary.BigEndian.Uint32(body[16:20]) |
| 46 | idLength := int(body[20]) |
| 47 | senderPublic := body[21 : 21+idLength] |
| 48 | record := body[21+idLength:] |
| 49 | if recordSize != 4096 || idLength != 65 { |
| 50 | t.Fatalf("header rs=%d idlen=%d" , recordSize, idLength) |
| 51 | } |
| 52 | sender, err := ecdh.P256().NewPublicKey(senderPublic) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | shared, err := receiver.ECDH(sender) |
| 57 | if err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | keyInfo := append(append([]byte("WebPush: info\x00" ), receiver.PublicKey().Bytes()...), senderPublic...) |
| 61 | ikm, _ := hkdf.Key(sha256.New, shared, auth, string(keyInfo), 32) |
| 62 | key, _ := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00" , 16) |
| 63 | nonce, _ := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00" , 12) |
| 64 | block, _ := aes.NewCipher(key) |
| 65 | gcm, _ := cipher.NewGCM(block) |
| 66 | plain, err := gcm.Open(nil, nonce, record, nil) |
| 67 | if err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | if plain[len(plain)-1] != 2 { |
| 71 | t.Fatalf("missing final record delimiter: %x" , plain[len(plain)-1]) |
| 72 | } |
| 73 | return plain[:len(plain)-1] |
| 74 | } |
| 75 | |
| 76 | func TestEncryptRoundTripsForASubscriber(t *testing.T) { |
| 77 | subscription, private, auth := subscriber(t) |
| 78 | if err := subscription.Validate(); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | payload := []byte(`{"title":"tiny","body":"hello"}` ) |
| 82 | first, err := Encrypt(subscription, payload) |
| 83 | if err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | second, _ := Encrypt(subscription, payload) |
| 87 | if bytes.Equal(first, second) { |
| 88 | t.Fatal("encryption reused its salt or ephemeral key" ) |
| 89 | } |
| 90 | if got := decrypt(t, first, private, auth); !bytes.Equal(got, payload) { |
| 91 | t.Fatalf("decrypted %q" , got) |
| 92 | } |
| 93 | if _, err := Encrypt(subscription, bytes.Repeat([]byte("x" ), 4000)); err == nil { |
| 94 | t.Fatal("oversized payload accepted" ) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestVAPIDAuthorizationVerifies(t *testing.T) { |
| 99 | keys, err := GenerateKeys() |
| 100 | if err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | encoded, err := keys.Marshal() |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | restored, err := ParseKeys(encoded) |
| 108 | if err != nil || restored.PublicKey() != keys.PublicKey() { |
| 109 | t.Fatalf("restore keys: %v" , err) |
| 110 | } |
| 111 | now := time.Unix(1_700_000_000, 0) |
| 112 | header, err := keys.Authorization("https://push.example/send/abc?x=1" , "mailto:owner@example" , now) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if !strings.HasPrefix(header, "vapid t=" ) || !strings.Contains(header, ", k=" +keys.PublicKey()) { |
| 117 | t.Fatalf("header %q" , header) |
| 118 | } |
| 119 | token := strings.TrimPrefix(strings.SplitN(header, ", k=" , 2)[0], "vapid t=" ) |
| 120 | parts := strings.Split(token, "." ) |
| 121 | if len(parts) != 3 { |
| 122 | t.Fatalf("token %q" , token) |
| 123 | } |
| 124 | claimsRaw, _ := base64.RawURLEncoding.DecodeString(parts[1]) |
| 125 | var claims map[string]any |
| 126 | if err := json.Unmarshal(claimsRaw, &claims); err != nil || claims["aud" ] != "https://push.example" || claims["sub" ] != "mailto:owner@example" || int64(claims["exp" ].(float64)) != now.Add(12*time.Hour).Unix() { |
| 127 | t.Fatalf("claims %s %v" , claimsRaw, err) |
| 128 | } |
| 129 | signature, _ := base64.RawURLEncoding.DecodeString(parts[2]) |
| 130 | digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) |
| 131 | public, _ := base64.RawURLEncoding.DecodeString(keys.PublicKey()) |
| 132 | x, y := elliptic.Unmarshal(elliptic.P256(), public) |
| 133 | if !ecdsa.Verify(&ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, digest[:], new(big.Int).SetBytes(signature[:32]), new(big.Int).SetBytes(signature[32:])) { |
| 134 | t.Fatal("JWT signature does not verify" ) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestSendReportsGoneSubscriptions(t *testing.T) { |
| 139 | keys, _ := GenerateKeys() |
| 140 | subscription, _, _ := subscriber(t) |
| 141 | var headers http.Header |
| 142 | status := 201 |
| 143 | server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 144 | headers = r.Header.Clone() |
| 145 | w.WriteHeader(status) |
| 146 | })) |
| 147 | defer server.Close() |
| 148 | subscription.Endpoint = server.URL + "/send/abc" |
| 149 | result, err := Send(context.Background(), server.Client(), keys, "mailto:owner@example" , subscription, []byte(`{"title":"t"}` ), time.Hour) |
| 150 | if err != nil || result.Status != 201 || result.Gone { |
| 151 | t.Fatalf("send: %v %+v" , err, result) |
| 152 | } |
| 153 | if headers.Get("Content-Encoding" ) != "aes128gcm" || headers.Get("TTL" ) != "3600" || !strings.HasPrefix(headers.Get("Authorization" ), "vapid t=" ) { |
| 154 | t.Fatalf("headers %v" , headers) |
| 155 | } |
| 156 | status = http.StatusGone |
| 157 | result, err = Send(context.Background(), server.Client(), keys, "mailto:owner@example" , subscription, []byte(`{}` ), time.Hour) |
| 158 | if err == nil || !result.Gone { |
| 159 | t.Fatalf("gone: %v %+v" , err, result) |
| 160 | } |
| 161 | } |