Skip to main content Skip to sidebar

End-to-End Encryption for APIs with X25519 and AES

Building secure APIs requires robust encryption to protect data in transit and ensure only intended recipients can access sensitive information. This post demonstrates how to implement end-to-end encryption for API communications using X25519 key exchange and AES-256-GCM authenticated encryption in Go.

Architecture Overview

The system uses X25519 key exchange with AES-256-GCM encryption for secure API communication:

  1. Key Discovery: Client fetches server’s public key from /.well-known/encryption-keys
  2. Key Exchange: Client generates ephemeral key pair and sends public key via header
  3. Shared Secret: Both parties compute identical shared secret using ECDH
  4. Encrypted Communication: All requests/responses encrypted with AES-256-GCM
End-to-end encryption sequence diagram

Core Cryptographic Components

X25519 Key Exchange Implementation

package main

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

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

type ServerKey struct {
	ID        string `json:"id"`
	PublicKey string `json:"public_key"`
}

type WellKnownKeys struct {
	Keys []ServerKey `json:"keys"`
}

func generateKeyPair() (*KeyPair, error) {
	var privateKey [32]byte
	if _, err := rand.Read(privateKey[:]); 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)

	// Use SHA256 to derive encryption key from shared secret
	return sha256.Sum256(sharedSecret[:]), nil
}

func (kp *KeyPair) PublicKeyBase64() string {
	return base64.StdEncoding.EncodeToString(kp.PublicKey[:])
}

func (kp *KeyPair) ID() string {
	return computeKeyFingerprint(kp.PublicKey)
}

func (kp *KeyPair) ToServerKey() ServerKey {
	return ServerKey{
		ID:        kp.ID(),
		PublicKey: kp.PublicKeyBase64(),
	}
}

func publicKeyFromBase64(base64Str string) ([32]byte, error) {
	var key [32]byte
	decoded, err := base64.StdEncoding.DecodeString(base64Str)
	if err != nil {
		return key, err
	}
	if len(decoded) != 32 {
		return key, fmt.Errorf("invalid key length: expected 32 bytes, got %d", len(decoded))
	}
	copy(key[:], decoded)
	return key, nil
}

func computeKeyFingerprint(publicKey [32]byte) string {
	keyFingerprint := sha256.Sum256(publicKey[:])
	return base64.URLEncoding.EncodeToString(keyFingerprint[:16]) // Use first 16 bytes of fingerprint
}

func verifyKeyID(publicKey [32]byte, keyID string) bool {
	expectedID := computeKeyFingerprint(publicKey)
	return expectedID == keyID
}

AES-256-GCM Encryption

package main

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

func encryptBytes(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
	}

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

	ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
	return ciphertext, nil
}

func decryptBytes(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")
	}

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

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

	return plaintext, nil
}

API Server Implementation

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"sync"

	"github.com/gorilla/mux"
)

type Server struct {
	keyPair *KeyPair
}

type SecureRequest struct {
	Data string `json:"data"`
}

type SecureResponse struct {
	Data string `json:"data"`
}

func NewServer() (*Server, error) {
	keyPair, err := generateKeyPair()
	if err != nil {
		return nil, err
	}

	return &Server{
		keyPair: keyPair,
	}, nil
}

