Release / Build and publish image (push) Failing after 10m0s
To huller fundet ved en gennemgang efter dagens arbejde. Begge har en prøve, der er set fejle før rettelsen. CalDAV PUT afgjorde adgang på det projekt, klienten *nævnte i adressen*, og tog derefter opgavens id fra den samme adresse. Intet bandt de to sammen. En viewer på et delt projekt kunne pege på et projekt, de selv ejer — indbakken, som alle har og alle må redigere — og lægge den delte opgaves id i filnavnet. Projekt- tjekket gik godt på indbakken, og skrivningen landede på en opgave, de kun må læse. GetTask lukkede det ikke: den svarer "må denne person se det", og det må en viewer. `CanEdit` er spørgsmålet, og det er det samme, handleUpdateTask altid har stillet — API-døren har spurgt hele tiden, CalDAV-døren spurgte ikke. Opgaven skal nu også ligge, hvor adressen siger. Uden det kan en redaktør på to projekter nå det ene gennem det andets adresse; intet eskaleres af det i sig selv, men en adresse, der har lov at lyve om hvor noget bor, er sådan sagen ovenfor kom ind. Web Push blev leveret med en almindelig http.Client. Adressen kommer fra browserens abonnement — altså fra den, der er logget ind — og serveren POSTer til den på et skema, de selv vælger: sæt en påmindelse et minut ude, og jobbet går igen hvert minut. Tilmeldingen afviser en privat adresse, og den kontrol er ikke nok alene: den kører på URL'en som skrevet, og et navn, den godkender, kan svare 127.0.0.1 næste gang det slås op. Det er præcis den sætning, `internal/safedial` selv står og advarer om. Leveringen går gennem safedial nu, som tjekker den opslåede adresse ved forbindelsen og hvert viderestillingshop på samme måde. Kontrollen ved tilmelding bliver, fordi den siger nej, mens nogen kigger på skærmen. Og en hærdning: /inbound/mail har fået et loft. Det er den svagere af de to offentlige skriveveje — beaconet skriver en række for en fremmed, denne skriver ingenting uden en 256-bit adresse, man skal kende i forvejen — men adressen deles med mailservere og står på en indstillingsside, og når den først er kendt, var der intet loft på, hvad der kunne laves med den. Tallet er sat generøst: modtageren er mailserveren, én adresse der leverer det hele. Co-Authored-By: Claude Opus 5 <[email protected]>
264 lines
8.2 KiB
Go
264 lines
8.2 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"`
|
|
}
|
|
|
|
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:[email protected]"
|
|
}
|
|
|
|
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
|
|
}
|