- RFC 8291 aes128gcm encryption from crypto/ecdh, crypto/hkdf and AES-GCM, matching the RFC 8291 Appendix A vector byte for byte - RFC 8292 vapid t=<ES256 JWT>, k=<key> header (aud origin, exp +12h, sub) - Pusher, Subscription, SendOptions, SubscriptionSource, Service and From reading push.* (enabled, keys, subject, ttl, allowed_hosts) - sends only to https endpoints on push.allowed_hosts, checked before dialing, and never follows redirects; 404/410 map to ErrSubscriptionGone - module README and root modules row
128 lines
8.5 KiB
Markdown
128 lines
8.5 KiB
Markdown
# flare
|
|
|
|
Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface.
|
|
|
|
`import "git.golem15.com/golem15/summercms/modules/flare"`
|
|
|
|
## Overview
|
|
|
|
flare sends browser push notifications. Push is a separate channel from realtime: `lighthouse` publishes to clients that hold an open connection, while flare hands a message to the browser vendor's push service, which wakes the browser even when no page is open.
|
|
|
|
The application owns the subscriptions. When a browser subscribes, the frontend posts its `PushSubscription` (the endpoint URL and the `p256dh` and `auth` keys) to the application, which stores it. flare never reads a database. Code that sends a push passes a `flare.Subscription` to a `flare.Pusher`, and operator tooling reads stored subscriptions through a `flare.SubscriptionSource` that the application publishes on the app.
|
|
|
|
`flare.From` builds the app-scoped `flare.Service` from `push.*` on first use. Its `flare.Service.Pusher` is the VAPID driver, `flare.VAPIDPusher`, which talks to push services directly with the standard library:
|
|
|
|
- The payload is encrypted for the subscriber with `flare.Encrypt`: an ephemeral P-256 key agreement (`crypto/ecdh`) with the subscription's `p256dh` key, mixed with its `auth` secret through HKDF-SHA-256 (`crypto/hkdf`), then one AES-128-GCM record in the `aes128gcm` content coding. The implementation reproduces the RFC 8291 Appendix A test vector byte for byte.
|
|
- Every request carries `Authorization: vapid t=<JWT>, k=<public key>` from `flare.VAPIDHeader`. The ES256 token's `aud` is the endpoint's origin, `exp` lies `flare.VAPIDTokenLifetime` (12 hours) ahead and `sub` is `push.subject`.
|
|
|
|
Endpoints come from browsers, so they are untrusted URLs. The driver only sends to `https` endpoints whose host is in `push.allowed_hosts`, checks this before it opens a connection, and never follows a redirect.
|
|
|
|
## Features
|
|
|
|
- `flare.Pusher` with one method, `Send(ctx, sub, payload, opts)`. `flare.SendOptions` sets the `TTL` (default `push.ttl`), `Urgency` and `Topic` headers.
|
|
- The VAPID driver POSTs the encrypted body with `TTL`, `Content-Encoding: aes128gcm`, `Content-Type: application/octet-stream`, the optional `Urgency` and `Topic` and the VAPID `Authorization` header. A 2xx answer is success. 404 and 410 return `flare.ErrSubscriptionGone`, so the caller can delete the subscription. Any other status returns a `*flare.StatusError` with the code and without the response body. Requests time out after `flare.DefaultTimeout` (10 s).
|
|
- Nothing is sent while `push.enabled` is false: `Send` returns `flare.ErrPushDisabled`.
|
|
- Endpoint allowlist: `flare.HostAllowed` matches a host against `push.allowed_hosts`, where `*.example.com` matches any subdomain of `example.com` (not `example.com` itself). The defaults, `flare.DefaultAllowedHosts`, are Firebase Cloud Messaging, Mozilla autopush, Apple and Windows push. A refused endpoint returns `flare.ErrEndpointNotAllowed`, and the error names the host, never the endpoint path.
|
|
- Payloads up to `flare.MaxPayloadSize` (3993 bytes, the RFC 8291 limit for a 4096-byte body). A larger one returns `flare.ErrPayloadTooLarge`.
|
|
- VAPID keys: `flare.GenerateVAPIDKeys` returns a P-256 pair as unpadded base64url (`flare.PublicKeyLength`, 87 characters, and `flare.PrivateKeyLength`, 43 characters). `flare.ParseVAPIDKeys` accepts padded or unpadded input and checks that the public key belongs to the private key; a bad pair returns `flare.ErrInvalidVAPIDKeys`. A subject that is not `mailto:` or `https:` returns `flare.ErrInvalidSubject`.
|
|
- The private key never reaches logs or formatted output: `flare.VAPIDKeys` and `flare.Config` redact it in `String`, `GoString` and (for the config) `LogValue`, and no error carries key material.
|
|
|
|
## Usage
|
|
|
|
Send a push to one stored subscription:
|
|
|
|
```go
|
|
svc, err := flare.From(app)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`)
|
|
err = svc.Pusher().Send(ctx, flare.Subscription{
|
|
Endpoint: row.Endpoint,
|
|
P256dh: row.P256dh,
|
|
Auth: row.Auth,
|
|
}, payload, flare.SendOptions{Urgency: "normal"})
|
|
if errors.Is(err, flare.ErrSubscriptionGone) {
|
|
// The browser unsubscribed: delete the row.
|
|
}
|
|
```
|
|
|
|
Publish a subscription source from a plugin's `Boot`, so operator commands can read the stored subscriptions of a user:
|
|
|
|
```go
|
|
type blogSubscriptions struct{ db *gorm.DB }
|
|
|
|
func (s blogSubscriptions) Subscriptions(ctx context.Context, userID uint) ([]flare.SubscriptionInfo, error) {
|
|
var user models.User
|
|
if err := s.db.WithContext(ctx).First(&user, userID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, flare.ErrUserNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
var rows []models.PushSubscription
|
|
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]flare.SubscriptionInfo, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, flare.SubscriptionInfo{
|
|
Subscription: flare.Subscription{Endpoint: r.Endpoint, P256dh: r.P256dh, Auth: r.Auth},
|
|
ID: r.ID,
|
|
UserAgent: r.UserAgent,
|
|
SubscribedAt: &r.CreatedAt,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// In Boot:
|
|
if err := app.Publish[flare.SubscriptionSource](blogSubscriptions{db: gdb}); err != nil {
|
|
return err
|
|
}
|
|
```
|
|
|
|
## API reference
|
|
|
|
| Identifier | Description |
|
|
|------------|-------------|
|
|
| `flare.From(app)` | The app's `*flare.Service`, built from `push.*` and published on first use. |
|
|
| `flare.Service` | `Config`, `Enabled`, `Pusher`, `SetHTTPClient` (replace the driver's HTTP client, for example in tests) and `Logger`. |
|
|
| `flare.Pusher` | `Send(ctx, sub, payload, opts) error`. |
|
|
| `flare.VAPIDPusher`, `flare.NewVAPIDPusher(cfg, hc)` | The VAPID driver. `hc` may be nil; a given client is copied and never follows redirects. |
|
|
| `flare.Subscription` | `Endpoint`, `P256dh`, `Auth`, as `PushSubscription.toJSON` returns them. |
|
|
| `flare.SendOptions` | `TTL`, `Urgency`, `Topic`. |
|
|
| `flare.SubscriptionSource`, `flare.SubscriptionInfo` | The application's subscription store: `Subscriptions(ctx, userID)` returns the subscriptions with `ID`, `UserAgent`, `SubscribedAt` and `LastUsedAt`. |
|
|
| `flare.Config`, `flare.LoadConfig` | The `push.*` settings with their defaults; `Keys` returns the key pair. |
|
|
| `flare.VAPIDKeys`, `flare.GenerateVAPIDKeys`, `flare.ParseVAPIDKeys` | VAPID key pairs as unpadded base64url. |
|
|
| `flare.VAPIDHeader(endpoint, subject, keys, now)` | The RFC 8292 `Authorization` header value. |
|
|
| `flare.Encrypt(payload, sub)` | The RFC 8291 `aes128gcm` request body. |
|
|
| `flare.HostAllowed(host, allowed)`, `flare.DefaultAllowedHosts` | The endpoint host allowlist and its default. |
|
|
| `flare.ErrPushDisabled`, `flare.ErrEndpointNotAllowed`, `flare.ErrSubscriptionGone`, `flare.ErrUserNotFound`, `flare.ErrPayloadTooLarge`, `flare.ErrInvalidVAPIDKeys`, `flare.ErrInvalidSubject`, `flare.StatusError` | Errors. |
|
|
| `flare.ContentEncoding`, `flare.MaxPayloadSize`, `flare.DefaultTTL`, `flare.DefaultTimeout`, `flare.VAPIDTokenLifetime`, `flare.PublicKeyLength`, `flare.PrivateKeyLength` | Constants. |
|
|
|
|
## Configuration
|
|
|
|
| Key | Default | Description |
|
|
|-----|---------|-------------|
|
|
| `push.enabled` | `false` | Nothing is sent while false. |
|
|
| `push.public_key` | `""` | VAPID public key, base64url (87 characters unpadded). |
|
|
| `push.private_key` | `""` | VAPID private key, base64url (43 characters). Keep it out of committed files; set `SUMMER_PUSH__PRIVATE_KEY`. |
|
|
| `push.subject` | `""` | VAPID `sub` claim: a `mailto:` or `https:` contact URI. |
|
|
| `push.ttl` | `2419200` | Default `TTL` header, in seconds or as a duration string. |
|
|
| `push.allowed_hosts` | FCM, Mozilla autopush, `*.push.apple.com`, `*.notify.windows.com` | Push service hosts an endpoint may point at, as a list or a comma-separated string. |
|
|
|
|
## Dependencies
|
|
|
|
- `backpack` and `compass` from this repository.
|
|
- `github.com/golang-jwt/jwt/v5` (the ES256 VAPID token).
|
|
- Everything else is the standard library: `crypto/ecdh`, `crypto/ecdsa`, `crypto/hkdf`, `crypto/aes`, `crypto/cipher` and `net/http`. No Web Push library is used.
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
go test ./modules/flare/...
|
|
```
|
|
|
|
`TestRFC8291AppendixA` fixes the RFC's salt and application server key and compares the output with the RFC's published bytes. The send tests run an `httptest.NewTLSServer` push service with `127.0.0.1` in the allowlist; it verifies the VAPID token with the key from `k=` and decrypts the body as a browser would. Pass the test server's client to `flare.NewVAPIDPusher` or `flare.Service.SetHTTPClient` so it trusts the test certificate.
|