Staff push notifications, and revalidate embedded assets
Notifications: staff turn them on per device from a new Notifications page, pick each type (bans, gags, mutes and silences added or lifted, server down and back up) and quiet hours. A watcher polls SimpleAdmin's tables, so in-game penalties are sent too. Web Push with VAPID and RFC 8291 encryption in internal/push, standard library only. Tables in sql/push.sql, created at startup; the VAPID key is made on first start and kept in saw_settings. Penalty notifications link to the public list searching for the player (#bans?q=<steamid64>). The Android app delegates notifications (versionCode 3). Caching: pages, scripts and styles are served with Cache-Control: no-cache and an ETag, so a deploy shows up at once instead of after Cloudflare's default 4-hour browser cache.
This commit is contained in:
parent
7900bf5b3e
commit
17232bd5a2
23 changed files with 2135 additions and 12 deletions
287
internal/push/push.go
Normal file
287
internal/push/push.go
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
// Package push sends Web Push messages (RFC 8030), encrypted for the browser (RFC 8291) and signed
|
||||
// for the push service with VAPID (RFC 8292). Only the standard library is used.
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
// decode64 accepts base64url with or without padding, which is how browsers hand out keys.
|
||||
func decode64(s string) ([]byte, error) {
|
||||
return b64.DecodeString(strings.TrimRight(s, "="))
|
||||
}
|
||||
|
||||
// Keys is the server's VAPID key pair. Browsers are given the public key when they subscribe, and
|
||||
// the push service only accepts messages signed with the matching private key.
|
||||
type Keys struct {
|
||||
priv *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
// GenerateKeys makes a new VAPID key pair.
|
||||
func GenerateKeys() (*Keys, error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Keys{priv: priv}, nil
|
||||
}
|
||||
|
||||
// ParseKeys reads a private key written by Keys.Private.
|
||||
func ParseKeys(private string) (*Keys, error) {
|
||||
raw, err := decode64(private)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("VAPID key: %w", err)
|
||||
}
|
||||
priv, err := ecdsa.ParseRawPrivateKey(elliptic.P256(), raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("VAPID key: %w", err)
|
||||
}
|
||||
return &Keys{priv: priv}, nil
|
||||
}
|
||||
|
||||
// Private is the private key as base64url, for storing.
|
||||
func (k *Keys) Private() string {
|
||||
raw, _ := k.priv.Bytes()
|
||||
return b64.EncodeToString(raw)
|
||||
}
|
||||
|
||||
// Public is the uncompressed public key as base64url: the applicationServerKey for subscribing.
|
||||
func (k *Keys) Public() string {
|
||||
raw, _ := k.priv.PublicKey.Bytes()
|
||||
return b64.EncodeToString(raw)
|
||||
}
|
||||
|
||||
// vapid returns the Authorization header for a push service: a JWT naming its origin, signed ES256.
|
||||
func (k *Keys) vapid(endpoint, subject string, now time.Time) (string, error) {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claims, _ := json.Marshal(map[string]any{
|
||||
"aud": u.Scheme + "://" + u.Host,
|
||||
"exp": now.Add(12 * time.Hour).Unix(),
|
||||
"sub": subject,
|
||||
})
|
||||
unsigned := b64.EncodeToString([]byte(`{"typ":"JWT","alg":"ES256"}`)) + "." + b64.EncodeToString(claims)
|
||||
digest := sha256.Sum256([]byte(unsigned))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, k.priv, digest[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
s.FillBytes(sig[32:])
|
||||
return "vapid t=" + unsigned + "." + b64.EncodeToString(sig) + ", k=" + k.Public(), nil
|
||||
}
|
||||
|
||||
// Subscription is what a browser's PushManager.subscribe() returns.
|
||||
type Subscription struct {
|
||||
Endpoint string
|
||||
P256dh string // the browser's public key, base64url
|
||||
Auth string // its 16-byte auth secret, base64url
|
||||
}
|
||||
|
||||
// Check validates the keys, so a bad subscription is refused when it's saved rather than on send.
|
||||
func (s Subscription) Check() error {
|
||||
pub, err := decode64(s.P256dh)
|
||||
if err != nil || len(pub) != 65 {
|
||||
return errors.New("the browser's push key isn't valid")
|
||||
}
|
||||
if _, err := ecdh.P256().NewPublicKey(pub); err != nil {
|
||||
return errors.New("the browser's push key isn't valid")
|
||||
}
|
||||
auth, err := decode64(s.Auth)
|
||||
if err != nil || len(auth) != 16 {
|
||||
return errors.New("the browser's push secret isn't valid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const recordSize = 4096
|
||||
|
||||
// encrypt is RFC 8291's aes128gcm encoding of one message for one browser. The salt and the
|
||||
// sender's one-off key are passed in so the RFC's test vector can check it.
|
||||
func encrypt(plaintext, uaPublic, authSecret []byte, as *ecdh.PrivateKey, salt []byte) ([]byte, error) {
|
||||
ua, err := ecdh.P256().NewPublicKey(uaPublic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := as.ECDH(ua)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
asPublic := as.PublicKey().Bytes()
|
||||
|
||||
prkKey, err := hkdf.Extract(sha256.New, secret, authSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyInfo := "WebPush: info\x00" + string(uaPublic) + string(asPublic)
|
||||
ikm, err := hkdf.Expand(sha256.New, prkKey, keyInfo, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prk, err := hkdf.Extract(sha256.New, ikm, salt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cek, err := hkdf.Expand(sha256.New, prk, "Content-Encoding: aes128gcm\x00", 16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce, err := hkdf.Expand(sha256.New, prk, "Content-Encoding: nonce\x00", 12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(cek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// One record, ended with the last-record delimiter and no padding.
|
||||
padded := append(append([]byte{}, plaintext...), 0x02)
|
||||
|
||||
header := make([]byte, 0, 16+4+1+len(asPublic))
|
||||
header = append(header, salt...)
|
||||
header = binary.BigEndian.AppendUint32(header, recordSize)
|
||||
header = append(header, byte(len(asPublic)))
|
||||
header = append(header, asPublic...)
|
||||
return gcm.Seal(header, nonce, padded, nil), nil
|
||||
}
|
||||
|
||||
// MaxPayload is the most a message can carry. Push services take 4096 bytes after encryption.
|
||||
const MaxPayload = recordSize - 86 - 16 - 1
|
||||
|
||||
// Message is one push.
|
||||
type Message struct {
|
||||
Payload []byte
|
||||
// TTL is how long the push service keeps it for a phone that's offline.
|
||||
TTL time.Duration
|
||||
// Urgency is very-low, low, normal or high. High wakes a phone in battery saving.
|
||||
Urgency string
|
||||
// Topic replaces an undelivered message with the same topic (at most 32 base64url characters).
|
||||
Topic string
|
||||
}
|
||||
|
||||
// Sender delivers messages. Subject is a mailto: or https: URL the push service can contact.
|
||||
type Sender struct {
|
||||
Keys *Keys
|
||||
Subject string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// ErrGone means the browser has unsubscribed or the subscription expired: delete it.
|
||||
var ErrGone = errors.New("push subscription is gone")
|
||||
|
||||
// Send encrypts and posts one message to one subscription.
|
||||
func (s *Sender) Send(ctx context.Context, sub Subscription, m Message) error {
|
||||
if len(m.Payload) > MaxPayload {
|
||||
return fmt.Errorf("push payload is %d bytes, over %d", len(m.Payload), MaxPayload)
|
||||
}
|
||||
uaPublic, err := decode64(sub.P256dh)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auth, err := decode64(sub.Auth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
as, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := encrypt(m.Payload, uaPublic, auth, as, salt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authz, err := s.Keys.vapid(sub.Endpoint, s.Subject, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sub.Endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Encoding", "aes128gcm")
|
||||
req.Header.Set("Authorization", authz)
|
||||
req.Header.Set("TTL", strconv.Itoa(int(m.TTL.Seconds())))
|
||||
if m.Urgency != "" {
|
||||
req.Header.Set("Urgency", m.Urgency)
|
||||
}
|
||||
if m.Topic != "" {
|
||||
req.Header.Set("Topic", m.Topic)
|
||||
}
|
||||
client := s.Client
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
msg, _ := io.ReadAll(io.LimitReader(res.Body, 512))
|
||||
switch {
|
||||
case res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusGone:
|
||||
return ErrGone
|
||||
case res.StatusCode >= 300:
|
||||
return fmt.Errorf("push service answered %s: %s", res.Status, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushHosts are the push services of the browsers in use. Subscriptions are only accepted for
|
||||
// these, so the panel can't be pointed at an arbitrary URL.
|
||||
var pushHosts = []string{
|
||||
"fcm.googleapis.com", // Chrome, Edge on Android, Samsung Internet, Brave, Opera
|
||||
"push.services.mozilla.com", // Firefox
|
||||
"web.push.apple.com", // Safari
|
||||
"notify.windows.com", // Edge on Windows
|
||||
}
|
||||
|
||||
// AllowedEndpoint reports whether an endpoint is an https URL on a known push service.
|
||||
func AllowedEndpoint(endpoint string) bool {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Scheme != "https" || u.User != nil || u.Port() != "" {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
for _, h := range pushHosts {
|
||||
if host == h || strings.HasSuffix(host, "."+h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
187
internal/push/push_test.go
Normal file
187
internal/push/push_test.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/ecdsa"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mustDecode(t *testing.T, s string) []byte {
|
||||
t.Helper()
|
||||
b, err := decode64(strings.Join(strings.Fields(s), ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestEncryptRFC8291 is the worked example in RFC 8291 section 5 and appendix A.
|
||||
func TestEncryptRFC8291(t *testing.T) {
|
||||
as, err := ecdh.P256().NewPrivateKey(mustDecode(t, "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := encrypt(
|
||||
[]byte("When I grow up, I want to be a watermelon"),
|
||||
mustDecode(t, "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"),
|
||||
mustDecode(t, "BTBZMqHH6r4Tts7J_aSIgg"),
|
||||
as,
|
||||
mustDecode(t, "DGv6ra1nlYgDCS1FRnbzlw"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := append(
|
||||
mustDecode(t, `DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z 9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml
|
||||
mlMoZIIgDll6e3vCYLocInmYWAmS6Tlz AC8wEqKK6PBru3jl7A8`),
|
||||
mustDecode(t, `8pfeW0KbunFT06SuDKoJH9Ql87S1QUrd irN6GcG7sFz1y1sqLgVi1VhjVkHsUoEs bI_0LpXMuGvnzQ`)...)
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("got %x\nwant %x", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeysRoundTrip(t *testing.T) {
|
||||
k, err := GenerateKeys()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k2, err := ParseKeys(k.Private())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k2.Public() != k.Public() || len(mustDecode(t, k.Public())) != 65 {
|
||||
t.Errorf("public key changed or has the wrong length: %s", k.Public())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSend posts to a fake push service, then checks the VAPID signature and decrypts the body
|
||||
// the way a browser would.
|
||||
func TestSend(t *testing.T) {
|
||||
keys, _ := GenerateKeys()
|
||||
ua, _ := ecdh.P256().GenerateKey(rand.Reader)
|
||||
auth := []byte("0123456789abcdef")
|
||||
|
||||
var got *http.Request
|
||||
var body []byte
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = r
|
||||
body, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := &Sender{Keys: keys, Subject: "https://bans.example.com", Client: srv.Client()}
|
||||
sub := Subscription{Endpoint: srv.URL + "/send/abc", P256dh: b64.EncodeToString(ua.PublicKey().Bytes()), Auth: b64.EncodeToString(auth)}
|
||||
if err := sub.Check(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Send(context.Background(), sub, Message{Payload: []byte(`{"title":"hi"}`), TTL: time.Hour, Urgency: "high", Topic: "server"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Header.Get("Content-Encoding") != "aes128gcm" || got.Header.Get("TTL") != "3600" || got.Header.Get("Urgency") != "high" || got.Header.Get("Topic") != "server" {
|
||||
t.Errorf("headers: %v", got.Header)
|
||||
}
|
||||
|
||||
// VAPID: the JWT's audience is the push service's origin, signed by the key in k=.
|
||||
authz := got.Header.Get("Authorization")
|
||||
tok, k, ok := strings.Cut(strings.TrimPrefix(authz, "vapid t="), ", k=")
|
||||
if !ok || k != keys.Public() {
|
||||
t.Fatalf("Authorization: %s", authz)
|
||||
}
|
||||
parts := strings.Split(tok, ".")
|
||||
var claims struct {
|
||||
Aud string `json:"aud"`
|
||||
Sub string `json:"sub"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
_ = json.Unmarshal(mustDecode(t, parts[1]), &claims)
|
||||
if claims.Aud != srv.URL || claims.Sub != s.Subject || claims.Exp < time.Now().Unix() {
|
||||
t.Errorf("claims: %+v", claims)
|
||||
}
|
||||
sig := mustDecode(t, parts[2])
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
r, ss := new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])
|
||||
if !ecdsa.Verify(&keys.priv.PublicKey, digest[:], r, ss) {
|
||||
t.Error("VAPID signature doesn't verify")
|
||||
}
|
||||
|
||||
// Decrypt as the browser: header is salt, record size, key id (the sender's one-off key).
|
||||
salt, asPub, ct := body[:16], body[21:86], body[86:]
|
||||
as, err := ecdh.P256().NewPublicKey(asPub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret, _ := ua.ECDH(as)
|
||||
plain := decryptForTest(t, secret, auth, ua.PublicKey().Bytes(), asPub, salt, ct)
|
||||
if string(plain) != `{"title":"hi"}`+"\x02" {
|
||||
t.Errorf("decrypted %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func decryptForTest(t *testing.T, secret, auth, uaPub, asPub, salt, ct []byte) []byte {
|
||||
t.Helper()
|
||||
// The same derivation as encrypt, from the receiving side.
|
||||
prkKey := hkdfExtract(t, secret, auth)
|
||||
ikm := hkdfExpand(t, prkKey, "WebPush: info\x00"+string(uaPub)+string(asPub), 32)
|
||||
prk := hkdfExtract(t, ikm, salt)
|
||||
cek := hkdfExpand(t, prk, "Content-Encoding: aes128gcm\x00", 16)
|
||||
nonce := hkdfExpand(t, prk, "Content-Encoding: nonce\x00", 12)
|
||||
block, _ := aes.NewCipher(cek)
|
||||
gcm, _ := cipher.NewGCM(block)
|
||||
plain, err := gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return plain
|
||||
}
|
||||
|
||||
func TestAllowedEndpoint(t *testing.T) {
|
||||
for ep, want := range map[string]bool{
|
||||
"https://fcm.googleapis.com/fcm/send/abc": true,
|
||||
"https://updates.push.services.mozilla.com/wpush/v2/abc": true,
|
||||
"https://web.push.apple.com/abc": true,
|
||||
"https://wns2-par02p.notify.windows.com/w/?token=abc": true,
|
||||
"http://fcm.googleapis.com/fcm/send/abc": false,
|
||||
"https://fcm.googleapis.com.evil.example/abc": false,
|
||||
"https://evilfcm.googleapis.com/abc": false,
|
||||
"https://fcm.googleapis.com:8443/abc": false,
|
||||
"https://user@fcm.googleapis.com/abc": false,
|
||||
"https://127.0.0.1/abc": false,
|
||||
} {
|
||||
if got := AllowedEndpoint(ep); got != want {
|
||||
t.Errorf("%s: got %v", ep, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hkdfExtract(t *testing.T, secret, salt []byte) []byte {
|
||||
t.Helper()
|
||||
b, err := hkdf.Extract(sha256.New, secret, salt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func hkdfExpand(t *testing.T, prk []byte, info string, n int) []byte {
|
||||
t.Helper()
|
||||
b, err := hkdf.Expand(sha256.New, prk, info, n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue