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
+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)
}