Skip to main content Skip to sidebar

Private messages with X25519 and AES-256-GCM

Building secure private messaging systems requires robust cryptographic primitives. This post demonstrates how to implement end-to-end encryption using X25519 key exchange for establishing shared secrets and AES-256-GCM for authenticated encryption in Go.

How the Process Works

The private messaging system operates in two distinct phases:

Phase 1: Key Exchange

  1. Key Generation: Both Alice and Bob independently generate their X25519 key pairs (private key + public key)
  2. Public Key Exchange: Alice and Bob exchange their public keys over any communication channel (this can be done openly)
  3. Shared Secret Computation:
    • Alice uses her private key + Bob’s public key to compute a shared secret
    • Bob uses his private key + Alice’s public key to compute the same shared secret
    • Due to the mathematical properties of X25519, both parties arrive at identical shared secrets

Phase 2: Secure Messaging

  1. Message Encryption: When Alice wants to send a message, she encrypts it using AES-256-GCM with the shared secret as the key
  2. Message Transmission: The encrypted message is sent to Bob over any channel (even insecure ones)
  3. Message Decryption: Bob receives the encrypted message and decrypts it using AES-256-GCM with the same shared secret
  4. Reply Process: Bob can reply by encrypting his response with the same shared secret and sending it back to Alice

Security Properties

  • Forward Secrecy: Each session can use ephemeral keys that are discarded after use
  • Authentication: AES-256-GCM provides built-in message authentication
  • Confidentiality: Only Alice and Bob can decrypt the messages
  • Integrity: Any tampering with messages will be detected during decryption

X25519 Key Exchange

X25519 is an elliptic curve Diffie-Hellman key exchange using Curve25519. It allows two parties to establish a shared secret over an insecure channel.

package main

import (
	"crypto/rand"
	"crypto/sha256"
	"fmt"
	"golang.org/x/crypto/curve25519"
)

type KeyPair struct {
	PrivateKey [32]byte
	PublicKey  [32]byte
}

func generateKeyPair() (*KeyPair, error) {
	var privateKey [32]byte
	_, err := rand.Read(privateKey[:])
	if err != nil {
		return nil, err
	}

	var publicKey [32]byte
	curve25519.ScalarBaseMult(&publicKey, &privateKey)

	return &KeyPair{
		PrivateKey: privateKey,
		PublicKey:  publicKey,
	}, nil
}

func computeSharedSecret(privateKey, peerPublicKey [32]byte) ([32]byte, error) {
	var sharedSecret [32]byte
	curve25519.ScalarMult(&sharedSecret, &privateKey, &peerPublicKey)

	// Derive a proper key from the shared secret using SHA256
	return sha256.Sum256(sharedSecret[:]), nil
}

func main() {
	// Alice generates her key pair
	alice, err := generateKeyPair()
	if err != nil {
		fmt.Println("Error generating Alice's keys:", err)
		return
	}

	// Bob generates his key pair
	bob, err := generateKeyPair()
	if err != nil {
		fmt.Println("Error generating Bob's keys:", err)
		return
	}

	// Alice computes shared secret using her private key and Bob's public key
	aliceShared, err := computeSharedSecret(alice.PrivateKey, bob.PublicKey)
	if err != nil {
		fmt.Println("Error computing Alice's shared secret:", err)
		return
	}

	// Bob computes shared secret using his private key and Alice's public key
	bobShared, err := computeSharedSecret(bob.PrivateKey, alice.PublicKey)
	if err != nil {
		fmt.Println("Error computing Bob's shared secret:", err)
		return
	}

	fmt.Printf("Alice's Public Key: %x\n", alice.PublicKey)
	fmt.Printf("Bob's Public Key: %x\n", bob.PublicKey)
	fmt.Printf("Alice's Shared Secret: %x\n", aliceShared)
	fmt.Printf("Bob's Shared Secret: %x\n", bobShared)
	fmt.Printf("Secrets Match: %t\n", aliceShared == bobShared)
}

AES-256-GCM Encryption

AES-256-GCM provides authenticated encryption, ensuring both confidentiality and integrity of messages.

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"fmt"
)

func encrypt(plaintext []byte, key [32]byte) ([]byte, error) {
	block, err := aes.NewCipher(key[:])
	if err != nil {
		return nil, err
	}

	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}

	// Generate a random nonce
	nonce := make([]byte, gcm.NonceSize())
	_, err = rand.Read(nonce)
	if err != nil {
		return nil, err
	}

	// Encrypt and authenticate
	ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
	return ciphertext, nil
}

