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"`
}
// 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 پاسخ یک سوال پروفایل، برای نمایش عمومی
+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"`
}