1package webpush23import (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)2627// 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}3536// 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 nil49}5051// Keys is the VAPID key pair identifying this relay to push services.52type Keys struct{ private *ecdsa.PrivateKey }5354// 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{}, err59 }60 return Keys{private: private}, nil61}6263// 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 "", err68 }69 return base64.RawURLEncoding.EncodeToString(der), nil70}7172// 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{}, err77 }78 parsed, err := x509.ParsePKCS8PrivateKey(der)79 if err != nil {80 return Keys{}, err81 }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}, nil87}8889// 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}9394// Authorization builds the VAPID header for one push service origin. The95// 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 "", err100 }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 "", err105 }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 "", err111 }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(), nil117}118119// Encrypt produces an aes128gcm body for the subscription. Each call uses a120// fresh ephemeral key and salt, so the same payload never encrypts twice121// 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, err129 }130 auth, err := decode(subscription.Keys.Auth)131 if err != nil {132 return nil, err133 }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, err142 }143 shared, err := ephemeral.ECDH(receiver)144 if err != nil {145 return nil, err146 }147 senderPublic := ephemeral.PublicKey().Bytes()148 salt := make([]byte, 16)149 if _, err := io.ReadFull(rand.Reader, salt); err != nil {150 return nil, err151 }152 return encrypt(plaintext, shared, receiverPublic, senderPublic, auth, salt)153}154155func 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, err160 }161 contentKey, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16)162 if err != nil {163 return nil, err164 }165 nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12)166 if err != nil {167 return nil, err168 }169 block, err := aes.NewCipher(contentKey)170 if err != nil {171 return nil, err172 }173 gcm, err := cipher.NewGCM(block)174 if err != nil {175 return nil, err176 }177 record := gcm.Seal(nil, nonce, append(append([]byte{}, plaintext...), 2), nil)178 var body bytes.Buffer179 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(), nil185}186187// Result reports the push service's answer for one message.188type Result struct {189 Status int190 // Gone means the subscription no longer exists and should be removed.191 Gone bool192}193194// Send delivers one encrypted message. A nil client uses http.DefaultClient;195// callers that reach the public internet should pass a client that refuses196// 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{}, err201 }202 authorization, err := keys.Authorization(subscription.Endpoint, subject, time.Now())203 if err != nil {204 return Result{}, err205 }206 request, err := http.NewRequestWithContext(ctx, http.MethodPost, subscription.Endpoint, bytes.NewReader(body))207 if err != nil {208 return Result{}, err209 }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.DefaultClient217 }218 response, err := client.Do(request)219 if err != nil {220 return Result{}, err221 }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, nil229}230231func decode(value string) ([]byte, error) {232 if raw, err := base64.RawURLEncoding.DecodeString(value); err == nil {233 return raw, nil234 }235 if raw, err := base64.URLEncoding.DecodeString(value); err == nil {236 return raw, nil237 }238 return base64.StdEncoding.DecodeString(value)239}