func decrypt(ciphertext []byte, key [32]byte) ([]byte, error) {
	block, err := aes.NewCipher(key[:])
	if err != nil {
		return nil, err
	}

	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}

	if len(ciphertext) < gcm.NonceSize() {
		return nil, fmt.Errorf("ciphertext too short")
	}

	// Extract nonce and ciphertext
	nonce := ciphertext[:gcm.NonceSize()]
	ciphertext = ciphertext[gcm.NonceSize():]

	// Decrypt and verify
	plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
	if err != nil {
		return nil, err
	}

	return plaintext, nil
}

func main() {
	// Example encryption key (32 bytes for AES-256)
	var key [32]byte
	_, err := rand.Read(key[:])
	if err != nil {
		fmt.Println("Error generating key:", err)
		return
	}

	message := []byte("Hello, this is a secret message!")

	// Encrypt the message
	encrypted, err := encrypt(message, key)
	if err != nil {
		fmt.Println("Encryption error:", err)
		return
	}

	// Decrypt the message
	decrypted, err := decrypt(encrypted, key)
	if err != nil {
		fmt.Println("Decryption error:", err)
		return
	}

	fmt.Printf("Original: %s\n", message)
	fmt.Printf("Encrypted: %x\n", encrypted)
	fmt.Printf("Decrypted: %s\n", decrypted)
}

Complete Private Messaging System

Here’s a complete example that combines X25519 key exchange with AES-256-GCM encryption:

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/sha256"
	"fmt"
	"golang.org/x/crypto/curve25519"
)

type KeyPair struct {
	PrivateKey [32]byte
	PublicKey  [32]byte
}

type PrivateMessage struct {
	SenderPublicKey [32]byte
	EncryptedData   []byte
}

func generateKeyPair() (*KeyPair, error) {
	var privateKey [32]byte
	_, err := rand.Read(privateKey[:])
	if err != nil {
		return nil, err
	}

	var publicKey [32]byte
	curve25519.ScalarBaseMult(&publicKey, &privateKey)

	return &KeyPair{
		PrivateKey: privateKey,
		PublicKey:  publicKey,
	}, nil
}

func computeSharedSecret(privateKey, peerPublicKey [32]byte) ([32]byte, error) {
	var sharedSecret [32]byte
	curve25519.ScalarMult(&sharedSecret, &privateKey, &peerPublicKey)
	return sha256.Sum256(sharedSecret[:]), nil
}

func encryptMessage(message []byte, sharedKey [32]byte) ([]byte, error) {
	block, err := aes.NewCipher(sharedKey[:])
	if err != nil {
		return nil, err
	}

	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}

	nonce := make([]byte, gcm.NonceSize())
	_, err = rand.Read(nonce)
	if err != nil {
		return nil, err
	}

	return gcm.Seal(nonce, nonce, message, nil), nil
}

func decryptMessage(ciphertext []byte, sharedKey [32]byte) ([]byte, error) {
	block, err := aes.NewCipher(sharedKey[:])
	if err != nil {
		return nil, err
	}

	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}

	if len(ciphertext) < gcm.NonceSize() {
		return nil, fmt.Errorf("ciphertext too short")
	}

	nonce := ciphertext[:gcm.NonceSize()]
	ciphertext = ciphertext[gcm.NonceSize():]

	return gcm.Open(nil, nonce, ciphertext, nil)
}

func sendMessage(senderKeys *KeyPair, recipientPublicKey [32]byte, message string) (*PrivateMessage, error) {
	// Compute shared secret
	sharedSecret, err := computeSharedSecret(senderKeys.PrivateKey, recipientPublicKey)
	if err != nil {
		return nil, err
	}

	// Encrypt the message
	encrypted, err := encryptMessage([]byte(message), sharedSecret)
	if err != nil {
		return nil, err
	}

	return &PrivateMessage{
		SenderPublicKey: senderKeys.PublicKey,
		EncryptedData:   encrypted,
	}, nil
}

func receiveMessage(recipientKeys *KeyPair, message *PrivateMessage) (string, error) {
	// Compute shared secret
	sharedSecret, err := computeSharedSecret(recipientKeys.PrivateKey, message.SenderPublicKey)
	if err != nil {
		return "", err
	}

	// Decrypt the message
	decrypted, err := decryptMessage(message.EncryptedData, sharedSecret)
	if err != nil {
		return "", err
	}

	return string(decrypted), nil
}

