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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user