MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s
- VAPID keys from VAPID_* or generated once (push_keys, migration 9); devices in push_subscriptions, forgotten on 404/410 from the push service - push column for every event (also due tasks) and the daily summary, bundles as one notification; /push/key, /devices, /subscribe, /unsubscribe, /test - settings: switch push on per device, test it, see the devices; the service worker shows notifications and focuses MindSift on click - tests decrypt a sent push like a browser (RFC 8291) and check VAPID Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
2.9 KiB
Go
88 lines
2.9 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/ecdh"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/hkdf"
|
|
)
|
|
|
|
// A push sent by the server can be decrypted like a browser does (RFC 8291, aes128gcm) and carries
|
|
// a VAPID authorization; a device the push service no longer knows (410) is forgotten.
|
|
func TestPushPayloadDecryptsLikeABrowser(t *testing.T) {
|
|
srv, store, _ := setupTestServer(t)
|
|
me, _ := newTestUser(t, store, "geraet@example.com")
|
|
|
|
// The "browser": its key pair and auth secret
|
|
recv, _ := ecdh.P256().GenerateKey(rand.Reader)
|
|
authSecret := make([]byte, 16)
|
|
_, _ = rand.Read(authSecret)
|
|
b64 := base64.RawURLEncoding
|
|
|
|
var body []byte
|
|
var authz string
|
|
status := http.StatusCreated
|
|
push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ = io.ReadAll(r.Body)
|
|
authz = r.Header.Get("Authorization")
|
|
w.WriteHeader(status)
|
|
}))
|
|
defer push.Close()
|
|
srv.pushHTTPClient = push.Client()
|
|
|
|
endpoint := push.URL + "/send/geraet-1"
|
|
if err := store.SavePushSubscription(me.ID, endpoint, b64.EncodeToString(recv.PublicKey().Bytes()), b64.EncodeToString(authSecret), "Test"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := srv.sendPush(me.ID, pushMessage{Title: "MindSift", Body: "Steuer fällig", URL: "/"}); err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
if !strings.HasPrefix(authz, "vapid t=") || !strings.Contains(authz, ", k=") {
|
|
t.Fatalf("missing VAPID authorization: %q", authz)
|
|
}
|
|
|
|
// aes128gcm header: salt(16) | record size(4) | key id length(1) | sender public key
|
|
salt, idLen := body[:16], int(body[20])
|
|
senderPub, cipherText := body[21:21+idLen], body[21+idLen:]
|
|
sender, err := ecdh.P256().NewPublicKey(senderPub)
|
|
if err != nil {
|
|
t.Fatalf("sender key: %v", err)
|
|
}
|
|
shared, _ := recv.ECDH(sender)
|
|
info := append(append([]byte("WebPush: info\x00"), recv.PublicKey().Bytes()...), senderPub...)
|
|
ikm := make([]byte, 32)
|
|
_, _ = io.ReadFull(hkdf.New(sha256.New, shared, authSecret, info), ikm)
|
|
cek := make([]byte, 16)
|
|
nonce := make([]byte, 12)
|
|
_, _ = io.ReadFull(hkdf.New(sha256.New, ikm, salt, []byte("Content-Encoding: aes128gcm\x00")), cek)
|
|
_, _ = io.ReadFull(hkdf.New(sha256.New, ikm, salt, []byte("Content-Encoding: nonce\x00")), nonce)
|
|
block, _ := aes.NewCipher(cek)
|
|
gcm, _ := cipher.NewGCM(block)
|
|
plain, err := gcm.Open(nil, nonce, cipherText, nil)
|
|
if err != nil {
|
|
t.Fatalf("decrypt: %v", err)
|
|
}
|
|
plain = plain[:strings.LastIndexByte(string(plain), 2)] // padding delimiter
|
|
var got pushMessage
|
|
if err := json.Unmarshal(plain, &got); err != nil || got.Body != "Steuer fällig" {
|
|
t.Fatalf("payload %q: %v", plain, err)
|
|
}
|
|
|
|
// The push service says the device is gone: it is removed
|
|
status = http.StatusGone
|
|
_ = srv.sendPush(me.ID, pushMessage{Title: "x"})
|
|
if subs, _ := store.PushSubscriptions(me.ID); len(subs) != 0 {
|
|
t.Fatalf("gone device must be forgotten, still %d", len(subs))
|
|
}
|
|
}
|