tinyrelayfile
tinyrelay/internal/webpush/webpush_test.go/

internal/webpush/webpush_test.go raw
1package webpush
2
3import (
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
25func 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.
42func 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
76func 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
98func 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
138func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs