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 }