Add end-to-end encryption for chat messages (X25519 / NaCl box)

Messages between matched users are now encrypted client-side with each
user's own key pair before they ever reach the server; the backend only
ever stores/relays opaque ciphertext + nonce and cannot read message
content, including for the last-message preview on the matches list.

- User gains a PublicKey field, registered/rotated via PUT /profile/public-key
- Message gains CipherText/Nonce; legacy plaintext Text is kept as-is for
  messages sent before this change
- MatchView exposes the partner's public key and the last message's
  ciphertext/nonce so the client can decrypt previews itself
- Removed the now-dead plaintext SanitizeMessageText utility — the server
  can no longer sanitize message content since it's opaque ciphertext

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 18:57:46 +03:30
parent 2c6d4bfbd2
commit 1e5ef02bdf
11 changed files with 154 additions and 180 deletions
+44 -28
View File
@@ -1,16 +1,19 @@
package service
import (
"encoding/base64"
"errors"
"time"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
"dating-app/internal/utils"
)
var ErrNotMatchMember = errors.New("این گفتگو مال تو نیست")
var (
ErrNotMatchMember = errors.New("این گفتگو مال تو نیست")
ErrInvalidMessageEncoding = errors.New("پیام رمزنگاری‌شده نامعتبر است (کدگذاری اشتباه)")
)
type ChatService struct {
matchRepo *repository.MatchRepository
@@ -85,17 +88,23 @@ func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) {
}
view := dto.MatchView{
ID: m.ID,
UserID: other.ID,
FirstName: other.FirstName,
Photos: orEmptyStrings(photosByUser[otherID]),
CreatedAt: m.CreatedAt.Format(time.RFC3339),
ID: m.ID,
UserID: other.ID,
FirstName: other.FirstName,
Photos: orEmptyStrings(photosByUser[otherID]),
CreatedAt: m.CreatedAt.Format(time.RFC3339),
PartnerPublicKey: other.PublicKey,
}
if last, ok := lastMessages[m.ID]; ok {
text := last.Text
at := last.CreatedAt.Format(time.RFC3339)
view.LastMessage = &text
view.LastAt = &at
if last.CipherText != nil {
view.LastMessageCipherText = last.CipherText
view.LastMessageNonce = last.Nonce
} else {
text := last.Text
view.LastMessage = &text
}
}
views = append(views, view)
}
@@ -125,39 +134,46 @@ func (s *ChatService) ListMessages(userID, matchID uint) ([]dto.MessageView, err
views := make([]dto.MessageView, 0, len(messages))
for _, m := range messages {
views = append(views, dto.MessageView{
ID: m.ID,
MatchID: m.MatchID,
SenderID: m.SenderID,
Text: m.Text,
CreatedAt: m.CreatedAt.Format(time.RFC3339),
IsMine: m.SenderID == userID,
ID: m.ID,
MatchID: m.MatchID,
SenderID: m.SenderID,
Text: m.Text,
CipherText: m.CipherText,
Nonce: m.Nonce,
CreatedAt: m.CreatedAt.Format(time.RFC3339),
IsMine: m.SenderID == userID,
})
}
return views, nil
}
// SendMessage یک پیام جدید توی یک مچ ثبت می‌کنه؛ فقط اگه کاربر عضو اون مچ باشه.
func (s *ChatService) SendMessage(userID, matchID uint, text string) (*dto.MessageView, error) {
// SendMessage یک پیام رمزنگاری‌شده توی یک مچ ثبت می‌کنه؛ فقط اگه کاربر عضو
// اون مچ باشه. سرور محتوای پیام رو نمی‌بینه و نمی‌تونه — CipherText/Nonce
// قبلاً روی دستگاه فرستنده با کلید عمومی طرف مقابل رمزنگاری شدن؛ اینجا فقط
// اندازه‌ی base64 اونا (از طریق binding توی SendMessageRequest) چک می‌شه.
func (s *ChatService) SendMessage(userID, matchID uint, cipherText, nonce string) (*dto.MessageView, error) {
if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
return nil, err
}
clean, err := utils.SanitizeMessageText(text)
if err != nil {
return nil, err
if _, err := base64.StdEncoding.DecodeString(cipherText); err != nil {
return nil, ErrInvalidMessageEncoding
}
if _, err := base64.StdEncoding.DecodeString(nonce); err != nil {
return nil, ErrInvalidMessageEncoding
}
msg := &models.Message{MatchID: matchID, SenderID: userID, Text: clean}
msg := &models.Message{MatchID: matchID, SenderID: userID, CipherText: &cipherText, Nonce: &nonce}
if err := s.messageRepo.Create(msg); err != nil {
return nil, err
}
return &dto.MessageView{
ID: msg.ID,
MatchID: msg.MatchID,
SenderID: msg.SenderID,
Text: msg.Text,
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
IsMine: true,
ID: msg.ID,
MatchID: msg.MatchID,
SenderID: msg.SenderID,
CipherText: msg.CipherText,
Nonce: msg.Nonce,
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
IsMine: true,
}, nil
}
+21 -4
View File
@@ -1,6 +1,7 @@
package service
import (
"encoding/base64"
"errors"
"strings"
"time"
@@ -13,10 +14,11 @@ import (
const MaxPhotosPerUser = 6
var (
ErrTooManyPhotos = errors.New("حداکثر تعداد عکس مجاز رو داری")
ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست")
ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه")
ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه")
ErrTooManyPhotos = errors.New("حداکثر تعداد عکس مجاز رو داری")
ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست")
ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه")
ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه")
ErrInvalidPublicKey = errors.New("کلید عمومی نامعتبر است")
)
const MaxPhotoSizeBytes = 5 * 1024 * 1024
@@ -163,3 +165,18 @@ func (s *ProfileService) UpdateNotificationSettings(user *models.User, req dto.U
func (s *ProfileService) DeleteAccount(user *models.User) error {
return s.userRepo.UpdateFields(user, map[string]interface{}{"is_active": false})
}
// UpdatePublicKey کلید عمومی X25519 کاربر رو برای رمزنگاری سرتاسری پیام‌ها
// ثبت/جایگزین می‌کنه. کلید خصوصی متناظرش هیچ‌وقت به سرور نمی‌رسه؛ اینجا فقط
// طول کلید خام (باید دقیقاً ۳۲ بایت باشه) بعد از decode چک می‌شه، نه محتواش.
func (s *ProfileService) UpdatePublicKey(user *models.User, publicKeyB64 string) error {
raw, err := base64.StdEncoding.DecodeString(publicKeyB64)
if err != nil || len(raw) != 32 {
return ErrInvalidPublicKey
}
if err := s.userRepo.UpdateFields(user, map[string]interface{}{"public_key": publicKeyB64}); err != nil {
return err
}
user.PublicKey = &publicKeyB64
return nil
}