This commit completes Beat 2 of the SequentialThinkingForCHORUS implementation, adding end-to-end age encryption for all MCP communications. ## Deliverables ### 1. Age Encryption/Decryption Package (pkg/seqthink/ageio/) - `crypto.go`: Core encryption/decryption with age - `testkeys.go`: Test key generation and convenience functions - `crypto_test.go`: Comprehensive unit tests (11 tests, all passing) - `golden_test.go`: Golden tests with real MCP payloads (12 tests, all passing) **Features:** - File-based identity and recipient key loading - Streaming encryption/decryption support - Proper error handling for all failure modes - Performance benchmarks showing 400+ MB/s throughput **Test Coverage:** - Round-trip encryption/decryption for various payload sizes - Unicode and emoji support - Large payload handling (100KB+) - Invalid ciphertext rejection - Wrong key detection - Truncated/modified ciphertext detection ### 2. Encrypted Proxy Handlers (pkg/seqthink/proxy/) - `server_encrypted.go`: Encrypted tool call handler - Updated `server.go`: Automatic routing based on encryption config - Content-Type enforcement: `application/age` required when encryption enabled - Metrics tracking for encryption/decryption failures **Flow:** 1. Client sends encrypted request with `Content-Type: application/age` 2. Wrapper decrypts using age identity 3. Wrapper calls MCP server (plaintext on loopback) 4. Wrapper encrypts response 5. Client receives encrypted response with `Content-Type: application/age` ### 3. SSE Streaming with Encryption (pkg/seqthink/proxy/sse.go) - `handleSSEEncrypted()`: Encrypted Server-Sent Events streaming - `handleSSEPlaintext()`: Plaintext SSE for testing - Base64-encoded encrypted frames for SSE transport - `DecryptSSEFrame()`: Client-side frame decryption helper - `ReadSSEStream()`: SSE stream parsing utility **SSE Frame Format (Encrypted):** ``` event: thought data: <base64-encoded age-encrypted JSON> id: 1 ``` ### 4. Configuration-Based Mode Switching The wrapper now operates in two modes based on environment variables: **Encrypted Mode** (AGE_IDENT_PATH and AGE_RECIPS_PATH set): - All requests/responses encrypted with age - Content-Type: application/age enforced - SSE frames base64-encoded and encrypted **Plaintext Mode** (no encryption paths set): - Direct plaintext proxying for development/testing - Standard JSON Content-Type - Plaintext SSE frames ## Testing Results ### Unit Tests ``` PASS: TestEncryptDecryptRoundTrip (all variants) PASS: TestEncryptEmptyData PASS: TestDecryptEmptyData PASS: TestDecryptInvalidCiphertext PASS: TestDecryptWrongKey PASS: TestStreamingEncryptDecrypt PASS: TestConvenienceFunctions ``` ### Golden Tests ``` PASS: TestGoldenEncryptionRoundTrip (7 scenarios) - sequential_thinking_request (283→483 bytes, 70.7% overhead) - sequential_thinking_revision (303→503 bytes, 66.0% overhead) - sequential_thinking_branching (315→515 bytes, 63.5% overhead) - sequential_thinking_final (320→520 bytes, 62.5% overhead) - large_context_payload (3800→4000 bytes, 5.3% overhead) - unicode_payload (264→464 bytes, 75.8% overhead) - special_characters (140→340 bytes, 142.9% overhead) PASS: TestGoldenDecryptionFailures (5 scenarios) ``` ### Performance Benchmarks ``` Encryption: - 1KB: 5.44 MB/s - 10KB: 52.57 MB/s - 100KB: 398.66 MB/s Decryption: - 1KB: 9.22 MB/s - 10KB: 85.41 MB/s - 100KB: 504.46 MB/s ``` ## Security Properties ✅ **Confidentiality**: All payloads encrypted with age (X25519+ChaCha20-Poly1305) ✅ **Authenticity**: age provides AEAD with Poly1305 MAC ✅ **Forward Secrecy**: Each encryption uses fresh ephemeral keys ✅ **Key Management**: File-based identity/recipient keys ✅ **Tampering Detection**: Modified ciphertext rejected ✅ **No Plaintext Leakage**: MCP server only on 127.0.0.1 loopback ## Next Steps (Beat 3) Beat 3 will add KACHING JWT policy enforcement: - JWT token validation (`pkg/seqthink/policy/`) - Scope checking for `sequentialthinking.run` - JWKS fetching and caching - Policy denial metrics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
160 lines
4.2 KiB
Go
160 lines
4.2 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"chorus/pkg/seqthink/mcpclient"
|
|
"chorus/pkg/seqthink/observability"
|
|
"github.com/gorilla/mux"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// ServerConfig holds the proxy server configuration
|
|
type ServerConfig struct {
|
|
MCPClient *mcpclient.Client
|
|
Metrics *observability.Metrics
|
|
MaxBodyMB int
|
|
AgeIdentPath string
|
|
AgeRecipsPath string
|
|
KachingJWKSURL string
|
|
RequiredScope string
|
|
}
|
|
|
|
// Server is the proxy server handling requests
|
|
type Server struct {
|
|
config ServerConfig
|
|
router *mux.Router
|
|
}
|
|
|
|
// NewServer creates a new proxy server
|
|
func NewServer(cfg ServerConfig) (*Server, error) {
|
|
s := &Server{
|
|
config: cfg,
|
|
router: mux.NewRouter(),
|
|
}
|
|
|
|
// Setup routes
|
|
s.setupRoutes()
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Handler returns the HTTP handler
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.router
|
|
}
|
|
|
|
// setupRoutes configures the HTTP routes
|
|
func (s *Server) setupRoutes() {
|
|
// Health checks
|
|
s.router.HandleFunc("/health", s.handleHealth).Methods("GET")
|
|
s.router.HandleFunc("/ready", s.handleReady).Methods("GET")
|
|
|
|
// MCP tool endpoint - route based on encryption config
|
|
if s.isEncryptionEnabled() {
|
|
log.Info().Msg("Encryption enabled - using encrypted endpoint")
|
|
s.router.HandleFunc("/mcp/tool", s.handleToolCallEncrypted).Methods("POST")
|
|
} else {
|
|
log.Warn().Msg("Encryption disabled - using plaintext endpoint")
|
|
s.router.HandleFunc("/mcp/tool", s.handleToolCall).Methods("POST")
|
|
}
|
|
|
|
// SSE endpoint - route based on encryption config
|
|
if s.isEncryptionEnabled() {
|
|
s.router.HandleFunc("/mcp/sse", s.handleSSEEncrypted).Methods("GET")
|
|
} else {
|
|
s.router.HandleFunc("/mcp/sse", s.handleSSEPlaintext).Methods("GET")
|
|
}
|
|
|
|
// Metrics endpoint
|
|
s.router.Handle("/metrics", s.config.Metrics.Handler())
|
|
}
|
|
|
|
// isEncryptionEnabled checks if encryption is configured
|
|
func (s *Server) isEncryptionEnabled() bool {
|
|
return s.config.AgeIdentPath != "" && s.config.AgeRecipsPath != ""
|
|
}
|
|
|
|
// handleHealth returns 200 OK if wrapper is running
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("OK"))
|
|
}
|
|
|
|
// handleReady checks if MCP server is ready
|
|
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := s.config.MCPClient.Health(ctx); err != nil {
|
|
log.Error().Err(err).Msg("MCP server not ready")
|
|
http.Error(w, "MCP server not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("READY"))
|
|
}
|
|
|
|
// handleToolCall proxies tool calls to MCP server (plaintext for Beat 1)
|
|
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
|
s.config.Metrics.IncrementRequests()
|
|
startTime := time.Now()
|
|
|
|
// Limit request body size
|
|
r.Body = http.MaxBytesReader(w, r.Body, int64(s.config.MaxBodyMB)*1024*1024)
|
|
|
|
// Read request body
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to read request body")
|
|
s.config.Metrics.IncrementErrors()
|
|
http.Error(w, "Failed to read request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse tool request
|
|
var toolReq mcpclient.ToolRequest
|
|
if err := json.Unmarshal(body, &toolReq); err != nil {
|
|
log.Error().Err(err).Msg("Failed to parse tool request")
|
|
s.config.Metrics.IncrementErrors()
|
|
http.Error(w, "Invalid request format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Str("tool", toolReq.Tool).
|
|
Msg("Proxying tool call to MCP server")
|
|
|
|
// Call MCP server
|
|
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
|
defer cancel()
|
|
|
|
toolResp, err := s.config.MCPClient.CallTool(ctx, &toolReq)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("MCP tool call failed")
|
|
s.config.Metrics.IncrementErrors()
|
|
http.Error(w, fmt.Sprintf("Tool call failed: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(toolResp); err != nil {
|
|
log.Error().Err(err).Msg("Failed to encode response")
|
|
s.config.Metrics.IncrementErrors()
|
|
return
|
|
}
|
|
|
|
duration := time.Since(startTime)
|
|
log.Info().
|
|
Str("tool", toolReq.Tool).
|
|
Dur("duration", duration).
|
|
Msg("Tool call completed")
|
|
}
|