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
}

Signed Message 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.