Добавляем E2E шифрование в чат
- вторник, 30 июня 2026 г. в 00:00:29

Привет, хабр! В этой статье на примере простого чата реализуем E2E шифрование.
Продолжаем нашу серию статей по сетевой разработке на Go. В прошлой статье мы писали простейший TCP-чат. Наши сообщения в открытую гуляли в сети, что делало их простой мишенью для компрометации. В этой статье добавим в наш чат шифрование для безопасного обмена данными.
Эта статья нацелена на простое понимание и применение на практике. На Хабре полно материалов, которые отлично и досконально разбирают все математические аспекты криптографии. Мы же сосредоточимся на инженерной реализации, но для старта нам необходимо зафиксировать три базовых понятия:
Симметричное шифрование — это метод, при котором для шифрования и расшифровки данных используется один и тот же секретный ключ. Он работает быстро и идеально подходит для защиты больших объемов данных. Главная проблема: как безопасно передать этот ключ собеседнику через интернет, чтобы его никто не перехватил?
Асимметричное шифрование — это метод, который решает проблему передачи ключей. Здесь у каждого пользователя есть пара из двух ключей: публичный, который используется только для шифрования и открыто передается по сети, приватный, который нужен для дешифрования и должен храниться в тайне.
Шифрование на эллиптических кривых (ECC / ECDH) — это современный подвид асимметричной криптографии, основанный на сложной математике точек на изогнутой кривой. У него есть два киллер-фичи: он работает в разы быстрее старого RSA, а его ключи гораздо короче при той же степени защиты (всего 32 байта для X25519 против огромных 4096 бит у RSA). Мы используем протокол ECDH (Elliptic Curve Diffie-Hellman): он позволяет двум людям, зная публичные ключи друг друга, за одну математическую операцию вычислить один и тот же общий секрет, вообще не передавая его по сети.
Весь процесс простым языком:
Клиент генерирует AES (Advanced Encryption Standard) ключ (для симметричного шифрования), приватный и публичный ECDH (Elliptic Curve Diffie-Hellman) ключи.
Происходит обмен публичными ключами между пользователями, каждый из своего приватного и чужого пабличного генерирует общий мастер-ключ.
С помощью полученного мастер-ключа каждый шифрует свой AES ключ и передает собеседнику.
После обмена симметричными ключами каждый расшифровывает ключ собеседника.
При отправке сообщения пользователь шифрует его своим симметричным ключем, который уже есть у собеседника. Получателю лишь остается расшифровать сообщение тем же ключем.
В прошлый раз мы ограничились реализацией серверной стороны, а в качестве клиента взяли telnet. Сейчас же нам нужно проработать и логику клиента.
Делаем структуру клиента:
type ClientChat struct { Address string Conn net.Conn } func NewClientChat(Addr string) *ClientChat { return &ClientChat{ Address: Addr, } }
Подключаемся к хосту:
func (c *ClientChat) Start() { var err error c.Conn, err = net.Dial("tcp", c.Address) if err != nil { log.Fatal("Failed connection: %w", err) } defer c.Conn.Close() c.enterToChat() go c.readLoop() scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { text := scanner.Text() if text == "" { continue } //логика обработки ввода } if err := scanner.Err(); err != nil { log.Printf("Failed read from console: %v", err) } }
Теперь дополним структуру нашего клиента для хранения собственных ключей и ключей собеседников из чата:
type CryptoKeys struct { PublicECDHKey []byte PrivateECDHKey []byte AESKey []byte } type P2PKeys struct { SecretECDH []byte AES []byte } type ClientChat struct { Address string Conn net.Conn Keys cryptography.CryptoKeys TopologyTable map[string]P2PKeys }
Деалем хэш-тиблицу, в которой ключ — адрес пользователя, значение мастер-ключ для обмена симметричными ключами между юзерами и сам симетричный ключ пользователя.
Далее прорабатываем все криптографические функции.
Генерация общего мастер-ключа:
func GenarateECDHSecret(privateKey []byte, publicKey []byte) []byte { curve := ecdh.X25519() myPrivateKey, _ := curve.NewPrivateKey(privateKey) peerPublicKey, _ := curve.NewPublicKey(publicKey) sharedSecret, _ := myPrivateKey.ECDH(peerPublicKey) aesKey := sha256.Sum256(sharedSecret) return aesKey[:] }
Шифруем наш симметричный ключ:
func EncryptAESKey(secretShared []byte, aesKey []byte) []byte { block, _ := aes.NewCipher(secretShared) aesGCM, _ := cipher.NewGCM(block) nonce := make([]byte, aesGCM.NonceSize()) io.ReadFull(rand.Reader, nonce) ciphertext := aesGCM.Seal(nonce, nonce, aesKey, nil) return ciphertext }
Зачем нужен GCM?
Режим GCM — это аутентифицированное шифрование (AEAD).
Алгоритм делает две вещи:
Шифрует aesKey с использованием ключа и твоего случайного nonce.
Берет получившийся шифротекст, берет этот же самый nonce, смешивает их с секретным ключом и создает в самом конце 16-байтный тег подлинности (Auth Tag).
Получается структура: [ 12 байт Nonce ] + [ Зашифрованный ключ ] + [ 16 байт Тега ]
Таким образом зашифрованный текст связывается с тегом подлинности, защищая пакет от модификации в пути.
Дешифруем переданный нам ключ собеседника:
func DecryptSenderKey(secretShared []byte, ciphertextWithNonce []byte) ([]byte, error) { block, _ := aes.NewCipher(secretShared) aesGCM, _ := cipher.NewGCM(block) nonceSize := aesGCM.NonceSize() if len(ciphertextWithNonce) < nonceSize { return nil, fmt.Errorf("ciphertext too short") } nonce := ciphertextWithNonce[:nonceSize] actualCiphertext := ciphertextWithNonce[nonceSize:] aesKey, err := aesGCM.Open(nil, nonce, actualCiphertext, nil) if err != nil { return nil, fmt.Errorf("failed to decrypt or authenticate: %w", err) } return aesKey, nil }
Шифруем и дешифруем сообщение:
func EncryptMessage(key []byte, plaintext []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("failed to create cipher: %w", err) } aesGCM, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("failed to create gcm: %w", err) } nonce := make([]byte, aesGCM.NonceSize()) if _, err = io.ReadFull(rand.Reader, nonce); err != nil { return nil, fmt.Errorf("failed to generate nonce: %w", err) } ciphertext := aesGCM.Seal(nonce, nonce, plaintext, nil) return ciphertext, nil } func DecryptMessage(key []byte, ciphertextWithNonce []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("failed to create cipher: %w", err) } aesGCM, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("failed to create gcm: %w", err) } nonceSize := aesGCM.NonceSize() if len(ciphertextWithNonce) < nonceSize { return nil, fmt.Errorf("ciphertext too short") } nonce := ciphertextWithNonce[:nonceSize] actualCiphertext := ciphertextWithNonce[nonceSize:] plaintext, err := aesGCM.Open(nil, nonce, actualCiphertext, nil) if err != nil { return nil, fmt.Errorf("failed to decrypt (fake tag or wrong key): %w", err) } return plaintext, nil }
Чтобы реализовать контракт обмена между пользователями нам понадобятся следующие структуры:
const ( TypeAuth = "AUTH" TypePeerList = "PEER_LIST" TypeNewPeer = "NEW_PEER" TypeGetPeerList = "GET_PEER_LIST" TypeSenderKey = "SENDER_KEY" TypeChatMsg = "CHAT_MSG" ) type Message struct { Author string `json:"author"` Text string `json:"text"` } type PeerInfo struct { Nickname string `json:"nickname"` PublicKey []byte `json:"public_key"` } type Packet struct { Type string `json:"type"` YourName string `json:"your_name"` //поле, чтобы отсеивать свои же пакеты FromPeer string `json:"from_peer,omitempty"` PublicKey []byte `json:"public_key,omitempty"` TargetPeer string `json:"target_peer,omitempty"` EncAESKey []byte `json:"enc_aes_key,omitempty"` Peers []PeerInfo `json:"peers,omitempty"` Message []byte `json:"message,omitempty"` }
TypeAuth ("AUTH") — отправляется клиентом при первом подключении; содержит его публичный ECDH-ключ для регистрации на сервере.
TypePeerList ("PEER_LIST") — отправляется сервером лично новичку в ответ на авторизацию; содержит список адресов и публичных ключей всех участников, которые уже находятся в чате.
TypeNewPeer ("NEW_PEER") — рассылается сервером (бродкастом) всем «старичкам» в чате; уведомляет их о подключении нового пользователя и передает его публичный ключ.
TypeGetPeerList ("GET_PEER_LIST") — сервисный запрос от клиента к серверу с требованием принудительно обновить список участников (используется как фолбек, если таблица топологии не синхронизирована).
TypeSenderKey ("SENDER_KEY") — отправляется между клиентами через сервер; содержит собственный симметричный AES-ключ отправителя, зашифрованный общим секретом ECDH для конкретного получателя.
TypeChatMsg ("CHAT_MSG") — пакет с обычным текстовым сообщением; само содержимое (Message) зашифровано AES-ключом отправителя, а сервер просто пересылает его остальным участникам.
Системный пакет для генерации пакетов:
func NewPeerPacket(publicKey []byte) *message.Packet { return &message.Packet{ Type: message.TypeAuth, PublicKey: publicKey, } } func ProcessedNewPeer(newPeerPacket message.Packet, privateKey []byte, aesKey []byte) *message.Packet { secret := cryptography.GenarateECDHSecret(privateKey, newPeerPacket.PublicKey) cryptAES := cryptography.EncryptAESKey(secret, aesKey) packet := message.Packet{ Type: message.TypeSenderKey, EncAESKey: cryptAES, TargetPeer: newPeerPacket.FromPeer, } return &packet } func CreateSenderKeyPacket(cryptoAES []byte, target string) *message.Packet { return &message.Packet{ Type: message.TypeSenderKey, TargetPeer: target, EncAESKey: cryptoAES, } }
Генерируем 3 ключа клиента:
func GenerateClientsCrypto() *CryptoKeys { privateKey, err := ecdh.X25519().GenerateKey(rand.Reader) if err != nil { log.Fatalf("failed generate private key: %v", err) } publicKey := privateKey.PublicKey() myAESKey := make([]byte, 32) _, err = rand.Read(myAESKey) if err != nil { log.Fatalf("failed generate AES key: %v", err) } return &CryptoKeys{ PublicECDHKey: publicKey.Bytes(), PrivateECDHKey: privateKey.Bytes(), AESKey: myAESKey, } }
Возвращаемся к логике клиента:
func (c *ClientChat) readLoop() { scanner := bufio.NewScanner(c.Conn) for scanner.Scan() { var packet message.Packet if err := json.Unmarshal(scanner.Bytes(), &packet); err != nil { log.Printf("Error parsing packet: %v", err) continue } c.processPacket(packet) } }
Читаем поток пакетов, которые передает сервер.
Разберем обработку полученного пакета по каждому кейсу отдельно:
func (c *ClientChat) processPacket(packet message.Packet) error { switch packet.Type { case message.TypeNewPeer: if packet.FromPeer == packet.YourName { c.addAES(packet.FromPeer, c.Keys.AESKey) return nil } fmt.Printf(" [System]: User %s enter to chat.\n", packet.FromPeer) sendKeyPacket := processedpacket.ProcessedNewPeer(packet, c.Keys.PrivateECDHKey, c.Keys.AESKey) secretECDH := cryptography.GenarateECDHSecret(c.Keys.PrivateECDHKey, packet.PublicKey) c.addECDH(packet.FromPeer, secretECDH) c.sendMessage(*sendKeyPacket) case message.TypeSenderKey: peerKeys := c.getP2PKeys(packet.FromPeer) aesKey, err := cryptography.DecryptSenderKey(peerKeys.SecretECDH, packet.EncAESKey) if err != nil { log.Printf("Failed decrypt aes: %v", err) return err } c.addAES(packet.FromPeer, aesKey) case message.TypeChatMsg: keys := c.getP2PKeys(packet.FromPeer) decryptMessage, _ := cryptography.DecryptMessage(keys.AES, packet.Message) var msg message.Message if err := json.Unmarshal(decryptMessage, &msg); err != nil { log.Printf("Failed decode message: %v\n", err) return err } fmt.Printf("[%s]: %s\n", packet.FromPeer, msg.Text) case message.TypePeerList: c.createTopologyTable(packet.Peers) c.BroadcastAES() default: log.Printf("incorrect packet type: %s", packet.Type) return fmt.Errorf("incorrect packet type: %s", packet.Type) } return nil }
Сервер сигнализирует, что подключился новый пользователь. Генерируем мастер-ключ с помощью переданного публичного ключа нового юзера и собственного приватного ключа данного пользователя. Генерирует ответный пакет для нового пользователя с своим зашифрованным AES ключем, сохраняет мастер ключ в свою хэш-таблицу:
case message.TypeNewPeer: if packet.FromPeer == packet.YourName { c.addAES(packet.FromPeer, c.Keys.AESKey) return nil } fmt.Printf(" [System]: User %s enter to chat.\n", packet.FromPeer) sendKeyPacket := processedpacket.ProcessedNewPeer(packet, c.Keys.PrivateECDHKey, c.Keys.AESKey) secretECDH := cryptography.GenarateECDHSecret(c.Keys.PrivateECDHKey, packet.PublicKey) c.addECDH(packet.FromPeer, secretECDH) c.sendMessage(*sendKeyPacket)
Когда пользователь получает пакет с зашифрованным AES ключем, он находит общий мастер ключ с этим пользователем у себя в локальной хэш-таблице (если не успел получить публичный ключ собеседника, то выполняет запрос к серверу, у которого хранятся все публичные ключи), расшифровывает и сохраняет симметричный ключ для данного пользователя.
case message.TypeSenderKey: peerKeys := c.getP2PKeys(packet.FromPeer) aesKey, err := cryptography.DecryptSenderKey(peerKeys.SecretECDH, packet.EncAESKey) if err != nil { log.Printf("Failed decrypt aes: %v", err) return err } c.addAES(packet.FromPeer, aesKey)
Расшифровка непосредственно передаваемого сообщения. Когда все участники чата получают сообщение, то находят у себя локально симметричный ключ для данного пользователя и расшифровывают переданный текст:
case message.TypeChatMsg: keys := c.getP2PKeys(packet.FromPeer) decryptMessage, _ := cryptography.DecryptMessage(keys.AES, packet.Message) var msg message.Message if err := json.Unmarshal(decryptMessage, &msg); err != nil { log.Printf("Failed decode message: %v\n", err) return err } fmt.Printf("[%s]: %s\n", packet.FromPeer, msg.Text)
И последний тип пакета, который обрабатывает клиент — пул публичных ключей участников чата, который передает сам сервер.
case message.TypePeerList: c.createTopologyTable(packet.Peers) c.BroadcastAES()
Мелкие системные методы:
func (c *ClientChat) BroadcastAES() { for peer, data := range c.TopologyTable { encryptedAES := cryptography.EncryptAESKey(data.SecretECDH, c.Keys.AESKey) packet := processedpacket.CreateSenderKeyPacket(encryptedAES, peer) c.sendMessage(*packet) } } func (c *ClientChat) createTopologyTable(peers []message.PeerInfo) { for _, peer := range peers { secret := cryptography.GenarateECDHSecret(c.Keys.PrivateECDHKey, peer.PublicKey) c.addECDH(peer.Nickname, secret) } } func (c *ClientChat) enterToChat() { helloPacket := processedpacket.NewPeerPacket(c.Keys.PublicECDHKey) c.sendMessage(*helloPacket) } func (c *ClientChat) sendMessage(packet message.Packet) error { packetBytes, err := json.Marshal(packet) if err != nil { return err } _, err = fmt.Fprintf(c.Conn, "%s\n", packetBytes) return err } func (c *ClientChat) getTopologyTable() { packet := message.Packet{ Type: message.TypeGetPeerList, } c.sendMessage(packet) } func (c *ClientChat) getP2PKeys(user string) P2PKeys { keys, exists := c.TopologyTable[user] if !exists { c.getTopologyTable() keys = c.TopologyTable[user] } return keys } func (c *ClientChat) addECDH(user string, ecdhKey []byte) { keys := c.TopologyTable[user] // если нет, вернется пустая структура keys.SecretECDH = ecdhKey c.TopologyTable[user] = keys } func (c *ClientChat) addAES(user string, aesKey []byte) { userKeys, exists := c.TopologyTable[user] if !exists { userKeys = P2PKeys{} } userKeys.AES = aesKey c.TopologyTable[user] = userKeys }
enterToChat() (Вход в чат) — стартовый метод рукопожатия. Формирует приветственный пакет, упаковывает в него наш публичный ECDH-ключ и отправляет на сервер, заявляя о своем присутствии в сети.
createTopologyTable(peers) (Инициализация таблицы связей) — вызывается новичком при получении списка участников от сервера. Метод итерируется по всем «старичкам», перемножает наш приватный ECDH-ключ с их публичными ключами, вычисляет уникальные общие секреты (shared secrets) для каждой пары и сохраняет их в TopologyTable.
BroadcastAES() (Рассылка ключа чата) — отправляет наш главный сессионный AES-ключ всем участникам. Метод проходит по таблице связей, шифрует наш AESKey уникальным общим секретом (SecretECDH) отдельно для каждого пира и отправляет персональные пакеты TypeSenderKey.
getP2PKeys(user) (Получение крипто-ключей собеседника) — внутренний менеджер ключей. Он достает сохраненные ключи (ECDH-секрет и AES-ключ) для конкретного пользователя. Если пользователя почему-то нет в таблице, метод делает фолбек-запрос к серверу (getTopologyTable) для обновления списка.
getTopologyTable() (Запрос списка участников) — сервисный метод, который отправляет серверу сигнал TypeGetPeerList с требованием принудительно прислать актуальную карту сети (используется для синхронизации, если прилетело сообщение от неизвестного адреса).
sendMessage(packet) (Низкоуровневая отправка по TCP) — транспортный узел клиента. Сериализует любой готовый пакет message.Packet в JSON-байты, принудительно добавляет в конец символ переноса строки \n (чтобы bufio.Scanner на стороне сервера четко понимал, где заканчивается пакет) и пуляет данные в сокет.
addECDH(user, ecdhKey) (Сохранение ECDH-секрета) — безопасный хелпер для атомарного обновления таблицы. Находит структуру пира в мапе, аккуратно записывает туда вычисленный асимметричный секрет и сохраняет измененную копию обратно в TopologyTable.
addAES(user, aesKey) (Сохранение AES-ключа собеседника) — финальный шаг крипто-настройки. Вызывается, когда нам прилетает расшифрованный AES-ключ от соседа. Находит нужного пользователя в мапе, «впаивает» его симметричный ключ в поле .AES и перезаписывает структуру в мапе, подготавливая клиента к чтению сообщений от этого юзера.
Теперь нам нужно проработать логику сервера, чтобы он корректно хранил публичные ключи пользователей, обрабатывал пакеты и поддерживал рукопожатие.
Структура сервера:
type Peer struct { Conn net.Conn PublicKey []byte ConnectedAt time.Time } type Server struct { Address string Listener net.Listener clients map[string]*Peer deadClients []net.Conn mu sync.RWMutex messagesChan chan message.Packet }
В структуру пользователя добавили его публичный ключ.
канал теперь будет передавать пакеты.
Добавляем рукопожатие и переносим в него регистрацию пользователя:
func (s *Server) handleConn(conn net.Conn) { defer conn.Close() err := s.handshake(conn) if err != nil { return } buf := make([]byte, 2048) for { n, err := conn.Read(buf) if err != nil { log.Printf("Connection error: %v", err) return } var packet message.Packet err = json.Unmarshal(buf[:n], &packet) if err != nil { log.Printf("Failed encoding packet: %v", err) continue } packet.FromPeer = conn.RemoteAddr().String() s.processPacket(packet) } } func (s *Server) handshake(conn net.Conn) error { if _, exists := s.clients[conn.RemoteAddr().String()]; exists { conn.Close() return fmt.Errorf("user already connection") } buf := make([]byte, 2048) n, err := conn.Read(buf) if err != nil { log.Printf("Handshake error: %v", err) return fmt.Errorf("failed handshake: %w", err) } var packet message.Packet err = json.Unmarshal(buf[:n], &packet) if err != nil { log.Printf("Handshake error: %v", err) return fmt.Errorf("failed handshake: %v", err) } if packet.Type != message.TypeAuth { log.Printf("Incorrect type packet") return fmt.Errorf("client hello packet need auth type") } s.registerPeer(conn, packet.PublicKey) packet.Type = message.TypeNewPeer packet.FromPeer = conn.RemoteAddr().String() s.processPacket(packet) packetListPeers := s.generatePacketListPeers(conn.RemoteAddr().String()) s.processPacket(*packetListPeers) return nil }
В рукопожатии ждем первый пакет авторизации от нового кользопателя. Регистрируем его на сервере вместо с публичным ключем. Передаем пакет с типом TypeNewPeer. Передаем его в обработку пакетов. Далее создаем пакет со списком всех участников чата и их публичных ключей для нового пользователя.
Генерируем пакет со списком публичных ключей:
func (s *Server) generatePacketListPeers(target string) *message.Packet { var packet message.Packet packet.TargetPeer = target packet.Type = message.TypePeerList peersInfo := make([]message.PeerInfo, 0, len(s.clients)) s.mu.Lock() for peer, data := range s.clients { if peer != target { peersInfo = append(peersInfo, message.PeerInfo{ Nickname: peer, PublicKey: data.PublicKey, }) } } s.mu.Unlock() packet.Peers = peersInfo return &packet }
Обработка полученных пакетов:
func (s *Server) processPacket(packet message.Packet) error { switch packet.Type { case message.TypeNewPeer, message.TypeChatMsg: s.messagesChan <- packet case message.TypePeerList, message.TypeSenderKey: targetConn, exists := s.getPeerConn(packet.TargetPeer) if !exists { log.Printf("not found target connection") return fmt.Errorf("not found target connection") } s.writeInConnection(targetConn, packet) case message.TypeGetPeerList: packetList := s.generatePacketListPeers(packet.FromPeer) targetConn, exists := s.getPeerConn(packet.TargetPeer) if !exists { log.Printf("not found target connection") return fmt.Errorf("not found target connection") } s.writeInConnection(targetConn, *packetList) default: return fmt.Errorf("incorrect packet type") } return nil }
Если пакет с публичным ключем нового пользователя или текстовое сообщение, то отправляем его на широковещательную рассылку:
case message.TypeNewPeer, message.TypeChatMsg: s.messagesChan <- packet
Если пакет нацелен на конкретного пользователя (передается список публичных ключей участников чата или передается зашифрованный симметричный ключ), тогда шлем напрямую:
case message.TypePeerList, message.TypeSenderKey: targetConn, exists := s.getPeerConn(packet.TargetPeer) if !exists { log.Printf("not found target connection") return fmt.Errorf("not found target connection") } s.writeInConnection(targetConn, packet)
Запрос пользователя на принудительную отправку пулы публичных ключей:
case message.TypeGetPeerList: packetList := s.generatePacketListPeers(packet.FromPeer) targetConn, exists := s.getPeerConn(packet.TargetPeer) if !exists { log.Printf("not found target connection") return fmt.Errorf("not found target connection") } s.writeInConnection(targetConn, *packetList)
Процесс шифрования готов! Добавим на сторону сервера попытку прочитать пакет с сообщением:
func (s *Server) processPacket(packet message.Packet) error { switch packet.Type { case message.TypeChatMsg: s.messagesChan <- packet var msg message.Message err := json.Unmarshal(packet.Message, &msg) if err != nil { log.Printf("failed decode message on server side: %v", err) log.Printf("message byte: %v", packet.Message) } case message.TypeNewPeer: ... case message.TypePeerList, message.TypeSenderKey: ... case message.TypeGetPeerList: ... default: return fmt.Errorf("incorrect packet type") } return nil }
Подключаемся к серверу с двух клиентов и отправляем сообщение:
─$ go run cmd/client/main.go Hello world! [[::1]:35176]: Hello world!
Успешно читаем на другом клиенте:
─$ go run cmd/client/main.go [System]: User [::1]:35176 enter to chat. [[::1]:35176]: Hello world!
Что видит сервер (мешанину из байт):
└─$ go run cmd/server/main.go 2026/06/28 20:35:01 Server started 2026/06/28 20:35:04 Welcome, [::1]:56122 2026/06/28 20:35:08 Welcome, [::1]:56126 2026/06/28 20:35:12 failed decode message on server side: invalid character 'B' looking for beginning of value 2026/06/28 20:35:12 message byte: [66 118 215 94 9 164 135 205 93 184 98 52 117 159 67 10 187 128 100 149 226 36 107 89 162 153 217 94 103 29 239 219 126 9 228 218 119 54 195 208 128 194 140 213 22 216 245 239 45 3 91 2 159 88 91 44 117 195 53 166 138 86 89]
Определенный успех для показательного примера внедрения End to End шифрования.
package client import ( "bufio" "encoding/json" "fmt" "log" "net" "os" cryptography "github.com/barashF/TCP-chat/crypto" "github.com/barashF/TCP-chat/message" processedpacket "github.com/barashF/TCP-chat/processed_packet" ) type P2PKeys struct { SecretECDH []byte AES []byte } type ClientChat struct { Address string Conn net.Conn Keys cryptography.CryptoKeys TopologyTable map[string]P2PKeys } func NewClientChat(Addr string) *ClientChat { return &ClientChat{ Address: Addr, Keys: *cryptography.GenerateClientsCrypto(), TopologyTable: make(map[string]P2PKeys), } } func (c *ClientChat) Start() { var err error c.Conn, err = net.Dial("tcp", c.Address) if err != nil { log.Fatal("Failed connection: %w", err) } defer c.Conn.Close() c.enterToChat() go c.readLoop() scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { text := scanner.Text() if text == "" { continue } msg, _ := json.Marshal(message.Message{ Text: text, }) encryptMsg, _ := cryptography.EncryptMessage(c.Keys.AESKey, msg) packet := message.Packet{ Type: message.TypeChatMsg, Message: encryptMsg, } if err := c.sendMessage(packet); err != nil { log.Printf("failed send message: %v", err) break } } if err := scanner.Err(); err != nil { log.Printf("Failed read from console: %v", err) } } func (c *ClientChat) readLoop() { scanner := bufio.NewScanner(c.Conn) for scanner.Scan() { var packet message.Packet if err := json.Unmarshal(scanner.Bytes(), &packet); err != nil { log.Printf("Error parsing packet: %v", err) continue } c.processPacket(packet) } } func (c *ClientChat) processPacket(packet message.Packet) error { switch packet.Type { case message.TypeNewPeer: if packet.FromPeer == packet.YourName { c.addAES(packet.FromPeer, c.Keys.AESKey) return nil } fmt.Printf(" [System]: User %s enter to chat.\n", packet.FromPeer) sendKeyPacket := processedpacket.ProcessedNewPeer(packet, c.Keys.PrivateECDHKey, c.Keys.AESKey) secretECDH := cryptography.GenarateECDHSecret(c.Keys.PrivateECDHKey, packet.PublicKey) c.addECDH(packet.FromPeer, secretECDH) c.sendMessage(*sendKeyPacket) case message.TypeSenderKey: peerKeys := c.getP2PKeys(packet.FromPeer) aesKey, err := cryptography.DecryptSenderKey(peerKeys.SecretECDH, packet.EncAESKey) if err != nil { log.Printf("Failed decrypt aes: %v", err) return err } c.addAES(packet.FromPeer, aesKey) case message.TypeChatMsg: keys := c.getP2PKeys(packet.FromPeer) decryptMessage, _ := cryptography.DecryptMessage(keys.AES, packet.Message) var msg message.Message if err := json.Unmarshal(decryptMessage, &msg); err != nil { log.Printf("Failed decode message: %v\n", err) return err } fmt.Printf("[%s]: %s\n", packet.FromPeer, msg.Text) case message.TypePeerList: c.createTopologyTable(packet.Peers) c.BroadcastAES() default: log.Printf("incorrect packet type: %s", packet.Type) return fmt.Errorf("incorrect packet type: %s", packet.Type) } return nil } func (c *ClientChat) BroadcastAES() { for peer, data := range c.TopologyTable { encryptedAES := cryptography.EncryptAESKey(data.SecretECDH, c.Keys.AESKey) packet := processedpacket.CreateSenderKeyPacket(encryptedAES, peer) c.sendMessage(*packet) } } func (c *ClientChat) createTopologyTable(peers []message.PeerInfo) { for _, peer := range peers { secret := cryptography.GenarateECDHSecret(c.Keys.PrivateECDHKey, peer.PublicKey) c.addECDH(peer.Nickname, secret) } } func (c *ClientChat) enterToChat() { helloPacket := processedpacket.NewPeerPacket(c.Keys.PublicECDHKey) c.sendMessage(*helloPacket) } func (c *ClientChat) sendMessage(packet message.Packet) error { packetBytes, err := json.Marshal(packet) if err != nil { return err } _, err = fmt.Fprintf(c.Conn, "%s\n", packetBytes) return err } func (c *ClientChat) getTopologyTable() { packet := message.Packet{ Type: message.TypeGetPeerList, } c.sendMessage(packet) } func (c *ClientChat) getP2PKeys(user string) P2PKeys { keys, exists := c.TopologyTable[user] if !exists { c.getTopologyTable() keys = c.TopologyTable[user] } return keys } func (c *ClientChat) addECDH(user string, ecdhKey []byte) { keys := c.TopologyTable[user] // если нет, вернется пустая структура keys.SecretECDH = ecdhKey c.TopologyTable[user] = keys } func (c *ClientChat) addAES(user string, aesKey []byte) { userKeys, exists := c.TopologyTable[user] if !exists { userKeys = P2PKeys{} } userKeys.AES = aesKey c.TopologyTable[user] = userKeys }
package server import ( "context" "encoding/json" "fmt" "log" "net" "os/signal" "sync" "syscall" "time" "github.com/barashF/TCP-chat/message" ) type Peer struct { Conn net.Conn PublicKey []byte ConnectedAt time.Time } type Server struct { Address string Listener net.Listener clients map[string]*Peer deadClients []net.Conn mu sync.RWMutex messagesChan chan message.Packet } func NewServer(address string) *Server { return &Server{ Address: address, messagesChan: make(chan message.Packet, 100), clients: make(map[string]*Peer), deadClients: make([]net.Conn, 0), } } func (s *Server) Start() error { var err error s.Listener, err = net.Listen("tcp", s.Address) if err != nil { log.Printf("Error accept: %v", err) return err } log.Printf("Server started") ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) go s.acceptLoop(ctx) go s.Broadcast(ctx) defer func() { cancel() s.Listener.Close() close(s.messagesChan) }() s.Stop(ctx) return nil } func (s *Server) acceptLoop(ctx context.Context) { for { conn, err := s.Listener.Accept() if err != nil { select { case <-ctx.Done(): log.Println("Accept loop stopped") return default: log.Printf("Failed accept client: %v", err) } } log.Printf("Welcome, %s", conn.RemoteAddr().String()) go s.handleConn(conn) } } func (s *Server) handleConn(conn net.Conn) { defer conn.Close() err := s.handshake(conn) if err != nil { return } buf := make([]byte, 2048) for { n, err := conn.Read(buf) if err != nil { log.Printf("Connection error: %v", err) return } var packet message.Packet err = json.Unmarshal(buf[:n], &packet) if err != nil { log.Printf("Failed encoding packet: %v", err) continue } packet.FromPeer = conn.RemoteAddr().String() s.processPacket(packet) } } func (s *Server) handshake(conn net.Conn) error { if _, exists := s.clients[conn.RemoteAddr().String()]; exists { conn.Close() return fmt.Errorf("user already connection") } buf := make([]byte, 2048) n, err := conn.Read(buf) if err != nil { log.Printf("Handshake error: %v", err) return fmt.Errorf("failed handshake: %w", err) } var packet message.Packet err = json.Unmarshal(buf[:n], &packet) if err != nil { log.Printf("Handshake error: %v", err) return fmt.Errorf("failed handshake: %v", err) } if packet.Type != message.TypeAuth { log.Printf("Incorrect type packet") return fmt.Errorf("client hello packet need auth type") } s.registerPeer(conn, packet.PublicKey) packet.Type = message.TypeNewPeer packet.FromPeer = conn.RemoteAddr().String() s.processPacket(packet) packetListPeers := s.generatePacketListPeers(conn.RemoteAddr().String()) s.processPacket(*packetListPeers) return nil } func (s *Server) generatePacketListPeers(target string) *message.Packet { var packet message.Packet packet.TargetPeer = target packet.Type = message.TypePeerList peersInfo := make([]message.PeerInfo, 0, len(s.clients)) s.mu.Lock() for peer, data := range s.clients { if peer != target { peersInfo = append(peersInfo, message.PeerInfo{ Nickname: peer, PublicKey: data.PublicKey, }) } } s.mu.Unlock() packet.Peers = peersInfo return &packet } func (s *Server) Broadcast(ctx context.Context) { for { select { case <-ctx.Done(): return case msg := <-s.messagesChan: s.mu.RLock() for _, client := range s.clients { s.writeInConnection(client.Conn, msg) } s.mu.RUnlock() for _, conn := range s.deadClients { s.unregisterPeer(conn) } s.deadClients = s.deadClients[:0] } } } func (s *Server) writeInConnection(conn net.Conn, message message.Packet) { message.YourName = conn.RemoteAddr().String() msgBytes, err := json.Marshal(message) if err != nil { log.Printf("Failed to marshal JSON: %v", err) return } payload := fmt.Sprintf("%s\n", string(msgBytes)) _, err = conn.Write([]byte(payload)) if err != nil { log.Printf("Failed write message: %v", err) s.deadClients = append(s.deadClients, conn) } } func (s *Server) processPacket(packet message.Packet) error { switch packet.Type { case message.TypeNewPeer, message.TypeChatMsg: s.messagesChan <- packet case message.TypePeerList, message.TypeSenderKey: targetConn, exists := s.getPeerConn(packet.TargetPeer) if !exists { log.Printf("not found target connection") return fmt.Errorf("not found target connection") } s.writeInConnection(targetConn, packet) case message.TypeGetPeerList: packetList := s.generatePacketListPeers(packet.FromPeer) targetConn, exists := s.getPeerConn(packet.TargetPeer) if !exists { log.Printf("not found target connection") return fmt.Errorf("not found target connection") } s.writeInConnection(targetConn, *packetList) default: return fmt.Errorf("incorrect packet type") } return nil } func (s *Server) registerPeer(conn net.Conn, publicKey []byte) { peer := &Peer{ Conn: conn, PublicKey: publicKey, ConnectedAt: time.Now(), } s.mu.Lock() defer s.mu.Unlock() s.clients[conn.RemoteAddr().String()] = peer } func (s *Server) unregisterPeer(conn net.Conn) { s.mu.Lock() defer s.mu.Unlock() conn.Close() delete(s.clients, conn.RemoteAddr().String()) log.Printf("Client disconnected: %s", conn.RemoteAddr().String()) } func (s *Server) getPeerConn(peer string) (net.Conn, bool) { s.mu.RLock() defer s.mu.RUnlock() conn, exists := s.clients[peer] return conn.Conn, exists } func (s *Server) Stop(ctx context.Context) { <-ctx.Done() for _, client := range s.clients { s.unregisterPeer(client.Conn) } }
Мы успешно реализовали честное End-to-End шифрование для нашего TCP-чата. Да, наш сервер всё еще координирует сеть и пересылает пакеты (выполняет роль сигнального сервера и роутера), но он больше не имеет доступа к самим сообщениям. Как видно из логов, попытка сервера десериализовать сообщение падает с ошибкой — для него это просто массив случайных байт.
Мы построили гибридную схему:
ECDH на кривой X25519 безопасно связал клиентов и помог им выработать общий секрет без риска перехвата.
AES-256 в режиме GCM взял на себя тяжелую работу по шифрованию больших потоков текста и гарантировал, что никто не сможет изменить байты сообщения в процессе транзита.
Наш чат стал приватным, но пока не стал по-настоящему надежным и безопасным от более изощренных атак. В следующих статьях мы разберем:
Защиту от MITM (Man-in-the-Middle): Сервер не может прочитать сообщения, но что мешает вредоносному серверу подменить публичные ключи участников в момент рукопожатия и читать всё? Будем решать проблему доверия к ключам.
Аутентификацию пользователей: Добавим полноценную сессию, подписи пакетов и разберемся, как привязать постоянный профиль пользователя к его временным сессионным ключам.