Files
gochat/backend/internal/service/push_delivery_service.go
T
Rogeeandrogee f719529d66 fix(security): harden auth and secret handling (HH-444) (#101)
* fix(security): harden auth and credential handling (HH-444)

* fix(security): address HH-444 review blockers

* fix(security): close remaining HH-444 review blockers

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-22 15:45:06 +08:00

324 lines
11 KiB
Go

package service
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
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_id=%d err=%v", userID, t.ID, 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_id=%d",
userID, t.Platform, t.ID)
// 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)
}
privateKey, err := parseVAPIDPrivateKey(s.vapidPrivateKey)
if err != nil {
return fmt.Errorf("parse VAPID private key: %w", err)
}
privateKeyBytes := privateKey.D.FillBytes(make([]byte, 32))
publicKey, err := privateKey.PublicKey.ECDH()
if err != nil {
return fmt.Errorf("convert VAPID public key: %w", err)
}
publicKeyBytes := publicKey.Bytes()
if s.vapidPublicKey != "" {
configuredPublicKey, err := base64URLDecode(strings.TrimRight(s.vapidPublicKey, "="))
if err != nil {
return fmt.Errorf("decode VAPID public key: %w", err)
}
if !bytes.Equal(configuredPublicKey, publicKeyBytes) {
return fmt.Errorf("VAPID public key does not match private key")
}
}
resp, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
Endpoint: token.Token,
Keys: webpush.Keys{
P256dh: token.P256DHKey,
Auth: token.AuthKey,
},
}, &webpush.Options{
HTTPClient: s.httpClient,
Subscriber: strings.TrimPrefix(s.vapidSubject, "mailto:"),
TTL: 86400,
Urgency: webpush.UrgencyNormal,
VAPIDPublicKey: base64URLEncode(publicKeyBytes),
VAPIDPrivateKey: base64URLEncode(privateKeyBytes),
})
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))
}
// --- 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)
}
var deliveryErr error
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)
deliveryErr = errors.Join(deliveryErr, fmt.Errorf("deliver subscription %d: %w", sub.ID, err))
}
}
return deliveryErr
}
// 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) (resultErr 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)
}
finalizeCtx := context.WithoutCancel(ctx)
defer func() {
if err := s.webhookSubRepo.UpdateDelivery(finalizeCtx, delivery); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("update webhook delivery: %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"
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
return fmt.Errorf("send webhook: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("close webhook response: %w", err))
}
}()
delivery.ResponseCode = resp.StatusCode
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
delivery.Status = "success"
now := time.Now()
sub.LastDeliveryStatus = "success"
sub.LastDeliveryAt = &now
if err := s.webhookSubRepo.Update(ctx, &sub); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("update webhook subscription delivery status: %w", err))
}
} else {
delivery.Status = "failed"
// Read response body (truncated) for debugging
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("read webhook error response: %w", err))
} else {
delivery.ResponseBody = string(bodyBytes)
}
}
return resultErr
}
// 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)
}
// 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)
if d.Sign() <= 0 || d.Cmp(elliptic.P256().Params().N) >= 0 {
return nil, fmt.Errorf("VAPID private key is out of range")
}
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 || ecdsaKey.Curve != elliptic.P256() || ecdsaKey.D.Sign() <= 0 || ecdsaKey.D.Cmp(elliptic.P256().Params().N) >= 0 {
return nil, fmt.Errorf("VAPID key is not ECDSA P-256")
}
return ecdsaKey, nil
}