490 lines
16 KiB
Go
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
|
|
} |