docs: publish Preview 19 dogfood evidence
Export the reviewed allowlisted snapshot from private source commit 05928cebd01b586cf9e9d4b8c8537a7605a6068c. This records the exact candidate, bounded capacity result, stateful migration scratch requirement, authenticated batch identity proof, and immediate live acceptance evidence. AI-Assisted: OpenAI Codex Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package webpush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gamertan.com/observatory/internal/identity"
|
||||
"gamertan.com/observatory/internal/storage"
|
||||
"gamertan.com/web/access"
|
||||
)
|
||||
|
||||
type subscriptionStore interface {
|
||||
PushSubscriptions(context.Context, string) ([]storage.PushSubscription, error)
|
||||
RecordPushResult(context.Context, string, string, string, time.Time) error
|
||||
DeletePushSubscription(context.Context, string, string, string) (bool, error)
|
||||
}
|
||||
|
||||
type authorizer interface {
|
||||
Authorize(context.Context, string, access.Scope, string) (access.Decision, error)
|
||||
}
|
||||
|
||||
type notificationSender interface {
|
||||
Send(context.Context, Subscription) error
|
||||
}
|
||||
|
||||
type Notifier struct {
|
||||
store subscriptionStore
|
||||
authorizer authorizer
|
||||
sender notificationSender
|
||||
queue chan string
|
||||
now func() time.Time
|
||||
pendingMu sync.Mutex
|
||||
pending map[string]struct{}
|
||||
enqueued atomic.Uint64
|
||||
delivered atomic.Uint64
|
||||
failed atomic.Uint64
|
||||
dropped atomic.Uint64
|
||||
}
|
||||
|
||||
type NotifierStats struct {
|
||||
Enqueued uint64
|
||||
Delivered uint64
|
||||
Failed uint64
|
||||
Dropped uint64
|
||||
}
|
||||
|
||||
func NewNotifier(store subscriptionStore, authorizer authorizer, sender notificationSender, capacity int) (*Notifier, error) {
|
||||
if store == nil || authorizer == nil || sender == nil || capacity < 1 || capacity > 1024 {
|
||||
return nil, errors.New("web push notifier options are invalid")
|
||||
}
|
||||
return &Notifier{store: store, authorizer: authorizer, sender: sender, queue: make(chan string, capacity), now: func() time.Time { return time.Now().UTC() }, pending: map[string]struct{}{}}, nil
|
||||
}
|
||||
|
||||
// Enqueue records only an opaque organization identity in a bounded in-memory
|
||||
// queue. A full queue or duplicate pending organization is deliberately
|
||||
// nonblocking: alert evaluation and incident persistence remain authoritative.
|
||||
func (n *Notifier) Enqueue(organizationID string) bool {
|
||||
n.pendingMu.Lock()
|
||||
if _, exists := n.pending[organizationID]; exists {
|
||||
n.pendingMu.Unlock()
|
||||
return true
|
||||
}
|
||||
n.pending[organizationID] = struct{}{}
|
||||
n.pendingMu.Unlock()
|
||||
select {
|
||||
case n.queue <- organizationID:
|
||||
n.enqueued.Add(1)
|
||||
return true
|
||||
default:
|
||||
n.pendingMu.Lock()
|
||||
delete(n.pending, organizationID)
|
||||
n.pendingMu.Unlock()
|
||||
n.dropped.Add(1)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notifier) Run(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case organizationID := <-n.queue:
|
||||
n.deliver(ctx, organizationID)
|
||||
n.pendingMu.Lock()
|
||||
delete(n.pending, organizationID)
|
||||
n.pendingMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notifier) Stats() NotifierStats {
|
||||
return NotifierStats{Enqueued: n.enqueued.Load(), Delivered: n.delivered.Load(), Failed: n.failed.Load(), Dropped: n.dropped.Load()}
|
||||
}
|
||||
|
||||
func (n *Notifier) deliver(ctx context.Context, organizationID string) {
|
||||
subscriptions, err := n.store.PushSubscriptions(ctx, organizationID)
|
||||
if err != nil {
|
||||
n.failed.Add(1)
|
||||
return
|
||||
}
|
||||
for _, subscription := range subscriptions {
|
||||
decision, authErr := n.authorizer.Authorize(ctx, subscription.UserID, access.Scope{OrganizationID: organizationID}, identity.PermissionIncidentsRead)
|
||||
if authErr != nil {
|
||||
n.failed.Add(1)
|
||||
continue
|
||||
}
|
||||
if !decision.Allowed {
|
||||
_, _ = n.store.DeletePushSubscription(ctx, organizationID, subscription.UserID, subscription.Endpoint)
|
||||
continue
|
||||
}
|
||||
deliveryErr := n.sender.Send(ctx, Subscription{Endpoint: subscription.Endpoint, P256DH: subscription.P256DH, Auth: subscription.Auth})
|
||||
outcome := "sent"
|
||||
if errors.Is(deliveryErr, ErrSubscriptionGone) {
|
||||
outcome = "gone"
|
||||
} else if deliveryErr != nil {
|
||||
outcome = "failed"
|
||||
}
|
||||
if recordErr := n.store.RecordPushResult(ctx, organizationID, subscription.ID, outcome, n.now()); recordErr != nil || deliveryErr != nil {
|
||||
n.failed.Add(1)
|
||||
continue
|
||||
}
|
||||
n.delivered.Add(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package webpush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/observatory/internal/storage"
|
||||
"gamertan.com/web/access"
|
||||
)
|
||||
|
||||
type fakePushStore struct {
|
||||
subscriptions []storage.PushSubscription
|
||||
mu sync.Mutex
|
||||
results []string
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (store *fakePushStore) DeletePushSubscription(_ context.Context, organizationID, userID, endpoint string) (bool, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
store.deleted = append(store.deleted, organizationID+"/"+userID+"/"+endpoint)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (store *fakePushStore) PushSubscriptions(context.Context, string) ([]storage.PushSubscription, error) {
|
||||
return append([]storage.PushSubscription(nil), store.subscriptions...), nil
|
||||
}
|
||||
|
||||
func (store *fakePushStore) RecordPushResult(_ context.Context, _, _ string, result string, _ time.Time) error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
store.results = append(store.results, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAuthorizer struct{ allowed map[string]bool }
|
||||
|
||||
func (authorizer fakeAuthorizer) Authorize(_ context.Context, userID string, _ access.Scope, permission string) (access.Decision, error) {
|
||||
if permission != "incidents.read" {
|
||||
return access.Decision{}, errors.New("unexpected permission")
|
||||
}
|
||||
return access.Decision{Allowed: authorizer.allowed[userID]}, nil
|
||||
}
|
||||
|
||||
type fakeNotificationSender struct {
|
||||
mu sync.Mutex
|
||||
requests []Subscription
|
||||
err error
|
||||
}
|
||||
|
||||
func (sender *fakeNotificationSender) Send(_ context.Context, subscription Subscription) error {
|
||||
sender.mu.Lock()
|
||||
defer sender.mu.Unlock()
|
||||
sender.requests = append(sender.requests, subscription)
|
||||
return sender.err
|
||||
}
|
||||
|
||||
func TestNotifierIsBoundedDeduplicatedAndAuthorizationAware(t *testing.T) {
|
||||
store := &fakePushStore{subscriptions: []storage.PushSubscription{
|
||||
{OrganizationID: "organization-a", ID: "push-a", UserID: "user-a", Endpoint: "https://push.example.test/a", P256DH: make([]byte, 65), Auth: make([]byte, 16)},
|
||||
{OrganizationID: "organization-a", ID: "push-b", UserID: "user-b", Endpoint: "https://push.example.test/b", P256DH: make([]byte, 65), Auth: make([]byte, 16)},
|
||||
}}
|
||||
sender := &fakeNotificationSender{}
|
||||
notifier, err := NewNotifier(store, fakeAuthorizer{allowed: map[string]bool{"user-a": true}}, sender, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !notifier.Enqueue("organization-a") || !notifier.Enqueue("organization-a") {
|
||||
t.Fatal("duplicate pending organization was not accepted as already queued")
|
||||
}
|
||||
if notifier.Enqueue("organization-b") {
|
||||
t.Fatal("full queue accepted a second organization")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { notifier.Run(ctx); close(done) }()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for notifier.Stats().Delivered != 1 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
if len(sender.requests) != 1 || sender.requests[0].Endpoint != "https://push.example.test/a" {
|
||||
t.Fatalf("requests=%+v", sender.requests)
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if len(store.results) != 1 || store.results[0] != "sent" || len(store.deleted) != 1 || !strings.Contains(store.deleted[0], "/user-b/") {
|
||||
t.Fatalf("results=%v deleted=%v", store.results, store.deleted)
|
||||
}
|
||||
stats := notifier.Stats()
|
||||
if stats.Enqueued != 1 || stats.Delivered != 1 || stats.Dropped != 1 || stats.Failed != 0 {
|
||||
t.Fatalf("stats=%+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierDeliveryFailureNeverBlocksEnqueue(t *testing.T) {
|
||||
store := &fakePushStore{subscriptions: []storage.PushSubscription{{OrganizationID: "organization-a", ID: "push-a", UserID: "user-a", Endpoint: "https://push.example.test/a", P256DH: make([]byte, 65), Auth: make([]byte, 16)}}}
|
||||
notifier, err := NewNotifier(store, fakeAuthorizer{allowed: map[string]bool{"user-a": true}}, &fakeNotificationSender{err: errors.New("offline")}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !notifier.Enqueue("organization-a") {
|
||||
t.Fatal("enqueue failed")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { notifier.Run(ctx); close(done) }()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for notifier.Stats().Failed == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
if notifier.Stats().Failed != 1 || len(store.results) != 1 || store.results[0] != "failed" {
|
||||
t.Fatalf("stats=%+v results=%v", notifier.Stats(), store.results)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Package webpush implements the deliberately narrow Web Push boundary used
|
||||
// by Observatory. It accepts only validated HTTPS push-service endpoints and
|
||||
// encrypts one fixed, generic notification payload.
|
||||
package webpush
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
GenericMessage = "Gamertan Observatory needs your attention."
|
||||
maxEndpointBytes = 2048
|
||||
maxResponseBodyBytes = 4096
|
||||
)
|
||||
|
||||
var ErrSubscriptionGone = errors.New("web push subscription is gone")
|
||||
|
||||
type Subscription struct {
|
||||
Endpoint string
|
||||
P256DH []byte
|
||||
Auth []byte
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
PrivateKey []byte
|
||||
Subject string
|
||||
Timeout time.Duration
|
||||
Client *http.Client
|
||||
Now func() time.Time
|
||||
Random io.Reader
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
private *ecdh.PrivateKey
|
||||
subject string
|
||||
timeout time.Duration
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
}
|
||||
|
||||
func New(options Options) (*Sender, error) {
|
||||
private, err := ecdh.P256().NewPrivateKey(options.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, errors.New("web push private key is invalid")
|
||||
}
|
||||
if err = validateSubject(options.Subject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if options.Timeout < time.Second || options.Timeout > 30*time.Second {
|
||||
return nil, errors.New("web push timeout must be between 1s and 30s")
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
if options.Random == nil {
|
||||
options.Random = rand.Reader
|
||||
}
|
||||
if options.Client == nil {
|
||||
options.Client = safeClient(options.Timeout)
|
||||
}
|
||||
return &Sender{private: private, subject: options.Subject, timeout: options.Timeout, client: options.Client, now: options.Now, random: options.Random}, nil
|
||||
}
|
||||
|
||||
func (s *Sender) PublicKey() string {
|
||||
return base64.RawURLEncoding.EncodeToString(s.private.PublicKey().Bytes())
|
||||
}
|
||||
|
||||
func (s *Sender) Send(ctx context.Context, subscription Subscription) error {
|
||||
endpoint, err := ValidateEndpoint(subscription.Endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, _, err := encrypt([]byte(GenericMessage), subscription, s.random)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jwt, err := s.vapid(endpoint, s.now(), s.random)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requestContext, cancel := context.WithTimeout(ctx, s.timeout)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return errors.New("create web push request")
|
||||
}
|
||||
request.Header.Set("Authorization", "vapid t="+jwt+", k="+s.PublicKey())
|
||||
request.Header.Set("Content-Encoding", "aes128gcm")
|
||||
request.Header.Set("Content-Type", "application/octet-stream")
|
||||
request.Header.Set("TTL", "300")
|
||||
request.Header.Set("Urgency", "high")
|
||||
response, err := s.client.Do(request)
|
||||
if err != nil {
|
||||
return errors.New("deliver web push notification")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxResponseBodyBytes))
|
||||
if response.StatusCode == http.StatusGone || response.StatusCode == http.StatusNotFound {
|
||||
return ErrSubscriptionGone
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode > 299 {
|
||||
return fmt.Errorf("web push service returned status %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateEndpoint(raw string) (*url.URL, error) {
|
||||
if len(raw) < len("https://a.b/x") || len(raw) > maxEndpointBytes || !strings.HasPrefix(raw, "https://") {
|
||||
return nil, errors.New("web push endpoint must be a bounded HTTPS URL")
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
|
||||
return nil, errors.New("web push endpoint is invalid")
|
||||
}
|
||||
if parsed.Port() != "" && parsed.Port() != "443" {
|
||||
return nil, errors.New("web push endpoint port is not permitted")
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if net.ParseIP(host) != nil || !validDNSName(host) {
|
||||
return nil, errors.New("web push endpoint host is invalid")
|
||||
}
|
||||
if parsed.Path == "" || parsed.Path[0] != '/' {
|
||||
return nil, errors.New("web push endpoint path is invalid")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validateSubject(subject string) error {
|
||||
if len(subject) < 8 || len(subject) > 512 || strings.ContainsAny(subject, "\r\n\t ") {
|
||||
return errors.New("web push subject is invalid")
|
||||
}
|
||||
parsed, err := url.Parse(subject)
|
||||
if err != nil || parsed.Fragment != "" || parsed.RawQuery != "" {
|
||||
return errors.New("web push subject is invalid")
|
||||
}
|
||||
if parsed.Scheme == "mailto" && parsed.Opaque != "" && strings.Contains(parsed.Opaque, "@") {
|
||||
return nil
|
||||
}
|
||||
if parsed.Scheme == "https" && parsed.Host != "" && parsed.User == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("web push subject must be a mailto address or HTTPS URL")
|
||||
}
|
||||
|
||||
func validDNSName(host string) bool {
|
||||
if host == "" || len(host) > 253 || strings.HasSuffix(host, ".") || strings.EqualFold(host, "localhost") {
|
||||
return false
|
||||
}
|
||||
labels := strings.Split(host, ".")
|
||||
if len(labels) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, label := range labels {
|
||||
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, character := range label {
|
||||
if character > 127 || !(character == '-' || character >= '0' && character <= '9' || character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeClient(timeout time.Duration) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: timeout, KeepAlive: 30 * time.Second}
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil || port != "443" || !validDNSName(host) {
|
||||
return nil, errors.New("web push dial target is invalid")
|
||||
}
|
||||
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil || len(addresses) == 0 {
|
||||
return nil, errors.New("resolve web push endpoint")
|
||||
}
|
||||
var last error
|
||||
for _, candidate := range addresses {
|
||||
if !publicIP(candidate.IP) {
|
||||
return nil, errors.New("web push endpoint resolved to a non-public address")
|
||||
}
|
||||
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(candidate.IP.String(), port))
|
||||
if dialErr == nil {
|
||||
return connection, nil
|
||||
}
|
||||
last = dialErr
|
||||
}
|
||||
if last != nil {
|
||||
return nil, errors.New("connect to web push endpoint")
|
||||
}
|
||||
return nil, errors.New("web push endpoint has no usable address")
|
||||
},
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: timeout,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: timeout, CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return errors.New("web push redirects are disabled")
|
||||
}}
|
||||
}
|
||||
|
||||
func publicIP(ip net.IP) bool {
|
||||
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() {
|
||||
return false
|
||||
}
|
||||
address, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
address = address.Unmap()
|
||||
for _, prefix := range deniedPushPrefixes {
|
||||
if prefix.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var deniedPushPrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("192.0.0.0/24"),
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("240.0.0.0/4"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
}
|
||||
|
||||
func encrypt(payload []byte, subscription Subscription, random io.Reader) ([]byte, []byte, error) {
|
||||
if len(payload) == 0 || len(payload) > 2048 || len(subscription.P256DH) != 65 || subscription.P256DH[0] != 4 || len(subscription.Auth) != 16 {
|
||||
return nil, nil, errors.New("web push subscription keys are invalid")
|
||||
}
|
||||
serverPrivate, err := ecdh.P256().GenerateKey(random)
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("generate web push content key")
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
if _, err = io.ReadFull(random, salt); err != nil {
|
||||
return nil, nil, errors.New("generate web push salt")
|
||||
}
|
||||
return encryptWithMaterial(payload, subscription, serverPrivate, salt)
|
||||
}
|
||||
|
||||
func encryptWithMaterial(payload []byte, subscription Subscription, serverPrivate *ecdh.PrivateKey, salt []byte) ([]byte, []byte, error) {
|
||||
if len(payload) == 0 || len(payload) > 2048 || len(subscription.P256DH) != 65 || subscription.P256DH[0] != 4 || len(subscription.Auth) != 16 || serverPrivate == nil || len(salt) != 16 {
|
||||
return nil, nil, errors.New("web push subscription keys are invalid")
|
||||
}
|
||||
clientPublic, err := ecdh.P256().NewPublicKey(subscription.P256DH)
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("web push subscription public key is invalid")
|
||||
}
|
||||
shared, err := serverPrivate.ECDH(clientPublic)
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("derive web push content key")
|
||||
}
|
||||
serverPublic := serverPrivate.PublicKey().Bytes()
|
||||
keyInfo := append([]byte("WebPush: info\x00"), subscription.P256DH...)
|
||||
keyInfo = append(keyInfo, serverPublic...)
|
||||
prkKey := hkdfExtract(subscription.Auth, shared)
|
||||
ikm := hkdfExpand(prkKey, keyInfo, 32)
|
||||
prk := hkdfExtract(salt, ikm)
|
||||
contentKey := hkdfExpand(prk, []byte("Content-Encoding: aes128gcm\x00"), 16)
|
||||
nonce := hkdfExpand(prk, []byte("Content-Encoding: nonce\x00"), 12)
|
||||
block, err := aes.NewCipher(contentKey)
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("create web push cipher")
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("create web push authenticated cipher")
|
||||
}
|
||||
plaintext := append(append([]byte(nil), payload...), 2)
|
||||
ciphertext := aead.Seal(nil, nonce, plaintext, nil)
|
||||
recordSize := uint32(4096)
|
||||
body := make([]byte, 0, 16+4+1+len(serverPublic)+len(ciphertext))
|
||||
body = append(body, salt...)
|
||||
record := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(record, recordSize)
|
||||
body = append(body, record...)
|
||||
body = append(body, byte(len(serverPublic)))
|
||||
body = append(body, serverPublic...)
|
||||
body = append(body, ciphertext...)
|
||||
return body, serverPublic, nil
|
||||
}
|
||||
|
||||
func (s *Sender) vapid(endpoint *url.URL, now time.Time, random io.Reader) (string, error) {
|
||||
header, _ := json.Marshal(struct {
|
||||
Type string `json:"typ"`
|
||||
Algorithm string `json:"alg"`
|
||||
}{"JWT", "ES256"})
|
||||
audience := endpoint.Scheme + "://" + strings.ToLower(endpoint.Hostname())
|
||||
payload, _ := json.Marshal(struct {
|
||||
Audience string `json:"aud"`
|
||||
Expiry int64 `json:"exp"`
|
||||
Subject string `json:"sub"`
|
||||
}{audience, now.UTC().Add(12 * time.Hour).Unix(), s.subject})
|
||||
unsigned := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload)
|
||||
digest := sha256.Sum256([]byte(unsigned))
|
||||
d := new(big.Int).SetBytes(s.private.Bytes())
|
||||
curve := elliptic.P256()
|
||||
x, y := curve.ScalarBaseMult(s.private.Bytes())
|
||||
private := &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: curve, X: x, Y: y}, D: d}
|
||||
r, signatureS, err := ecdsa.Sign(random, private, digest[:])
|
||||
if err != nil {
|
||||
return "", errors.New("sign VAPID token")
|
||||
}
|
||||
signature := make([]byte, 64)
|
||||
r.FillBytes(signature[:32])
|
||||
signatureS.FillBytes(signature[32:])
|
||||
return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
func hkdfExtract(salt, input []byte) []byte {
|
||||
mac := hmac.New(sha256.New, salt)
|
||||
_, _ = mac.Write(input)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func hkdfExpand(key, info []byte, length int) []byte {
|
||||
var output, previous []byte
|
||||
for counter := byte(1); len(output) < length; counter++ {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write(previous)
|
||||
_, _ = mac.Write(info)
|
||||
_, _ = mac.Write([]byte{counter})
|
||||
previous = mac.Sum(nil)
|
||||
output = append(output, previous...)
|
||||
}
|
||||
return output[:length]
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package webpush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (function roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return function(request)
|
||||
}
|
||||
|
||||
func TestSenderUsesEncryptedGenericPayloadAndValidVAPID(t *testing.T) {
|
||||
serverKey, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientKey, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
if _, err = rand.Read(auth); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var captured *http.Request
|
||||
var encrypted []byte
|
||||
client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
captured = request.Clone(context.Background())
|
||||
encrypted, err = io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader("accepted")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
fixed := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
|
||||
sender, err := New(Options{PrivateKey: serverKey.Bytes(), Subject: "mailto:security@sandwichhime.com", Timeout: 5 * time.Second, Client: client, Now: func() time.Time { return fixed }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subscription := Subscription{Endpoint: "https://push.example.test/send/opaque-token", P256DH: clientKey.PublicKey().Bytes(), Auth: auth}
|
||||
if err = sender.Send(context.Background(), subscription); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if captured == nil || captured.Method != http.MethodPost || captured.URL.String() != subscription.Endpoint {
|
||||
t.Fatalf("request=%v", captured)
|
||||
}
|
||||
if captured.Header.Get("Content-Encoding") != "aes128gcm" || captured.Header.Get("TTL") != "300" || captured.Header.Get("Urgency") != "high" {
|
||||
t.Fatalf("headers=%v", captured.Header)
|
||||
}
|
||||
if len(encrypted) < 100 || strings.Contains(string(encrypted), GenericMessage) {
|
||||
t.Fatalf("payload length=%d plaintext=%t", len(encrypted), strings.Contains(string(encrypted), GenericMessage))
|
||||
}
|
||||
if decrypted := decryptTestPayload(t, encrypted, clientKey, auth); string(decrypted) != GenericMessage {
|
||||
t.Fatalf("decrypted=%q", decrypted)
|
||||
}
|
||||
authorization := captured.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authorization, "vapid t=") || !strings.Contains(authorization, ", k="+sender.PublicKey()) {
|
||||
t.Fatalf("authorization=%q", authorization)
|
||||
}
|
||||
token := strings.TrimPrefix(strings.Split(authorization, ", k=")[0], "vapid t=")
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("token parts=%d", len(parts))
|
||||
}
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload struct {
|
||||
Audience string `json:"aud"`
|
||||
Expiry int64 `json:"exp"`
|
||||
Subject string `json:"sub"`
|
||||
}
|
||||
if err = json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Audience != "https://push.example.test" || payload.Subject != "mailto:security@sandwichhime.com" || payload.Expiry != fixed.Add(12*time.Hour).Unix() {
|
||||
t.Fatalf("payload=%+v", payload)
|
||||
}
|
||||
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil || len(signature) != 64 {
|
||||
t.Fatalf("signature length=%d err=%v", len(signature), err)
|
||||
}
|
||||
publicBytes, err := base64.RawURLEncoding.DecodeString(sender.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
x, y := elliptic.Unmarshal(elliptic.P256(), publicBytes)
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
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:])) {
|
||||
t.Fatal("VAPID signature did not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptionMatchesRFC8291SectionFiveVector(t *testing.T) {
|
||||
decode := func(value string) []byte {
|
||||
t.Helper()
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
serverPrivate, err := ecdh.P256().NewPrivateKey(decode("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subscription := Subscription{
|
||||
P256DH: decode("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"),
|
||||
Auth: decode("BTBZMqHH6r4Tts7J_aSIgg"),
|
||||
}
|
||||
payload := decode("V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24")
|
||||
body, _, err := encryptWithMaterial(payload, subscription, serverPrivate, decode("DGv6ra1nlYgDCS1FRnbzlw"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := decode("DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN")
|
||||
if string(body) != string(expected) {
|
||||
t.Fatalf("RFC 8291 vector mismatch\n got: %s\nwant: %s", base64.RawURLEncoding.EncodeToString(body), base64.RawURLEncoding.EncodeToString(expected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSenderClassifiesGoneSubscription(t *testing.T) {
|
||||
serverKey, _ := ecdh.P256().GenerateKey(rand.Reader)
|
||||
clientKey, _ := ecdh.P256().GenerateKey(rand.Reader)
|
||||
auth := make([]byte, 16)
|
||||
_, _ = rand.Read(auth)
|
||||
sender, err := New(Options{PrivateKey: serverKey.Bytes(), Subject: "https://observatory.example/security", Timeout: time.Second, Client: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusGone, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
|
||||
})}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = sender.Send(context.Background(), Subscription{Endpoint: "https://push.example.test/s/expired", P256DH: clientKey.PublicKey().Bytes(), Auth: auth})
|
||||
if !errors.Is(err, ErrSubscriptionGone) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointAndAddressValidation(t *testing.T) {
|
||||
valid := []string{"https://push.example.test/send/token", "https://push.example.test:443/send/token", "https://push.example.test/send/token?opaque=one"}
|
||||
for _, candidate := range valid {
|
||||
if _, err := ValidateEndpoint(candidate); err != nil {
|
||||
t.Errorf("valid endpoint %q: %v", candidate, err)
|
||||
}
|
||||
}
|
||||
invalid := []string{
|
||||
"http://push.example.test/send/token", "https://localhost/send/token", "https://127.0.0.1/send/token",
|
||||
"https://push.example.test:8443/send/token", "https://user@push.example.test/send/token", "https://push.example.test",
|
||||
"https://push.example.test/send/token#secret",
|
||||
}
|
||||
for _, candidate := range invalid {
|
||||
if _, err := ValidateEndpoint(candidate); err == nil {
|
||||
t.Errorf("invalid endpoint accepted: %q", candidate)
|
||||
}
|
||||
}
|
||||
for _, candidate := range []string{"127.0.0.1", "10.0.0.1", "169.254.1.1", "224.0.0.1", "::1", "fc00::1", "fe80::1"} {
|
||||
if publicIP(net.ParseIP(candidate)) {
|
||||
t.Errorf("non-public address accepted: %s", candidate)
|
||||
}
|
||||
}
|
||||
for _, candidate := range []string{"100.64.0.1", "192.0.2.1", "198.18.0.1", "203.0.113.1", "2001:db8::1"} {
|
||||
if publicIP(net.ParseIP(candidate)) {
|
||||
t.Errorf("special-use address accepted: %s", candidate)
|
||||
}
|
||||
}
|
||||
for _, candidate := range []string{"8.8.8.8", "2606:4700:4700::1111"} {
|
||||
if !publicIP(net.ParseIP(candidate)) {
|
||||
t.Errorf("public address rejected: %s", candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSenderRejectsInvalidInputs(t *testing.T) {
|
||||
private, _ := ecdh.P256().GenerateKey(rand.Reader)
|
||||
for _, test := range []Options{
|
||||
{PrivateKey: []byte("short"), Subject: "mailto:security@example.test", Timeout: time.Second},
|
||||
{PrivateKey: private.Bytes(), Subject: "javascript:alert(1)", Timeout: time.Second},
|
||||
{PrivateKey: private.Bytes(), Subject: "mailto:security@example.test", Timeout: time.Millisecond},
|
||||
} {
|
||||
if _, err := New(test); err == nil {
|
||||
t.Fatalf("invalid options accepted: %+v", test)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decryptTestPayload(t *testing.T, body []byte, clientPrivate *ecdh.PrivateKey, auth []byte) []byte {
|
||||
t.Helper()
|
||||
if len(body) < 16+4+1+65+16 || binary.BigEndian.Uint32(body[16:20]) != 4096 || body[20] != 65 {
|
||||
t.Fatalf("invalid aes128gcm record length=%d", len(body))
|
||||
}
|
||||
return decryptWithAuth(t, body, clientPrivate, auth)
|
||||
}
|
||||
|
||||
func decryptWithAuth(t *testing.T, body []byte, clientPrivate *ecdh.PrivateKey, auth []byte) []byte {
|
||||
t.Helper()
|
||||
salt := body[:16]
|
||||
serverPublicBytes := body[21:86]
|
||||
serverPublic, err := ecdh.P256().NewPublicKey(serverPublicBytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shared, err := clientPrivate.ECDH(serverPublic)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyInfo := append([]byte("WebPush: info\x00"), clientPrivate.PublicKey().Bytes()...)
|
||||
keyInfo = append(keyInfo, serverPublicBytes...)
|
||||
ikm := hkdfExpand(hkdfExtract(auth, shared), keyInfo, 32)
|
||||
prk := hkdfExtract(salt, ikm)
|
||||
block, err := aes.NewCipher(hkdfExpand(prk, []byte("Content-Encoding: aes128gcm\x00"), 16))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var aead cipher.AEAD
|
||||
aead, err = cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plaintext, err := aead.Open(nil, hkdfExpand(prk, []byte("Content-Encoding: nonce\x00"), 12), body[86:], nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plaintext) < 1 || plaintext[len(plaintext)-1] != 2 {
|
||||
t.Fatalf("invalid record delimiter: %x", plaintext)
|
||||
}
|
||||
return plaintext[:len(plaintext)-1]
|
||||
}
|
||||
Reference in New Issue
Block a user