func (s *Server) wellKnownHandler(w http.ResponseWriter, r *http.Request) {
	response := WellKnownKeys{
		Keys: []ServerKey{s.keyPair.ToServerKey()},
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

func (s *Server) computeSharedSecret(r *http.Request) ([32]byte, error) {
	clientPublicKeyB64 := r.Header.Get("X-Client-Public-Key")
	if clientPublicKeyB64 == "" {
		return [32]byte{}, fmt.Errorf("missing X-Client-Public-Key header")
	}

	clientPublicKey, err := publicKeyFromBase64(clientPublicKeyB64)
	if err != nil {
		return [32]byte{}, fmt.Errorf("invalid client public key: %v", err)
	}

	return computeSharedSecret(s.keyPair.PrivateKey, clientPublicKey)
}

func (s *Server) secureHandler(w http.ResponseWriter, r *http.Request) {
	var req SecureRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid request", http.StatusBadRequest)
		return
	}

	sharedSecret, err := s.computeSharedSecret(r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	// Decrypt request
	encryptedData, err := base64.StdEncoding.DecodeString(req.Data)
	if err != nil {
		http.Error(w, "Invalid data encoding", http.StatusBadRequest)
		return
	}

	decryptedData, err := decryptBytes(encryptedData, sharedSecret)
	if err != nil {
		http.Error(w, "Decryption failed", http.StatusBadRequest)
		return
	}

	// Process request (echo back)
	responseData := fmt.Sprintf("Echo: %s", string(decryptedData))

	// Encrypt response
	encryptedResponse, err := encryptBytes([]byte(responseData), sharedSecret)
	if err != nil {
		http.Error(w, "Encryption failed", http.StatusInternalServerError)
		return
	}

	response := SecureResponse{
		Data: base64.StdEncoding.EncodeToString(encryptedResponse),
	}

	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("X-Encrypted", "e2ee")
	json.NewEncoder(w).Encode(response)
}

func (s *Server) Start(addr string) error {
	router := mux.NewRouter()
	router.HandleFunc("/.well-known/encryption-keys", s.wellKnownHandler).Methods("GET")
	router.HandleFunc("/api/v1/secure", s.secureHandler).Methods("POST")

	return http.ListenAndServe(addr, router)
}

API Client Implementation

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

type Client struct {
	baseURL    string
	clientKeys *KeyPair
	serverKey  *ServerKey
	httpClient *http.Client
}

func NewClient(baseURL string) (*Client, error) {
	clientKeys, err := generateKeyPair()
	if err != nil {
		return nil, err
	}

	return &Client{
		baseURL:    baseURL,
		clientKeys: clientKeys,
		httpClient: &http.Client{},
	}, nil
}

func (c *Client) fetchServerKey() error {
	resp, err := c.httpClient.Get(c.baseURL + "/.well-known/encryption-keys")
	if err != nil {
		return fmt.Errorf("failed to fetch server keys: %v", err)
	}
	defer resp.Body.Close()

	var wellKnownKeys WellKnownKeys
	if err := json.NewDecoder(resp.Body).Decode(&wellKnownKeys); err != nil {
		return fmt.Errorf("failed to decode server keys: %v", err)
	}

	if len(wellKnownKeys.Keys) == 0 {
		return fmt.Errorf("server has no available keys")
	}

	// Use first available key
	c.serverKey = &wellKnownKeys.Keys[0]
	return nil
}

func (c *Client) computeSharedSecret() ([32]byte, error) {
	if c.serverKey == nil {
		return [32]byte{}, fmt.Errorf("no server key available")
	}

	serverPublicKey, err := publicKeyFromBase64(c.serverKey.PublicKey)
	if err != nil {
		return [32]byte{}, fmt.Errorf("invalid server public key: %v", err)
	}

	return computeSharedSecret(c.clientKeys.PrivateKey, serverPublicKey)
}

func (c *Client) SendMessage(message string) (string, error) {
	// Fetch server key if not already done
	if c.serverKey == nil {
		if err := c.fetchServerKey(); err != nil {
			return "", fmt.Errorf("failed to fetch server key: %v", err)
		}
	}

	// Compute shared secret
	sharedSecret, err := c.computeSharedSecret()
	if err != nil {
		return "", fmt.Errorf("failed to compute shared secret: %v", err)
	}

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

	secureReq := SecureRequest{
		Data: base64.StdEncoding.EncodeToString(encryptedData),
	}

	reqBody, err := json.Marshal(secureReq)
	if err != nil {
		return "", err
	}

	// Create the request
	req, err := http.NewRequest("POST", c.baseURL+"/api/v1/secure", bytes.NewBuffer(reqBody))
	if err != nil {
		return "", err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Client-Public-Key", c.clientKeys.PublicKeyBase64())
	req.Header.Set("X-Encrypted", "e2ee")

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	var secureResp SecureResponse
	if err := json.NewDecoder(resp.Body).Decode(&secureResp); err != nil {
		return "", err
	}

	// Decrypt response
	responseData, err := base64.StdEncoding.DecodeString(secureResp.Data)
	if err != nil {
		return "", err
	}

	decryptedResponse, err := decryptBytes(responseData, sharedSecret)
	if err != nil {
		return "", err
	}

	return string(decryptedResponse), nil
}

Usage Example

package main

import (
	"fmt"
	"log"
	"time"
)

func main() {
	// Start the server
	server, err := NewServer()
	if err != nil {
		log.Fatal("Failed to create server:", err)
	}

	go func() {
		if err := server.Start(":8080"); err != nil {
			log.Fatal("Server failed:", err)
		}
	}()

	// Give server time to start
	time.Sleep(time.Second)

	// Create client
	client, err := NewClient("http://localhost:8080")
	if err != nil {
		log.Fatal("Failed to create client:", err)
	}

	// Send encrypted message
	response, err := client.SendMessage("Hello, secure world!")
	if err != nil {
		log.Fatal("Failed to send message:", err)
	}

	fmt.Printf("Server response: %s\n", response)
}

Benefits of Well-Known Key Discovery

The .well-known/encryption-keys endpoint provides several advantages:

  • Standards-based: Follows RFC 8615 and industry patterns (OAuth, OIDC)
  • Simple discovery: Single endpoint for all server public keys
  • Cacheable: Keys can be cached by clients, CDNs, and proxies
  • Debugging-friendly: Easy to inspect keys via curl or browser
  • Scalable: No server-side session state required
  • Fingerprint verification: Key IDs prevent tampering and corruption

Example Well-Known Response

{
  "keys": [
    {
      "id": "mv_LtU3vIRuL49Iu9q",
      "public_key": "mvfLxb5D7yq4xdKe+Wajw8TU4fiLWryf1tOgx+ThFYpS"
    }
  ]
}

Note: The id field is the base64url-encoded SHA256 fingerprint of the public key (first 16 bytes), and the public_key field uses standard base64 encoding.

This implementation provides a robust foundation for secure API communications with modern cryptographic standards, ensuring data confidentiality, integrity, and authenticity in transit.