Files
verdande/internal/push/push.go
T
Kristian WindandClaude Opus 5 4058ff0533 feat(phase2,phase4): comments, attachments, notifications, Web Push, import/export
Comments and attachments close out phase 2. Files are stored
content-addressed by hash, so the path never contains anything the
uploader chose — a filename is attacker-controlled text, and building a
path from it is how "../" becomes a write outside the data directory.
Everything downloads as application/octet-stream: an uploaded SVG served
inline would run its own script on this origin with the session cookie
attached.

Web Push is written directly against RFC 8030/8291/8292 rather than
pulled in — it is three specs' worth of well-defined crypto and about
two hundred lines, and the alternatives carry a JWT and crypto stack
each larger than the file.

Phase 4 imports Todoist's CSV and exports it back. Todoist numbers
priorities the opposite way to how it displays them — its 4 is P1 — so
the two are converted rather than copied; getting it backwards silently
inverts the urgency of every imported task. Nesting is positional, so
the reader keeps an indent stack and the writer never sorts.

The round trip is tested as the brief asks: a CSV in, out, and back
produces an identical project, and two exports of one project are
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:42:55 +02:00

250 lines
7.4 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"
)
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"`
}
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)
resp, err := (&http.Client{Timeout: 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
}