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
+20 -5
View File
@@ -22,7 +22,10 @@ type SwipeResponse struct {
Match *MatchView `json:"match,omitempty"`
}
// MatchView خلاصه‌ی یک مچ برای نمایش در لیست مچ‌ها/گفتگوها
// MatchView خلاصه‌ی یک مچ برای نمایش در لیست مچ‌ها/گفتگوها. برای پیش‌نمایش
// آخرین پیام، سرور رمزگشایی نمی‌کنه (نمی‌تونه) — CipherText/Nonce خودِ آخرین
// پیام رو برمی‌گردونه تا کلاینت خودش رمزگشایی کنه؛ LastMessage فقط برای
// پیام‌های قدیمیِ قبل از E2EE پر می‌شه.
type MatchView struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
@@ -30,22 +33,34 @@ type MatchView struct {
Photos []string `json:"photos"`
CreatedAt string `json:"created_at"`
LastMessage *string `json:"last_message,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 {
ID uint `json:"id"`
MatchID uint `json:"match_id"`
SenderID uint `json:"sender_id"`
Text string `json:"text"`
Text string `json:"text,omitempty"`
CipherText *string `json:"cipher_text,omitempty"`
Nonce *string `json:"nonce,omitempty"`
CreatedAt string `json:"created_at"`
IsMine bool `json:"is_mine"`
}
// SendMessageRequest بدنه‌ی درخواست ارسال پیام
// SendMessageRequest بدنه‌ی درخواست ارسال پیام رمزنگاری‌شده. متن خام پیام
// هیچ‌وقت به سرور نمی‌رسه؛ کلاینت قبل از ارسال با کلید عمومی طرف مقابل
// رمزنگاریش می‌کنه.
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 پاسخ یک سوال پروفایل، برای نمایش عمومی
+7
View File
@@ -23,3 +23,10 @@ type UpdateNotificationSettingsRequest struct {
NotifyMessages bool `json:"notify_messages"`
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)
}
// 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) {
user, ok := currentUser(c)
if !ok {
@@ -66,10 +68,10 @@ func (h *ChatHandler) SendMessage(c *gin.Context) {
}
var req dto.SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "متن پیام نمی‌تونه خالی باشه", err.Error())
utils.Fail(c, http.StatusBadRequest, "پیام رمزنگاری‌شده نامعتبر است", err.Error())
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 {
h.handleError(c, err)
return
@@ -81,9 +83,7 @@ func (h *ChatHandler) handleError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrNotMatchMember):
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
case errors.Is(err, utils.ErrInvalidMessageEncoding),
errors.Is(err, utils.ErrMessageEmpty),
errors.Is(err, utils.ErrMessageTooLong):
case errors.Is(err, service.ErrInvalidMessageEncoding):
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
default:
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)
}
// 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)
}
+7 -1
View File
@@ -55,12 +55,18 @@ func (Match) TableName() string {
return "matches"
}
// Message یک پیام متنی داخل یک مچ
// Message یک پیام داخل یک مچ. از رمزنگاری سرتاسری (E2EE) استفاده می‌شه:
// سرور فقط CipherText+Nonce (خروجی رمزنگاری‌شده‌ی سمت کلاینت با NaCl box،
// X25519-XSalsa20-Poly1305) رو ذخیره می‌کنه و هیچ‌وقت متن خام پیام رو نمی‌بینه.
// Text قدیمی (قبل از اضافه شدن E2EE) برای نمایش پیام‌های تاریخی نگه داشته می‌شه؛
// پیام‌های جدید Text خالی دارن و به‌جاش CipherText/Nonce پر می‌شه.
type Message struct {
ID uint `gorm:"primaryKey" json:"id"`
MatchID uint `gorm:"not null;index" json:"match_id"`
SenderID uint `gorm:"not null" json:"sender_id"`
Text string `gorm:"size:2000;not null" json:"text"`
CipherText *string `gorm:"size:12000" json:"cipher_text,omitempty"`
Nonce *string `gorm:"size:64" json:"nonce,omitempty"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
+6
View File
@@ -108,6 +108,12 @@ type User struct {
Latitude *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"`
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.PATCH("/notifications", h.Profile.UpdateNotificationSettings)
profile.DELETE("/me", h.Profile.DeleteAccount)
profile.PUT("/public-key", h.Profile.UpdatePublicKey)
}
// -------- آنبوردینگ (نیاز به ورود) --------
+28 -12
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
@@ -90,12 +93,18 @@ func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) {
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)
}
@@ -129,6 +138,8 @@ func (s *ChatService) ListMessages(userID, matchID uint) ([]dto.MessageView, err
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,
})
@@ -136,18 +147,22 @@ func (s *ChatService) ListMessages(userID, matchID uint) ([]dto.MessageView, err
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
}
@@ -156,7 +171,8 @@ func (s *ChatService) SendMessage(userID, matchID uint, text string) (*dto.Messa
ID: msg.ID,
MatchID: msg.MatchID,
SenderID: msg.SenderID,
Text: msg.Text,
CipherText: msg.CipherText,
Nonce: msg.Nonce,
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
IsMine: true,
}, nil
+17
View File
@@ -1,6 +1,7 @@
package service
import (
"encoding/base64"
"errors"
"strings"
"time"
@@ -17,6 +18,7 @@ var (
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
}
-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))
}
}