clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / internal / webpush / webpush_test.go
webpush_test.go 5.3K
1package webpush23import (4 "bytes"5 "context"6 "crypto/aes"7 "crypto/cipher"8 "crypto/ecdh"9 "crypto/ecdsa"10 "crypto/elliptic"11 "crypto/hkdf"12 "crypto/rand"13 "crypto/sha256"14 "encoding/base64"15 "encoding/binary"16 "encoding/json"17 "math/big"18 "net/http"19 "net/http/httptest"20 "strings"21 "testing"22 "time"23)2425func subscriber(t *testing.T) (Subscription, *ecdh.PrivateKey, []byte) {26 t.Helper()27 private, err := ecdh.P256().GenerateKey(rand.Reader)28 if err != nil {29 t.Fatal(err)30 }31 auth := make([]byte, 16)32 rand.Read(auth)33 var subscription Subscription34 subscription.Endpoint = "https://push.example/send/abc"35 subscription.Keys.P256dh = base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes())36 subscription.Keys.Auth = base64.RawURLEncoding.EncodeToString(auth)37 return subscription, private, auth38}3940// decrypt mirrors the browser side of RFC 8291 so the test proves the body41// is readable by a real subscriber.42func decrypt(t *testing.T, body []byte, receiver *ecdh.PrivateKey, auth []byte) []byte {43 t.Helper()44 salt := body[:16]45 recordSize := binary.BigEndian.Uint32(body[16:20])46 idLength := int(body[20])47 senderPublic := body[21 : 21+idLength]48 record := body[21+idLength:]49 if recordSize != 4096 || idLength != 65 {50 t.Fatalf("header rs=%d idlen=%d", recordSize, idLength)51 }52 sender, err := ecdh.P256().NewPublicKey(senderPublic)53 if err != nil {54 t.Fatal(err)55 }56 shared, err := receiver.ECDH(sender)57 if err != nil {58 t.Fatal(err)59 }60 keyInfo := append(append([]byte("WebPush: info\x00"), receiver.PublicKey().Bytes()...), senderPublic...)61 ikm, _ := hkdf.Key(sha256.New, shared, auth, string(keyInfo), 32)62 key, _ := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16)63 nonce, _ := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12)64 block, _ := aes.NewCipher(key)65 gcm, _ := cipher.NewGCM(block)66 plain, err := gcm.Open(nil, nonce, record, nil)67 if err != nil {68 t.Fatal(err)69 }70 if plain[len(plain)-1] != 2 {71 t.Fatalf("missing final record delimiter: %x", plain[len(plain)-1])72 }73 return plain[:len(plain)-1]74}7576func TestEncryptRoundTripsForASubscriber(t *testing.T) {77 subscription, private, auth := subscriber(t)78 if err := subscription.Validate(); err != nil {79 t.Fatal(err)80 }81 payload := []byte(`{"title":"tiny","body":"hello"}`)82 first, err := Encrypt(subscription, payload)83 if err != nil {84 t.Fatal(err)85 }86 second, _ := Encrypt(subscription, payload)87 if bytes.Equal(first, second) {88 t.Fatal("encryption reused its salt or ephemeral key")89 }90 if got := decrypt(t, first, private, auth); !bytes.Equal(got, payload) {91 t.Fatalf("decrypted %q", got)92 }93 if _, err := Encrypt(subscription, bytes.Repeat([]byte("x"), 4000)); err == nil {94 t.Fatal("oversized payload accepted")95 }96}9798func TestVAPIDAuthorizationVerifies(t *testing.T) {99 keys, err := GenerateKeys()100 if err != nil {101 t.Fatal(err)102 }103 encoded, err := keys.Marshal()104 if err != nil {105 t.Fatal(err)106 }107 restored, err := ParseKeys(encoded)108 if err != nil || restored.PublicKey() != keys.PublicKey() {109 t.Fatalf("restore keys: %v", err)110 }111 now := time.Unix(1_700_000_000, 0)112 header, err := keys.Authorization("https://push.example/send/abc?x=1", "mailto:owner@example", now)113 if err != nil {114 t.Fatal(err)115 }116 if !strings.HasPrefix(header, "vapid t=") || !strings.Contains(header, ", k="+keys.PublicKey()) {117 t.Fatalf("header %q", header)118 }119 token := strings.TrimPrefix(strings.SplitN(header, ", k=", 2)[0], "vapid t=")120 parts := strings.Split(token, ".")121 if len(parts) != 3 {122 t.Fatalf("token %q", token)123 }124 claimsRaw, _ := base64.RawURLEncoding.DecodeString(parts[1])125 var claims map[string]any126 if err := json.Unmarshal(claimsRaw, &claims); err != nil || claims["aud"] != "https://push.example" || claims["sub"] != "mailto:owner@example" || int64(claims["exp"].(float64)) != now.Add(12*time.Hour).Unix() {127 t.Fatalf("claims %s %v", claimsRaw, err)128 }129 signature, _ := base64.RawURLEncoding.DecodeString(parts[2])130 digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))131 public, _ := base64.RawURLEncoding.DecodeString(keys.PublicKey())132 x, y := elliptic.Unmarshal(elliptic.P256(), public)133 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:])) {134 t.Fatal("JWT signature does not verify")135 }136}137138func TestSendReportsGoneSubscriptions(t *testing.T) {139 keys, _ := GenerateKeys()140 subscription, _, _ := subscriber(t)141 var headers http.Header142 status := 201143 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {144 headers = r.Header.Clone()145 w.WriteHeader(status)146 }))147 defer server.Close()148 subscription.Endpoint = server.URL + "/send/abc"149 result, err := Send(context.Background(), server.Client(), keys, "mailto:owner@example", subscription, []byte(`{"title":"t"}`), time.Hour)150 if err != nil || result.Status != 201 || result.Gone {151 t.Fatalf("send: %v %+v", err, result)152 }153 if headers.Get("Content-Encoding") != "aes128gcm" || headers.Get("TTL") != "3600" || !strings.HasPrefix(headers.Get("Authorization"), "vapid t=") {154 t.Fatalf("headers %v", headers)155 }156 status = http.StatusGone157 result, err = Send(context.Background(), server.Client(), keys, "mailto:owner@example", subscription, []byte(`{}`), time.Hour)158 if err == nil || !result.Gone {159 t.Fatalf("gone: %v %+v", err, result)160 }161}