tinyrelayfile
tinyrelay/internal/webpush/webpush.go/

internal/webpush/webpush.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 "crypto/x509"
15 "encoding/base64"
16 "encoding/binary"
17 "encoding/json"
18 "errors"
19 "fmt"
20 "io"
21 "net/http"
22 "net/url"
23 "strconv"
24 "time"
25)
26
27// Subscription is what the browser's PushManager hands to the page.
28type Subscription struct {
29 Endpoint string `json:"endpoint"`
30 Keys struct {
31 P256dh string `json:"p256dh"`
32 Auth string `json:"auth"`
33 } `json:"keys"`
34}
35
36// Validate checks the shape of a subscription before it is stored.
37func (s Subscription) Validate() error {
38 parsed, err := url.Parse(s.Endpoint)
39 if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || len(s.Endpoint) > 2048 {
40 return errors.New("invalid: push endpoint must be an HTTPS URL")
41 }
42 if key, err := decode(s.Keys.P256dh); err != nil || len(key) != 65 || key[0] != 4 {
43 return errors.New("invalid: push p256dh key must be an uncompressed P-256 point")
44 }
45 if auth, err := decode(s.Keys.Auth); err != nil || len(auth) != 16 {
46 return errors.New("invalid: push auth secret must be 16 bytes")
47 }
48 return nil
49}
50
51// Keys is the VAPID key pair identifying this relay to push services.
52type Keys struct{ private *ecdsa.PrivateKey }
53
54// GenerateKeys creates a fresh P-256 VAPID key pair.
55func GenerateKeys() (Keys, error) {
56 private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
57 if err != nil {
58 return Keys{}, err
59 }
60 return Keys{private: private}, nil
61}
62
63// Marshal serializes the private key for the settings table.
64func (k Keys) Marshal() (string, error) {
65 der, err := x509.MarshalPKCS8PrivateKey(k.private)
66 if err != nil {
67 return "", err
68 }
69 return base64.RawURLEncoding.EncodeToString(der), nil
70}
71
72// ParseKeys restores a key pair from Marshal output.
73func ParseKeys(encoded string) (Keys, error) {
74 der, err := decode(encoded)
75 if err != nil {
76 return Keys{}, err
77 }
78 parsed, err := x509.ParsePKCS8PrivateKey(der)
79 if err != nil {
80 return Keys{}, err
81 }
82 private, ok := parsed.(*ecdsa.PrivateKey)
83 if !ok || private.Curve != elliptic.P256() {
84 return Keys{}, errors.New("webpush: VAPID key is not a P-256 key")
85 }
86 return Keys{private: private}, nil
87}
88
89// PublicKey is the applicationServerKey the browser subscribes with.
90func (k Keys) PublicKey() string {
91 return base64.RawURLEncoding.EncodeToString(elliptic.Marshal(elliptic.P256(), k.private.PublicKey.X, k.private.PublicKey.Y))
92}
93
94// Authorization builds the VAPID header for one push service origin. The
95// token is an ES256 JWT with the endpoint origin as its audience.
96func (k Keys) Authorization(endpoint, subject string, now time.Time) (string, error) {
97 parsed, err := url.Parse(endpoint)
98 if err != nil {
99 return "", err
100 }
101 header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"ES256"}`))
102 claims, err := json.Marshal(map[string]any{"aud": parsed.Scheme + "://" + parsed.Host, "exp": now.Add(12 * time.Hour).Unix(), "sub": subject})
103 if err != nil {
104 return "", err
105 }
106 signing := header + "." + base64.RawURLEncoding.EncodeToString(claims)
107 digest := sha256.Sum256([]byte(signing))
108 r, s, err := ecdsa.Sign(rand.Reader, k.private, digest[:])
109 if err != nil {
110 return "", err
111 }
112 signature := make([]byte, 64)
113 r.FillBytes(signature[:32])
114 s.FillBytes(signature[32:])
115 token := signing + "." + base64.RawURLEncoding.EncodeToString(signature)
116 return "vapid t=" + token + ", k=" + k.PublicKey(), nil
117}
118
119// Encrypt produces an aes128gcm body for the subscription. Each call uses a
120// fresh ephemeral key and salt, so the same payload never encrypts twice
121// the same way.
122func Encrypt(subscription Subscription, plaintext []byte) ([]byte, error) {
123 if len(plaintext) > 3993 {
124 return nil, errors.New("webpush: payload exceeds one record")
125 }
126 receiverPublic, err := decode(subscription.Keys.P256dh)
127 if err != nil {
128 return nil, err
129 }
130 auth, err := decode(subscription.Keys.Auth)
131 if err != nil {
132 return nil, err
133 }
134 curve := ecdh.P256()
135 receiver, err := curve.NewPublicKey(receiverPublic)
136 if err != nil {
137 return nil, fmt.Errorf("webpush: subscriber key: %w", err)
138 }
139 ephemeral, err := curve.GenerateKey(rand.Reader)
140 if err != nil {
141 return nil, err
142 }
143 shared, err := ephemeral.ECDH(receiver)
144 if err != nil {
145 return nil, err
146 }
147 senderPublic := ephemeral.PublicKey().Bytes()
148 salt := make([]byte, 16)
149 if _, err := io.ReadFull(rand.Reader, salt); err != nil {
150 return nil, err
151 }
152 return encrypt(plaintext, shared, receiverPublic, senderPublic, auth, salt)
153}
154
155func encrypt(plaintext, shared, receiverPublic, senderPublic, auth, salt []byte) ([]byte, error) {
156 keyInfo := append(append([]byte("WebPush: info\x00"), receiverPublic...), senderPublic...)
157 ikm, err := hkdf.Key(sha256.New, shared, auth, string(keyInfo), 32)
158 if err != nil {
159 return nil, err
160 }
161 contentKey, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16)
162 if err != nil {
163 return nil, err
164 }
165 nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12)
166 if err != nil {
167 return nil, err
168 }
169 block, err := aes.NewCipher(contentKey)
170 if err != nil {
171 return nil, err
172 }
173 gcm, err := cipher.NewGCM(block)
174 if err != nil {
175 return nil, err
176 }
177 record := gcm.Seal(nil, nonce, append(append([]byte{}, plaintext...), 2), nil)
178 var body bytes.Buffer
179 body.Write(salt)
180 _ = binary.Write(&body, binary.BigEndian, uint32(4096))
181 body.WriteByte(byte(len(senderPublic)))
182 body.Write(senderPublic)
183 body.Write(record)
184 return body.Bytes(), nil
185}
186
187// Result reports the push service's answer for one message.
188type Result struct {
189 Status int
190 // Gone means the subscription no longer exists and should be removed.
191 Gone bool
192}
193
194// Send delivers one encrypted message. A nil client uses http.DefaultClient;
195// callers that reach the public internet should pass a client that refuses
196// private addresses.
197func Send(ctx context.Context, client *http.Client, keys Keys, subject string, subscription Subscription, payload []byte, ttl time.Duration) (Result, error) {
198 body, err := Encrypt(subscription, payload)
199 if err != nil {
200 return Result{}, err
201 }
202 authorization, err := keys.Authorization(subscription.Endpoint, subject, time.Now())
203 if err != nil {
204 return Result{}, err
205 }
206 request, err := http.NewRequestWithContext(ctx, http.MethodPost, subscription.Endpoint, bytes.NewReader(body))
207 if err != nil {
208 return Result{}, err
209 }
210 request.Header.Set("Content-Type", "application/octet-stream")
211 request.Header.Set("Content-Encoding", "aes128gcm")
212 request.Header.Set("TTL", strconv.Itoa(int(ttl.Seconds())))
213 request.Header.Set("Urgency", "normal")
214 request.Header.Set("Authorization", authorization)
215 if client == nil {
216 client = http.DefaultClient
217 }
218 response, err := client.Do(request)
219 if err != nil {
220 return Result{}, err
221 }
222 defer response.Body.Close()
223 _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
224 result := Result{Status: response.StatusCode, Gone: response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusGone}
225 if response.StatusCode < 200 || response.StatusCode >= 300 {
226 return result, fmt.Errorf("webpush: push service returned HTTP %d", response.StatusCode)
227 }
228 return result, nil
229}
230
231func decode(value string) ([]byte, error) {
232 if raw, err := base64.RawURLEncoding.DecodeString(value); err == nil {
233 return raw, nil
234 }
235 if raw, err := base64.URLEncoding.DecodeString(value); err == nil {
236 return raw, nil
237 }
238 return base64.StdEncoding.DecodeString(value)
239}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs