Files
verdande/internal/push/push.go
T
kristianwindandClaude Opus 5 b041b32aa2 feat(beskeder): appens ikon bærer tallet på ulæste
Klokken i hovedet siger, hvad der er nyt — men kun til den, der kigger på
fanen. Det halve af pointen med at få besked er at få den, mens man laver
noget andet, og det eneste sted, appen kan sige noget dér, er på sit eget
ikon: mærket i Dock'en på en Mac, på proceslinjen på en pc.

Sat to steder, fordi der er to tilstande. Appen selv, mens den er åben og
kender tallet, og service-workeren, når den ikke er — og det er netop
dér, mærket er det, der siger, at der er sket noget.

Tallet kommer med i push-beskeden frem for at blive talt op af
service-workeren. Den husker ingenting mellem to opvågninger og kunne
derfor kun lægge én til noget, den ikke ved hvad er: et mærke, der tæller
sig selv op og aldrig kommer ned igen. Serveren ved det i forvejen, og en
tælling, der fejler, udelader feltet frem for at sende et nul — så står
mærket, som det stod, i stedet for at blive tørret af en optælling, der
ikke lykkedes.

Ét tal, aldrig en optælling: mærket sættes til hvad der *er*, så det går
ned af sig selv, når beskederne bliver læst. Og det ryddes ved log ud —
det står i Dock'en, også når ingen er logget ind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:12:54 +02:00

271 lines
8.6 KiB
Go

// Package push sends Web Push messages (RFC 8030) with VAPID (RFC 8292) and
// aes128gcm payload encryption (RFC 8291).
//
// Written directly rather than pulled in as a dependency because the whole thing is
// three specs' worth of well-defined crypto and about two hundred lines, and
// because the alternative libraries carry a JWT package and a crypto stack that
// would each be larger than this file.
//
// The shape of it: derive a shared secret with the browser's public key, build a
// key and nonce from that plus both salts, encrypt the payload with AES-128-GCM,
// and authorise the request with a signed JWT naming this server.
package push
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"
"math/big"
"net/http"
"net/url"
"strings"
"time"
"github.com/kristianwind/verdande/internal/safedial"
)
type Subscription struct {
Endpoint string
P256dh string // the browser's public key, base64url
Auth string // the browser's auth secret, base64url
}
type VAPID struct {
Public string
Private string
// Subject identifies the sender to the push service, as a mailto: or https:
// URL. Firefox rejects a request without one.
Subject string
}
// Payload is what the service worker receives.
type Payload struct {
Title string `json:"title"`
Body string `json:"body,omitempty"`
URL string `json:"url,omitempty"`
Tag string `json:"tag,omitempty"`
// Unread er tallet, mærket på appens ikon skal stå på, når beskeden lander.
//
// Sendt med frem for talt i service-workeren. Den har ingen hukommelse mellem
// to opvågninger, så den kan kun lægge én til noget, den ikke ved hvad er — og
// et mærke, der tæller sig selv op, kommer aldrig ned igen af sig selv. Tallet
// er serverens, og serveren ved det i forvejen.
Unread int `json:"unread,omitempty"`
}
var b64 = base64.RawURLEncoding
// ErrGone means the subscription is dead and should be deleted rather than retried.
var ErrGone = errors.New("push: the subscription no longer exists")
func IsGone(err error) bool { return errors.Is(err, ErrGone) }
// GenerateVAPIDKeys produces a P-256 keypair, base64url encoded.
func GenerateVAPIDKeys() (public, private string, err error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", err
}
pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y)
return b64.EncodeToString(pub), b64.EncodeToString(key.D.Bytes()), nil
}
// Send delivers one message to one subscription.
func Send(ctx context.Context, sub Subscription, payload Payload, vapid VAPID) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
encrypted, err := encrypt(sub, body)
if err != nil {
return fmt.Errorf("push: encrypt: %w", err)
}
token, err := signJWT(sub.Endpoint, vapid)
if err != nil {
return fmt.Errorf("push: sign: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sub.Endpoint, bytes.NewReader(encrypted))
if err != nil {
return err
}
req.Header.Set("Content-Encoding", "aes128gcm")
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("TTL", "86400")
// Low urgency would let a service sit on a reminder for hours.
req.Header.Set("Urgency", "normal")
req.Header.Set("Authorization", "vapid t="+token+", k="+vapid.Public)
// safedial, not a plain client. The endpoint comes from the browser's
// subscription, which is to say from whoever is signed in, and this server
// POSTs to it on a schedule they choose — set a reminder a minute out and the
// job goes again every minute.
//
// The subscribe handler already refuses a private address, and that check is
// not enough on its own: it runs on the URL as written, and a name it approves
// can answer 127.0.0.1 the next time it is looked up. safedial checks the
// *resolved* address at connect time, which is the only moment the address
// that will actually be used is known — and it checks every redirect hop the
// same way. The parse-time check stays, because it says no while somebody is
// looking at the screen.
resp, err := safedial.Client(15 * time.Second).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNotFound, resp.StatusCode == http.StatusGone:
return ErrGone
case resp.StatusCode >= 300:
return fmt.Errorf("push: %s said %s", resp.Request.URL.Host, resp.Status)
}
return nil
}
// encrypt builds an RFC 8291 aes128gcm message.
func encrypt(sub Subscription, plaintext []byte) ([]byte, error) {
clientPubBytes, err := b64.DecodeString(sub.P256dh)
if err != nil {
return nil, fmt.Errorf("client key: %w", err)
}
authSecret, err := b64.DecodeString(sub.Auth)
if err != nil {
return nil, fmt.Errorf("auth secret: %w", err)
}
curve := ecdh.P256()
clientPub, err := curve.NewPublicKey(clientPubBytes)
if err != nil {
return nil, fmt.Errorf("client key: %w", err)
}
// A fresh keypair per message: reusing one would let anybody who recovered a
// single message key read every later one.
serverPriv, err := curve.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
shared, err := serverPriv.ECDH(clientPub)
if err != nil {
return nil, err
}
serverPubBytes := serverPriv.PublicKey().Bytes()
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return nil, err
}
// The key derivation is prescribed by RFC 8291 §3.3, down to the exact info
// strings and their trailing NULs. Every byte here is load-bearing.
prkKey := hkdf(authSecret, shared,
append(append([]byte("WebPush: info\x00"), clientPubBytes...), serverPubBytes...), 32)
cek := hkdf(salt, prkKey, []byte("Content-Encoding: aes128gcm\x00"), 16)
nonce := hkdf(salt, prkKey, []byte("Content-Encoding: nonce\x00"), 12)
block, err := aes.NewCipher(cek)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// A single record, so the padding delimiter is 0x02 ("last record") rather
// than 0x01.
record := append(append([]byte{}, plaintext...), 0x02)
ciphertext := gcm.Seal(nil, nonce, record, nil)
// Header: salt(16) | record size(4) | key id length(1) | key id.
var out bytes.Buffer
out.Write(salt)
binary.Write(&out, binary.BigEndian, uint32(4096))
out.WriteByte(byte(len(serverPubBytes)))
out.Write(serverPubBytes)
out.Write(ciphertext)
return out.Bytes(), nil
}
// hkdf is HKDF-SHA256 for outputs of at most one hash block, which is all the sizes
// used here. Written out rather than imported because the full generalised form
// would be more code than this.
func hkdf(salt, ikm, info []byte, length int) []byte {
extract := hmac.New(sha256.New, salt)
extract.Write(ikm)
prk := extract.Sum(nil)
expand := hmac.New(sha256.New, prk)
expand.Write(info)
expand.Write([]byte{0x01})
return expand.Sum(nil)[:length]
}
// signJWT builds the VAPID authorisation token: an ES256 JWT whose audience is the
// push service's origin.
func signJWT(endpoint string, vapid VAPID) (string, error) {
u, err := url.Parse(endpoint)
if err != nil {
return "", err
}
subject := vapid.Subject
if subject == "" || strings.HasSuffix(subject, "@localhost") {
// Firefox rejects a missing or obviously bogus subject. A generic mailto
// is better than being refused outright on an instance with no mail set up.
subject = "mailto:verdande@example.invalid"
}
header := b64.EncodeToString([]byte(`{"typ":"JWT","alg":"ES256"}`))
claims, err := json.Marshal(map[string]any{
"aud": u.Scheme + "://" + u.Host,
// Twelve hours. The spec caps this at 24; shorter limits how long a
// captured token is worth anything.
"exp": time.Now().Add(12 * time.Hour).Unix(),
"sub": subject,
})
if err != nil {
return "", err
}
signingInput := header + "." + b64.EncodeToString(claims)
privBytes, err := b64.DecodeString(vapid.Private)
if err != nil {
return "", err
}
key := &ecdsa.PrivateKey{
PublicKey: ecdsa.PublicKey{Curve: elliptic.P256()},
D: new(big.Int).SetBytes(privBytes),
}
key.PublicKey.X, key.PublicKey.Y = elliptic.P256().ScalarBaseMult(privBytes)
sum := sha256.Sum256([]byte(signingInput))
r, sSig, err := ecdsa.Sign(rand.Reader, key, sum[:])
if err != nil {
return "", err
}
// JOSE wants the two halves as fixed-width 32-byte values, not the ASN.1
// encoding ecdsa.Sign's other form produces — a short R silently breaks
// verification on some services and not others.
signature := make([]byte, 64)
r.FillBytes(signature[:32])
sSig.FillBytes(signature[32:])
return signingInput + "." + b64.EncodeToString(signature), nil
}