func main() {
	// Generate key pairs for Alice and Bob
	alice, err := generateKeyPair()
	if err != nil {
		fmt.Println("Error generating Alice's keys:", err)
		return
	}

	bob, err := generateKeyPair()
	if err != nil {
		fmt.Println("Error generating Bob's keys:", err)
		return
	}

	fmt.Printf("Alice's Public Key: %x\n", alice.PublicKey)
	fmt.Printf("Bob's Public Key: %x\n", bob.PublicKey)
	fmt.Println()

	// Alice sends a message to Bob
	secretMessage := "Hello Bob! This is a private message from Alice."

	encryptedMsg, err := sendMessage(alice, bob.PublicKey, secretMessage)
	if err != nil {
		fmt.Println("Error sending message:", err)
		return
	}

	fmt.Printf("Encrypted message: %x\n", encryptedMsg.EncryptedData)
	fmt.Println()

	// Bob receives and decrypts the message
	decryptedMsg, err := receiveMessage(bob, encryptedMsg)
	if err != nil {
		fmt.Println("Error receiving message:", err)
		return
	}

	fmt.Printf("Original message: %s\n", secretMessage)
	fmt.Printf("Decrypted message: %s\n", decryptedMsg)
	fmt.Printf("Messages match: %t\n", secretMessage == decryptedMsg)

	// Bob sends a reply to Alice
	replyMessage := "Hi Alice! I received your message securely."

	encryptedReply, err := sendMessage(bob, alice.PublicKey, replyMessage)
	if err != nil {
		fmt.Println("Error sending reply:", err)
		return
	}

	decryptedReply, err := receiveMessage(alice, encryptedReply)
	if err != nil {
		fmt.Println("Error receiving reply:", err)
		return
	}

	fmt.Printf("\nBob's reply: %s\n", decryptedReply)
}

Security Considerations

  • Key Management: Store private keys securely and never transmit them
  • Forward Secrecy: Consider implementing ephemeral keys for each session
  • Authentication: Add digital signatures to verify sender identity
  • Replay Protection: Include timestamps or sequence numbers
  • Key Rotation: Regularly rotate encryption keys

Usage Example

When running the complete private messaging example:

Alice's Public Key: 8f7e6d5c4b3a2918f7e6d5c4b3a2918f7e6d5c4b3a2918f7e6d5c4b3a2918
Bob's Public Key: 1a2b3c4d5e6f70821a2b3c4d5e6f70821a2b3c4d5e6f70821a2b3c4d5e6f7082

Encrypted message: 4f8c2a1b9d7e3f5a8c4b6d9e2f7a0c8e5b1d4f8a2c7e9b3f6d0a5c8e2b7d4f9a1c6e8b3f7d0a2c5e9b4f8d1a6c9e3f7b0d5a8c2e6f9b4d7a1c5e8b2f6d9a3c7e0b5d8a4c6f9b2e7d0a3c6e9b5f8d1a4c7e0b6d9a2c5f8b3e7d0a6c9e4f7b1d5a8c2e6f9b7d0a3c6e9b4f8d1a5c7e0b6d9a2c5f8b3e7d0a4c6e9b5f8d1a7c0e6b9d2a5c8f3e7b0d4a6c9e2f5b8d1a7c0e6b9d3a5c8f4e7b0d1a6c9e2f5b8
Original message: Hello Bob! This is a private message from Alice.
Decrypted message: Hello Bob! This is a private message from Alice.
Messages match: true

Bob's reply: Hi Alice! I received your message securely.

This implementation provides a foundation for secure private messaging with modern cryptographic standards.

Bonus: Adding Ed25519 Signatures

For enhanced security, we can add Ed25519 digital signatures to verify sender authenticity and prevent message tampering. Here are the additional components needed:

New Types and Imports

import (
	"crypto/ed25519"
	"encoding/json"
	// ... existing imports from above
)

type SigningKeyPair struct {
	PrivateKey ed25519.PrivateKey
	PublicKey  ed25519.PublicKey
}

type SignedMessage struct {
	SenderPublicKey    [32]byte             `json:"sender_public_key"`
	SenderSigningKey   ed25519.PublicKey    `json:"sender_signing_key"`
	EncryptedData      []byte               `json:"encrypted_data"`
	Signature          []byte               `json:"signature"`
}

Ed25519 Key Generation

func generateSigningKeyPair() (*SigningKeyPair, error) {
	publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		return nil, err
	}
	
	return &SigningKeyPair{
		PrivateKey: privateKey,
		PublicKey:  publicKey,
	}, nil
}

Enhanced Messaging Functions

