Implement Beat 2: Age Encryption Envelope
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>
This commit is contained in:
291
pkg/seqthink/ageio/crypto_test.go
Normal file
291
pkg/seqthink/ageio/crypto_test.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package ageio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
)
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
// Generate test key pair
|
||||
tmpDir := t.TempDir()
|
||||
identityPath, recipientPath, err := GenerateTestKeyPair(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("generate test key pair: %v", err)
|
||||
}
|
||||
|
||||
// Create encryptor and decryptor
|
||||
enc, err := NewEncryptor(recipientPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create encryptor: %v", err)
|
||||
}
|
||||
|
||||
dec, err := NewDecryptor(identityPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create decryptor: %v", err)
|
||||
}
|
||||
|
||||
// Test data
|
||||
testCases := []struct {
|
||||
name string
|
||||
plaintext []byte
|
||||
}{
|
||||
{
|
||||
name: "simple text",
|
||||
plaintext: []byte("hello world"),
|
||||
},
|
||||
{
|
||||
name: "json data",
|
||||
plaintext: []byte(`{"tool":"sequentialthinking","payload":{"thought":"test"}}`),
|
||||
},
|
||||
{
|
||||
name: "large data",
|
||||
plaintext: bytes.Repeat([]byte("ABCDEFGHIJ"), 1000), // 10KB
|
||||
},
|
||||
{
|
||||
name: "unicode",
|
||||
plaintext: []byte("Hello 世界 🌍"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Encrypt
|
||||
ciphertext, err := enc.Encrypt(tc.plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Verify ciphertext is not empty and different from plaintext
|
||||
if len(ciphertext) == 0 {
|
||||
t.Fatal("ciphertext is empty")
|
||||
}
|
||||
|
||||
if bytes.Equal(ciphertext, tc.plaintext) {
|
||||
t.Fatal("ciphertext equals plaintext (not encrypted)")
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := dec.Decrypt(ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
|
||||
// Verify decrypted matches original
|
||||
if !bytes.Equal(decrypted, tc.plaintext) {
|
||||
t.Fatalf("decrypted data doesn't match original\ngot: %q\nwant: %q", decrypted, tc.plaintext)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptEmptyData(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
_, recipientPath, err := GenerateTestKeyPair(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("generate test key pair: %v", err)
|
||||
}
|
||||
|
||||
enc, err := NewEncryptor(recipientPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create encryptor: %v", err)
|
||||
}
|
||||
|
||||
_, err = enc.Encrypt([]byte{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error encrypting empty data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptEmptyData(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
identityPath, _, err := GenerateTestKeyPair(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("generate test key pair: %v", err)
|
||||
}
|
||||
|
||||
dec, err := NewDecryptor(identityPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create decryptor: %v", err)
|
||||
}
|
||||
|
||||
_, err = dec.Decrypt([]byte{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error decrypting empty data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptInvalidCiphertext(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
identityPath, _, err := GenerateTestKeyPair(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("generate test key pair: %v", err)
|
||||
}
|
||||
|
||||
dec, err := NewDecryptor(identityPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create decryptor: %v", err)
|
||||
}
|
||||
|
||||
// Try to decrypt garbage data
|
||||
_, err = dec.Decrypt([]byte("not a valid age ciphertext"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error decrypting invalid ciphertext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWrongKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Generate two separate key pairs
|
||||
identity1Path := filepath.Join(tmpDir, "key1.age")
|
||||
recipient1Path := filepath.Join(tmpDir, "key1.pub")
|
||||
identity2Path := filepath.Join(tmpDir, "key2.age")
|
||||
|
||||
// Create first key pair
|
||||
id1, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatalf("generate key 1: %v", err)
|
||||
}
|
||||
os.WriteFile(identity1Path, []byte(id1.String()+"\n"), 0600)
|
||||
os.WriteFile(recipient1Path, []byte(id1.Recipient().String()+"\n"), 0644)
|
||||
|
||||
// Create second key pair
|
||||
id2, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatalf("generate key 2: %v", err)
|
||||
}
|
||||
os.WriteFile(identity2Path, []byte(id2.String()+"\n"), 0600)
|
||||
|
||||
// Encrypt with key 1
|
||||
enc, err := NewEncryptor(recipient1Path)
|
||||
if err != nil {
|
||||
t.Fatalf("create encryptor: %v", err)
|
||||
}
|
||||
|
||||
ciphertext, err := enc.Encrypt([]byte("secret message"))
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Try to decrypt with key 2 (should fail)
|
||||
dec, err := NewDecryptor(identity2Path)
|
||||
if err != nil {
|
||||
t.Fatalf("create decryptor: %v", err)
|
||||
}
|
||||
|
||||
_, err = dec.Decrypt(ciphertext)
|
||||
if err == nil {
|
||||
t.Fatal("expected error decrypting with wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEncryptorInvalidPath(t *testing.T) {
|
||||
_, err := NewEncryptor("/nonexistent/path/to/recipients")
|
||||
if err == nil {
|
||||
t.Fatal("expected error with nonexistent recipients file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDecryptorInvalidPath(t *testing.T) {
|
||||
_, err := NewDecryptor("/nonexistent/path/to/identity")
|
||||
if err == nil {
|
||||
t.Fatal("expected error with nonexistent identity file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEncryptorEmptyPath(t *testing.T) {
|
||||
_, err := NewEncryptor("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error with empty recipients path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDecryptorEmptyPath(t *testing.T) {
|
||||
_, err := NewDecryptor("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error with empty identity path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingEncryptDecrypt(t *testing.T) {
|
||||
// Generate test key pair
|
||||
tmpDir := t.TempDir()
|
||||
identityPath, recipientPath, err := GenerateTestKeyPair(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("generate test key pair: %v", err)
|
||||
}
|
||||
|
||||
// Create encryptor and decryptor
|
||||
enc, err := NewEncryptor(recipientPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create encryptor: %v", err)
|
||||
}
|
||||
|
||||
dec, err := NewDecryptor(identityPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create decryptor: %v", err)
|
||||
}
|
||||
|
||||
// Test streaming encryption
|
||||
plaintext := []byte("streaming test data")
|
||||
var ciphertextBuf bytes.Buffer
|
||||
|
||||
encWriter, err := enc.EncryptStream(&ciphertextBuf)
|
||||
if err != nil {
|
||||
t.Fatalf("create encrypt stream: %v", err)
|
||||
}
|
||||
|
||||
if _, err := encWriter.Write(plaintext); err != nil {
|
||||
t.Fatalf("write to encrypt stream: %v", err)
|
||||
}
|
||||
|
||||
if err := encWriter.Close(); err != nil {
|
||||
t.Fatalf("close encrypt stream: %v", err)
|
||||
}
|
||||
|
||||
// Test streaming decryption
|
||||
decReader, err := dec.DecryptStream(&ciphertextBuf)
|
||||
if err != nil {
|
||||
t.Fatalf("create decrypt stream: %v", err)
|
||||
}
|
||||
|
||||
decrypted := make([]byte, len(plaintext))
|
||||
n, err := decReader.Read(decrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("read from decrypt stream: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(decrypted[:n], plaintext) {
|
||||
t.Fatalf("decrypted data doesn't match original\ngot: %q\nwant: %q", decrypted[:n], plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvenienceFunctions(t *testing.T) {
|
||||
// Generate test keys in memory
|
||||
identity, recipient, err := GenerateTestKeys()
|
||||
if err != nil {
|
||||
t.Fatalf("generate test keys: %v", err)
|
||||
}
|
||||
|
||||
plaintext := []byte("test message")
|
||||
|
||||
// Encrypt with convenience function
|
||||
ciphertext, err := EncryptBytes(plaintext, recipient)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt bytes: %v", err)
|
||||
}
|
||||
|
||||
// Decrypt with convenience function
|
||||
decrypted, err := DecryptBytes(ciphertext, identity)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt bytes: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(decrypted, plaintext) {
|
||||
t.Fatalf("decrypted data doesn't match original\ngot: %q\nwant: %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user