diff --git a/internal/dto/discovery_dto.go b/internal/dto/discovery_dto.go index 5490fdb..861d4ec 100644 --- a/internal/dto/discovery_dto.go +++ b/internal/dto/discovery_dto.go @@ -22,30 +22,45 @@ 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"` - FirstName string `json:"first_name"` - Photos []string `json:"photos"` - CreatedAt string `json:"created_at"` - LastMessage *string `json:"last_message,omitempty"` - LastAt *string `json:"last_at,omitempty"` + ID uint `json:"id"` + UserID uint `json:"user_id"` + FirstName string `json:"first_name"` + 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"` - CreatedAt string `json:"created_at"` - IsMine bool `json:"is_mine"` + ID uint `json:"id"` + MatchID uint `json:"match_id"` + SenderID uint `json:"sender_id"` + 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 پاسخ یک سوال پروفایل، برای نمایش عمومی diff --git a/internal/dto/profile_dto.go b/internal/dto/profile_dto.go index 29e6ba3..0a56e83 100644 --- a/internal/dto/profile_dto.go +++ b/internal/dto/profile_dto.go @@ -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"` +} diff --git a/internal/handler/chat_handler.go b/internal/handler/chat_handler.go index f6c9a16..f061d7a 100644 --- a/internal/handler/chat_handler.go +++ b/internal/handler/chat_handler.go @@ -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) diff --git a/internal/handler/profile_handler.go b/internal/handler/profile_handler.go index e64d7a5..c23471a 100644 --- a/internal/handler/profile_handler.go +++ b/internal/handler/profile_handler.go @@ -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) +} diff --git a/internal/models/discovery.go b/internal/models/discovery.go index f5204ae..a7e577c 100644 --- a/internal/models/discovery.go +++ b/internal/models/discovery.go @@ -55,14 +55,20 @@ 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"` - ReadAt *time.Time `json:"read_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + 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"` } func (Message) TableName() string { diff --git a/internal/models/user.go b/internal/models/user.go index 38ff00b..b5fcd35 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -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"` diff --git a/internal/router/router.go b/internal/router/router.go index 1daa6ee..bdd90db 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) } // -------- آنبوردینگ (نیاز به ورود) -------- diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 72eb4ed..a82cc79 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -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 } diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index 511ed26..cfc4a19 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -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 +} diff --git a/internal/utils/sanitize.go b/internal/utils/sanitize.go deleted file mode 100644 index ddd2bc3..0000000 --- a/internal/utils/sanitize.go +++ /dev/null @@ -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 -} diff --git a/internal/utils/sanitize_test.go b/internal/utils/sanitize_test.go deleted file mode 100644 index bfe8a8d..0000000 --- a/internal/utils/sanitize_test.go +++ /dev/null @@ -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)) - } -}