golang

Добавляем E2E шифрование в чат

  • вторник, 30 июня 2026 г. в 00:00:29
https://habr.com/ru/articles/1053030/

Привет, хабр! В этой статье на примере простого чата реализуем 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).

Алгоритм делает две вещи:

  1. Шифрует aesKey с использованием ключа и твоего случайного nonce.

  2. Берет получившийся шифротекст, берет этот же самый 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-чата. Да, наш сервер всё еще координирует сеть и пересылает пакеты (выполняет роль сигнального сервера и роутера), но он больше не имеет доступа к самим сообщениям. Как видно из логов, попытка сервера десериализовать сообщение падает с ошибкой — для него это просто массив случайных байт.

Мы построили гибридную схему:

  1. ECDH на кривой X25519 безопасно связал клиентов и помог им выработать общий секрет без риска перехвата.

  2. AES-256 в режиме GCM взял на себя тяжелую работу по шифрованию больших потоков текста и гарантировал, что никто не сможет изменить байты сообщения в процессе транзита.

Что дальше?

Наш чат стал приватным, но пока не стал по-настоящему надежным и безопасным от более изощренных атак. В следующих статьях мы разберем:

  • Защиту от MITM (Man-in-the-Middle): Сервер не может прочитать сообщения, но что мешает вредоносному серверу подменить публичные ключи участников в момент рукопожатия и читать всё? Будем решать проблему доверия к ключам.

  • Аутентификацию пользователей: Добавим полноценную сессию, подписи пакетов и разберемся, как привязать постоянный профиль пользователя к его временным сессионным ключам.