func sendSignedMessage(encryptionKeys *KeyPair, signingKeys *SigningKeyPair, 
	recipientPublicKey [32]byte, message string) (*SignedMessage, error) {
	
	// Use existing functions: computeSharedSecret() and encryptMessage()
	sharedSecret, err := computeSharedSecret(encryptionKeys.PrivateKey, recipientPublicKey)
	if err != nil {
		return nil, err
	}

	encrypted, err := encryptMessage([]byte(message), sharedSecret)
	if err != nil {
		return nil, err
	}

	// Create message structure
	signedMsg := &SignedMessage{
		SenderPublicKey:  encryptionKeys.PublicKey,
		SenderSigningKey: signingKeys.PublicKey,
		EncryptedData:    encrypted,
	}

	// Create signature payload (encrypted data + sender info)
	signaturePayload, err := json.Marshal(struct {
		SenderPublicKey  [32]byte          `json:"sender_public_key"`
		SenderSigningKey ed25519.PublicKey `json:"sender_signing_key"`
		EncryptedData    []byte            `json:"encrypted_data"`
	}{
		SenderPublicKey:  signedMsg.SenderPublicKey,
		SenderSigningKey: signedMsg.SenderSigningKey,
		EncryptedData:    signedMsg.EncryptedData,
	})
	if err != nil {
		return nil, err
	}

	// Sign the payload
	signature := ed25519.Sign(signingKeys.PrivateKey, signaturePayload)
	signedMsg.Signature = signature

	return signedMsg, nil
}

func receiveSignedMessage(encryptionKeys *KeyPair, message *SignedMessage) (string, error) {
	// Verify signature first
	signaturePayload, err := json.Marshal(struct {
		SenderPublicKey  [32]byte          `json:"sender_public_key"`
		SenderSigningKey ed25519.PublicKey `json:"sender_signing_key"`
		EncryptedData    []byte            `json:"encrypted_data"`
	}{
		SenderPublicKey:  message.SenderPublicKey,
		SenderSigningKey: message.SenderSigningKey,
		EncryptedData:    message.EncryptedData,
	})
	if err != nil {
		return "", err
	}

	if !ed25519.Verify(message.SenderSigningKey, signaturePayload, message.Signature) {
		return "", fmt.Errorf("signature verification failed")
	}

	// Use existing functions: computeSharedSecret() and decryptMessage()
	sharedSecret, err := computeSharedSecret(encryptionKeys.PrivateKey, message.SenderPublicKey)
	if err != nil {
		return "", err
	}

	decrypted, err := decryptMessage(message.EncryptedData, sharedSecret)
	if err != nil {
		return "", err
	}

	return string(decrypted), nil
}

Usage Example

// Generate both encryption and signing key pairs
aliceEncryption, _ := generateKeyPair()        // From above
aliceSigning, _ := generateSigningKeyPair()    // New function

bobEncryption, _ := generateKeyPair()          // From above  
bobSigning, _ := generateSigningKeyPair()      // New function

// Send signed message
signedMsg, _ := sendSignedMessage(aliceEncryption, aliceSigning, 
	bobEncryption.PublicKey, "Hello Bob!")

// Receive and verify signed message
decryptedMsg, _ := receiveSignedMessage(bobEncryption, signedMsg)
fmt.Println("Message:", decryptedMsg)
fmt.Println("Signature verified successfully!")

Why Add Signatures?

The Ed25519 signature system provides several critical security enhancements:

Enhanced Security Properties

  • Non-repudiation: Senders cannot deny sending a message once it’s signed
  • Identity Verification: Recipients can cryptographically prove who sent the message
  • Message Integrity: Any tampering with the message or metadata is detected
  • Replay Attack Prevention: Signatures are bound to specific message content

How It Works

  1. Dual Key System: Each party now has two key pairs:

    • X25519 keys: For encryption/decryption (Diffie-Hellman)
    • Ed25519 keys: For signing/verification (Digital signatures)
  2. Sign-then-Encrypt: The signature covers the encrypted payload plus metadata, providing authentication of the entire message structure

  3. Verification First: Recipients verify the signature before decryption, rejecting tampered messages immediately

Key Benefits

  • Perfect Forward Secrecy: Combine ephemeral X25519 keys with persistent Ed25519 identity keys
  • Defense in Depth: Even if AES-GCM authentication fails, signature verification catches tampering
  • Scalability: Ed25519 public keys can be distributed and verified independently
  • Performance: Ed25519 is extremely fast for both signing and verification

This dual-cryptosystem approach (X25519 + AES-256-GCM + Ed25519) represents current best practices for secure messaging protocols like Signal and Matrix.