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>
127 lines
3.0 KiB
Go
127 lines
3.0 KiB
Go
package ageio
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
|
|
"filippo.io/age"
|
|
)
|
|
|
|
// Encryptor handles age encryption operations
|
|
type Encryptor struct {
|
|
recipients []age.Recipient
|
|
}
|
|
|
|
// Decryptor handles age decryption operations
|
|
type Decryptor struct {
|
|
identities []age.Identity
|
|
}
|
|
|
|
// NewEncryptor creates an encryptor from a recipients file
|
|
func NewEncryptor(recipientsPath string) (*Encryptor, error) {
|
|
if recipientsPath == "" {
|
|
return nil, fmt.Errorf("recipients path is empty")
|
|
}
|
|
|
|
data, err := os.ReadFile(recipientsPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read recipients file: %w", err)
|
|
}
|
|
|
|
recipients, err := age.ParseRecipients(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse recipients: %w", err)
|
|
}
|
|
|
|
if len(recipients) == 0 {
|
|
return nil, fmt.Errorf("no recipients found in file")
|
|
}
|
|
|
|
return &Encryptor{recipients: recipients}, nil
|
|
}
|
|
|
|
// NewDecryptor creates a decryptor from an identity file
|
|
func NewDecryptor(identityPath string) (*Decryptor, error) {
|
|
if identityPath == "" {
|
|
return nil, fmt.Errorf("identity path is empty")
|
|
}
|
|
|
|
data, err := os.ReadFile(identityPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read identity file: %w", err)
|
|
}
|
|
|
|
identities, err := age.ParseIdentities(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse identities: %w", err)
|
|
}
|
|
|
|
if len(identities) == 0 {
|
|
return nil, fmt.Errorf("no identities found in file")
|
|
}
|
|
|
|
return &Decryptor{identities: identities}, nil
|
|
}
|
|
|
|
// Encrypt encrypts plaintext data with age
|
|
func (e *Encryptor) Encrypt(plaintext []byte) ([]byte, error) {
|
|
if len(plaintext) == 0 {
|
|
return nil, fmt.Errorf("plaintext is empty")
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
w, err := age.Encrypt(&buf, e.recipients...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create encryptor: %w", err)
|
|
}
|
|
|
|
if _, err := w.Write(plaintext); err != nil {
|
|
return nil, fmt.Errorf("write plaintext: %w", err)
|
|
}
|
|
|
|
if err := w.Close(); err != nil {
|
|
return nil, fmt.Errorf("close encryptor: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// Decrypt decrypts age-encrypted data
|
|
func (d *Decryptor) Decrypt(ciphertext []byte) ([]byte, error) {
|
|
if len(ciphertext) == 0 {
|
|
return nil, fmt.Errorf("ciphertext is empty")
|
|
}
|
|
|
|
r, err := age.Decrypt(bytes.NewReader(ciphertext), d.identities...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create decryptor: %w", err)
|
|
}
|
|
|
|
plaintext, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read plaintext: %w", err)
|
|
}
|
|
|
|
return plaintext, nil
|
|
}
|
|
|
|
// EncryptStream creates an encrypted writer for streaming
|
|
func (e *Encryptor) EncryptStream(w io.Writer) (io.WriteCloser, error) {
|
|
ew, err := age.Encrypt(w, e.recipients...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create stream encryptor: %w", err)
|
|
}
|
|
return ew, nil
|
|
}
|
|
|
|
// DecryptStream creates a decrypted reader for streaming
|
|
func (d *Decryptor) DecryptStream(r io.Reader) (io.Reader, error) {
|
|
dr, err := age.Decrypt(r, d.identities...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create stream decryptor: %w", err)
|
|
}
|
|
return dr, nil
|
|
}
|