Files
gochat/internal/service/push_delivery_service.go
T
2026-06-04 15:44:48 +08:00

490 lines
16 KiB
Go

package service
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"time"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
)
// PushDeliveryService sends push notifications to user devices.
// Reference: Chatwoot web_push_notification_service.rb + P2B M8 spec
type PushDeliveryService struct {
pushTokenRepo *repository.PushTokenRepo
httpClient *http.Client
vapidPublicKey string
vapidPrivateKey string
vapidSubject string
}
// NewPushDeliveryService creates a new PushDelivery service.
func NewPushDeliveryService(pushTokenRepo *repository.PushTokenRepo, vapidPublicKey, vapidPrivateKey, vapidSubject string) *PushDeliveryService {
return &PushDeliveryService{
pushTokenRepo: pushTokenRepo,
httpClient: &http.Client{Timeout: 10 * time.Second},
vapidPublicKey: vapidPublicKey,
vapidPrivateKey: vapidPrivateKey,
vapidSubject: vapidSubject,
}
}
// PushPayload represents the payload sent to a push notification service.
type PushPayload struct {
Title string `json:"title"`
Body string `json:"body"`
Data map[string]interface{} `json:"data,omitempty"`
Icon string `json:"icon,omitempty"`
URL string `json:"url,omitempty"`
}
// SendPushNotification delivers a push notification to all devices for a user.
// Web Push: RFC 8030 + VAPID (RFC 8291) — encrypts payload and POSTs to subscription endpoint.
// Mobile: logs intent; FCM/APNs integration requires external config.
func (s *PushDeliveryService) SendPushNotification(ctx context.Context, userID uint, payload PushPayload) error {
tokens, err := s.pushTokenRepo.ListByUser(ctx, userID)
if err != nil {
return fmt.Errorf("fetch push tokens: %w", err)
}
if len(tokens) == 0 {
applogger.L().Debugf("No push tokens for user %d, skipping push delivery", userID)
return nil
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal push payload: %w", err)
}
var successCount, failCount int
for _, t := range tokens {
switch t.Platform {
case "web":
if err := s.deliverWebPush(ctx, t, payloadJSON); err != nil {
applogger.L().Errorf("Web push delivery failed: user=%d token=%s err=%v", userID, t.Token[:min(8, len(t.Token))]+"...", err)
failCount++
} else {
successCount++
}
case "ios", "android":
// FCM/APNs delivery requires Firebase/Apple config — log as pending integration
applogger.L().Infof("Mobile push delivery pending (FCM/APNs): user=%d platform=%s token=%s payload=%s",
userID, t.Platform, t.Token[:min(8, len(t.Token))]+"...", string(payloadJSON))
// Future: call FCM HTTP v1 API or APNs HTTP/2 API
default:
applogger.L().Warnf("Unknown push platform %s for user %d, skipping", t.Platform, userID)
}
}
applogger.L().Infof("Push delivery summary: user=%d success=%d fail=%d total=%d", userID, successCount, failCount, len(tokens))
return nil
}
// deliverWebPush encrypts and sends a push notification via the Web Push Protocol (RFC 8030).
// Requires the push token to have P256DHKey and AuthKey (from browser PushSubscription.keys).
func (s *PushDeliveryService) deliverWebPush(ctx context.Context, token model.PushToken, payload []byte) error {
if token.P256DHKey == "" || token.AuthKey == "" {
return fmt.Errorf("web push token missing encryption keys (p256dh/auth): token_id=%d", token.ID)
}
// Encrypt payload using ECDH + AES-128-GCM (RFC 8291)
clientPubKey, err := base64URLDecode(token.P256DHKey)
if err != nil {
return fmt.Errorf("decode p256dh key: %w", err)
}
authSecret, err := base64URLDecode(token.AuthKey)
if err != nil {
return fmt.Errorf("decode auth key: %w", err)
}
encryptedContent, _, err := encryptWebPushPayload(payload, clientPubKey, authSecret)
if err != nil {
return fmt.Errorf("encrypt push payload: %w", err)
}
// Generate VAPID JWT for authorization header
vapidJWT, vapidPubKeyRaw, err := s.generateVAPIDJWT(token.Token)
if err != nil {
return fmt.Errorf("generate VAPID JWT: %w", err)
}
// POST to push subscription endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodPost, token.Token, bytes.NewReader(encryptedContent))
if err != nil {
return fmt.Errorf("create push request: %w", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Encoding", "aes128gcm")
req.Header.Set("Authorization", fmt.Sprintf("vapid t=%s,k=%s", vapidJWT, base64URLEncode(vapidPubKeyRaw)))
req.Header.Set("TTL", "86400") // 24 hours
req.Header.Set("Urgency", "normal")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send push request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
applogger.L().Debugf("Web push delivered: token_id=%d status=%d", token.ID, resp.StatusCode)
return nil
}
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("push endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
// encryptWebPushPayload implements RFC 8291 encryption for Web Push.
// Uses ECDH to derive a shared secret, then AES-128-GCM to encrypt the payload.
func encryptWebPushPayload(payload []byte, clientPubKey []byte, authSecret []byte) ([]byte, []byte, error) {
// Generate ephemeral ECDH key pair (P-256)
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("generate ephemeral key: %w", err)
}
// Parse client public key as ECDSA P-256 point
clientX, clientY := elliptic.Unmarshal(elliptic.P256(), clientPubKey)
if clientX == nil {
return nil, nil, fmt.Errorf("invalid client public key")
}
clientPub := &ecdsa.PublicKey{Curve: elliptic.P256(), X: clientX, Y: clientY}
// ECDH shared secret
sharedX, _ := elliptic.P256().ScalarMult(clientPub.X, clientPub.Y, privKey.D.Bytes())
_sharedSecret := sharedX.Bytes()
// HKDF derive Content Encryption Key (CEK) and Nonce
// Input keying material = sharedSecret || authSecret
ikm := append(_sharedSecret, authSecret...)
cek, nonce, err := deriveWebPushKeys(ikm, clientPubKey, privKey.PublicKey)
if err != nil {
return nil, nil, fmt.Errorf("derive encryption keys: %w", err)
}
// AES-128-GCM encrypt: pad payload, then encrypt
paddedPayload := append(payload, byte(0x02)) // RFC 8291: padding delimiter
// Add minimal padding to reach at least 1 block
if len(paddedPayload)%16 != 0 {
padLen := 16 - (len(paddedPayload) % 16)
paddedPayload = append(paddedPayload, bytes.Repeat([]byte{0x00}, padLen)...)
}
block, err := aes.NewCipher(cek)
if err != nil {
return nil, nil, fmt.Errorf("create AES cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, fmt.Errorf("create GCM: %w", err)
}
ciphertext := aead.Seal(nil, nonce, paddedPayload, nil)
// RFC 8291 output: ephemeralPublicKey || authSecret-length(16) || ciphertext
// The auth secret length field is uint16 big-endian = 16
ephemeralPubKey := elliptic.Marshal(elliptic.P256(), privKey.PublicKey.X, privKey.PublicKey.Y)
authLen := make([]byte, 2)
authLen[0] = 0 // big-endian uint16(16) = 0x00, 0x10
authLen[1] = byte(len(authSecret))
result := append(ephemeralPubKey, authLen...)
result = append(result, ciphertext...)
return result, cek, nil
}
// deriveWebPushKeys uses HKDF-SHA-256 to derive CEK and nonce from the input keying material.
func deriveWebPushKeys(ikm []byte, clientPubKey []byte, ecdsaPub ecdsa.PublicKey) ([]byte, []byte, error) {
ephemeralPubKey := elliptic.Marshal(elliptic.P256(), ecdsaPub.X, ecdsaPub.Y)
salt := make([]byte, 16) // RFC 8291: zero salt for first derivation
// PRK = HKDF-Extract(salt, IKM)
h := hmac.New(sha256.New, salt)
h.Write(ikm)
prk := h.Sum(nil)
// CEK = HKDF-Expand(PRK, "Content-Encoding: aes128gcm\x00" || ephemeralPubKey, 16)
cekInfo := append([]byte("Content-Encoding: aes128gcm\x00"), ephemeralPubKey...)
cek := hkdfExpand(prk, cekInfo, 16)
// Nonce = HKDF-Expand(PRK, "Content-Encoding: nonce\x00" || ephemeralPubKey, 12)
nonceInfo := append([]byte("Content-Encoding: nonce\x00"), ephemeralPubKey...)
nonce := hkdfExpand(prk, nonceInfo, 12)
return cek, nonce, nil
}
// hkdfExpand implements HKDF-Expand (RFC 5869).
func hkdfExpand(prk []byte, info []byte, length int) []byte {
n := (length + sha256.Size - 1) / sha256.Size
var result []byte
var prev []byte
for i := 1; i <= n; i++ {
h := hmac.New(sha256.New, prk)
h.Write(prev)
h.Write(info)
h.Write([]byte{byte(i)})
prev = h.Sum(nil)
result = append(result, prev...)
}
return result[:length]
}
// generateVAPIDJWT creates a VAPID JWT (RFC 8292) for the push subscription origin.
func (s *PushDeliveryService) generateVAPIDJWT(pushEndpoint string) (string, []byte, error) {
// Extract origin from push endpoint URL
u, err := url.Parse(pushEndpoint)
if err != nil {
return "", nil, fmt.Errorf("parse push endpoint URL: %w", err)
}
origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
now := time.Now()
claims := struct {
Aud string `json:"aud"`
Sub string `json:"sub"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}{
Aud: origin,
Sub: s.vapidSubject,
Iat: now.Unix(),
Exp: now.Add(12 * time.Hour).Unix(),
}
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", nil, fmt.Errorf("marshal VAPID claims: %w", err)
}
// Parse VAPID private key (ECDSA P-256, base64url-encoded DER)
vapidPrivKey, err := parseVAPIDPrivateKey(s.vapidPrivateKey)
if err != nil {
return "", nil, fmt.Errorf("parse VAPID private key: %w", err)
}
// JWT header: {"typ":"JWT","alg":"ES256"}
header := base64URLEncode([]byte(`{"typ":"JWT","alg":"ES256"}`))
payload := base64URLEncode(claimsJSON)
signingInput := header + "." + payload
// Sign with ES256 (ECDSA P-256 + SHA-256)
r, sSig, err := ecdsa.Sign(rand.Reader, vapidPrivKey, hashSigningInput(signingInput))
if err != nil {
return "", nil, fmt.Errorf("sign VAPID JWT: %w", err)
}
// ECDSA signature to DER then to base64url
sig := encodeECDSASignature(r, sSig)
jwt := signingInput + "." + base64URLEncode(sig)
// Return raw public key bytes for the k= header
vapidPubKeyRaw := elliptic.Marshal(elliptic.P256(), vapidPrivKey.PublicKey.X, vapidPrivKey.PublicKey.Y)
return jwt, vapidPubKeyRaw, nil
}
// --- Encoding helpers for Web Push (RFC 8291/8292) ---
// --- WebhookDeliveryService --- (in same package for convenience)
// WebhookDeliveryService sends outgoing webhook events to subscribed URLs.
// Reference: Chatwoot webhook_service.rb + P2B M8 spec
type WebhookDeliveryService struct {
webhookSubRepo *repository.WebhookSubscriptionRepo
httpClient *http.Client
}
// NewWebhookDeliveryService creates a new WebhookDelivery service.
func NewWebhookDeliveryService(webhookSubRepo *repository.WebhookSubscriptionRepo) *WebhookDeliveryService {
return &WebhookDeliveryService{
webhookSubRepo: webhookSubRepo,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// DeliverEvent sends an event payload to all matching webhook subscriptions for an account.
func (s *WebhookDeliveryService) DeliverEvent(ctx context.Context, accountID uint, eventType string, payload map[string]interface{}) error {
subscriptions, err := s.webhookSubRepo.ListByAccountAndEvent(ctx, accountID, eventType)
if err != nil {
return fmt.Errorf("fetch webhook subscriptions: %w", err)
}
if len(subscriptions) == 0 {
return nil
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal webhook payload: %w", err)
}
for _, sub := range subscriptions {
if err := s.deliverToSubscription(ctx, sub, eventType, payloadJSON); err != nil {
applogger.L().Errorf("Webhook delivery failed: subscription=%d url=%s err=%v", sub.ID, sub.URL, err)
// Continue trying other subscriptions even if one fails
}
}
return nil
}
// deliverToSubscription sends a signed webhook payload to a single subscription URL.
func (s *WebhookDeliveryService) deliverToSubscription(ctx context.Context, sub model.WebhookSubscription, eventType string, payloadJSON []byte) error {
// Create delivery record
delivery := &model.WebhookDelivery{
SubscriptionID: sub.ID,
EventType: eventType,
Payload: payloadJSON,
Status: "pending",
Attempts: 0,
}
if err := s.webhookSubRepo.CreateDelivery(ctx, delivery); err != nil {
return fmt.Errorf("create delivery record: %w", err)
}
// Sign the payload with HMAC-SHA256 using the subscription secret
signature := SignPayload(payloadJSON, sub.Secret)
// Build the HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sub.URL, nil)
if err != nil {
delivery.Status = "failed"
s.webhookSubRepo.UpdateDelivery(ctx, delivery)
return fmt.Errorf("build webhook request: %w", err)
}
// Set headers (Chatwoot webhook pattern)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Webhook-Signature", signature)
req.Header.Set("X-Webhook-Event", eventType)
req.Header.Set("X-Webhook-Delivery-ID", fmt.Sprintf("%d", delivery.ID))
// Actually send the body
req.Body = io.NopCloser(bytes.NewReader(payloadJSON))
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(payloadJSON)), nil }
resp, err := s.httpClient.Do(req)
delivery.Attempts++
if err != nil {
delivery.Status = "failed"
delivery.ResponseCode = 0
s.webhookSubRepo.UpdateDelivery(ctx, delivery)
return fmt.Errorf("send webhook: %w", err)
}
defer resp.Body.Close()
delivery.ResponseCode = resp.StatusCode
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
delivery.Status = "success"
now := time.Now()
sub.LastDeliveryStatus = "success"
sub.LastDeliveryAt = &now
s.webhookSubRepo.Update(ctx, &sub)
} else {
delivery.Status = "failed"
// Read response body (truncated) for debugging
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
delivery.ResponseBody = string(bodyBytes)
}
s.webhookSubRepo.UpdateDelivery(ctx, delivery)
return nil
}
// SignPayload computes HMAC-SHA256 signature for webhook payload.
func SignPayload(payload []byte, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil))
}
// base64URLEncode encodes bytes to base64url without padding (RFC 8291).
func base64URLEncode(data []byte) string {
return base64.RawURLEncoding.EncodeToString(data)
}
// base64URLDecode decodes base64url without padding (RFC 8291).
func base64URLDecode(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(s)
}
// hashSigningInput hashes the JWT signing input with SHA-256.
func hashSigningInput(signingInput string) []byte {
h := sha256.Sum256([]byte(signingInput))
return h[:]
}
// encodeECDSASignature encodes ECDSA r and s values as DER.
func encodeECDSASignature(r, s *big.Int) []byte {
// Manual DER encoding for ECDSA signature
derLen := r.BitLen()/8 + 2 + s.BitLen()/8 + 2
result := make([]byte, 0, derLen+2)
result = append(result, 0x30, byte(derLen))
result = append(result, 0x02)
rBytes := r.Bytes()
if rBytes[0]&0x80 != 0 {
result = append(result, byte(len(rBytes)+1), 0x00)
} else {
result = append(result, byte(len(rBytes)))
}
result = append(result, rBytes...)
result = append(result, 0x02)
sBytes := s.Bytes()
if sBytes[0]&0x80 != 0 {
result = append(result, byte(len(sBytes)+1), 0x00)
} else {
result = append(result, byte(len(sBytes)))
}
result = append(result, sBytes...)
return result
}
// parseVAPIDPrivateKey parses a base64url-encoded ECDSA P-256 private key.
func parseVAPIDPrivateKey(keyStr string) (*ecdsa.PrivateKey, error) {
keyBytes, err := base64URLDecode(keyStr)
if err != nil {
return nil, fmt.Errorf("decode VAPID key: %w", err)
}
// Try PKCS8 first, then SEC1 (raw EC)
key, err := x509.ParsePKCS8PrivateKey(keyBytes)
if err != nil {
// Try SEC1/Raw format
var ecdsaKey *ecdsa.PrivateKey
d := new(big.Int).SetBytes(keyBytes)
ecdsaKey = &ecdsa.PrivateKey{
PublicKey: ecdsa.PublicKey{
Curve: elliptic.P256(),
X: elliptic.P256().Params().Gx,
Y: elliptic.P256().Params().Gy,
},
D: d,
}
// Recalculate public key from D
ecdsaKey.PublicKey.X, ecdsaKey.PublicKey.Y = elliptic.P256().ScalarBaseMult(d.Bytes())
return ecdsaKey, nil
}
ecdsaKey, ok := key.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("VAPID key is not ECDSA P-256")
}
return ecdsaKey, nil
}