clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

deliver relay notifications to opted-in devices over Web Push

Dami committed a5b469f
commit
a5b469fc4059087d07278775b82ace3450fe31c7
parent
addbbb83bc9fad4c91782bf5641aeb8344b5428b
author
Dami <dami.etoile@gmail.com>
12 files changed, +826 -8
Minternal/daemon/http.go4+
Minternal/daemon/notifications.go1+
Minternal/daemon/replication_services.go4+-
Minternal/daemon/services.go6+-
Minternal/daemon/tenant.go4+
Ainternal/daemon/webpush.go268++++++++++++++++++++++++++++++
Ainternal/daemon/webpush_test.go113+++++++++++++
Minternal/records/records.go25++-
Minternal/replication/callback.go4+
Minternal/telemetry/telemetry.go2+-
Ainternal/webpush/webpush.go242+++++++++++++++++++++++++++
Ainternal/webpush/webpush_test.go161++++++++++++++++++
M internal/daemon/http.go +4 -0
M internal/daemon/notifications.go +1 -0
M internal/daemon/replication_services.go +2 -2
@@ -199,7 +199,7 @@ func (p tenantReplicationProvider) Restore(ctx context.Context, state replicatio
199199 if _, err := p.tenant.config.ApplyWithOptions(ctx, parsed, configport.ApplyOptions{MigrationOwner: identity.Owner}); err != nil {200200 return fmt.Errorf("daemon: legacy config apply: %w", err)201201 }202- fresh, err := records.New(ctx, records.Config{Store: p.tenant.store, Policy: p.tenant.Policy, RelayURL: p.tenant.RelayURL(), GroupID: p.tenant.meta.Name, OnGenerated: p.tenant.generatedRecord, DeliverNotification: p.tenant.deliverNotification, SetPolicy: func(next policy.Policy) error { return p.tenant.applyPolicy(context.Background(), next) }})202+ fresh, err := records.New(ctx, records.Config{Store: p.tenant.store, Policy: p.tenant.Policy, RelayURL: p.tenant.RelayURL(), GroupID: p.tenant.meta.Name, OnGenerated: p.tenant.generatedRecord, DeliverNotification: p.tenant.deliverNotification, PushNotification: p.tenant.enqueuePush, SetPolicy: func(next policy.Policy) error { return p.tenant.applyPolicy(context.Background(), next) }})203203 if err != nil {204204 return fmt.Errorf("daemon: refresh records after legacy config: %w", err)205205 }
@@ -231,7 +231,7 @@ func (p tenantReplicationProvider) Restore(ctx context.Context, state replicatio
231231 p.tenant.policy = next232232 p.tenant.mu.Unlock()233233 }234- fresh, err := records.New(ctx, records.Config{Store: p.tenant.store, Policy: p.tenant.Policy, RelayURL: p.tenant.RelayURL(), GroupID: p.tenant.meta.Name, OnGenerated: p.tenant.generatedRecord, DeliverNotification: p.tenant.deliverNotification, SetPolicy: func(next policy.Policy) error { return p.tenant.applyPolicy(context.Background(), next) }})234+ fresh, err := records.New(ctx, records.Config{Store: p.tenant.store, Policy: p.tenant.Policy, RelayURL: p.tenant.RelayURL(), GroupID: p.tenant.meta.Name, OnGenerated: p.tenant.generatedRecord, DeliverNotification: p.tenant.deliverNotification, PushNotification: p.tenant.enqueuePush, SetPolicy: func(next policy.Policy) error { return p.tenant.applyPolicy(context.Background(), next) }})235235 if err != nil {236236 return fmt.Errorf("daemon: refresh records identity: %w", err)237237 }
M internal/daemon/services.go +5 -1
M internal/daemon/tenant.go +4 -0
A internal/daemon/webpush.go +268 -0
@@ -0,0 +1,268 @@
1+package daemon2+3+// Web Push delivers relay notifications to installed browsers. A member4+// registers a device subscription with a signed request; the relay keeps one5+// VAPID key pair per tenant and sends each notification summary encrypted to6+// every registered device. Push services see only ciphertext.7+8+import (9+ "context"10+ "database/sql"11+ "encoding/json"12+ "errors"13+ "fmt"14+ "net/http"15+ "strings"16+ "time"17+18+ "github.com/FelineStateMachine/tinyrelay/internal/replication"19+ "github.com/FelineStateMachine/tinyrelay/internal/storage"20+ "github.com/FelineStateMachine/tinyrelay/internal/webpush"21+ "github.com/FelineStateMachine/tinyrelay/internal/work"22+ "github.com/google/uuid"23+)24+25+const (26+ pushSettingKey = "webpush.vapid"27+ pushMaxPerPubkey = 828+ pushMaxFailures = 529+ pushTTL = 24 * time.Hour30+ pushSummaryMaxLength = 16031+)32+33+const pushSchema = `CREATE TABLE IF NOT EXISTS web_push(endpoint TEXT PRIMARY KEY, pubkey TEXT NOT NULL, subscription TEXT NOT NULL, created_at INTEGER NOT NULL, failures INTEGER NOT NULL DEFAULT 0);34+CREATE INDEX IF NOT EXISTS web_push_pubkey ON web_push(pubkey);`35+36+type pushPayload struct {37+ Recipient string `json:"recipient"`38+ Kind string `json:"kind"`39+ Subject string `json:"subject"`40+ Text string `json:"text"`41+}42+43+// pushMessage is what the service worker receives after decryption.44+type pushMessage struct {45+ Title string `json:"title"`46+ Body string `json:"body"`47+ URL string `json:"url"`48+ Tag string `json:"tag"`49+}50+51+func (t *Tenant) initPush(ctx context.Context) error {52+ _, err := t.store.DB().ExecContext(ctx, pushSchema)53+ return err54+}55+56+// pushVAPIDKeys returns the tenant's VAPID pair, creating it on first use.57+func (t *Tenant) pushVAPIDKeys(ctx context.Context) (webpush.Keys, error) {58+ t.pushMu.Lock()59+ defer t.pushMu.Unlock()60+ if t.pushVAPID != nil {61+ return *t.pushVAPID, nil62+ }63+ var encoded string64+ err := t.store.GetSetting(ctx, pushSettingKey, &encoded)65+ if err != nil && !errors.Is(err, sql.ErrNoRows) {66+ return webpush.Keys{}, err67+ }68+ var keys webpush.Keys69+ if encoded != "" {70+ keys, err = webpush.ParseKeys(encoded)71+ } else {72+ keys, err = webpush.GenerateKeys()73+ if err == nil {74+ encoded, err = keys.Marshal()75+ }76+ if err == nil {77+ err = t.store.PutSetting(ctx, pushSettingKey, encoded)78+ }79+ }80+ if err != nil {81+ return webpush.Keys{}, err82+ }83+ t.pushVAPID = &keys84+ return keys, nil85+}86+87+// pushSubject identifies the relay operator to push services.88+func (t *Tenant) pushSubject() string {89+ if contact := strings.TrimSpace(t.Policy().Contact); contact != "" && strings.Contains(contact, "@") && !strings.Contains(contact, ":") {90+ return "mailto:" + contact91+ }92+ return t.publicURL93+}94+95+func (t *Tenant) pushHTTP(w http.ResponseWriter, r *http.Request) {96+ switch {97+ case r.URL.Path == "/push/key" && r.Method == http.MethodGet:98+ keys, err := t.pushVAPIDKeys(r.Context())99+ if err != nil {100+ writeJSON(w, 500, map[string]string{"error": "push keys unavailable"})101+ return102+ }103+ writeJSON(w, 200, map[string]string{"key": keys.PublicKey()})104+ case (r.URL.Path == "/push/subscribe" || r.URL.Path == "/push/unsubscribe") && r.Method == http.MethodPost:105+ // Registration is a signed mutation like every other change made106+ // from the browser; the session cookie alone is not enough.107+ actor, err := t.resolveUIActor(r)108+ if err != nil || actor == "" {109+ writeJSON(w, 401, map[string]string{"error": "auth-required: sign this request"})110+ return111+ }112+ body, err := t.requestBody(w, r)113+ if err != nil {114+ writeJSON(w, 400, map[string]string{"error": err.Error()})115+ return116+ }117+ var subscription webpush.Subscription118+ if err := json.Unmarshal(body, &subscription); err != nil || subscription.Validate() != nil {119+ writeJSON(w, 400, map[string]string{"error": "invalid: push subscription"})120+ return121+ }122+ if r.URL.Path == "/push/unsubscribe" {123+ if _, err := t.store.DB().ExecContext(r.Context(), "DELETE FROM web_push WHERE endpoint=? AND pubkey=?", subscription.Endpoint, actor); err != nil {124+ writeJSON(w, 500, map[string]string{"error": "unsubscribe failed"})125+ return126+ }127+ writeJSON(w, 200, map[string]any{"ok": true})128+ return129+ }130+ if role, err := t.community.Role(r.Context(), actor); err != nil || (role == "" && actor != t.Policy().Owner) {131+ writeJSON(w, 403, map[string]string{"error": "restricted: notifications are limited to relay members"})132+ return133+ }134+ if err := t.savePushSubscription(r.Context(), actor, subscription); err != nil {135+ writeJSON(w, statusForPush(err), map[string]string{"error": err.Error()})136+ return137+ }138+ writeJSON(w, 200, map[string]any{"ok": true})139+ default:140+ http.NotFound(w, r)141+ }142+}143+144+func statusForPush(err error) int {145+ if strings.HasPrefix(err.Error(), "invalid:") {146+ return 400147+ }148+ return 500149+}150+151+func (t *Tenant) savePushSubscription(ctx context.Context, actor string, subscription webpush.Subscription) error {152+ raw, err := json.Marshal(subscription)153+ if err != nil {154+ return err155+ }156+ return t.store.WithTx(ctx, func(tx *sql.Tx) error {157+ var count int158+ if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM web_push WHERE pubkey=? AND endpoint<>?", actor, subscription.Endpoint).Scan(&count); err != nil {159+ return err160+ }161+ if count >= pushMaxPerPubkey {162+ return fmt.Errorf("invalid: at most %d devices per key", pushMaxPerPubkey)163+ }164+ _, err := tx.ExecContext(ctx, "INSERT INTO web_push(endpoint,pubkey,subscription,created_at,failures) VALUES(?,?,?,?,0) ON CONFLICT(endpoint) DO UPDATE SET pubkey=excluded.pubkey, subscription=excluded.subscription, failures=0", subscription.Endpoint, actor, string(raw), time.Now().Unix())165+ return err166+ })167+}168+169+// enqueuePush records one notification summary for device delivery. It is170+// suitable for records.Config.PushNotification and does nothing for171+// recipients without registered devices.172+func (t *Tenant) enqueuePush(ctx context.Context, recipient, kind, subject, text string) error {173+ var count int174+ if err := t.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM web_push WHERE pubkey=?", recipient).Scan(&count); err != nil || count == 0 {175+ return err176+ }177+ if len(text) > pushSummaryMaxLength {178+ text = text[:pushSummaryMaxLength]179+ }180+ payload, err := json.Marshal(pushPayload{Recipient: recipient, Kind: kind, Subject: subject, Text: text})181+ if err != nil {182+ return err183+ }184+ return t.store.WithTx(ctx, func(tx *sql.Tx) error {185+ return storage.AddIntents(ctx, tx, []storage.Intent{{Kind: notificationPush, EventID: uuid.NewString(), Target: recipient, Payload: string(payload)}}, time.Now().Unix())186+ })187+}188+189+func (t *Tenant) handleNotificationPush(ctx context.Context, intent work.Intent) error {190+ var payload pushPayload191+ if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil || payload.Recipient != intent.Target {192+ return errors.New("notification-push: invalid payload")193+ }194+ rows, err := t.store.DB().QueryContext(ctx, "SELECT endpoint, subscription, failures FROM web_push WHERE pubkey=?", payload.Recipient)195+ if err != nil {196+ return err197+ }198+ type device struct {199+ endpoint string200+ subscription webpush.Subscription201+ failures int202+ }203+ var devices []device204+ for rows.Next() {205+ var item device206+ var raw string207+ if err := rows.Scan(&item.endpoint, &raw, &item.failures); err != nil {208+ rows.Close()209+ return err210+ }211+ if json.Unmarshal([]byte(raw), &item.subscription) == nil {212+ devices = append(devices, item)213+ }214+ }215+ rows.Close()216+ if len(devices) == 0 {217+ return nil218+ }219+ keys, err := t.pushVAPIDKeys(ctx)220+ if err != nil {221+ return err222+ }223+ message, err := json.Marshal(t.pushMessage(payload))224+ if err != nil {225+ return err226+ }227+ client := t.pushClient228+ if client == nil {229+ client = replication.NewPinnedClient(10 * time.Second)230+ }231+ var result error232+ for _, item := range devices {233+ sent, sendErr := webpush.Send(ctx, client, keys, t.pushSubject(), item.subscription, message, pushTTL)234+ switch {235+ case sendErr == nil:236+ _, _ = t.store.DB().ExecContext(ctx, "UPDATE web_push SET failures=0 WHERE endpoint=?", item.endpoint)237+ case sent.Gone || item.failures+1 >= pushMaxFailures:238+ _, _ = t.store.DB().ExecContext(ctx, "DELETE FROM web_push WHERE endpoint=?", item.endpoint)239+ default:240+ _, _ = t.store.DB().ExecContext(ctx, "UPDATE web_push SET failures=failures+1 WHERE endpoint=?", item.endpoint)241+ result = errors.Join(result, sendErr)242+ }243+ }244+ return result245+}246+247+// pushMessage turns a notification summary into what the device shows.248+func (t *Tenant) pushMessage(payload pushPayload) pushMessage {249+ title := t.Policy().Name250+ if title == "" {251+ title = t.meta.Name252+ }253+ body := payload.Text254+ if body == "" {255+ body = map[string]string{256+ "reports": "A new report needs moderation.", "jobs": "A background job finished.",257+ "succession": "Relay succession changed.", "digest": "Your relay digest is ready.",258+ "test": "This is a test notification.", "succession-transfer": "Relay ownership was transferred.",259+ }[payload.Kind]260+ }261+ if body == "" {262+ body = "New relay notification."263+ }264+ if payload.Subject != "" && payload.Subject != "relay" && !strings.EqualFold(payload.Subject, payload.Kind) {265+ title = title + ": " + payload.Subject266+ }267+ return pushMessage{Title: title, Body: body, URL: strings.TrimRight(t.publicURL, "/") + "/inbox", Tag: "tiny-" + payload.Kind}268+}
A internal/daemon/webpush_test.go +113 -0
@@ -0,0 +1,113 @@
1+package daemon2+3+import (4+ "context"5+ "crypto/ecdh"6+ "crypto/rand"7+ "encoding/base64"8+ "encoding/json"9+ "net/http"10+ "net/http/httptest"11+ "strings"12+ "testing"13+14+ "github.com/FelineStateMachine/tinyrelay/internal/webpush"15+ "github.com/FelineStateMachine/tinyrelay/internal/work"16+)17+18+func testSubscription(t *testing.T, endpoint string) webpush.Subscription {19+ t.Helper()20+ private, err := ecdh.P256().GenerateKey(rand.Reader)21+ if err != nil {22+ t.Fatal(err)23+ }24+ auth := make([]byte, 16)25+ rand.Read(auth)26+ var subscription webpush.Subscription27+ subscription.Endpoint = endpoint28+ subscription.Keys.P256dh = base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes())29+ subscription.Keys.Auth = base64.RawURLEncoding.EncodeToString(auth)30+ return subscription31+}32+33+func TestPushSubscriptionRequiresSignatureAndDeliversNotifications(t *testing.T) {34+ a, tenant := testTenant(t)35+ received := make(chan http.Header, 4)36+ status := http.StatusCreated37+ service := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {38+ received <- r.Header.Clone()39+ w.WriteHeader(status)40+ }))41+ defer service.Close()42+ tenant.pushClient = service.Client()43+ subscription := testSubscription(t, service.URL+"/send/device-1")44+ body, _ := json.Marshal(subscription)45+46+ keyReq := httptest.NewRequest("GET", "http://relay.test/push/key", nil)47+ keyRes := httptest.NewRecorder()48+ a.ServeHTTP(keyRes, keyReq)49+ var key map[string]string50+ if keyRes.Code != 200 || json.NewDecoder(keyRes.Body).Decode(&key) != nil || len(key["key"]) < 80 {51+ t.Fatalf("push key %d %s", keyRes.Code, keyRes.Body.String())52+ }53+54+ unsigned := httptest.NewRequest("POST", "http://relay.test/push/subscribe", strings.NewReader(string(body)))55+ unsignedRes := httptest.NewRecorder()56+ a.ServeHTTP(unsignedRes, unsigned)57+ if unsignedRes.Code != 401 {58+ t.Fatalf("unsigned subscribe %d", unsignedRes.Code)59+ }60+61+ signed := httptest.NewRequest("POST", "http://relay.test/push/subscribe", strings.NewReader(string(body)))62+ signRequest(t, signed, string(body))63+ signedRes := httptest.NewRecorder()64+ a.ServeHTTP(signedRes, signed)65+ if signedRes.Code != 200 {66+ t.Fatalf("subscribe %d %s", signedRes.Code, signedRes.Body.String())67+ }68+69+ ctx := context.Background()70+ owner := tenant.Policy().Owner71+ if err := tenant.enqueuePush(ctx, owner, "test", "relay", "hello from the relay"); err != nil {72+ t.Fatal(err)73+ }74+ var payload string75+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=? AND target=?", notificationPush, owner).Scan(&payload); err != nil {76+ t.Fatalf("push intent not queued: %v", err)77+ }78+ if err := tenant.handleNotificationPush(ctx, work.Intent{Kind: notificationPush, Target: owner, Payload: payload}); err != nil {79+ t.Fatal(err)80+ }81+ headers := <-received82+ if headers.Get("Content-Encoding") != "aes128gcm" || !strings.HasPrefix(headers.Get("Authorization"), "vapid t=") || headers.Get("TTL") == "" {83+ t.Fatalf("push headers %v", headers)84+ }85+86+ // A subscription the push service reports gone is removed.87+ status = http.StatusGone88+ if err := tenant.handleNotificationPush(ctx, work.Intent{Kind: notificationPush, Target: owner, Payload: payload}); err != nil {89+ t.Fatalf("gone device should not fail the intent: %v", err)90+ }91+ <-received92+ var remaining int93+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM web_push").Scan(&remaining); err != nil || remaining != 0 {94+ t.Fatalf("gone subscription kept: %d %v", remaining, err)95+ }96+ // Without devices, summaries are not queued at all.97+ before := countIntents(t, tenant, notificationPush)98+ if err := tenant.enqueuePush(ctx, owner, "test", "relay", ""); err != nil {99+ t.Fatal(err)100+ }101+ if countIntents(t, tenant, notificationPush) != before {102+ t.Fatal("push intent queued without a device")103+ }104+}105+106+func countIntents(t *testing.T, tenant *Tenant, kind string) int {107+ t.Helper()108+ var count int109+ if err := tenant.store.DB().QueryRowContext(context.Background(), "SELECT count(*) FROM work_intents WHERE kind=?", kind).Scan(&count); err != nil {110+ t.Fatal(err)111+ }112+ return count113+}
M internal/records/records.go +21 -4
@@ -32,7 +32,10 @@ type Config struct {
3232 GroupID string3333 OnGenerated func(context.Context, event.Event) error3434 DeliverNotification func(context.Context, event.Event, string) error35- OnTransfer func(context.Context, string, string) error35+ // PushNotification receives a plaintext summary of each notification for36+ // device delivery. The gift wrap itself is opaque to the relay once sealed.37+ PushNotification func(context.Context, string, string, string, string) error38+ OnTransfer func(context.Context, string, string) error3639 }3740 3841 type Service struct {
@@ -44,6 +47,7 @@ type Service struct {
4447 secret string4548 onGenerated func(context.Context, event.Event) error4649 deliverNotification func(context.Context, event.Event, string) error50+ pushNotification func(context.Context, string, string, string, string) error4751 onTransfer func(context.Context, string, string) error4852 mu sync.Mutex4953 }
@@ -86,7 +90,7 @@ func New(ctx context.Context, cfg Config) (*Service, error) {
8690 if groupID == "" {8791 groupID = cfg.RelayURL8892 }89- return &Service{store: cfg.Store, policy: cfg.Policy, setPolicy: cfg.SetPolicy, relayURL: cfg.RelayURL, groupID: groupID, secret: secret, onGenerated: cfg.OnGenerated, deliverNotification: cfg.DeliverNotification, onTransfer: cfg.OnTransfer}, nil93+ return &Service{store: cfg.Store, policy: cfg.Policy, setPolicy: cfg.SetPolicy, relayURL: cfg.RelayURL, groupID: groupID, secret: secret, onGenerated: cfg.OnGenerated, deliverNotification: cfg.DeliverNotification, pushNotification: cfg.PushNotification, onTransfer: cfg.OnTransfer}, nil9094 }9195 9296 func (s *Service) group() string { return s.groupID }
@@ -1386,7 +1390,10 @@ func (s *Service) notify(ctx context.Context, typ, owner, target string, now int
13861390 return err13871391 }13881392 if s.deliverNotification != nil {1389- return s.deliverNotification(ctx, wrapped, target)1393+ if err := s.deliverNotification(ctx, wrapped, target); err != nil {1394+ return err1395+ }1396+ return s.push(ctx, target, typ, "relay", "")13901397 }13911398 if s.onGenerated != nil {13921399 return s.onGenerated(ctx, wrapped)
@@ -1394,6 +1401,13 @@ func (s *Service) notify(ctx context.Context, typ, owner, target string, now int
13941401 return err13951402 }13961403 1404+func (s *Service) push(ctx context.Context, recipient, kind, subject, text string) error {1405+ if s.pushNotification == nil {1406+ return nil1407+ }1408+ return s.pushNotification(ctx, recipient, kind, subject, text)1409+}1410+13971411 // Notify applies the source notification preferences and emits a durable13981412 // gift-wrap through OnGenerated. The host callback is responsible for saving13991413 // the event and enqueueing remote delivery in the same transaction.
@@ -1423,7 +1437,10 @@ func (s *Service) notifyText(ctx context.Context, kind, text, subject, target st
14231437 return err14241438 }14251439 if s.deliverNotification != nil {1426- return s.deliverNotification(ctx, wrapped, target)1440+ if err := s.deliverNotification(ctx, wrapped, target); err != nil {1441+ return err1442+ }1443+ return s.push(ctx, target, kind, subject, text)14271444 }14281445 if s.onGenerated != nil {14291446 return s.onGenerated(ctx, wrapped)
M internal/replication/callback.go +4 -0
M internal/telemetry/telemetry.go +1 -1
@@ -239,7 +239,7 @@ func latencyBuckets() []float64 {
239239 var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}}240240 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}}241241 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}242-var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification": {}}242+var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}}243243 244244 func bounded(value string, allowed map[string]struct{}) string {245245 if _, ok := allowed[value]; ok {
A internal/webpush/webpush.go +242 -0
@@ -0,0 +1,242 @@
1+// Package webpush sends Web Push messages: RFC 8291 payload encryption with2+// the aes128gcm content encoding and RFC 8292 VAPID authorization. It uses3+// only the standard library so the relay keeps its dependency footprint.4+package webpush5+6+import (7+ "bytes"8+ "context"9+ "crypto/aes"10+ "crypto/cipher"11+ "crypto/ecdh"12+ "crypto/ecdsa"13+ "crypto/elliptic"14+ "crypto/hkdf"15+ "crypto/rand"16+ "crypto/sha256"17+ "crypto/x509"18+ "encoding/base64"19+ "encoding/binary"20+ "encoding/json"21+ "errors"22+ "fmt"23+ "io"24+ "net/http"25+ "net/url"26+ "strconv"27+ "time"28+)29+30+// Subscription is what the browser's PushManager hands to the page.31+type Subscription struct {32+ Endpoint string `json:"endpoint"`33+ Keys struct {34+ P256dh string `json:"p256dh"`35+ Auth string `json:"auth"`36+ } `json:"keys"`37+}38+39+// Validate checks the shape of a subscription before it is stored.40+func (s Subscription) Validate() error {41+ parsed, err := url.Parse(s.Endpoint)42+ if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || len(s.Endpoint) > 2048 {43+ return errors.New("invalid: push endpoint must be an HTTPS URL")44+ }45+ if key, err := decode(s.Keys.P256dh); err != nil || len(key) != 65 || key[0] != 4 {46+ return errors.New("invalid: push p256dh key must be an uncompressed P-256 point")47+ }48+ if auth, err := decode(s.Keys.Auth); err != nil || len(auth) != 16 {49+ return errors.New("invalid: push auth secret must be 16 bytes")50+ }51+ return nil52+}53+54+// Keys is the VAPID key pair identifying this relay to push services.55+type Keys struct{ private *ecdsa.PrivateKey }56+57+// GenerateKeys creates a fresh P-256 VAPID key pair.58+func GenerateKeys() (Keys, error) {59+ private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)60+ if err != nil {61+ return Keys{}, err62+ }63+ return Keys{private: private}, nil64+}65+66+// Marshal serializes the private key for the settings table.67+func (k Keys) Marshal() (string, error) {68+ der, err := x509.MarshalPKCS8PrivateKey(k.private)69+ if err != nil {70+ return "", err71+ }72+ return base64.RawURLEncoding.EncodeToString(der), nil73+}74+75+// ParseKeys restores a key pair from Marshal output.76+func ParseKeys(encoded string) (Keys, error) {77+ der, err := decode(encoded)78+ if err != nil {79+ return Keys{}, err80+ }81+ parsed, err := x509.ParsePKCS8PrivateKey(der)82+ if err != nil {83+ return Keys{}, err84+ }85+ private, ok := parsed.(*ecdsa.PrivateKey)86+ if !ok || private.Curve != elliptic.P256() {87+ return Keys{}, errors.New("webpush: VAPID key is not a P-256 key")88+ }89+ return Keys{private: private}, nil90+}91+92+// PublicKey is the applicationServerKey the browser subscribes with.93+func (k Keys) PublicKey() string {94+ return base64.RawURLEncoding.EncodeToString(elliptic.Marshal(elliptic.P256(), k.private.PublicKey.X, k.private.PublicKey.Y))95+}96+97+// Authorization builds the VAPID header for one push service origin. The98+// token is an ES256 JWT with the endpoint origin as its audience.99+func (k Keys) Authorization(endpoint, subject string, now time.Time) (string, error) {100+ parsed, err := url.Parse(endpoint)101+ if err != nil {102+ return "", err103+ }104+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"ES256"}`))105+ claims, err := json.Marshal(map[string]any{"aud": parsed.Scheme + "://" + parsed.Host, "exp": now.Add(12 * time.Hour).Unix(), "sub": subject})106+ if err != nil {107+ return "", err108+ }109+ signing := header + "." + base64.RawURLEncoding.EncodeToString(claims)110+ digest := sha256.Sum256([]byte(signing))111+ r, s, err := ecdsa.Sign(rand.Reader, k.private, digest[:])112+ if err != nil {113+ return "", err114+ }115+ signature := make([]byte, 64)116+ r.FillBytes(signature[:32])117+ s.FillBytes(signature[32:])118+ token := signing + "." + base64.RawURLEncoding.EncodeToString(signature)119+ return "vapid t=" + token + ", k=" + k.PublicKey(), nil120+}121+122+// Encrypt produces an aes128gcm body for the subscription. Each call uses a123+// fresh ephemeral key and salt, so the same payload never encrypts twice124+// the same way.125+func Encrypt(subscription Subscription, plaintext []byte) ([]byte, error) {126+ if len(plaintext) > 3993 {127+ return nil, errors.New("webpush: payload exceeds one record")128+ }129+ receiverPublic, err := decode(subscription.Keys.P256dh)130+ if err != nil {131+ return nil, err132+ }133+ auth, err := decode(subscription.Keys.Auth)134+ if err != nil {135+ return nil, err136+ }137+ curve := ecdh.P256()138+ receiver, err := curve.NewPublicKey(receiverPublic)139+ if err != nil {140+ return nil, fmt.Errorf("webpush: subscriber key: %w", err)141+ }142+ ephemeral, err := curve.GenerateKey(rand.Reader)143+ if err != nil {144+ return nil, err145+ }146+ shared, err := ephemeral.ECDH(receiver)147+ if err != nil {148+ return nil, err149+ }150+ senderPublic := ephemeral.PublicKey().Bytes()151+ salt := make([]byte, 16)152+ if _, err := io.ReadFull(rand.Reader, salt); err != nil {153+ return nil, err154+ }155+ return encrypt(plaintext, shared, receiverPublic, senderPublic, auth, salt)156+}157+158+func encrypt(plaintext, shared, receiverPublic, senderPublic, auth, salt []byte) ([]byte, error) {159+ keyInfo := append(append([]byte("WebPush: info\x00"), receiverPublic...), senderPublic...)160+ ikm, err := hkdf.Key(sha256.New, shared, auth, string(keyInfo), 32)161+ if err != nil {162+ return nil, err163+ }164+ contentKey, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16)165+ if err != nil {166+ return nil, err167+ }168+ nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12)169+ if err != nil {170+ return nil, err171+ }172+ block, err := aes.NewCipher(contentKey)173+ if err != nil {174+ return nil, err175+ }176+ gcm, err := cipher.NewGCM(block)177+ if err != nil {178+ return nil, err179+ }180+ record := gcm.Seal(nil, nonce, append(append([]byte{}, plaintext...), 2), nil)181+ var body bytes.Buffer182+ body.Write(salt)183+ _ = binary.Write(&body, binary.BigEndian, uint32(4096))184+ body.WriteByte(byte(len(senderPublic)))185+ body.Write(senderPublic)186+ body.Write(record)187+ return body.Bytes(), nil188+}189+190+// Result reports the push service's answer for one message.191+type Result struct {192+ Status int193+ // Gone means the subscription no longer exists and should be removed.194+ Gone bool195+}196+197+// Send delivers one encrypted message. A nil client uses http.DefaultClient;198+// callers that reach the public internet should pass a client that refuses199+// private addresses.200+func Send(ctx context.Context, client *http.Client, keys Keys, subject string, subscription Subscription, payload []byte, ttl time.Duration) (Result, error) {201+ body, err := Encrypt(subscription, payload)202+ if err != nil {203+ return Result{}, err204+ }205+ authorization, err := keys.Authorization(subscription.Endpoint, subject, time.Now())206+ if err != nil {207+ return Result{}, err208+ }209+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, subscription.Endpoint, bytes.NewReader(body))210+ if err != nil {211+ return Result{}, err212+ }213+ request.Header.Set("Content-Type", "application/octet-stream")214+ request.Header.Set("Content-Encoding", "aes128gcm")215+ request.Header.Set("TTL", strconv.Itoa(int(ttl.Seconds())))216+ request.Header.Set("Urgency", "normal")217+ request.Header.Set("Authorization", authorization)218+ if client == nil {219+ client = http.DefaultClient220+ }221+ response, err := client.Do(request)222+ if err != nil {223+ return Result{}, err224+ }225+ defer response.Body.Close()226+ _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))227+ result := Result{Status: response.StatusCode, Gone: response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusGone}228+ if response.StatusCode < 200 || response.StatusCode >= 300 {229+ return result, fmt.Errorf("webpush: push service returned HTTP %d", response.StatusCode)230+ }231+ return result, nil232+}233+234+func decode(value string) ([]byte, error) {235+ if raw, err := base64.RawURLEncoding.DecodeString(value); err == nil {236+ return raw, nil237+ }238+ if raw, err := base64.URLEncoding.DecodeString(value); err == nil {239+ return raw, nil240+ }241+ return base64.StdEncoding.DecodeString(value)242+}
A internal/webpush/webpush_test.go +161 -0
@@ -0,0 +1,161 @@
1+package webpush2+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 Subscription34+ 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, auth38+}39+40+// decrypt mirrors the browser side of RFC 8291 so the test proves the body41+// 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]any126+ 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.Header142+ status := 201143+ 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.StatusGone157+ 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+}