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
- Key Generation: Both Alice and Bob independently generate their X25519 key pairs (private key + public key)
- Public Key Exchange: Alice and Bob exchange their public keys over any communication channel (this can be done openly)
- 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
- Message Encryption: When Alice wants to send a message, she encrypts it using AES-256-GCM with the shared secret as the key
- Message Transmission: The encrypted message is sent to Bob over any channel (even insecure ones)
- Message Decryption: Bob receives the encrypted message and decrypts it using AES-256-GCM with the same shared secret
- 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
Dual Key System: Each party now has two key pairs:
- X25519 keys: For encryption/decryption (Diffie-Hellman)
- Ed25519 keys: For signing/verification (Digital signatures)
Sign-then-Encrypt: The signature covers the encrypted payload plus metadata, providing authentication of the entire message structure
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.