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
+32 -17
View File
@@ -22,30 +22,45 @@ type SwipeResponse struct {
Match *MatchView `json:"match,omitempty"` Match *MatchView `json:"match,omitempty"`
} }
// MatchView خلاصه‌ی یک مچ برای نمایش در لیست مچ‌ها/گفتگوها // MatchView خلاصه‌ی یک مچ برای نمایش در لیست مچ‌ها/گفتگوها. برای پیش‌نمایش
// آخرین پیام، سرور رمزگشایی نمی‌کنه (نمی‌تونه) — CipherText/Nonce خودِ آخرین
// پیام رو برمی‌گردونه تا کلاینت خودش رمزگشایی کنه؛ LastMessage فقط برای
// پیام‌های قدیمیِ قبل از E2EE پر می‌شه.
type MatchView struct { type MatchView struct {
ID uint `json:"id"` ID uint `json:"id"`
UserID uint `json:"user_id"` UserID uint `json:"user_id"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
Photos []string `json:"photos"` Photos []string `json:"photos"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
LastMessage *string `json:"last_message,omitempty"` LastMessage *string `json:"last_message,omitempty"`
LastAt *string `json:"last_at,omitempty"` LastMessageCipherText *string `json:"last_message_cipher_text,omitempty"`
LastMessageNonce *string `json:"last_message_nonce,omitempty"`
LastAt *string `json:"last_at,omitempty"`
// PartnerPublicKey کلید عمومی X25519 طرف مقابل (base64) برای رمزنگاری
// سرتاسری؛ اگه طرف مقابل هنوز کلیدی ثبت نکرده باشه خالیه.
PartnerPublicKey *string `json:"partner_public_key,omitempty"`
} }
// MessageView یک پیام در یک گفتگو // MessageView یک پیام در یک گفتگو. یا Text (پیام قدیمیِ قبل از E2EE) پر
// می‌شه، یا CipherText+Nonce (پیام رمزنگاری‌شده) — سرور خودش نمی‌دونه محتوای
// رمزنگاری‌شده چیه، فقط بین دو طرف مچ ردوبدلش می‌کنه.
type MessageView struct { type MessageView struct {
ID uint `json:"id"` ID uint `json:"id"`
MatchID uint `json:"match_id"` MatchID uint `json:"match_id"`
SenderID uint `json:"sender_id"` SenderID uint `json:"sender_id"`
Text string `json:"text"` Text string `json:"text,omitempty"`
CreatedAt string `json:"created_at"` CipherText *string `json:"cipher_text,omitempty"`
IsMine bool `json:"is_mine"` Nonce *string `json:"nonce,omitempty"`
CreatedAt string `json:"created_at"`
IsMine bool `json:"is_mine"`
} }
// SendMessageRequest بدنه‌ی درخواست ارسال پیام // SendMessageRequest بدنه‌ی درخواست ارسال پیام رمزنگاری‌شده. متن خام پیام
// هیچ‌وقت به سرور نمی‌رسه؛ کلاینت قبل از ارسال با کلید عمومی طرف مقابل
// رمزنگاریش می‌کنه.
type SendMessageRequest struct { type SendMessageRequest struct {
Text string `json:"text" binding:"required,min=1,max=2000"` CipherText string `json:"cipher_text" binding:"required,min=1,max=12000"`
Nonce string `json:"nonce" binding:"required,len=32"`
} }
// CandidateAnswer پاسخ یک سوال پروفایل، برای نمایش عمومی // CandidateAnswer پاسخ یک سوال پروفایل، برای نمایش عمومی
+7
View File
@@ -23,3 +23,10 @@ type UpdateNotificationSettingsRequest struct {
NotifyMessages bool `json:"notify_messages"` NotifyMessages bool `json:"notify_messages"`
NotifyLikes bool `json:"notify_likes"` NotifyLikes bool `json:"notify_likes"`
} }
// UpdatePublicKeyRequest ثبت/به‌روزرسانی کلید عمومی X25519 کاربر برای
// رمزنگاری سرتاسری پیام‌ها. کلید باید دقیقاً ۳۲ بایت خام باشه که به‌صورت
// base64 استاندارد (۴۴ کاراکتر، شامل padding) کدگذاری شده.
type UpdatePublicKeyRequest struct {
PublicKey string `json:"public_key" binding:"required,len=44"`
}
+6 -6
View File
@@ -53,7 +53,9 @@ func (h *ChatHandler) ListMessages(c *gin.Context) {
utils.Success(c, http.StatusOK, "", messages) utils.Success(c, http.StatusOK, "", messages)
} }
// SendMessage POST /matches/:id/messages body: {text} // SendMessage POST /matches/:id/messages body: {cipher_text, nonce}
// متن خام پیام هیچ‌وقت اینجا نمی‌رسه؛ کلاینت قبل از ارسال با کلید عمومی
// طرف مقابل (NaCl box / X25519) رمزنگاریش کرده.
func (h *ChatHandler) SendMessage(c *gin.Context) { func (h *ChatHandler) SendMessage(c *gin.Context) {
user, ok := currentUser(c) user, ok := currentUser(c)
if !ok { if !ok {
@@ -66,10 +68,10 @@ func (h *ChatHandler) SendMessage(c *gin.Context) {
} }
var req dto.SendMessageRequest var req dto.SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "متن پیام نمی‌تونه خالی باشه", err.Error()) utils.Fail(c, http.StatusBadRequest, "پیام رمزنگاری‌شده نامعتبر است", err.Error())
return return
} }
msg, err := h.chatService.SendMessage(user.ID, uint(matchID), req.Text) msg, err := h.chatService.SendMessage(user.ID, uint(matchID), req.CipherText, req.Nonce)
if err != nil { if err != nil {
h.handleError(c, err) h.handleError(c, err)
return return
@@ -81,9 +83,7 @@ func (h *ChatHandler) handleError(c *gin.Context, err error) {
switch { switch {
case errors.Is(err, service.ErrNotMatchMember): case errors.Is(err, service.ErrNotMatchMember):
utils.Fail(c, http.StatusForbidden, err.Error(), nil) utils.Fail(c, http.StatusForbidden, err.Error(), nil)
case errors.Is(err, utils.ErrInvalidMessageEncoding), case errors.Is(err, service.ErrInvalidMessageEncoding):
errors.Is(err, utils.ErrMessageEmpty),
errors.Is(err, utils.ErrMessageTooLong):
utils.Fail(c, http.StatusBadRequest, err.Error(), nil) utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
default: default:
utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil) utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil)
+24
View File
@@ -201,3 +201,27 @@ func (h *ProfileHandler) DeleteAccount(c *gin.Context) {
} }
utils.Success(c, http.StatusOK, "حساب کاربری غیرفعال شد", nil) utils.Success(c, http.StatusOK, "حساب کاربری غیرفعال شد", nil)
} }
// UpdatePublicKey PUT /profile/public-key — ثبت/جایگزینی کلید عمومی X25519
// برای رمزنگاری سرتاسری پیام‌ها. کلاینت این کلید رو خودش تولید می‌کنه؛ کلید
// خصوصی متناظرش هیچ‌وقت به این endpoint (یا هیچ endpoint دیگه‌ای) فرستاده نمی‌شه.
func (h *ProfileHandler) UpdatePublicKey(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.UpdatePublicKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "کلید عمومی نامعتبر است", err.Error())
return
}
if err := h.profileService.UpdatePublicKey(user, req.PublicKey); err != nil {
if errors.Is(err, service.ErrInvalidPublicKey) {
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
return
}
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیره‌ی کلید عمومی", nil)
return
}
utils.Success(c, http.StatusOK, "کلید عمومی ذخیره شد", nil)
}
+13 -7
View File
@@ -55,14 +55,20 @@ func (Match) TableName() string {
return "matches" return "matches"
} }
// Message یک پیام متنی داخل یک مچ // Message یک پیام داخل یک مچ. از رمزنگاری سرتاسری (E2EE) استفاده می‌شه:
// سرور فقط CipherText+Nonce (خروجی رمزنگاری‌شده‌ی سمت کلاینت با NaCl box،
// X25519-XSalsa20-Poly1305) رو ذخیره می‌کنه و هیچ‌وقت متن خام پیام رو نمی‌بینه.
// Text قدیمی (قبل از اضافه شدن E2EE) برای نمایش پیام‌های تاریخی نگه داشته می‌شه؛
// پیام‌های جدید Text خالی دارن و به‌جاش CipherText/Nonce پر می‌شه.
type Message struct { type Message struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
MatchID uint `gorm:"not null;index" json:"match_id"` MatchID uint `gorm:"not null;index" json:"match_id"`
SenderID uint `gorm:"not null" json:"sender_id"` SenderID uint `gorm:"not null" json:"sender_id"`
Text string `gorm:"size:2000;not null" json:"text"` Text string `gorm:"size:2000;not null" json:"text"`
ReadAt *time.Time `json:"read_at,omitempty"` CipherText *string `gorm:"size:12000" json:"cipher_text,omitempty"`
CreatedAt time.Time `json:"created_at"` Nonce *string `gorm:"size:64" json:"nonce,omitempty"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
} }
func (Message) TableName() string { func (Message) TableName() string {
+6
View File
@@ -108,6 +108,12 @@ type User struct {
Latitude *float64 `json:"-"` Latitude *float64 `json:"-"`
Longitude *float64 `json:"-"` Longitude *float64 `json:"-"`
// -------- کلید عمومی رمزنگاری سرتاسری پیام‌ها (E2EE) --------
// کلید خصوصی هیچ‌وقت به سرور نمی‌رسه و فقط روی دستگاه کاربر (localStorage)
// ذخیره می‌شه؛ سرور فقط این کلید عمومی رو نگه می‌داره تا طرف مقابل بتونه
// باهاش پیام رمزنگاری کنه. کلید X25519 هست، base64-encoded (۳۲ بایت خام).
PublicKey *string `gorm:"size:64" json:"public_key,omitempty"`
// -------- وضعیت آنبوردینگ -------- // -------- وضعیت آنبوردینگ --------
CurrentStep OnboardingStep `gorm:"default:1;not null" json:"current_step"` CurrentStep OnboardingStep `gorm:"default:1;not null" json:"current_step"`
IsOnboarded bool `gorm:"default:false;not null" json:"is_onboarded"` IsOnboarded bool `gorm:"default:false;not null" json:"is_onboarded"`
+1
View File
@@ -63,6 +63,7 @@ func Setup(
profile.POST("/checkin", h.Profile.CheckIn) profile.POST("/checkin", h.Profile.CheckIn)
profile.PATCH("/notifications", h.Profile.UpdateNotificationSettings) profile.PATCH("/notifications", h.Profile.UpdateNotificationSettings)
profile.DELETE("/me", h.Profile.DeleteAccount) profile.DELETE("/me", h.Profile.DeleteAccount)
profile.PUT("/public-key", h.Profile.UpdatePublicKey)
} }
// -------- آنبوردینگ (نیاز به ورود) -------- // -------- آنبوردینگ (نیاز به ورود) --------
+44 -28
View File
@@ -1,16 +1,19 @@
package service package service
import ( import (
"encoding/base64"
"errors" "errors"
"time" "time"
"dating-app/internal/dto" "dating-app/internal/dto"
"dating-app/internal/models" "dating-app/internal/models"
"dating-app/internal/repository" "dating-app/internal/repository"
"dating-app/internal/utils"
) )
var ErrNotMatchMember = errors.New("این گفتگو مال تو نیست") var (
ErrNotMatchMember = errors.New("این گفتگو مال تو نیست")
ErrInvalidMessageEncoding = errors.New("پیام رمزنگاری‌شده نامعتبر است (کدگذاری اشتباه)")
)
type ChatService struct { type ChatService struct {
matchRepo *repository.MatchRepository matchRepo *repository.MatchRepository
@@ -85,17 +88,23 @@ func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) {
} }
view := dto.MatchView{ view := dto.MatchView{
ID: m.ID, ID: m.ID,
UserID: other.ID, UserID: other.ID,
FirstName: other.FirstName, FirstName: other.FirstName,
Photos: orEmptyStrings(photosByUser[otherID]), Photos: orEmptyStrings(photosByUser[otherID]),
CreatedAt: m.CreatedAt.Format(time.RFC3339), CreatedAt: m.CreatedAt.Format(time.RFC3339),
PartnerPublicKey: other.PublicKey,
} }
if last, ok := lastMessages[m.ID]; ok { if last, ok := lastMessages[m.ID]; ok {
text := last.Text
at := last.CreatedAt.Format(time.RFC3339) at := last.CreatedAt.Format(time.RFC3339)
view.LastMessage = &text
view.LastAt = &at 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) 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)) views := make([]dto.MessageView, 0, len(messages))
for _, m := range messages { for _, m := range messages {
views = append(views, dto.MessageView{ views = append(views, dto.MessageView{
ID: m.ID, ID: m.ID,
MatchID: m.MatchID, MatchID: m.MatchID,
SenderID: m.SenderID, SenderID: m.SenderID,
Text: m.Text, Text: m.Text,
CreatedAt: m.CreatedAt.Format(time.RFC3339), CipherText: m.CipherText,
IsMine: m.SenderID == userID, Nonce: m.Nonce,
CreatedAt: m.CreatedAt.Format(time.RFC3339),
IsMine: m.SenderID == userID,
}) })
} }
return views, nil return views, nil
} }
// SendMessage یک پیام جدید توی یک مچ ثبت می‌کنه؛ فقط اگه کاربر عضو اون مچ باشه. // SendMessage یک پیام رمزنگاری‌شده توی یک مچ ثبت می‌کنه؛ فقط اگه کاربر عضو
func (s *ChatService) SendMessage(userID, matchID uint, text string) (*dto.MessageView, error) { // اون مچ باشه. سرور محتوای پیام رو نمی‌بینه و نمی‌تونه — 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 { if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
return nil, err return nil, err
} }
if _, err := base64.StdEncoding.DecodeString(cipherText); err != nil {
clean, err := utils.SanitizeMessageText(text) return nil, ErrInvalidMessageEncoding
if err != nil { }
return nil, err 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 { if err := s.messageRepo.Create(msg); err != nil {
return nil, err return nil, err
} }
return &dto.MessageView{ return &dto.MessageView{
ID: msg.ID, ID: msg.ID,
MatchID: msg.MatchID, MatchID: msg.MatchID,
SenderID: msg.SenderID, SenderID: msg.SenderID,
Text: msg.Text, CipherText: msg.CipherText,
CreatedAt: msg.CreatedAt.Format(time.RFC3339), Nonce: msg.Nonce,
IsMine: true, CreatedAt: msg.CreatedAt.Format(time.RFC3339),
IsMine: true,
}, nil }, nil
} }
+21 -4
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"encoding/base64"
"errors" "errors"
"strings" "strings"
"time" "time"
@@ -13,10 +14,11 @@ import (
const MaxPhotosPerUser = 6 const MaxPhotosPerUser = 6
var ( var (
ErrTooManyPhotos = errors.New("حداکثر تعداد عکس مجاز رو داری") ErrTooManyPhotos = errors.New("حداکثر تعداد عکس مجاز رو داری")
ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست") ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست")
ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه") ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه")
ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه") ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه")
ErrInvalidPublicKey = errors.New("کلید عمومی نامعتبر است")
) )
const MaxPhotoSizeBytes = 5 * 1024 * 1024 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 { func (s *ProfileService) DeleteAccount(user *models.User) error {
return s.userRepo.UpdateFields(user, map[string]interface{}{"is_active": false}) 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
}
-47
View File
@@ -1,47 +0,0 @@
package utils
import (
"errors"
"strings"
"unicode/utf8"
)
var (
ErrInvalidMessageEncoding = errors.New("متن پیام معتبر نیست (کدگذاری اشتباه)")
ErrMessageEmpty = errors.New("متن پیام نمی‌تواند خالی باشد")
ErrMessageTooLong = errors.New("متن پیام بیش از حد مجاز طولانی است")
)
const MaxMessageLength = 2000
// SanitizeMessageText متن یک پیام چت رو قبل از ذخیره‌سازی پاک‌سازی/اعتبارسنجی می‌کنه:
// - رد کردن رشته‌هایی که UTF-8 معتبر نیستن (بایت خراب/دستکاری‌شده)
// - حذف کاراکترهای کنترلی (به‌جز \n و \t) که می‌تونن نمایش یا لاگ‌ها رو خراب کنن
// - trim فاصله‌های اضافه و محدود کردن طول
func SanitizeMessageText(raw string) (string, error) {
if !utf8.ValidString(raw) {
return "", ErrInvalidMessageEncoding
}
var b strings.Builder
b.Grow(len(raw))
for _, r := range raw {
if r == '\n' || r == '\t' {
b.WriteRune(r)
continue
}
if r < 0x20 || r == 0x7F {
continue
}
b.WriteRune(r)
}
text := strings.TrimSpace(b.String())
if text == "" {
return "", ErrMessageEmpty
}
if utf8.RuneCountInString(text) > MaxMessageLength {
return "", ErrMessageTooLong
}
return text, nil
}
-71
View File
@@ -1,71 +0,0 @@
package utils
import (
"errors"
"strings"
"testing"
)
func TestSanitizeMessageText_TrimsWhitespace(t *testing.T) {
got, err := SanitizeMessageText(" سلام دوست من ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "سلام دوست من" {
t.Fatalf("expected trimmed text, got %q", got)
}
}
func TestSanitizeMessageText_EmptyAfterTrim(t *testing.T) {
_, err := SanitizeMessageText(" ")
if !errors.Is(err, ErrMessageEmpty) {
t.Fatalf("expected ErrMessageEmpty, got %v", err)
}
}
func TestSanitizeMessageText_StripsControlChars(t *testing.T) {
got, err := SanitizeMessageText("سلام\x00\x07دنیا")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "سلامدنیا" {
t.Fatalf("expected control chars stripped, got %q", got)
}
}
func TestSanitizeMessageText_KeepsNewlinesAndTabs(t *testing.T) {
got, err := SanitizeMessageText("خط اول\nخط دوم\tبا تب")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "خط اول\nخط دوم\tبا تب" {
t.Fatalf("expected newline/tab preserved, got %q", got)
}
}
func TestSanitizeMessageText_InvalidUTF8(t *testing.T) {
invalid := string([]byte{0xff, 0xfe, 0xfd})
_, err := SanitizeMessageText(invalid)
if !errors.Is(err, ErrInvalidMessageEncoding) {
t.Fatalf("expected ErrInvalidMessageEncoding, got %v", err)
}
}
func TestSanitizeMessageText_TooLong(t *testing.T) {
long := strings.Repeat("a", MaxMessageLength+1)
_, err := SanitizeMessageText(long)
if !errors.Is(err, ErrMessageTooLong) {
t.Fatalf("expected ErrMessageTooLong, got %v", err)
}
}
func TestSanitizeMessageText_ExactlyMaxLength(t *testing.T) {
exact := strings.Repeat("a", MaxMessageLength)
got, err := SanitizeMessageText(exact)
if err != nil {
t.Fatalf("unexpected error at exact max length: %v", err)
}
if len(got) != MaxMessageLength {
t.Fatalf("expected length %d, got %d", MaxMessageLength, len(got))
}
}