feat(11-04): add flare Web Push with a stdlib VAPID driver

- 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
This commit is contained in:
Jakub Zych
2026-09-30 13:43:09 +02:00
parent a8305a015d
commit a9af0d77c7
7 changed files with 1337 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
package flare
import (
"bytes"
"crypto/ecdh"
"encoding/base64"
"strings"
"testing"
)
// Values copied verbatim from RFC 8291 Section 5 and Appendix A
// (https://www.rfc-editor.org/rfc/rfc8291), with the line-wrapping
// whitespace removed.
const (
rfcPlaintext = "V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24"
rfcASPublic = "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8"
rfcASPrivate = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"
rfcUAPublic = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"
rfcUAPrivate = "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"
rfcSalt = "DGv6ra1nlYgDCS1FRnbzlw"
rfcAuthSecret = "BTBZMqHH6r4Tts7J_aSIgg"
rfcCEK = "oIhVW04MRdy2XN9CiKLxTg"
rfcNonce = "4h_95klXJ5E_qnoN"
rfcHeader = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8"
rfcCiphertext = "8pfeW0KbunFT06SuDKoJH9Ql87S1QUrdirN6GcG7sFz1y1sqLgVi1VhjVkHsUoEsbI_0LpXMuGvnzQ"
// The request body of Section 5: header || ciphertext.
rfcBody = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml" +
"mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT" +
"pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"
)
func b64(t *testing.T, s string) []byte {
t.Helper()
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
t.Fatalf("decode %q: %v", s, err)
}
return b
}
func TestRFC8291AppendixA(t *testing.T) {
plaintext := b64(t, rfcPlaintext)
if string(plaintext) != "When I grow up, I want to be a watermelon" {
t.Fatalf("plaintext = %q", plaintext)
}
asKey, err := ecdh.P256().NewPrivateKey(b64(t, rfcASPrivate))
if err != nil {
t.Fatalf("as_private: %v", err)
}
if !bytes.Equal(asKey.PublicKey().Bytes(), b64(t, rfcASPublic)) {
t.Fatal("as_private does not produce the RFC as_public")
}
sub := Subscription{Endpoint: "https://push.example.net/push/JzLQ3raZJfFBR0aqvOMsLrt54w4rJUsV", P256dh: rfcUAPublic, Auth: rfcAuthSecret}
got, err := encrypt(plaintext, sub, asKey, b64(t, rfcSalt))
if err != nil {
t.Fatalf("encrypt: %v", err)
}
want := b64(t, rfcBody)
if !bytes.Equal(got, want) {
t.Fatalf("body mismatch\n got %s\nwant %s", base64.RawURLEncoding.EncodeToString(got), rfcBody)
}
header := b64(t, rfcHeader)
if len(header) != 86 || !bytes.Equal(got[:86], header) {
t.Fatalf("header mismatch: %s", base64.RawURLEncoding.EncodeToString(got[:86]))
}
if !bytes.Equal(got[86:], b64(t, rfcCiphertext)) {
t.Fatalf("ciphertext mismatch: %s", base64.RawURLEncoding.EncodeToString(got[86:]))
}
// The intermediate CEK and nonce, derived from the receiver side.
uaKey, err := ecdh.P256().NewPrivateKey(b64(t, rfcUAPrivate))
if err != nil {
t.Fatalf("ua_private: %v", err)
}
secret, err := uaKey.ECDH(asKey.PublicKey())
if err != nil {
t.Fatal(err)
}
cek, nonce, err := deriveKeys(secret, b64(t, rfcAuthSecret), b64(t, rfcSalt), b64(t, rfcUAPublic), b64(t, rfcASPublic))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(cek, b64(t, rfcCEK)) || !bytes.Equal(nonce, b64(t, rfcNonce)) {
t.Fatalf("CEK/NONCE = %x/%x", cek, nonce)
}
// The receiver decrypts the RFC body back to the plaintext.
back, err := decryptForTest(want, rfcUAPrivate, rfcAuthSecret)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if !bytes.Equal(back, plaintext) {
t.Fatalf("decrypted %q", back)
}
// Encrypt with fresh randomness also round-trips, and refuses a
// payload above 3993 bytes.
body, err := Encrypt(plaintext, sub)
if err != nil {
t.Fatal(err)
}
if back, err := decryptForTest(body, rfcUAPrivate, rfcAuthSecret); err != nil || !bytes.Equal(back, plaintext) {
t.Fatalf("round trip: %q, %v", back, err)
}
if _, err := Encrypt([]byte(strings.Repeat("x", MaxPayloadSize+1)), sub); err != ErrPayloadTooLarge {
t.Fatalf("oversized payload: %v", err)
}
if _, err := Encrypt([]byte(strings.Repeat("x", MaxPayloadSize)), sub); err != nil {
t.Fatalf("3993-byte payload: %v", err)
}
}